Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.activitywatch.android.widget.AsyncBroadcast

private const val TAG = "AlarmReceiver"

Expand Down Expand Up @@ -40,13 +41,14 @@ class AlarmReceiver : BroadcastReceiver() {
Log.w(TAG, "Action ${intent.action}, running sendHeartbeats")
if (UsageStatsWatcher.isUsageAllowed(context)) {
val pendingResult = goAsync()
val finish = AsyncBroadcast.FinishOnce(pendingResult::finish)
CoroutineScope(Dispatchers.IO).launch {
try {
usw.sendHeartbeatsSuspend()
} catch (e: Exception) {
Log.e(TAG, "Failed to send heartbeats from alarm", e)
} finally {
pendingResult.finish()
finish.finish()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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.launch
import kotlinx.coroutines.runBlocking
import net.activitywatch.android.widget.AsyncBroadcast
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

/**
* Regression tests for the AlarmReceiver goAsync + finish pattern after applying FinishOnce.
*
* Without FinishOnce, an OEM pre-finishing the broadcast before the coroutine's `finally` block
* causes `PendingResult.finish()` → `IllegalStateException("Broadcast already finished")`, which
* escapes the coroutine and kills the process. This is the same crash class fixed for the widget
* in ActivityWatch/aw-android#360.
*/
class AlarmReceiverFinishOnceTest {

private val uncaught = CopyOnWriteArrayList<Throwable>()
private val logged = CopyOnWriteArrayList<String>()
private val log: (String, Throwable?) -> Unit = { msg, _ -> logged.add(msg) }

private fun scope() = CoroutineScope(
SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e -> uncaught.add(e) }
)

/**
* OEM pre-finishes the broadcast before the coroutine's finally block; without FinishOnce
* this would throw IllegalStateException and escape the coroutine.
*/
@Test
fun oemDoubleFinish_doesNotEscapeCoroutine() = runBlocking {
Comment thread
TimeToBuildBob marked this conversation as resolved.
val finishCalls = AtomicInteger()
val finish = AsyncBroadcast.FinishOnce(
finish = {
finishCalls.incrementAndGet()
throw IllegalStateException("Broadcast already finished")
},
log = log,
)

val job = scope().launch {
try {
// heartbeat work (no-op in test)
} catch (e: Exception) {
// logged in production
} finally {
finish.finish()
}
Comment thread
TimeToBuildBob marked this conversation as resolved.
Outdated
}
job.join()

assertEquals("finish() must be attempted exactly once", 1, finishCalls.get())
assertTrue("IllegalStateException escaped the coroutine: $uncaught", uncaught.isEmpty())
assertTrue("missing 'already finished' log entry", logged.any { it.contains("already finished") })
}

@Test
fun normalHeartbeat_finishesExactlyOnce() = runBlocking {
val finishCalls = AtomicInteger()
val finish = AsyncBroadcast.FinishOnce({ finishCalls.incrementAndGet() }, log)

val job = scope().launch {
try {
// heartbeat work succeeds
} finally {
finish.finish()
}
}
job.join()

assertEquals(1, finishCalls.get())
assertTrue(uncaught.isEmpty())
}

@Test
fun heartbeatException_stillFinishes() = runBlocking {
val finishCalls = AtomicInteger()
val finish = AsyncBroadcast.FinishOnce({ finishCalls.incrementAndGet() }, log)

val job = scope().launch {
try {
throw RuntimeException("server unreachable")
} catch (e: Exception) {
// logged
} finally {
finish.finish()
}
}
job.join()

assertEquals(1, finishCalls.get())
assertTrue(uncaught.isEmpty())
}
}
Loading