diff --git a/firka/android/app/build.gradle.kts b/firka/android/app/build.gradle.kts
index 7544859..6d79275 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 5e70159..8271611 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 0000000..6d4ac9c
--- /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 a27a2f4..3bf06d1 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 db49b1a..bf22f68 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 11cd859..ec2746d 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;