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 7c0942da..af92f54e 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/AlarmReceiver.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/AlarmReceiver.kt @@ -7,10 +7,31 @@ import android.content.Intent import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import net.activitywatch.android.widget.AsyncBroadcast private const val TAG = "AlarmReceiver" +/** The receiver's heartbeat/completion path, with its existing unbounded IO lifetime. */ +internal fun launchAlarmHeartbeats( + finish: () -> Unit, + scope: CoroutineScope = CoroutineScope(Dispatchers.IO), + log: (String, Throwable?) -> Unit = { message, error -> Log.e(TAG, message, error); Unit }, + work: suspend () -> Unit, +): Job { + val once = AsyncBroadcast.FinishOnce(finish, log) + return scope.launch { + try { + work() + } catch (e: Exception) { + log("Failed to send heartbeats from alarm", e) + } finally { + once.finish() + } + } +} + class AlarmReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { Log.w(TAG, "AlarmReceiver called") @@ -40,14 +61,8 @@ class AlarmReceiver : BroadcastReceiver() { Log.w(TAG, "Action ${intent.action}, running sendHeartbeats") if (UsageStatsWatcher.isUsageAllowed(context)) { val pendingResult = goAsync() - CoroutineScope(Dispatchers.IO).launch { - try { - usw.sendHeartbeatsSuspend() - } catch (e: Exception) { - Log.e(TAG, "Failed to send heartbeats from alarm", e) - } finally { - pendingResult.finish() - } + launchAlarmHeartbeats(finish = pendingResult::finish) { + usw.sendHeartbeatsSuspend() } } } else { diff --git a/mobile/src/test/java/net/activitywatch/android/watcher/AlarmReceiverFinishOnceTest.kt b/mobile/src/test/java/net/activitywatch/android/watcher/AlarmReceiverFinishOnceTest.kt new file mode 100644 index 00000000..072f13c3 --- /dev/null +++ b/mobile/src/test/java/net/activitywatch/android/watcher/AlarmReceiverFinishOnceTest.kt @@ -0,0 +1,79 @@ +package net.activitywatch.android.watcher + +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Tests the completion path called by AlarmReceiver, not a copy of its coroutine. */ +class AlarmReceiverFinishOnceTest { + private val uncaught = CopyOnWriteArrayList() + private val logged = CopyOnWriteArrayList>() + private val log: (String, Throwable?) -> Unit = { msg, error -> logged.add(msg to error) } + + private fun scope() = CoroutineScope( + SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e -> uncaught.add(e) } + ) + + /** Simulates the framework finishing first, so the receiver's finish attempt throws. */ + @Test + fun oemDoubleFinish_doesNotEscapeCoroutine() = runBlocking { + val finishCalls = AtomicInteger() + val alreadyFinished = java.util.concurrent.atomic.AtomicBoolean(false) + val pendingFinish = { + finishCalls.incrementAndGet() + check(alreadyFinished.compareAndSet(false, true)) { "Broadcast already finished" } + } + pendingFinish() // Framework/OEM finishes the broadcast before our coroutine completes. + + launchAlarmHeartbeats( + finish = pendingFinish, + scope = scope(), + log = log, + work = {}, + ).join() + + assertEquals("one framework finish and one receiver attempt", 2, finishCalls.get()) + assertTrue("IllegalStateException escaped the coroutine: $uncaught", uncaught.isEmpty()) + assertTrue(logged.any { it.first.contains("already finished") && it.second is IllegalStateException }) + } + + @Test + fun normalHeartbeat_finishesExactlyOnceAfterWork() = runBlocking { + val events = CopyOnWriteArrayList() + launchAlarmHeartbeats( + finish = { events.add("finish"); Unit }, + scope = scope(), + log = log, + work = { events.add("heartbeat") }, + ).join() + + assertEquals(listOf("heartbeat", "finish"), events.toList()) + assertTrue(uncaught.isEmpty()) + assertTrue(logged.isEmpty()) + } + + @Test + fun heartbeatException_stillFinishesAndLogsFailure() = runBlocking { + val finishCalls = AtomicInteger() + val failure = RuntimeException("server unreachable") + launchAlarmHeartbeats( + finish = { finishCalls.incrementAndGet(); Unit }, + scope = scope(), + log = log, + work = { throw failure }, + ).join() + + assertEquals(1, finishCalls.get()) + assertTrue(uncaught.isEmpty()) + assertEquals(1, logged.size) + assertSame(failure, logged.single().second) + } +}