Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 39 additions & 11 deletions mobile/src/main/java/net/activitywatch/android/BackgroundService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading