diff --git a/android/app/build.gradle b/android/app/build.gradle index 016650c..e728737 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -158,6 +158,7 @@ android { dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") + implementation("com.google.android.gms:play-services-wearable:20.0.1") def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; diff --git a/android/app/src/main/java/com/colonelpanic/mova/MainApplication.kt b/android/app/src/main/java/com/colonelpanic/mova/MainApplication.kt index e706f9a..dd2b823 100644 --- a/android/app/src/main/java/com/colonelpanic/mova/MainApplication.kt +++ b/android/app/src/main/java/com/colonelpanic/mova/MainApplication.kt @@ -26,6 +26,7 @@ class MainApplication : Application(), ReactApplication { PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example: add(SharedStoragePackage()) + add(WearSyncPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/android/app/src/main/java/com/colonelpanic/mova/WearSyncModule.kt b/android/app/src/main/java/com/colonelpanic/mova/WearSyncModule.kt new file mode 100644 index 0000000..485f858 --- /dev/null +++ b/android/app/src/main/java/com/colonelpanic/mova/WearSyncModule.kt @@ -0,0 +1,55 @@ +package com.colonelpanic.mova + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.google.android.gms.wearable.PutDataMapRequest +import com.google.android.gms.wearable.Wearable + +private const val CONFIG_PATH = "/mova/config" + +class WearSyncModule( + private val reactContext: ReactApplicationContext, +) : ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = "WearSync" + + @ReactMethod + fun syncCredentials( + apiUrl: String, + username: String, + password: String, + promise: Promise, + ) { + val request = PutDataMapRequest.create(CONFIG_PATH).apply { + dataMap.putBoolean("configured", true) + dataMap.putString("apiUrl", apiUrl) + dataMap.putString("username", username) + dataMap.putString("password", password) + dataMap.putLong("updatedAt", System.currentTimeMillis()) + }.asPutDataRequest().setUrgent() + + Wearable.getDataClient(reactContext) + .putDataItem(request) + .addOnSuccessListener { promise.resolve(null) } + .addOnFailureListener { error -> + promise.reject("WEAR_SYNC_FAILED", error) + } + } + + @ReactMethod + fun clearCredentials(promise: Promise) { + val request = PutDataMapRequest.create(CONFIG_PATH).apply { + dataMap.putBoolean("configured", false) + dataMap.putLong("updatedAt", System.currentTimeMillis()) + }.asPutDataRequest().setUrgent() + + Wearable.getDataClient(reactContext) + .putDataItem(request) + .addOnSuccessListener { promise.resolve(null) } + .addOnFailureListener { error -> + promise.reject("WEAR_SYNC_FAILED", error) + } + } +} diff --git a/android/app/src/main/java/com/colonelpanic/mova/WearSyncPackage.kt b/android/app/src/main/java/com/colonelpanic/mova/WearSyncPackage.kt new file mode 100644 index 0000000..22e345c --- /dev/null +++ b/android/app/src/main/java/com/colonelpanic/mova/WearSyncPackage.kt @@ -0,0 +1,16 @@ +package com.colonelpanic.mova + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class WearSyncPackage : ReactPackage { + override fun createNativeModules( + reactContext: ReactApplicationContext, + ): MutableList = mutableListOf(WearSyncModule(reactContext)) + + override fun createViewManagers( + reactContext: ReactApplicationContext, + ): MutableList> = mutableListOf() +} diff --git a/android/settings.gradle b/android/settings.gradle index 91cedda..53ca8be 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -36,6 +36,7 @@ rootProject.name = 'mova' expoAutolinking.useExpoVersionCatalog() include ':app' +include ':wear' includeBuild(expoAutolinking.reactNativeGradlePlugin) include ':detox' diff --git a/android/wear/build.gradle b/android/wear/build.gradle new file mode 100644 index 0000000..4d15993 --- /dev/null +++ b/android/wear/build.gradle @@ -0,0 +1,41 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" + +def packageJson = new groovy.json.JsonSlurper().parse(file("../../package.json")) + +android { + namespace "com.colonelpanic.mova.wear" + compileSdk rootProject.ext.compileSdkVersion + + defaultConfig { + applicationId "com.colonelpanic.mova" + minSdkVersion 26 + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName packageJson.version + } + + signingConfigs { + debug { + storeFile file("../app/debug.keystore") + storePassword "android" + keyAlias "androiddebugkey" + keyPassword "android" + } + } + + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + signingConfig signingConfigs.debug + minifyEnabled false + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + } +} + +dependencies { + implementation("com.google.android.gms:play-services-wearable:20.0.1") +} diff --git a/android/wear/proguard-rules.pro b/android/wear/proguard-rules.pro new file mode 100644 index 0000000..f8a2c9d --- /dev/null +++ b/android/wear/proguard-rules.pro @@ -0,0 +1,2 @@ +# Keep the Wear module small for now. Add rules here if release minification is +# enabled later. diff --git a/android/wear/src/main/AndroidManifest.xml b/android/wear/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4733c59 --- /dev/null +++ b/android/wear/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/wear/src/main/java/com/colonelpanic/mova/wear/CaptureClient.kt b/android/wear/src/main/java/com/colonelpanic/mova/wear/CaptureClient.kt new file mode 100644 index 0000000..1f24a47 --- /dev/null +++ b/android/wear/src/main/java/com/colonelpanic/mova/wear/CaptureClient.kt @@ -0,0 +1,57 @@ +package com.colonelpanic.mova.wear + +import android.util.Base64 +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +data class CaptureResult( + val success: Boolean, + val message: String, +) + +object CaptureClient { + fun capture(credentials: WearCredentials, text: String): CaptureResult { + val connection = try { + URL("${credentials.apiUrl}/capture").openConnection() as HttpURLConnection + } catch (error: Exception) { + return CaptureResult(false, "Invalid server URL") + } + + return try { + connection.requestMethod = "POST" + connection.connectTimeout = 8000 + connection.readTimeout = 12000 + connection.doOutput = true + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("Authorization", credentials.authHeader()) + + val body = JSONObject() + .put("template", "default") + .put( + "values", + JSONObject().put("Title", text), + ) + .toString() + + connection.outputStream.use { stream -> + stream.write(body.toByteArray(Charsets.UTF_8)) + } + + when (val responseCode = connection.responseCode) { + in 200..299 -> CaptureResult(true, "Captured") + 401 -> CaptureResult(false, "Authentication failed") + else -> CaptureResult(false, "Server error $responseCode") + } + } catch (error: Exception) { + CaptureResult(false, "Network error") + } finally { + connection.disconnect() + } + } + + private fun WearCredentials.authHeader(): String { + val token = "$username:$password" + return "Basic " + Base64.encodeToString(token.toByteArray(), Base64.NO_WRAP) + } +} diff --git a/android/wear/src/main/java/com/colonelpanic/mova/wear/ConfigListenerService.kt b/android/wear/src/main/java/com/colonelpanic/mova/wear/ConfigListenerService.kt new file mode 100644 index 0000000..309e752 --- /dev/null +++ b/android/wear/src/main/java/com/colonelpanic/mova/wear/ConfigListenerService.kt @@ -0,0 +1,37 @@ +package com.colonelpanic.mova.wear + +import com.google.android.gms.wearable.DataEvent +import com.google.android.gms.wearable.DataEventBuffer +import com.google.android.gms.wearable.DataMapItem +import com.google.android.gms.wearable.WearableListenerService + +class ConfigListenerService : WearableListenerService() { + override fun onDataChanged(dataEvents: DataEventBuffer) { + try { + dataEvents + .filter { event -> + event.type == DataEvent.TYPE_CHANGED && + event.dataItem.uri.path == CONFIG_PATH + } + .forEach { event -> + val dataMap = DataMapItem.fromDataItem(event.dataItem).dataMap + val configured = dataMap.getBoolean("configured", false) + + if (!configured) { + MovaWearStorage.clearCredentials(this) + return@forEach + } + + val apiUrl = dataMap.getString("apiUrl") + val username = dataMap.getString("username") + val password = dataMap.getString("password") + + if (!apiUrl.isNullOrBlank() && !username.isNullOrBlank() && password != null) { + MovaWearStorage.saveCredentials(this, apiUrl, username, password) + } + } + } finally { + dataEvents.release() + } + } +} diff --git a/android/wear/src/main/java/com/colonelpanic/mova/wear/MainActivity.kt b/android/wear/src/main/java/com/colonelpanic/mova/wear/MainActivity.kt new file mode 100644 index 0000000..5b0dcbd --- /dev/null +++ b/android/wear/src/main/java/com/colonelpanic/mova/wear/MainActivity.kt @@ -0,0 +1,263 @@ +package com.colonelpanic.mova.wear + +import android.app.Activity +import android.content.Context +import android.os.Bundle +import android.text.InputType +import android.view.Gravity +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import com.google.android.gms.wearable.DataMapItem +import com.google.android.gms.wearable.Wearable + +class MainActivity : Activity() { + private lateinit var titleInput: EditText + private lateinit var submitButton: Button + private lateinit var retryButton: Button + private lateinit var statusText: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(buildContentView()) + + submitButton.setOnClickListener { + submitCurrentText() + } + retryButton.setOnClickListener { + processPendingTodos() + } + titleInput.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEND || actionId == EditorInfo.IME_ACTION_DONE) { + submitCurrentText() + true + } else { + false + } + } + } + + override fun onResume() { + super.onResume() + refreshStatus() + syncConfigFromDataLayer() + titleInput.requestFocus() + titleInput.postDelayed({ + val inputMethodManager = + getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.showSoftInput(titleInput, InputMethodManager.SHOW_IMPLICIT) + }, 250) + } + + private fun buildContentView(): ScrollView { + val padding = dp(18) + val root = ScrollView(this).apply { + setBackgroundColor(getColor(R.color.background)) + isFillViewport = true + } + + val content = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setPadding(padding, padding, padding, padding) + } + + val heading = TextView(this).apply { + text = "Mova" + textSize = 20f + setTextColor(getColor(R.color.text_primary)) + gravity = Gravity.CENTER + } + + statusText = TextView(this).apply { + textSize = 12f + setTextColor(getColor(R.color.text_secondary)) + gravity = Gravity.CENTER + setPadding(0, dp(6), 0, dp(10)) + } + + titleInput = EditText(this).apply { + hint = "Todo" + setSingleLine(true) + inputType = InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + imeOptions = EditorInfo.IME_ACTION_SEND + setTextColor(getColor(R.color.text_primary)) + setHintTextColor(getColor(R.color.text_secondary)) + setBackgroundColor(getColor(R.color.surface)) + setPadding(dp(12), 0, dp(12), 0) + } + + submitButton = Button(this).apply { + text = "Capture" + minHeight = dp(44) + } + + retryButton = Button(this).apply { + text = "Retry queued" + minHeight = dp(40) + } + + content.addView(heading, matchWrap()) + content.addView(statusText, matchWrap()) + content.addView(titleInput, matchFixedHeight(48)) + content.addView(submitButton, matchWrap(topMargin = dp(8))) + content.addView(retryButton, matchWrap(topMargin = dp(4))) + root.addView(content) + return root + } + + private fun submitCurrentText() { + val text = titleInput.text.toString().trim() + if (text.isBlank()) { + setStatus("Enter a todo") + return + } + + val credentials = MovaWearStorage.getCredentials(this) + if (credentials == null) { + MovaWearStorage.queueTodo(this, text) + titleInput.text.clear() + refreshStatus("Queued until phone syncs Mova settings") + return + } + + setBusy(true, "Capturing...") + Thread { + val result = CaptureClient.capture(credentials, text) + runOnUiThread { + setBusy(false) + if (result.success) { + titleInput.text.clear() + processPendingTodos("Captured") + } else { + MovaWearStorage.queueTodo(this, text) + titleInput.text.clear() + refreshStatus("${result.message}; queued") + } + } + }.start() + } + + private fun processPendingTodos(successPrefix: String? = null) { + val credentials = MovaWearStorage.getCredentials(this) + if (credentials == null) { + refreshStatus("Open Mova on phone to sync settings") + return + } + + val pending = MovaWearStorage.getPendingTodos(this) + if (pending.isEmpty()) { + refreshStatus(successPrefix) + return + } + + setBusy(true, "Retrying ${pending.size} queued") + Thread { + var captured = 0 + for (todo in pending) { + val result = CaptureClient.capture(credentials, todo.text) + if (result.success) { + MovaWearStorage.removePendingTodo(this, todo.timestamp) + captured += 1 + } else { + runOnUiThread { + setBusy(false) + refreshStatus("Retried $captured; ${result.message}") + } + return@Thread + } + } + + runOnUiThread { + setBusy(false) + refreshStatus( + when { + successPrefix != null && captured > 0 -> "$successPrefix; synced $captured queued" + successPrefix != null -> successPrefix + else -> "Synced $captured queued" + }, + ) + } + }.start() + } + + private fun refreshStatus(prefix: String? = null) { + val configured = MovaWearStorage.getCredentials(this) != null + val pendingCount = MovaWearStorage.getPendingTodos(this).size + val configText = if (configured) "Ready" else "Needs phone sync" + val queueText = if (pendingCount > 0) " ยท $pendingCount queued" else "" + setStatus(listOfNotNull(prefix, "$configText$queueText").joinToString("\n")) + } + + private fun syncConfigFromDataLayer() { + Wearable.getDataClient(this).dataItems + .addOnSuccessListener { dataItems -> + try { + dataItems + .filter { item -> item.uri.path == CONFIG_PATH } + .forEach { item -> + val dataMap = DataMapItem.fromDataItem(item).dataMap + val configured = dataMap.getBoolean("configured", false) + + if (!configured) { + MovaWearStorage.clearCredentials(this) + return@forEach + } + + val apiUrl = dataMap.getString("apiUrl") + val username = dataMap.getString("username") + val password = dataMap.getString("password") + + if (!apiUrl.isNullOrBlank() && !username.isNullOrBlank() && password != null) { + MovaWearStorage.saveCredentials(this, apiUrl, username, password) + } + } + } finally { + dataItems.release() + } + if ( + MovaWearStorage.getCredentials(this) != null && + MovaWearStorage.getPendingTodos(this).isNotEmpty() + ) { + processPendingTodos() + } else { + refreshStatus() + } + } + } + + private fun setBusy(isBusy: Boolean, status: String? = null) { + titleInput.isEnabled = !isBusy + submitButton.isEnabled = !isBusy + retryButton.isEnabled = !isBusy + if (status != null) { + setStatus(status) + } + } + + private fun setStatus(status: String) { + statusText.text = status + } + + private fun matchWrap(topMargin: Int = 0): LinearLayout.LayoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ).apply { + this.topMargin = topMargin + } + + private fun matchFixedHeight(height: Int): LinearLayout.LayoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + height, + ) + + private fun dp(value: Int): Int = + (value * resources.displayMetrics.density).toInt() +} diff --git a/android/wear/src/main/java/com/colonelpanic/mova/wear/MovaWearStorage.kt b/android/wear/src/main/java/com/colonelpanic/mova/wear/MovaWearStorage.kt new file mode 100644 index 0000000..6423ced --- /dev/null +++ b/android/wear/src/main/java/com/colonelpanic/mova/wear/MovaWearStorage.kt @@ -0,0 +1,107 @@ +package com.colonelpanic.mova.wear + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject + +private const val PREFS_NAME = "mova_wear_prefs" +private const val KEY_API_URL = "mova_api_url" +private const val KEY_USERNAME = "mova_username" +private const val KEY_PASSWORD = "mova_password" +private const val KEY_PENDING_TODOS = "mova_pending_todos" + +data class WearCredentials( + val apiUrl: String, + val username: String, + val password: String, +) + +data class PendingTodo( + val text: String, + val timestamp: Long, +) + +object MovaWearStorage { + fun saveCredentials( + context: Context, + apiUrl: String, + username: String, + password: String, + ) { + prefs(context).edit() + .putString(KEY_API_URL, apiUrl) + .putString(KEY_USERNAME, username) + .putString(KEY_PASSWORD, password) + .apply() + } + + fun clearCredentials(context: Context) { + prefs(context).edit() + .remove(KEY_API_URL) + .remove(KEY_USERNAME) + .remove(KEY_PASSWORD) + .apply() + } + + fun getCredentials(context: Context): WearCredentials? { + val prefs = prefs(context) + val apiUrl = prefs.getString(KEY_API_URL, null) + val username = prefs.getString(KEY_USERNAME, null) + val password = prefs.getString(KEY_PASSWORD, null) + + return if (!apiUrl.isNullOrBlank() && !username.isNullOrBlank() && password != null) { + WearCredentials(apiUrl, username, password) + } else { + null + } + } + + fun queueTodo(context: Context, text: String) { + val pending = getPendingTodos(context).toMutableList() + pending.add(PendingTodo(text, System.currentTimeMillis())) + savePendingTodos(context, pending) + } + + fun getPendingTodos(context: Context): List { + val raw = prefs(context).getString(KEY_PENDING_TODOS, null) ?: return emptyList() + return try { + val array = JSONArray(raw) + (0 until array.length()).mapNotNull { index -> + val item = array.optJSONObject(index) ?: return@mapNotNull null + val text = item.optString("text", "") + val timestamp = item.optLong("timestamp", 0L) + if (text.isBlank() || timestamp == 0L) { + null + } else { + PendingTodo(text, timestamp) + } + } + } catch (_: Exception) { + emptyList() + } + } + + fun removePendingTodo(context: Context, timestamp: Long) { + savePendingTodos( + context, + getPendingTodos(context).filter { it.timestamp != timestamp }, + ) + } + + private fun savePendingTodos(context: Context, todos: List) { + val array = JSONArray() + todos.forEach { todo -> + array.put( + JSONObject() + .put("text", todo.text) + .put("timestamp", todo.timestamp), + ) + } + prefs(context).edit() + .putString(KEY_PENDING_TODOS, array.toString()) + .apply() + } + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) +} diff --git a/android/wear/src/main/java/com/colonelpanic/mova/wear/WearConstants.kt b/android/wear/src/main/java/com/colonelpanic/mova/wear/WearConstants.kt new file mode 100644 index 0000000..595c46c --- /dev/null +++ b/android/wear/src/main/java/com/colonelpanic/mova/wear/WearConstants.kt @@ -0,0 +1,3 @@ +package com.colonelpanic.mova.wear + +const val CONFIG_PATH = "/mova/config" diff --git a/android/wear/src/main/res/drawable/ic_launcher.xml b/android/wear/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..4e37959 --- /dev/null +++ b/android/wear/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/wear/src/main/res/values/colors.xml b/android/wear/src/main/res/values/colors.xml new file mode 100644 index 0000000..9d7d639 --- /dev/null +++ b/android/wear/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + #101820 + #80CBC4 + #1B2630 + #243442 + #F6F8FA + #B9C2CA + diff --git a/android/wear/src/main/res/values/strings.xml b/android/wear/src/main/res/values/strings.xml new file mode 100644 index 0000000..6c24944 --- /dev/null +++ b/android/wear/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Mova + diff --git a/android/wear/src/main/res/values/styles.xml b/android/wear/src/main/res/values/styles.xml new file mode 100644 index 0000000..269575e --- /dev/null +++ b/android/wear/src/main/res/values/styles.xml @@ -0,0 +1,7 @@ + + + diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx index 412dfc5..ad16a99 100644 --- a/context/AuthContext.tsx +++ b/context/AuthContext.tsx @@ -136,6 +136,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const { apiUrl, username, password } = await getStoredCredentials(); if (apiUrl && username && password) { + await saveCredentialsToWidget(apiUrl, username, password); setState({ apiUrl, username, @@ -315,6 +316,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (id === activeServerId && state.isAuthenticated) { const updated = await findServerById(id); if (updated) { + await saveCredentialsToWidget( + updated.apiUrl, + updated.username, + updated.password, + ); setState((prev) => ({ ...prev, apiUrl: updated.apiUrl, diff --git a/widgets/storage.ts b/widgets/storage.ts index cdd50c4..b6109ca 100644 --- a/widgets/storage.ts +++ b/widgets/storage.ts @@ -1,7 +1,7 @@ import { NativeModules, Platform } from "react-native"; // Native SharedStorage module for cross-process data sharing -const { SharedStorage } = NativeModules; +const { SharedStorage, WearSync } = NativeModules; export interface PendingTodo { text: string; @@ -70,6 +70,15 @@ export async function saveCredentialsToWidget( error, ); } + + if (WearSync) { + try { + await WearSync.syncCredentials(apiUrl, username, password); + console.log("[Wear] Credentials synced to Wear OS"); + } catch (error) { + console.error("[Wear] Failed to sync credentials:", error); + } + } } /** @@ -87,6 +96,15 @@ export async function clearWidgetCredentials(): Promise { } catch (error) { console.error("[Widget] Failed to clear widget credentials:", error); } + + if (WearSync) { + try { + await WearSync.clearCredentials(); + console.log("[Wear] Credentials cleared on Wear OS"); + } catch (error) { + console.error("[Wear] Failed to clear credentials:", error); + } + } } /**