From ef7e173cafb08645f39a142ababc3b8d82155173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Fri, 29 May 2020 12:38:22 +0200 Subject: [PATCH] Implement listening in signal blocks. --- .../minicards/CanvasView.java | 9 ++ .../minicards/SignalListenerManager.java | 108 ++++++++++++++++++ .../minicards/StubPartGrid.java | 5 + .../bridge/ProgramakerBridgeService.java | 6 +- .../parts/ProgramakerCustomBlockPart.java | 67 ++++++++++- .../minicards/types/PartGrid.java | 2 + .../minicards/types/functional/Tuple2.java | 39 +++++++ .../com/programaker/api/ProgramakerApi.kt | 17 +++ .../api/ProgramakerListeningChannel.kt | 78 +++++++++++++ .../api/ProgramakerSignalListener.kt | 7 ++ .../api/data/ProgramakerCustomBlock.kt | 21 +++- .../api/data/ProgramakerCustomBlockSaveTo.kt | 1 - .../ProgramakerCustomBlockSubkeyDefinition.kt | 29 +++++ 13 files changed, 378 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/codigoparallevar/minicards/SignalListenerManager.java create mode 100644 app/src/main/java/com/programaker/api/ProgramakerListeningChannel.kt create mode 100644 app/src/main/java/com/programaker/api/ProgramakerSignalListener.kt create mode 100644 app/src/main/java/com/programaker/api/data/ProgramakerCustomBlockSubkeyDefinition.kt diff --git a/app/src/main/java/com/codigoparallevar/minicards/CanvasView.java b/app/src/main/java/com/codigoparallevar/minicards/CanvasView.java index b94e38b..d9154d9 100644 --- a/app/src/main/java/com/codigoparallevar/minicards/CanvasView.java +++ b/app/src/main/java/com/codigoparallevar/minicards/CanvasView.java @@ -66,6 +66,7 @@ public class CanvasView extends View implements PartGrid { @Nullable private Tuple2 _mouseDownPoint = null; private int cardBackgroundColor; + private SignalListenerManager listenerManager = null; public CanvasView(Context context) { super(context); @@ -359,6 +360,14 @@ public class CanvasView extends View implements PartGrid { return api; } + @Override + public SignalListenerManager getListenerManager() { + if (listenerManager == null) { + listenerManager = new SignalListenerManager(getApi()); + } + return listenerManager; + } + @Override @Nullable public SignalInputConnector getSignalInputConnectorOn(int x, int y) { diff --git a/app/src/main/java/com/codigoparallevar/minicards/SignalListenerManager.java b/app/src/main/java/com/codigoparallevar/minicards/SignalListenerManager.java new file mode 100644 index 0000000..badbcc8 --- /dev/null +++ b/app/src/main/java/com/codigoparallevar/minicards/SignalListenerManager.java @@ -0,0 +1,108 @@ +package com.codigoparallevar.minicards; + +import android.util.Log; + +import com.codigoparallevar.minicards.types.functional.Tuple2; +import com.programaker.api.ProgramakerApi; +import com.programaker.api.ProgramakerListeningChannel; +import com.programaker.api.ProgramakerSignalListener; + +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +public class SignalListenerManager implements ProgramakerSignalListener { + private final ProgramakerApi api; + private final Map, List> channelToListener = new LinkedHashMap<>(); + private final Map>> listenerChannels = new LinkedHashMap<>(); + private final Map, ProgramakerListeningChannel> idToChannel = new LinkedHashMap<>(); + private long RECONNECT_SLEEP_TIME = 2000; // 2seconds + private final static String LogTag = "Signal Listener Manager"; + + public SignalListenerManager(ProgramakerApi api) { + this.api = api; + } + + public void registerSignalListener(String bridgeId, String key, ProgramakerSignalListener listener) { + Tuple2 id = new Tuple2<>(bridgeId, key); + if (!idToChannel.containsKey(id)) { + // Channel has to be opened + idToChannel.put(id, this.api.openChannelTo(bridgeId, key, this, + () -> { + SignalListenerManager.this.onDisconnect(bridgeId, key); + })); + } + + if (!channelToListener.containsKey(id)) { + channelToListener.put(id, new LinkedList<>()); + } + List listeners = channelToListener.get(id); + listeners.add(listener); + + if (!listenerChannels.containsKey(listener)) { + listenerChannels.put(listener, new LinkedList<>()); + } + listenerChannels.get(listener).add(id); + } + + public void unregisterSignalListener(ProgramakerSignalListener listener) { + List> channels = listenerChannels.get(listener); + listenerChannels.remove(listener); + for (Tuple2 id : channels) { + List remainingListeners = channelToListener.get(id); + + remainingListeners.remove(listener); + if (remainingListeners.size() == 0) { + ProgramakerListeningChannel channel = idToChannel.get(id); + channel.stop(); + idToChannel.remove(id); + channelToListener.remove(id); + } + } + } + + private void onDisconnect(String bridgeId, String key) { + Log.w(LogTag, "Connection lost to (bridge="+bridgeId+",key="+key + ")"); + Tuple2 id = new Tuple2<>(bridgeId, key); + + // On disconnect disable the connection, wait 2 seconds and retry + idToChannel.put(id, null); + + new Thread(() -> { + try { + Thread.sleep(RECONNECT_SLEEP_TIME); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + idToChannel.put(id, this.api.openChannelTo(bridgeId, key, this, + () -> { + SignalListenerManager.this.onDisconnect(bridgeId, key); + })); + }).start(); + } + + @Override + public void onNewSignal(@NotNull String bridgeId, @NotNull String key, @NotNull HashMap signal) { + Tuple2 id = new Tuple2<>(bridgeId, key); + + if (!channelToListener.containsKey(id)) { + Log.e(LogTag, "Got signal to unlistened channel (bridgeId=" + bridgeId + ",key=" + key + ")"); + return; + } + + for (ProgramakerSignalListener listener : channelToListener.get(id)) { + try { + listener.onNewSignal(bridgeId, key, signal); + } + catch (Exception ex) { + Log.e(LogTag, "Error passing message (bridge=" + bridgeId + ",key" + key + + ") to " + listener, ex); + } + } + } +} diff --git a/app/src/main/java/com/codigoparallevar/minicards/StubPartGrid.java b/app/src/main/java/com/codigoparallevar/minicards/StubPartGrid.java index 2d75446..b1a49a7 100644 --- a/app/src/main/java/com/codigoparallevar/minicards/StubPartGrid.java +++ b/app/src/main/java/com/codigoparallevar/minicards/StubPartGrid.java @@ -19,6 +19,11 @@ class StubPartGrid implements PartGrid { return null; } + @Override + public SignalListenerManager getListenerManager() { + return null; + } + @Override public SignalInputConnector getSignalInputConnectorOn(int x, int y) { return null; diff --git a/app/src/main/java/com/codigoparallevar/minicards/bridge/ProgramakerBridgeService.java b/app/src/main/java/com/codigoparallevar/minicards/bridge/ProgramakerBridgeService.java index c97733e..91b3189 100644 --- a/app/src/main/java/com/codigoparallevar/minicards/bridge/ProgramakerBridgeService.java +++ b/app/src/main/java/com/codigoparallevar/minicards/bridge/ProgramakerBridgeService.java @@ -59,6 +59,7 @@ public class ProgramakerBridgeService extends Service { ProgramakerBridgeService.BridgeStatusNotificationChannel, ProgramakerBridgeService.BridgeStatusNotificationChannelName, NotificationManager.IMPORTANCE_DEFAULT); + channel.enableVibration(false); notificationManager.createNotificationChannel(channel); } @@ -105,7 +106,10 @@ public class ProgramakerBridgeService extends Service { @Override public int onStartCommand(Intent intent, int flags, int startId) { - String action = intent.getAction(); + String action = null; + if (intent != null) { // Apparently this (intent=null) can happen... + action = intent.getAction(); + } if (action == null) { action = ""; } diff --git a/app/src/main/java/com/codigoparallevar/minicards/parts/ProgramakerCustomBlockPart.java b/app/src/main/java/com/codigoparallevar/minicards/parts/ProgramakerCustomBlockPart.java index 68eadf2..8ceb2ba 100644 --- a/app/src/main/java/com/codigoparallevar/minicards/parts/ProgramakerCustomBlockPart.java +++ b/app/src/main/java/com/codigoparallevar/minicards/parts/ProgramakerCustomBlockPart.java @@ -21,21 +21,24 @@ import com.codigoparallevar.minicards.types.wireData.Signal; import com.codigoparallevar.minicards.types.wireData.WireDataType; import com.codigoparallevar.minicards.ui_helpers.DoAsync; import com.programaker.api.ProgramakerApi; +import com.programaker.api.ProgramakerSignalListener; import com.programaker.api.data.ProgramakerCustomBlock; import com.programaker.api.data.ProgramakerCustomBlockArgument; import com.programaker.api.data.ProgramakerCustomBlockSaveTo; import com.programaker.api.data.ProgramakerFunctionCallResult; +import org.jetbrains.annotations.NotNull; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.UUID; -public class ProgramakerCustomBlockPart implements Part { +public class ProgramakerCustomBlockPart implements Part, ProgramakerSignalListener { private static final int WIDTH_PADDING = 50; private static final int HEIGHT_PADDING = 50; private static final int IO_RADIUS = 50; @@ -59,7 +62,6 @@ public class ProgramakerCustomBlockPart implements Part { private Tuple2 saveToOutput; private RoundOutputConnector pulseOutput; - public ProgramakerCustomBlockPart(String id, PartGrid grid, Tuple2 center, ProgramakerCustomBlock block) { this._id = id; this._partGrid = grid; @@ -207,7 +209,7 @@ public class ProgramakerCustomBlockPart implements Part { this.updatePortPositions(); } - + private void updatePortPositions() { { // Update inputs @@ -505,11 +507,67 @@ public class ProgramakerCustomBlockPart implements Part { @Override public void resume() { this.active = true; + + String type = _block.getBlock_type(); + if (type != null && (type.equals("trigger"))) { + // Listen to signal + ProgramakerApi api = _partGrid.getApi(); + if (api == null) { + Log.e(LogTag, "Cannot listen to API (API not found)"); + return; + } + + new DoAsync().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, + new Tuple2<>( + () -> { + _partGrid.getListenerManager().registerSignalListener( + _block.getBridge_id(), _block.getKey(), + this); + }, + ex -> { + Log.e(LogTag, "Error establishing connection to monitor", ex); + } + )); + } + } + + @Override + public void onNewSignal(@NotNull String bridgeId, @NotNull String key, @NotNull HashMap signal) { + // Propagate signal + // Stream object on save_to, then trigger pulse + Object content = signal.get("content"); + if (this.saveToOutput != null) { + // this.saveToOutput.item2.send(content); // TODO: Have an output type that allows this + } + + if (this.pulseOutput != null) { + this.pulseOutput.send(new Signal()); + } + _partGrid.update(); } @Override public void pause() { this.active = false; + + String type = _block.getBlock_type(); + if (type != null && (type.equals("trigger"))) { + // Release listening of signal + ProgramakerApi api = _partGrid.getApi(); + if (api == null) { + return; + } + + new DoAsync().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, + new Tuple2<>( + () -> { + _partGrid.getListenerManager().unregisterSignalListener(this); + }, + ex -> { + Log.e(LogTag, "Error disconnecting from monitor", ex); + } + )); + } } @Override @@ -623,7 +681,8 @@ public class ProgramakerCustomBlockPart implements Part { @Override public void unlink() { - this.active = false; + pause(); + for (InputConnector input : getInputConnectors()) { input.unlink(); } diff --git a/app/src/main/java/com/codigoparallevar/minicards/types/PartGrid.java b/app/src/main/java/com/codigoparallevar/minicards/types/PartGrid.java index 36d5a9c..9cc90b6 100644 --- a/app/src/main/java/com/codigoparallevar/minicards/types/PartGrid.java +++ b/app/src/main/java/com/codigoparallevar/minicards/types/PartGrid.java @@ -1,5 +1,6 @@ package com.codigoparallevar.minicards.types; +import com.codigoparallevar.minicards.SignalListenerManager; import com.codigoparallevar.minicards.types.connectors.input.BooleanInputConnector; import com.codigoparallevar.minicards.types.connectors.input.SignalInputConnector; import com.codigoparallevar.minicards.types.connectors.input.StringInputConnector; @@ -9,6 +10,7 @@ import com.programaker.api.ProgramakerApi; public interface PartGrid { Selectable getPartOn(int x, int y); ProgramakerApi getApi(); + SignalListenerManager getListenerManager(); SignalInputConnector getSignalInputConnectorOn(int x, int y); BooleanInputConnector getBooleanInputConnectorOn(int x, int y); diff --git a/app/src/main/java/com/codigoparallevar/minicards/types/functional/Tuple2.java b/app/src/main/java/com/codigoparallevar/minicards/types/functional/Tuple2.java index 9835e58..6832492 100644 --- a/app/src/main/java/com/codigoparallevar/minicards/types/functional/Tuple2.java +++ b/app/src/main/java/com/codigoparallevar/minicards/types/functional/Tuple2.java @@ -8,4 +8,43 @@ public class Tuple2 { this.item1 = item1; this.item2 = item2; } + + @Override + public int hashCode() { + int hash1 = this.item1 == null ? 1 : this.item1.hashCode(); + int hash2 = this.item2 == null ? 2 : this.item2.hashCode(); + + return hash1 ^ hash2; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof Tuple2)) { + return false; + } + + Tuple2 other = (Tuple2) obj; + if (other.item1 == null) { + if (this.item1 != null) { + return false; + } + } + else { + if (!other.item1.equals(this.item1)) { + return false; + } + } + + if (other.item2 == null) { + if (this.item2 != null) { + return false; + } + } + else { + if (!other.item2.equals(this.item2)) { + return false; + } + } + return true; + } } diff --git a/app/src/main/java/com/programaker/api/ProgramakerApi.kt b/app/src/main/java/com/programaker/api/ProgramakerApi.kt index 5bab848..2b5de80 100644 --- a/app/src/main/java/com/programaker/api/ProgramakerApi.kt +++ b/app/src/main/java/com/programaker/api/ProgramakerApi.kt @@ -222,6 +222,18 @@ class ProgramakerApi(private val ApiRoot: String="https://programaker.com/api") } } + fun openChannelTo(bridgeId: String, key: String, + listener: ProgramakerSignalListener, + onDisconnectCallback: Runnable): ProgramakerListeningChannel { + val channel = ProgramakerListeningChannel( + bridgeId, key, + this.token!!, + getListenSignalUrl(bridgeId, key), + listener, onDisconnectCallback) + channel.start() + return channel + } + // Private functions private fun getCheckUrl(): String { return "$ApiRoot/v0/sessions/check" @@ -261,6 +273,11 @@ class ProgramakerApi(private val ApiRoot: String="https://programaker.com/api") return "$ApiRoot/v0/users/$userName/services/id/$bridgeId/register" } + private fun getListenSignalUrl(bridgeId: String, key: String): String { + this.withUserId() + return "$ApiRoot/v0/users/id/$userId/bridges/id/$bridgeId/signals/$key" + } + fun getUserId(): String? { this.withUserId() return userId; diff --git a/app/src/main/java/com/programaker/api/ProgramakerListeningChannel.kt b/app/src/main/java/com/programaker/api/ProgramakerListeningChannel.kt new file mode 100644 index 0000000..90890be --- /dev/null +++ b/app/src/main/java/com/programaker/api/ProgramakerListeningChannel.kt @@ -0,0 +1,78 @@ +package com.programaker.api + +import android.util.Log +import com.google.gson.Gson +import okhttp3.* +import okio.ByteString +import org.json.JSONException +import java.nio.charset.Charset +import java.util.concurrent.TimeUnit + +class ProgramakerListeningChannel( + private val bridgeId: String, + private val key: String, + private val token: String, + private val url: String, + private val listener: ProgramakerSignalListener, + private var onDisconnect: Runnable +): WebSocketListener() { + private var webSocket: WebSocket? = null + private val utf8: Charset = Charset.forName("UTF-8") + private val gson = Gson() + + private val LogTag: String = "PM-ListeningChannel" + private val PING_PERIOD_MILLIS: Long = 15000 // 15seconds + + fun start() { + val client: OkHttpClient = OkHttpClient.Builder() + .pingInterval(PING_PERIOD_MILLIS, TimeUnit.MILLISECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .build() + + val request: Request = Request.Builder() + .url(url) + .addHeader("Authorization", token) + .build() + client.newWebSocket(request, this) + + // Trigger shutdown of the dispatcher's executor so this process can exit cleanly. + client.dispatcher.executorService.shutdown() + } + + // Websocket management + override fun onOpen(webSocket: WebSocket, response: Response) { + this.webSocket = webSocket + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + onMessage(webSocket, bytes.string(utf8)) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + Log.d(LogTag, "Message: $text") + val json = gson.fromJson(text, HashMap::class.java) + if (json == null) { + this.onFailure(webSocket, JSONException("Error decoding: $text"), null) + } + else { + this.listener.onNewSignal(bridgeId, key, json) + } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(1000, null) + Log.i(LogTag, "Closing bridge socket {code=$code, reason=$reason}") + onDisconnect.run() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e(LogTag, "Error: $t", t) + onDisconnect.run() + } + + fun stop() { + onDisconnect = Runnable { } // Skip disconnection procedure + webSocket?.close(1000, null) + webSocket = null + } +} diff --git a/app/src/main/java/com/programaker/api/ProgramakerSignalListener.kt b/app/src/main/java/com/programaker/api/ProgramakerSignalListener.kt new file mode 100644 index 0000000..a5c8577 --- /dev/null +++ b/app/src/main/java/com/programaker/api/ProgramakerSignalListener.kt @@ -0,0 +1,7 @@ +package com.programaker.api + +import java.util.HashMap + +interface ProgramakerSignalListener { + fun onNewSignal(bridgeId: String, key: String, signal: HashMap<*, *>) +} diff --git a/app/src/main/java/com/programaker/api/data/ProgramakerCustomBlock.kt b/app/src/main/java/com/programaker/api/data/ProgramakerCustomBlock.kt index fb1050c..0510af5 100644 --- a/app/src/main/java/com/programaker/api/data/ProgramakerCustomBlock.kt +++ b/app/src/main/java/com/programaker/api/data/ProgramakerCustomBlock.kt @@ -11,7 +11,9 @@ class ProgramakerCustomBlock( val block_type: String?, val block_result_type: String?, var bridge_id: String?, - val save_to: ProgramakerCustomBlockSaveTo? + val save_to: ProgramakerCustomBlockSaveTo?, + var key: String?, + val subkey: ProgramakerCustomBlockSubkeyDefinition? ) { fun serialize(): JSONObject { @@ -33,10 +35,17 @@ class ProgramakerCustomBlock( "message" to message, "arguments" to serializedArguments, "block_type" to block_type, - "block_result_type" to block_result_type, "bridge_id" to bridge_id, "save_to" to saveToSerialized - ); + ) + + if (key != null) { + serialized.put("key", key) + serialized.put("subkey", subkey?.serialize()) + } + if (block_result_type != null || key == null) { + serialized.put("block_result_type", block_result_type) + } return JSONObject(serialized as Map<*, *>) } @@ -49,9 +58,11 @@ class ProgramakerCustomBlock( obj.getString("message"), ProgramakerCustomBlockArgument.deserialize(obj.optJSONArray("arguments")), obj.getString("block_type"), - obj.getString("block_result_type"), + obj.optString("block_result_type"), obj.optString("bridge_id"), - ProgramakerCustomBlockSaveTo.deserialize(obj.optJSONObject("save_to")) + ProgramakerCustomBlockSaveTo.deserialize(obj.optJSONObject("save_to")), + obj.optString("key"), + ProgramakerCustomBlockSubkeyDefinition.deserialize(obj.optJSONObject("subkey")) ) return block diff --git a/app/src/main/java/com/programaker/api/data/ProgramakerCustomBlockSaveTo.kt b/app/src/main/java/com/programaker/api/data/ProgramakerCustomBlockSaveTo.kt index e359c33..cee0a04 100644 --- a/app/src/main/java/com/programaker/api/data/ProgramakerCustomBlockSaveTo.kt +++ b/app/src/main/java/com/programaker/api/data/ProgramakerCustomBlockSaveTo.kt @@ -24,5 +24,4 @@ class ProgramakerCustomBlockSaveTo ( save_to.getInt("index")) } } - } diff --git a/app/src/main/java/com/programaker/api/data/ProgramakerCustomBlockSubkeyDefinition.kt b/app/src/main/java/com/programaker/api/data/ProgramakerCustomBlockSubkeyDefinition.kt new file mode 100644 index 0000000..db2cabe --- /dev/null +++ b/app/src/main/java/com/programaker/api/data/ProgramakerCustomBlockSubkeyDefinition.kt @@ -0,0 +1,29 @@ +package com.programaker.api.data + +import org.json.JSONObject + +class ProgramakerCustomBlockSubkeyDefinition( + val type: String, + val value: String +) { + fun serialize(): JSONObject { + return JSONObject(hashMapOf( + "type" to type, + "value" to value + ) as Map<*, *>) + } + + companion object { + @JvmStatic + fun deserialize(subkey: JSONObject?): ProgramakerCustomBlockSubkeyDefinition? { + if (subkey == null) { + return null; + } + + return ProgramakerCustomBlockSubkeyDefinition( + subkey.getString("type"), + subkey.getString("value")) + } + } + +}