From a88ee1bedbe511755c77f055383ca2a619813db0 Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 13 Sep 2026 10:29:46 +0000 Subject: [PATCH] fix(android): handle rejected foreground service starts Git-Session-Id: 56a0524d-5875-5322-964b-a4d6e1967011 --- .../android/BackgroundService.kt | 50 +++++++++++++++---- .../net/activitywatch/android/MainActivity.kt | 4 +- .../android/SyncSettingsActivity.kt | 1 + .../android/watcher/AlarmReceiver.kt | 23 +++++++-- .../android/BackgroundServicePolicyTest.kt | 28 +++++++++++ 5 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 mobile/src/test/java/net/activitywatch/android/BackgroundServicePolicyTest.kt diff --git a/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt b/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt index 99196731..334799af 100644 --- a/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt +++ b/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt @@ -2,6 +2,7 @@ package net.activitywatch.android import android.app.Notification import android.app.NotificationChannel +import android.app.ForegroundServiceStartNotAllowedException import android.app.NotificationManager import android.app.PendingIntent import android.app.Service @@ -23,12 +24,22 @@ private const val TAG = "BackgroundService" private const val CHANNEL_ID = "aw_background_channel" private const val NOTIFICATION_ID = 1 +internal const val BACKGROUND_SERVICE_RESTART_MODE = Service.START_NOT_STICKY + +internal fun backgroundServiceStartOrigin(explicitOrigin: String?): String = + explicitOrigin ?: BackgroundService.START_ORIGIN_SYSTEM_RESTART + class BackgroundService : Service() { companion object { // Sent by SyncSettingsActivity when the user toggles sync on/off so the // running scheduler reflects the new setting immediately without a restart. const val ACTION_SYNC_ENABLED_CHANGED = "net.activitywatch.android.SYNC_ENABLED_CHANGED" + const val EXTRA_START_ORIGIN = "net.activitywatch.android.extra.START_ORIGIN" + const val START_ORIGIN_ACTIVITY = "activity" + const val START_ORIGIN_BOOT = "boot" + const val START_ORIGIN_SETTINGS = "settings" + const val START_ORIGIN_SYSTEM_RESTART = "system-restart" } private lateinit var syncScheduler: SyncScheduler @@ -48,20 +59,35 @@ class BackgroundService : Service() { // slow/no-KVM emulators (CI). createNotificationChannel() val notification = createNotification() - ServiceCompat.startForeground( - this, - NOTIFICATION_ID, - notification, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) - ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE - else - 0 - ) + try { + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + else + 0 + ) + } catch (e: ForegroundServiceStartNotAllowedException) { + Log.e(TAG, "Foreground promotion rejected; stopping before initialization", e) + stopSelf() + return + } rustInterface = RustInterface(this) syncScheduler = SyncScheduler(this) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (!::rustInterface.isInitialized || !::syncScheduler.isInitialized) { + Log.w(TAG, "Ignoring start command because foreground promotion failed") + stopSelf(startId) + return BACKGROUND_SERVICE_RESTART_MODE + } + + val startOrigin = backgroundServiceStartOrigin(intent?.getStringExtra(EXTRA_START_ORIGIN)) + Log.i(TAG, "BackgroundService start origin: $startOrigin") + // Only short-circuit for the scheduler-toggle action when the service is already // fully running. If Android killed the service while SyncSettingsActivity was open, // the toggle re-creates the service with this action as its first command — in that @@ -71,7 +97,7 @@ class BackgroundService : Service() { val enabled = AWPreferences(this).isSyncEnabled() Log.i(TAG, "Sync enabled changed to $enabled; ${if (enabled) "starting" else "stopping"} scheduler") if (enabled) syncScheduler.start() else syncScheduler.stop() - return START_STICKY + return BACKGROUND_SERVICE_RESTART_MODE } Log.i(TAG, "BackgroundService started") @@ -124,7 +150,9 @@ class BackgroundService : Service() { scheduleNotifyChecks() isFullyStarted = true - return START_STICKY + // A sticky recreation can occur while the app is backgrounded, where Android 12+ + // may reject foreground promotion. Only explicit, eligible callers restart us. + return BACKGROUND_SERVICE_RESTART_MODE } private fun migrateWatcherAndroidTestBuckets(prefs: AWPreferences) { diff --git a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt index 967af7c0..ca781707 100644 --- a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt @@ -120,7 +120,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte // Ensure API key exists in config before the server starts so it picks it up at init. dashboardApiKey = ensureDashboardApiKey(this) // Start background service to keep server and sync running - val serviceIntent = Intent(this, BackgroundService::class.java) + val serviceIntent = Intent(this, BackgroundService::class.java).apply { + putExtra(BackgroundService.EXTRA_START_ORIGIN, BackgroundService.START_ORIGIN_ACTIVITY) + } startForegroundService(serviceIntent) onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { diff --git a/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt b/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt index 91babbeb..f31c6b54 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt @@ -125,6 +125,7 @@ class SyncSettingsActivity : AppCompatActivity() { // rather than waiting for the next service restart. startService(Intent(this, BackgroundService::class.java).apply { action = BackgroundService.ACTION_SYNC_ENABLED_CHANGED + putExtra(BackgroundService.EXTRA_START_ORIGIN, BackgroundService.START_ORIGIN_SETTINGS) }) } diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/AlarmReceiver.kt b/mobile/src/main/java/net/activitywatch/android/watcher/AlarmReceiver.kt index c38bee4c..7c0942da 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/AlarmReceiver.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/AlarmReceiver.kt @@ -1,5 +1,6 @@ package net.activitywatch.android.watcher +import android.app.ForegroundServiceStartNotAllowedException import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -17,11 +18,23 @@ class AlarmReceiver : BroadcastReceiver() { if (intent.action == "android.intent.action.BOOT_COMPLETED") { Log.w(TAG, "Received BOOT_COMPLETED, setting up alarm and starting background service") usw.setupAlarm() - val serviceIntent = Intent(context, net.activitywatch.android.BackgroundService::class.java) - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - context.startForegroundService(serviceIntent) - } else { - context.startService(serviceIntent) + val serviceIntent = Intent( + context, + net.activitywatch.android.BackgroundService::class.java, + ).apply { + putExtra( + net.activitywatch.android.BackgroundService.EXTRA_START_ORIGIN, + net.activitywatch.android.BackgroundService.START_ORIGIN_BOOT, + ) + } + try { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent) + } else { + context.startService(serviceIntent) + } + } catch (e: ForegroundServiceStartNotAllowedException) { + Log.e(TAG, "Foreground-service start rejected after boot; service remains stopped", e) } } else if (intent.action == "net.activitywatch.android.watcher.LOG_DATA") { Log.w(TAG, "Action ${intent.action}, running sendHeartbeats") diff --git a/mobile/src/test/java/net/activitywatch/android/BackgroundServicePolicyTest.kt b/mobile/src/test/java/net/activitywatch/android/BackgroundServicePolicyTest.kt new file mode 100644 index 00000000..1b6b47dd --- /dev/null +++ b/mobile/src/test/java/net/activitywatch/android/BackgroundServicePolicyTest.kt @@ -0,0 +1,28 @@ +package net.activitywatch.android + +import android.app.Service +import org.junit.Assert.assertEquals +import org.junit.Test + +class BackgroundServicePolicyTest { + @Test + fun missingOriginIdentifiesSystemRestart() { + assertEquals( + BackgroundService.START_ORIGIN_SYSTEM_RESTART, + backgroundServiceStartOrigin(null), + ) + } + + @Test + fun explicitOriginIsPreserved() { + assertEquals( + BackgroundService.START_ORIGIN_BOOT, + backgroundServiceStartOrigin(BackgroundService.START_ORIGIN_BOOT), + ) + } + + @Test + fun serviceDoesNotRequestUnsafeStickyRestart() { + assertEquals(Service.START_NOT_STICKY, BACKGROUND_SERVICE_RESTART_MODE) + } +}