From 3580dc2ef8ff7cd63e7745291ea1423b34a99f52 Mon Sep 17 00:00:00 2001 From: Armand <4831c0@proton.me> Date: Sun, 1 Mar 2026 12:48:35 +0100 Subject: [PATCH] firka(android): Wear sync foreground service, getLocalizedString, no Android strings --- firka/android/app/build.gradle.kts | 1 + .../android/app/src/main/AndroidManifest.xml | 6 + .../kotlin/app/firka/naplo/MainActivity.kt | 52 +++++ .../firka/naplo/WearSyncForegroundService.kt | 221 ++++++++++++++++++ firka/lib/app/initialization_screen.dart | 3 + firka/lib/core/settings.dart | 5 +- firka/lib/services/watch_sync_helper.dart | 33 ++- 7 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 firka/android/app/src/main/kotlin/app/firka/naplo/WearSyncForegroundService.kt diff --git a/firka/android/app/build.gradle.kts b/firka/android/app/build.gradle.kts index 7544859c..6d79275b 100644 --- a/firka/android/app/build.gradle.kts +++ b/firka/android/app/build.gradle.kts @@ -76,6 +76,7 @@ android { } dependencies { implementation("androidx.glance:glance-appwidget:1.1.1") + implementation("com.google.android.gms:play-services-wearable:18.1.0") } // Ensure .env exists before Flutter bundles assets (copy from .env.example if missing) diff --git a/firka/android/app/src/main/AndroidManifest.xml b/firka/android/app/src/main/AndroidManifest.xml index 5e701594..82716111 100644 --- a/firka/android/app/src/main/AndroidManifest.xml +++ b/firka/android/app/src/main/AndroidManifest.xml @@ -5,12 +5,18 @@ + + + + when (call.method) { + "startWearSyncService" -> { + val args = call.arguments as? Map<*, *> + val cachePath = args?.get("cachePath") as? String + val appDirPath = args?.get("appDirPath") as? String + if (cachePath != null && appDirPath != null) { + val messenger = flutterEngine.dartExecutor.binaryMessenger + val ch = MethodChannel(messenger, wearSyncChannel) + ch.invokeMethod("getLocalizedString", "wearSyncNotificationTitle", object : MethodChannel.Result { + override fun success(titleResult: Any?) { + val title = titleResult as? String ?: "Syncing with watch" + ch.invokeMethod("getLocalizedString", "wearSyncNotificationText", object : MethodChannel.Result { + override fun success(textResult: Any?) { + val text = textResult as? String ?: "" + val intent = Intent(this@MainActivity, WearSyncForegroundService::class.java).apply { + action = WearSyncForegroundService.ACTION_START + putExtra(WearSyncForegroundService.EXTRA_CACHE_PATH, cachePath) + putExtra(WearSyncForegroundService.EXTRA_APP_DIR_PATH, appDirPath) + putExtra(WearSyncForegroundService.EXTRA_NOTIFICATION_TITLE, title) + putExtra(WearSyncForegroundService.EXTRA_NOTIFICATION_TEXT, text) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + result.success(null) + } + override fun error(code: String, msg: String?, details: Any?) { result.success(null) } + override fun notImplemented() { result.success(null) } + }) + } + override fun error(code: String, msg: String?, details: Any?) { result.error(code, msg, details) } + override fun notImplemented() { result.notImplemented() } + }) + } else { + result.error("INVALID_ARGS", "cachePath and appDirPath required", null) + } + } + "stopWearSyncService" -> { + val intent = Intent(this, WearSyncForegroundService::class.java).apply { + action = WearSyncForegroundService.ACTION_STOP + } + startService(intent) + result.success(null) + } + else -> result.notImplemented() + } + } + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel).setMethodCallHandler { call, result -> when (call.method) { diff --git a/firka/android/app/src/main/kotlin/app/firka/naplo/WearSyncForegroundService.kt b/firka/android/app/src/main/kotlin/app/firka/naplo/WearSyncForegroundService.kt new file mode 100644 index 00000000..6d4ac9c7 --- /dev/null +++ b/firka/android/app/src/main/kotlin/app/firka/naplo/WearSyncForegroundService.kt @@ -0,0 +1,221 @@ +package app.firka.naplo + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import java.io.ByteArrayInputStream +import java.io.ObjectInputStream +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.Wearable +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.embedding.engine.loader.FlutterLoader +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.delay + +/** + * Foreground service that keeps the app able to respond to Wear OS sync requests. + * When the watch sends request_sync, starts a Dart background isolate to fetch data, + * then reads the cache file and sends sync_data to the watch. + */ +class WearSyncForegroundService : Service(), MessageClient.OnMessageReceivedListener { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + private var cachePath: String? = null + private var appDirPath: String? = null + + private val channelId = "firka_wear_sync" + private val notificationId = 4001 + private var notificationTitle: String = "Syncing with watch" + private var notificationText: String = "" + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> { + cachePath = intent.getStringExtra(EXTRA_CACHE_PATH) + appDirPath = intent.getStringExtra(EXTRA_APP_DIR_PATH) + notificationTitle = intent.getStringExtra(EXTRA_NOTIFICATION_TITLE) ?: "Syncing with watch" + notificationText = intent.getStringExtra(EXTRA_NOTIFICATION_TEXT) ?: "" + startForegroundWithNotification() + Wearable.getMessageClient(this@WearSyncForegroundService) + .addListener(this@WearSyncForegroundService) + } + ACTION_STOP -> { + stopForegroundService() + return START_NOT_STICKY + } + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + try { + Wearable.getMessageClient(this@WearSyncForegroundService) + .removeListener(this@WearSyncForegroundService) + .addOnCompleteListener { } + } catch (_: Exception) { } + scope.cancel() + super.onDestroy() + } + + override fun onMessageReceived(messageEvent: MessageEvent) { + if (messageEvent.path != PATH_WATCH_CONNECTIVITY || + !isRequestSyncPayload(messageEvent.data) + ) return + val cPath = cachePath + val aPath = appDirPath + if (cPath == null || aPath == null) return + scope.launch { + runSyncInBackground(cPath, aPath) + } + } + + /** + * watch_connectivity plugin sends with path "watch_connectivity" and serializes the message + * map with Java ObjectOutputStream. Parse payload and check for id == "request_sync". + */ + private fun isRequestSyncPayload(data: ByteArray?): Boolean { + if (data == null || data.isEmpty()) return false + return try { + ObjectInputStream(ByteArrayInputStream(data)).use { ois -> + val map = ois.readObject() + if (map is Map<*, *>) map["id"] == "request_sync" else false + } + } catch (_: Exception) { + false + } + } + + private fun startForegroundWithNotification() { + val notification = buildNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(notificationId, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + @Suppress("DEPRECATION") + startForeground(notificationId, notification) + } + } + + private fun buildNotification(): Notification { + val pendingIntent = PendingIntent.getActivity( + this, + 0, + packageManager.getLaunchIntentForPackage(packageName), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, channelId) + .setContentTitle(notificationTitle) + .setContentText(notificationText) + .setSmallIcon(android.R.drawable.ic_menu_my_calendar) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + "Wear sync", + NotificationManager.IMPORTANCE_LOW + ).apply { setShowBadge(false) } + (getSystemService(NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(channel) + } + } + + private fun stopForegroundService() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + stopSelf() + } + + private suspend fun runSyncInBackground(cPath: String, aPath: String) = withContext(Dispatchers.Default) { + val flutterLoader = FlutterLoader() + if (!flutterLoader.initialized()) { + withContext(Dispatchers.Main) { + flutterLoader.startInitialization(applicationContext) + flutterLoader.ensureInitializationComplete(applicationContext, null) + } + } + val (engine, bgChannel) = withContext(Dispatchers.Main) { + val eng = FlutterEngine(applicationContext) + val entrypoint = DartExecutor.DartEntrypoint( + flutterLoader.findAppBundlePath(), + "package:firka/services/wear_sync_background.dart", + "wearSyncBackgroundEntrypoint" + ) + eng.dartExecutor.executeDartEntrypoint(entrypoint) + val ch = MethodChannel(eng.dartExecutor.binaryMessenger, "app.firka/wear_sync_background") + Pair(eng, ch) + } + val completer = CompletableDeferred() + delay(500) + withContext(Dispatchers.Main) { + bgChannel.invokeMethod("request_sync", mapOf( + "cachePath" to cPath, + "appDirPath" to aPath + ), object : MethodChannel.Result { + override fun success(result: Any?) { + completer.complete(Unit) + } + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + Log.e(TAG, "request_sync error: $errorCode $errorMessage") + completer.complete(Unit) + } + override fun notImplemented() { + completer.complete(Unit) + } + }) + } + try { + withTimeout(30_000) { + completer.await() + } + } catch (_: kotlinx.coroutines.TimeoutCancellationException) { + Log.w(TAG, "Wear sync isolate timed out") + } + withContext(Dispatchers.Main) { + engine.destroy() + } + } + + companion object { + private const val TAG = "WearSyncService" + const val ACTION_START = "app.firka.naplo.WearSyncForegroundService.START" + const val ACTION_STOP = "app.firka.naplo.WearSyncForegroundService.STOP" + const val EXTRA_CACHE_PATH = "cachePath" + const val EXTRA_APP_DIR_PATH = "appDirPath" + const val EXTRA_NOTIFICATION_TITLE = "notificationTitle" + const val EXTRA_NOTIFICATION_TEXT = "notificationText" + private const val PATH_WATCH_CONNECTIVITY = "watch_connectivity" + } +} diff --git a/firka/lib/app/initialization_screen.dart b/firka/lib/app/initialization_screen.dart index a27a2f49..3bf06d1b 100644 --- a/firka/lib/app/initialization_screen.dart +++ b/firka/lib/app/initialization_screen.dart @@ -70,6 +70,9 @@ class _InitializationScreenState extends State { FlutterNativeSplash.remove(); WatchSyncHelper.initialize(); + if (Platform.isAndroid) { + WatchSyncHelper.setWearSyncMethodCallHandler(); + } if (Platform.isIOS) { unawaited(() async { try { diff --git a/firka/lib/core/settings.dart b/firka/lib/core/settings.dart index db49b1a3..bf22f683 100644 --- a/firka/lib/core/settings.dart +++ b/firka/lib/core/settings.dart @@ -381,7 +381,10 @@ class SettingsStore { if (payload != null) { final path = await getWearSyncCachePath(); await writeWearSyncCache(path, payload); - await WatchSyncHelper.startWearSyncService(path); + await WatchSyncHelper.startWearSyncService( + path, + initData.appDir.path, + ); } } else { await WatchSyncHelper.stopWearSyncService(); diff --git a/firka/lib/services/watch_sync_helper.dart b/firka/lib/services/watch_sync_helper.dart index 11cd8596..ec2746d2 100644 --- a/firka/lib/services/watch_sync_helper.dart +++ b/firka/lib/services/watch_sync_helper.dart @@ -599,14 +599,43 @@ class WatchSyncHelper { } /// Starts the Wear sync foreground service (Android only). Call after writing initial cache. - static Future startWearSyncService(String cachePath) async { + /// [appDirPath] is the application documents directory path (for the background isolate). + static Future startWearSyncService( + String cachePath, + String appDirPath, + ) async { if (!Platform.isAndroid) return; await _wearSyncChannel.invokeMethod( 'startWearSyncService', - cachePath, + {'cachePath': cachePath, 'appDirPath': appDirPath}, ); } + /// Sets the method call handler for getLocalizedString (Android). Call once when initData is ready. + static void setWearSyncMethodCallHandler() { + if (!Platform.isAndroid) return; + _wearSyncChannel.setMethodCallHandler((MethodCall call) async { + if (call.method == 'getLocalizedString') { + final key = call.arguments as String?; + return getLocalizedString(key); + } + return null; + }); + } + + /// Returns the localized string for [key] from l10n. Used by Kotlin for notification title/text. + static String? getLocalizedString(String? key) { + if (key == null || !initDone) return null; + switch (key) { + case 'wearSyncNotificationTitle': + return initData.l10n.wearSyncNotificationTitle; + case 'wearSyncNotificationText': + return initData.l10n.wearSyncNotificationText; + default: + return null; + } + } + /// Stops the Wear sync foreground service (Android only). static Future stopWearSyncService() async { if (!Platform.isAndroid) return;