diff --git a/app/build.gradle b/app/build.gradle index bef0b57..f2090bd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -41,6 +41,8 @@ dependencies { implementation 'androidx.cardview:cardview:1.0.0' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'com.google.code.gson:gson:2.8.6' + + implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.7.2' } repositories { mavenCentral() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aa60a2f..b5ca93e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ android:required="true" /> + , List>>() .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, - new Tuple3<>(new Producer, List>>() { - @Override - public Tuple2, List> get() { - return new Tuple2<>( - CardActivity.this.ProgramakerApi.fetchConnectedBridges(), - CardActivity.this.ProgramakerApi.fetchCustomBlocks()); - } - }, new Consumer,List>>() { - @Override - public void apply(Tuple2,List> result) { - partsHolder.addCustomBlocks(result.item1, result.item2); - Log.d("CARDActivity", "custom blocks: " + result.toString()); - } - }, new Consumer () { - @Override - public void apply(Throwable exception) { - Log.e("CARDActivity", "error retrieving custom blocks: " + exception.toString()); - } - })); - + new Tuple3<>(() -> + new Tuple2<>( + CardActivity.this.ProgramakerApi.fetchConnectedBridges(), + CardActivity.this.ProgramakerApi.fetchCustomBlocks()), + result -> { + partsHolder.addCustomBlocks(result.item1, result.item2); + Log.d("CARDActivity", "custom blocks: " + result.toString()); + }, + exception -> Log.e("CARDActivity", "error retrieving custom blocks: " + exception.toString()))); // Hide action bar ActionBar actionBar = getSupportActionBar(); diff --git a/app/src/main/java/com/codigoparallevar/minicards/ConfigManager.java b/app/src/main/java/com/codigoparallevar/minicards/ConfigManager.java index c30d300..447f22a 100644 --- a/app/src/main/java/com/codigoparallevar/minicards/ConfigManager.java +++ b/app/src/main/java/com/codigoparallevar/minicards/ConfigManager.java @@ -2,14 +2,14 @@ package com.codigoparallevar.minicards; import android.content.Context; import android.content.SharedPreferences; -import android.view.View; import static android.content.Context.MODE_PRIVATE; public class ConfigManager { private final Context context; - private static final String TOKEN_KEY = "PROGRAMAKER_API_TOKEN"; private static final String PREFERENCES_NAME = "MINICARDS_PREFERENCES"; + private static final String TOKEN_KEY = "PROGRAMAKER_API_TOKEN"; + private static final String BRIDGE_ID_KEY = "PROGRAMAKER_BRIDGE_ID"; public ConfigManager(Context ctx) { this.context = ctx; @@ -20,16 +20,33 @@ public class ConfigManager { SharedPreferences.Editor edit = preferences.edit(); edit.putString(TOKEN_KEY, token); - edit.commit(); + edit.apply(); } public String getToken() { SharedPreferences preferences = this.context.getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE); - if (!preferences.contains(TOKEN_KEY)) { - return null; - } - else { - return preferences.getString(TOKEN_KEY, null); + return preferences.getString(TOKEN_KEY, null); + } + + public void removeBridgeId() { + SharedPreferences preferences = this.context.getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE); + SharedPreferences.Editor edit = preferences.edit(); + + if (preferences.contains(BRIDGE_ID_KEY)) { + edit.remove(BRIDGE_ID_KEY).apply(); } } + + public void setBridgeId(String bridgeId) { + SharedPreferences preferences = this.context.getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE); + SharedPreferences.Editor edit = preferences.edit(); + + edit.putString(BRIDGE_ID_KEY, bridgeId); + edit.commit(); + } + + public String getBridgeId() { + SharedPreferences preferences = this.context.getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE); + return preferences.getString(BRIDGE_ID_KEY, null); + } } diff --git a/app/src/main/java/com/codigoparallevar/minicards/DeckPreviewActivity.java b/app/src/main/java/com/codigoparallevar/minicards/DeckPreviewActivity.java index de62056..194924d 100644 --- a/app/src/main/java/com/codigoparallevar/minicards/DeckPreviewActivity.java +++ b/app/src/main/java/com/codigoparallevar/minicards/DeckPreviewActivity.java @@ -4,14 +4,6 @@ import android.app.Dialog; import android.content.DialogInterface; import android.os.AsyncTask; import android.os.Bundle; - -import com.codigoparallevar.minicards.types.functional.Consumer; -import com.codigoparallevar.minicards.types.functional.Tuple2; -import com.codigoparallevar.minicards.types.functional.Tuple3; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.widget.Toolbar; - import android.text.Editable; import android.text.TextWatcher; import android.util.Log; @@ -23,19 +15,32 @@ import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; + +import com.codigoparallevar.minicards.bridge.ProgramakerAndroidBridge; +import com.codigoparallevar.minicards.types.functional.Consumer; +import com.codigoparallevar.minicards.types.functional.Tuple2; +import com.codigoparallevar.minicards.types.functional.Tuple3; +import com.codigoparallevar.minicards.ui_helpers.DoAsync; +import com.codigoparallevar.minicards.ui_helpers.GetAsync; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.programaker.api.ProgramakerApi; + import java.io.File; import java.io.IOException; import java.util.List; import java.util.Vector; -import com.programaker.api.ProgramakerApi; - public class DeckPreviewActivity extends ReloadableAppCompatActivity { public static final String INTENT = "com.codigoparallevar.minicards.DECK"; + private static final String LogTag = "DeckPreview"; + private ListView listView; private CardPreviewArrayAdapter cardArrayAdapter; private ProgramakerApi ProgramakerApi; private ConfigManager Config; + private ProgramakerAndroidBridge bridge = null; protected void openLoginDialog(View view) { AlertDialog.Builder builder = new AlertDialog.Builder(this); @@ -92,22 +97,20 @@ public class DeckPreviewActivity extends ReloadableAppCompatActivity { new Tuple3<>( loginUsernameText.getText().toString(), loginPasswordText.getText().toString(), - new Consumer() { - public void apply(String token) { - if (token == null) { - messageLabel.setText(R.string.invalid_user_pass); - } else { - DeckPreviewActivity.this.Config.setToken(token); - DeckPreviewActivity.this.ProgramakerApi.setToken(token); - final Button loginToProgramakerButton = findViewById(R.id.login_in_programaker_button); + token -> { + if (token == null) { + messageLabel.setText(R.string.invalid_user_pass); + } else { + DeckPreviewActivity.this.Config.setToken(token); + DeckPreviewActivity.this.ProgramakerApi.setToken(token); + final Button loginToProgramakerButton = findViewById(R.id.login_in_programaker_button); - loginToProgramakerButton.setVisibility(View.GONE); - // Re-check... just in case - new CheckNeededLoginButton().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, - new Tuple2<>(DeckPreviewActivity.this.ProgramakerApi, - loginToProgramakerButton)); - dialog.cancel(); - } + loginToProgramakerButton.setVisibility(View.GONE); + // Re-check... just in case + checkNeededLoginButton( + DeckPreviewActivity.this.ProgramakerApi, + loginToProgramakerButton); + dialog.cancel(); } })); } @@ -119,15 +122,14 @@ public class DeckPreviewActivity extends ReloadableAppCompatActivity { @Override protected Tuple2> doInBackground(Tuple3>... tuples) { ProgramakerApi api = new ProgramakerApi(); - boolean logged = false; String token = null; try { token = api.login(tuples[0]._x, tuples[0]._y); } catch (Exception e) { - Log.e("Login to PrograMaker", e.toString()); + Log.e("Login to PrograMaker", e.toString(), e); } - return new Tuple2>(token, tuples[0]._z); + return new Tuple2<>(token, tuples[0]._z); } @Override @@ -136,20 +138,6 @@ public class DeckPreviewActivity extends ReloadableAppCompatActivity { } } - static class CheckNeededLoginButton extends AsyncTask, Void, Tuple2>{ - @Override - protected Tuple2 doInBackground(Tuple2... tuples) { - return new Tuple2<>(tuples[0].item1.check(), tuples[0].item2); - } - - @Override - protected void onPostExecute(Tuple2 result) { - if (!result.item1) { - result.item2.setVisibility(View.VISIBLE); - } - } - } - @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -183,14 +171,54 @@ public class DeckPreviewActivity extends ReloadableAppCompatActivity { loginButton.setVisibility(View.VISIBLE); } else { + this.ProgramakerApi.setToken(token); loginButton.setVisibility(View.GONE); // Double check that is not needed, token might have been deleted - new CheckNeededLoginButton().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, - new Tuple2<>(this.ProgramakerApi, loginButton)); + checkNeededLoginButton(this.ProgramakerApi, loginButton); } } + private void checkNeededLoginButton(ProgramakerApi api, Button loginButton) { + new GetAsync().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, + new Tuple3<>( + () -> api.check(), + result -> { + if (!result) { + loginButton.setVisibility(View.VISIBLE); + DeckPreviewActivity.this.Config.removeBridgeId(); + } else { + String bridgeId = DeckPreviewActivity.this.Config.getBridgeId(); + if (bridgeId == null) { + new GetAsync().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, + new Tuple3<>( + () -> api.createBridge(ProgramakerAndroidBridge.GetBridgeName(this)), + newBridgeId -> { + DeckPreviewActivity.this.Config.setBridgeId(newBridgeId); + this.bridge = ProgramakerAndroidBridge.configure(this, api.getUserId(), newBridgeId); + new DoAsync().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, + new Tuple2<>( + () -> this.bridge.start(), + ex -> Log.e(LogTag, "Error on bridge: " + ex, ex) + )); + }, + ex -> Log.e(LogTag, "Error creating bridge: " + ex, ex) + )); + } + else { + this.bridge = ProgramakerAndroidBridge.configure(this, api.getUserId(), bridgeId); + new DoAsync().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, + new Tuple2<>( + () -> this.bridge.start(), + ex -> Log.e(LogTag, "Error on bridge: " + ex, ex) + )); + } + } + }, + ex -> Log.e(LogTag, "Error checking API:" + ex, ex) + )); + } + @Override protected void onResume() { super.onResume(); diff --git a/app/src/main/java/com/codigoparallevar/minicards/bridge/ProgramakerAndroidBridge.java b/app/src/main/java/com/codigoparallevar/minicards/bridge/ProgramakerAndroidBridge.java new file mode 100644 index 0000000..3ef3705 --- /dev/null +++ b/app/src/main/java/com/codigoparallevar/minicards/bridge/ProgramakerAndroidBridge.java @@ -0,0 +1,52 @@ +package com.codigoparallevar.minicards.bridge; + +import android.content.Context; +import android.provider.Settings; + +import com.programaker.bridge.ProgramakerBridge; +import com.programaker.bridge.ProgramakerBridgeConfiguration; + +import java.util.Collections; + +public class ProgramakerAndroidBridge { + private static final String LogTag = "PM Android Bridge"; + private ProgramakerBridge bridgeRunner = null; + + // Static + public static ProgramakerAndroidBridge configure(Context ctx, String userId, String bridgeId) { + return new ProgramakerAndroidBridge(ctx, userId, bridgeId); + } + + public static String GetBridgeName(Context ctx) { + String deviceName = Settings.Secure.getString(ctx.getContentResolver(), "bluetooth_name"); + String serviceName = "MiniCards on " + deviceName; + return serviceName; + } + +// public static ProgramakerBridgeConfiguration GetConfiguration(Context ctx) { +// List blocks = new LinkedList<>(); +// return new ProgramakerBridgeConfiguration(serviceName, blocks); +// } + // Builder + + private final Context ctx; + private final String userId; + private final String bridgeId; + + private ProgramakerAndroidBridge(Context ctx, String userId, String bridgeId) { + this.ctx = ctx; + this.userId = userId; + this.bridgeId = bridgeId; + } + + public void start() { + ProgramakerBridgeConfiguration configuration = new ProgramakerBridgeConfiguration( + ProgramakerAndroidBridge.GetBridgeName(this.ctx), + Collections.emptyList() + ); + + this.bridgeRunner = new ProgramakerBridge(this.bridgeId, this.userId, configuration); + this.bridgeRunner.run(); + } + +} 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 0ffff73..92867e4 100644 --- a/app/src/main/java/com/codigoparallevar/minicards/parts/ProgramakerCustomBlockPart.java +++ b/app/src/main/java/com/codigoparallevar/minicards/parts/ProgramakerCustomBlockPart.java @@ -354,15 +354,15 @@ public class ProgramakerCustomBlockPart implements Part { ProgramakerCustomBlockPart.this.runBlockOperation(); ProgramakerCustomBlockPart.this.freeBlock(token); - }, param -> { + }, ex -> { Log.e(LogTag, "Error executing function=" + this._block.getFunction_name() - + "; Error=" + param); + + "; Error=" + ex, ex); ProgramakerCustomBlockPart.this.freeBlock(token); })); } catch (Exception ex) { Log.e(LogTag, "Error executing function=" + this._block.getFunction_name() - + "; Error=" + ex); + + "; Error=" + ex, ex); this.freeBlock(token); } } else { @@ -444,7 +444,7 @@ public class ProgramakerCustomBlockPart implements Part { index = Integer.parseInt(chunks[1]); } catch (NumberFormatException ex) { - Log.e(LogTag, "Error parsing connector id="+inputConnectorId); + Log.e(LogTag, "Error parsing connector id="+inputConnectorId, ex); } if (index != null && index < inputConnectors.size()) { diff --git a/app/src/main/java/com/programaker/api/ProgramakerApi.kt b/app/src/main/java/com/programaker/api/ProgramakerApi.kt index aad148e..b6da424 100644 --- a/app/src/main/java/com/programaker/api/ProgramakerApi.kt +++ b/app/src/main/java/com/programaker/api/ProgramakerApi.kt @@ -107,7 +107,7 @@ class ProgramakerApi(private val ApiRoot: String="https://programaker.com/api") throw ProgramakerProtocolException() } catch (ex: Exception) { - Log.e(LogTag, "Unexpected exception: " + ex) + Log.e(LogTag, "Unexpected exception: " + ex, ex) throw ProgramakerProtocolException() } return result.result @@ -137,6 +137,7 @@ class ProgramakerApi(private val ApiRoot: String="https://programaker.com/api") } return result.result } + fun callBlock(block: ProgramakerCustomBlock, arguments: List): ProgramakerFunctionCallResult { val conn = URL(getBlockUrl(block)).openConnection() as HttpURLConnection conn.setRequestProperty("Content-Type", "application/json") @@ -159,6 +160,28 @@ class ProgramakerApi(private val ApiRoot: String="https://programaker.com/api") return result } + fun createBridge(name: String): String { + val conn = URL(getCreateBridgeUrl()).openConnection() as HttpURLConnection + conn.setRequestProperty("Content-Type", "application/json") + addAuthHeader(conn) + + conn.requestMethod = "POST"; + conn.doOutput = true; + + val postData = JSONObject(hashMapOf( + "name" to name + ) as Map<*, *>) + + val wr = DataOutputStream(conn.outputStream) + wr.writeBytes(postData.toString()); + wr.flush(); + wr.close(); + + val result: ProgramakerCreateBridgeResult + result = parseJson(conn.inputStream, ProgramakerCreateBridgeResult::class.java) + return result.getBridgeId() + } + // Initialization init { // Disable connection reuse if necessary @@ -192,6 +215,16 @@ class ProgramakerApi(private val ApiRoot: String="https://programaker.com/api") return "$ApiRoot/v0/users/id/$userId/bridges/id/${block.bridge_id}/functions/${block.function_name}" } + private fun getCreateBridgeUrl(): String { + this.withUserName() + return "$ApiRoot/v0/users/$userName/bridges" + } + + fun getUserId(): String? { + this.withUserId() + return userId; + } + private fun withUserName() { if (userName == null) { if (token == null) { @@ -240,6 +273,6 @@ private fun FileNotFoundException.logError(tag: String) { class JsonParseException(private val content: String, private val cls: Type) : Exception() { fun logError(tag: String) { - Log.e(tag, "Cannot JSON parse: ${this.content} as ${this.cls}") + Log.e(tag, "Cannot JSON parse: ${this.content} as ${this.cls}", this) } } diff --git a/app/src/main/java/com/programaker/api/data/api_results/ProgramakerCreateBridgeResult.kt b/app/src/main/java/com/programaker/api/data/api_results/ProgramakerCreateBridgeResult.kt new file mode 100644 index 0000000..4d96e44 --- /dev/null +++ b/app/src/main/java/com/programaker/api/data/api_results/ProgramakerCreateBridgeResult.kt @@ -0,0 +1,17 @@ +package com.programaker.api.data.api_results + +class ProgramakerCreateBridgeResult ( + val control_url: String +) { + fun getBridgeId(): String { + val url = control_url.trim('/'); + if (url.endsWith("communication")) { + val no_communication = url.substring(0, url.length - "communication".length); + val chunks = no_communication.trim('/').split("/"); + return chunks[chunks.size - 1] + } + else { + throw IllegalArgumentException("Unknown control_url format") + } + } +} diff --git a/app/src/main/java/com/programaker/api/exceptions/ProgramakerProtocolException.kt b/app/src/main/java/com/programaker/api/exceptions/ProgramakerProtocolException.kt index de1a765..6e4f78b 100644 --- a/app/src/main/java/com/programaker/api/exceptions/ProgramakerProtocolException.kt +++ b/app/src/main/java/com/programaker/api/exceptions/ProgramakerProtocolException.kt @@ -1,5 +1,6 @@ package com.programaker.api.exceptions -class ProgramakerProtocolException : Throwable() { +import java.lang.Exception +class ProgramakerProtocolException() : Exception() { } diff --git a/app/src/main/java/com/programaker/bridge/ProgramakerBridge.kt b/app/src/main/java/com/programaker/bridge/ProgramakerBridge.kt new file mode 100644 index 0000000..b182831 --- /dev/null +++ b/app/src/main/java/com/programaker/bridge/ProgramakerBridge.kt @@ -0,0 +1,59 @@ +package com.programaker.bridge + +import android.util.Log +import okhttp3.* +import okio.ByteString +import java.util.concurrent.TimeUnit + + +class ProgramakerBridge( + val bridge_id: String, + val user_id: String, + val config: ProgramakerBridgeConfiguration +) : WebSocketListener() { + private val PING_PERIOD_MILLIS: Long = 15000 + private val PING_TIMEOUT_MILLIS: Long = 15000 + private val apiRoot: String = "wss://programaker.com/api" + private val LogTag = "ProgramakerBridge" + + fun run() { + val client: OkHttpClient = OkHttpClient.Builder() + .pingInterval(PING_PERIOD_MILLIS, TimeUnit.MILLISECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .build() + + val request: Request = Request.Builder() + .url(getBridgeControlUrl()) + .build() + client.newWebSocket(request, this) + + // Trigger shutdown of the dispatcher's executor so this process can exit cleanly. + client.dispatcher.executorService.shutdown() + } + + + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send(config.serialize()) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + println("MESSAGE: $text") + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + println("MESSAGE: " + bytes.hex()) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(1000, null) + Log.i(LogTag, "Closing bridge socket {code=$code, reason=$reason}") + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e(LogTag, t.toString(), t) + } + + private fun getBridgeControlUrl(): String { + return "$apiRoot/v0/users/id/$user_id/bridges/id/$bridge_id/communication" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/programaker/bridge/ProgramakerBridgeConfiguration.kt b/app/src/main/java/com/programaker/bridge/ProgramakerBridgeConfiguration.kt new file mode 100644 index 0000000..bd2dd06 --- /dev/null +++ b/app/src/main/java/com/programaker/bridge/ProgramakerBridgeConfiguration.kt @@ -0,0 +1,35 @@ +package com.programaker.bridge + +import org.json.JSONObject + +class ProgramakerBridgeConfiguration( + val service_name: String, + val blocks: List + // val is_public: boolean, // No reason for this use case + // val registration: ???, // No reason for this use case + // val allow_multiple_connections: boolean // No reason for this use case + // val icon: ???, // Not supported yet // TODO: Add support +) { + fun serialize(): String { + var serializedBlocks = listOf() + if (blocks != null) { + serializedBlocks = blocks.map { it.serialize() } + } + + val config = JSONObject(hashMapOf( + "service_name" to service_name, + "is_public" to false, + "registration" to null, + "allow_multiple_connections" to null, + "icon" to null, + "blocks" to serializedBlocks + ) as Map<*, *>) + + val wrapper = JSONObject(hashMapOf( + "type" to "CONFIGURATION", + "value" to config + ) as Map<*, *>) + + return wrapper.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/programaker/bridge/ProgramakerBridgeConfigurationBlock.kt b/app/src/main/java/com/programaker/bridge/ProgramakerBridgeConfigurationBlock.kt new file mode 100644 index 0000000..83a909a --- /dev/null +++ b/app/src/main/java/com/programaker/bridge/ProgramakerBridgeConfigurationBlock.kt @@ -0,0 +1,11 @@ +package com.programaker.bridge + +import org.json.JSONObject + +class ProgramakerBridgeConfigurationBlock { + fun serialize() : JSONObject { + val obj = JSONObject(); + return obj; + } + +} diff --git a/build.gradle b/build.gradle index 3c8f96b..d301845 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ buildscript { ext.kotlin_version = '1.3.61' + ext.ktor_version = '1.3.0' repositories { google()