Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
TimeToBuildBob marked this conversation as resolved.
}
}
}

class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.w(TAG, "AlarmReceiver called")
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Throwable>()
private val logged = CopyOnWriteArrayList<Pair<String, Throwable?>>()
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 {
Comment thread
TimeToBuildBob marked this conversation as resolved.
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.
Comment thread
TimeToBuildBob marked this conversation as resolved.

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<String>()
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)
}
}
Loading