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
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package net.activitywatch.android.widget

import android.util.Log
import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull

private const val TAG = "CategoryTimeWidget"

/**
* Runs the work of a widget broadcast that called `goAsync()`, so that nothing can throw out
* of the coroutine and the `PendingResult` is finished exactly once, within the broadcast's
* time budget.
*
* The widget's top Play crash (`CategoryTimeWidgetProvider$onUpdate$1.invokeSuspend`,
* `IllegalStateException`) was thrown by `PendingResult.finish()` → `sendFinished()`, which
* only throws when the result is already finished ("Broadcast already finished"). The app
* finished it once, so something else had finished it first: some OEM builds do this
* themselves, notably when the work runs past the broadcast deadline (AndroidX Glance
* catches the same exception for the same reason, b/257513022). The `finish()` call sat in
* a `finally` outside the `try`/`catch`, so its exception escaped an IO coroutine with no
* handler and killed the process.
*/
internal object AsyncBroadcast {
/**
* Foreground broadcasts get 10 s and background broadcasts 60 s before an ANR. Stay
* under the smaller one: the system and OEM watchdogs measure from dispatch, not from
* when this coroutine starts.
*/
const val DEFAULT_BUDGET_MS = 8_000L

/**
* Calls [finish] at most once. An [IllegalStateException] from it means the system or
* OEM framework already finished the broadcast, so it is logged, not rethrown.
*/
internal class FinishOnce(
private val finish: () -> Unit,
private val log: (String, Throwable?) -> Unit = ::logError,
) {
private val finished = AtomicBoolean(false)

fun finish() {
if (!finished.compareAndSet(false, true)) return
try {
finish.invoke()
} catch (e: IllegalStateException) {
log("Broadcast was already finished by the system", e)
}
}
}

/** Budget for [launch]'s `onFailure`, on top of the work budget (8 s + 1 s < 10 s). */
const val FALLBACK_BUDGET_MS = 1_000L

/**
* True while a fallback is running. A fallback stuck in `updateAppWidget` outlives its
* await, so later failures skip theirs instead of piling more onto `Dispatchers.IO`;
* the fallback is best effort and the next pass renders anyway.
*/
private val fallbackRunning = AtomicBoolean(false)

internal val isFallbackRunning: Boolean get() = fallbackRunning.get()

/** Starts [work] in a coroutine that is not a child of the caller, for [launch]. */
fun detached(
dispatcher: CoroutineDispatcher = Dispatchers.IO,
work: suspend () -> Unit,
): Deferred<Unit> = CoroutineScope(SupervisorJob() + dispatcher).async { work() }

/**
* Launches [work] for a broadcast and finishes it through [finish] exactly once.
*
* [work] starts (or queues) the actual work in a coroutine that is not a child of this one,
* e.g. with [detached] or a [CoalescingWorker], and the broadcast waits for the returned
* [Deferred] for at most [budgetMs]. Detached, because the widget's work blocks in calls
* that ignore coroutine cancellation (JNI into the Rust datastore,
* `DatastoreStartup.awaitReady()`), so `withTimeout` around a child would still wait for
* the child to return. Work that overruns keeps going and still renders its own result.
*
* If [work] throws or overruns, [onFailure] is called with the exception, or with a
* [TimeoutException] when it overran (the work is then still running, so [onFailure] must
* not overwrite what it renders). [onFailure] also runs detached, for at most
* [fallbackBudgetMs], so a slow fallback can't hold the broadcast either. Anything it
* throws is logged.
*/
fun launch(
finish: () -> Unit,
budgetMs: Long = DEFAULT_BUDGET_MS,
fallbackBudgetMs: Long = FALLBACK_BUDGET_MS,
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
workDispatcher: CoroutineDispatcher = Dispatchers.IO,
log: (String, Throwable?) -> Unit = ::logError,
onFailure: (Throwable) -> Unit = {},
work: () -> Deferred<Unit>,
): Job {
val once = FinishOnce(finish, log)
return scope.launch {
try {
val failure: Throwable? = try {
val pending = work()
val done = withTimeoutOrNull(budgetMs) { pending.await() }
if (done == null) TimeoutException("Widget update exceeded ${budgetMs} ms") else null
} catch (e: Throwable) {
e
}
if (failure != null) {
log("Widget broadcast work failed", failure)
if (fallbackRunning.compareAndSet(false, true)) {
val fallback = detached(workDispatcher) {
try {
onFailure(failure)
} catch (e: Throwable) {
log("Rendering the widget fallback failed", e)
} finally {
fallbackRunning.set(false)
}
}
if (withTimeoutOrNull(fallbackBudgetMs) { fallback.await() } == null) {
log("Widget fallback exceeded ${fallbackBudgetMs} ms", null)
}
} else {
log("Previous widget fallback still running; skipping this one", null)
Comment thread
ErikBjare marked this conversation as resolved.
}
}
} catch (e: Throwable) {
log("Widget broadcast wrapper failed", e)
} finally {
once.finish()
}
}
}

private fun logError(msg: String, e: Throwable?) {
try {
Log.e(TAG, msg, e)
} catch (_: Throwable) {
// android.util.Log is not available in JVM unit tests.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import android.view.View
import android.widget.RemoteViews
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.SupervisorJob
import net.activitywatch.android.R
import net.activitywatch.android.watcher.UsageStatsWatcher

Expand All @@ -24,8 +24,9 @@ private const val UPDATE_INTERVAL_MS = 5 * 60 * 1000L // 5 minutes

/**
* Widget provider for displaying category time stats.
* Updates are handled every 15 mins by the work manager and every 30 mins by
* Android's widget update mechanism.
* Updates are handled every 5 mins by an AlarmManager alarm and every 30 mins by
* Android's widget update mechanism. Every broadcast's async work goes through
* [AsyncBroadcast], which finishes the PendingResult exactly once and never throws.
*/
class CategoryTimeWidgetProvider : AppWidgetProvider() {

Expand All @@ -38,17 +39,10 @@ class CategoryTimeWidgetProvider : AppWidgetProvider() {
// Restore the periodic alarm after reboot (Android calls onUpdate for existing widgets on boot).
schedulePeriodicUpdates(context)
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
for (appWidgetId in appWidgetIds) {
updateWidgetWithRefreshButton(context, appWidgetManager, appWidgetId)
}
} catch (e: Exception) {
Log.e(TAG, "Error in onUpdate", e)
} finally {
pendingResult.finish()
}
}
AsyncBroadcast.launch(
finish = { pendingResult?.finish() },
onFailure = { CategoryTimeWidgetUpdater.onUpdateFailed(context, it) },
) { requestUpdate(context, refreshUsage = false) }
}

override fun onReceive(context: Context, intent: Intent) {
Expand All @@ -68,35 +62,26 @@ class CategoryTimeWidgetProvider : AppWidgetProvider() {

// goAsync() extends BroadcastReceiver lifetime past onReceive() return
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
// Await event persistence before updating the widget
UsageStatsWatcher(context).sendHeartbeatsSuspend()
Log.d(TAG, "Usage events re-parsed; updating widgets")
for (appWidgetId in appWidgetIds) {
updateWidgetWithRefreshButton(context, appWidgetManager, appWidgetId)
}
} catch (e: Exception) {
Log.e(TAG, "Error re-parsing usage events", e)
} finally {
pendingResult.finish()
}
}
AsyncBroadcast.launch(
finish = { pendingResult?.finish() },
onFailure = { CategoryTimeWidgetUpdater.onUpdateFailed(context, it) },
) { requestUpdate(context, refreshUsage = true) }
}
ACTION_PERIODIC_UPDATE -> {
Log.d(TAG, "Periodic update triggered by AlarmManager")
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
CategoryTimeWidgetUpdater.updateAllWidgets(context)
} catch (e: Exception) {
Log.e(TAG, "Error during periodic update", e)
} finally {
// Reschedule the next exact alarm
schedulePeriodicUpdates(context)
pendingResult.finish()
}
}
AsyncBroadcast.launch(
finish = {
// Reschedule the next exact alarm, even if this update failed
try {
schedulePeriodicUpdates(context)
} catch (e: Exception) {
Log.e(TAG, "Could not reschedule the periodic widget update", e)
}
pendingResult?.finish()
},
onFailure = { CategoryTimeWidgetUpdater.onUpdateFailed(context, it) },
) { requestUpdate(context, refreshUsage = false) }
}
}
}
Expand All @@ -114,6 +99,38 @@ class CategoryTimeWidgetProvider : AppWidgetProvider() {
}

companion object {
/**
* What a widget broadcast asks for. Every pass renders all current widget ids, so
* requests only differ in whether usage events are re-parsed first.
*/
private data class WidgetRequest(val context: Context, val refreshUsage: Boolean)

/**
* Shared by every widget broadcast: one update pass at a time, so passes stuck in
* the datastore can't pile up, and requests that arrive meanwhile are merged into
* one follow-up pass instead of being dropped.
*/
private val widgetUpdates = CoalescingWorker<WidgetRequest>(
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
merge = { a, b -> a.copy(refreshUsage = a.refreshUsage || b.refreshUsage) },
process = { request ->
if (request.refreshUsage) {
try {
// Await event persistence before updating the widget
UsageStatsWatcher(request.context).sendHeartbeatsSuspend()
Log.d(TAG, "Usage events re-parsed; updating widgets")
} catch (e: Exception) {
// Still show whatever is already stored.
Log.e(TAG, "Error re-parsing usage events", e)
}
}
CategoryTimeWidgetUpdater.updateAllWidgets(request.context)
},
)

private fun requestUpdate(context: Context, refreshUsage: Boolean) =
widgetUpdates.submit(WidgetRequest(context.applicationContext, refreshUsage))

/**
* Show loading state - show spinner
*/
Expand All @@ -128,18 +145,6 @@ class CategoryTimeWidgetProvider : AppWidgetProvider() {
Log.d(TAG, "Showing loading indicator for widget $appWidgetId")
}

/**
* Update widget data and set up tap-to-refresh on the whole widget
*/
fun updateWidgetWithRefreshButton(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
// updateSingleWidget handles data, click handler, and loading state
CategoryTimeWidgetUpdater.updateSingleWidget(context, appWidgetManager, appWidgetId)
}

private fun getUpdateIntent(context: Context): PendingIntent {
val intent = Intent(context, CategoryTimeWidgetProvider::class.java).apply {
action = ACTION_PERIODIC_UPDATE
Expand Down
Loading
Loading