From 96509d0f9d6bd8b8cea65fbe06b93d2ae6088b55 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 17:56:00 +0200 Subject: [PATCH] glance: widget refresh helpers (updatePlaced, refresh stamp, WidgetRefresher, throttled re-arm) (#18) Widget-refresh plumbing that Calendula's 2.24.0 widget refactor needs, and that Agendula already hand-rolls. - `GlanceAppWidget.updatePlaced(context, receiverClass)` redraws every placed instance using ids from `AppWidgetManager` + `getGlanceIdBy`. `updateAll` uses Glance's provider-name map, which is empty in a process that hasn't hosted a widget session yet, so the redraw gets dropped (calendula#18, calendula#420). - `updatePlaced(..., bumpRefreshStamp = true)` also moves each instance's `currentRefreshStamp()`. An update to a running session only recomposes and never re-runs `provideGlance`, so a widget that keys its data load on the stamp reloads there too. This is the per-widget `data_stamp` idea from calendula#420. - `WidgetRefresher` turns a burst of "data changed" requests into one debounced redraw, the generic part of Agendula's `TaskWidgetUpdater`. Calendula will use it for its own writes, so they don't have to wait for `PROVIDER_CHANGED`. - `FloretWidgetUpdateReceiver.extraRearmInterval` lets extra actions also re-arm the rollover alarm, at most once per interval. This moves Calendula's 15-minute `PROVIDER_CHANGED` backstop upstream. It defaults to off, so Agendula is unchanged. Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/18 --- CHANGELOG.md | 20 ++ README.md | 2 +- glance/build.gradle.kts | 2 + .../glance/FloretWidgetUpdateReceiver.kt | 43 ++++- .../floret/glance/WidgetUpdates.kt | 173 ++++++++++++++++++ .../glance/FloretWidgetUpdateReceiverTest.kt | 22 +++ .../floret/glance/WidgetRefresherTest.kt | 46 +++++ 7 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetUpdates.kt create mode 100644 glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/WidgetRefresherTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a1dc2..0f9d6e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,26 @@ are no binary releases; "version" tracks the shared `version` in the root build ### Added +- **`glance`** — widget-refresh plumbing lifted from Calendula and Agendula. + `GlanceAppWidget.updatePlaced(context, receiverClass)` redraws every placed + instance using ids from `AppWidgetManager`, because `updateAll`'s own lookup + comes back empty in a process that hasn't hosted a widget session yet. With + `bumpRefreshStamp = true` it also moves each instance's `currentRefreshStamp()`, + which a widget can key its data load on, since an update to a running + session only recomposes and never re-runs `provideGlance`. + `updateWithRefresh(context, glanceId)` does the same for one instance, and + `reloadOnUpdate` replaces a receiver's `super.onUpdate` so the periodic + update reloads too. `WidgetData` is + the composition side: `provideGlance` creates it with what it loaded and the + `refreshStamp` it loaded under, and `current(keys…) { load() }` reloads when + the keys move. One instance is shared by every size of a `SizeMode.Exact` + widget, and a failed load keeps the last data on screen. + `WidgetRefresher` turns a burst of "data changed" requests into one debounced + redraw, for an app's own writes. `FloretWidgetUpdateReceiver` takes + `extraRearmInterval`, so an extra action such as `PROVIDER_CHANGED` can also + re-arm the rollover alarm, at most once per interval, as a backstop for an + alarm the system dropped. `skipRedraw(action)` lets an app drop a redraw it has + already done, such as the `PROVIDER_CHANGED` that echoes its own write. - **`components`** — `StepNumber` is public, with a `done` flag that swaps the numeral for a check, for numbered steps an app builds from its own `GroupedRow`s (e.g. rows that open a setting). diff --git a/README.md b/README.md index 6054b1d..5f87219 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption mode | `core-crash` | Android | Privacy-respecting on-device crash capture + report dialog + issue-tracker hand-off. Parameterized per app via `CrashConfig`. | | `identity` | Android | The M3 Expressive theme factory `FloretExpressiveTheme(…)`, `rememberNavSlideSpec()`, the content transitions (`expandEnter`/`collapseExit`/`itemEnter`/…) and the `predictiveBack` peek. Each app supplies its own seed/palette. | | `components` | Android | Shared Compose vocabulary + recipes: `GroupedSurface`/`GroupedRow`/`GroupedSwitchRow`/`GroupedHint`, `IconInfoCard`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`/`OptionRows`/`CustomExpandRow`, `ReorderableColumn`, `DebugRibbon`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`, `SnackChip`. | -| `glance` | Android | Home-screen-widget plumbing on Jetpack Glance: `FloretGlanceTheme`, the rounded `WidgetSurface` (solid/tinted/transparent backgrounds), `WidgetHeader`/`WidgetActionButton`, the position-aware `WidgetGroupedRow`, `WidgetMessage`, `WidgetRollover` (the midnight-rollover alarm) and `FloretWidgetUpdateReceiver`. App supplies its own colour schemes, data and receiver glue. | +| `glance` | Android | Home-screen-widget plumbing on Jetpack Glance: `FloretGlanceTheme`, the rounded `WidgetSurface` (solid/tinted/transparent backgrounds), `WidgetHeader`/`WidgetActionButton`, the position-aware `WidgetGroupedRow`, `WidgetMessage`, `WidgetRollover` (the midnight-rollover alarm), `FloretWidgetUpdateReceiver`, and the refresh helpers `updatePlaced` / `WidgetData` / `WidgetRefresher`. App supplies its own colour schemes, data and receiver glue. | _The principle: the **mechanics** are shared, the **look** stays per-app. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for what's extracted, deferred, and diff --git a/glance/build.gradle.kts b/glance/build.gradle.kts index 94d3771..41bd29d 100644 --- a/glance/build.gradle.kts +++ b/glance/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.coroutines.core) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) @@ -60,4 +61,5 @@ dependencies { testRuntimeOnly(libs.junit.jupiter.engine) testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.truth) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiver.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiver.kt index 6789049..01bbe86 100644 --- a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiver.kt +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiver.kt @@ -4,10 +4,13 @@ import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.os.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicLong +import kotlin.time.Duration /** * Redraws an app's widgets when their day or language goes stale, and keeps @@ -15,7 +18,10 @@ import kotlinx.coroutines.launch * to check for placed instances, the rollover action + `PendingIntent`, how to * redraw, and — for a widget whose data lives outside the app (a calendar * provider, say) — any extra action that should trigger a redraw without - * re-arming the alarm, via [extraHandledActions]. + * re-arming the alarm, via [extraHandledActions]. Setting [extraRearmInterval] + * lets those extra actions re-arm too, at most once per interval: the backstop + * for an inexact alarm the system dropped in a restricted standby bucket, since a + * data change is the one signal that keeps arriving. * * Exported for the system broadcasts; an extra redraw from another app is * harmless. @@ -37,6 +43,16 @@ abstract class FloretWidgetUpdateReceiver : BroadcastReceiver() { /** Actions, beyond the shared set, that should redraw (never re-arm) — e.g. a content-provider change. */ protected open val extraHandledActions: Set = emptySet() + /** When set, an [extraHandledActions] broadcast also re-arms, throttled to once per this interval. */ + protected open val extraRearmInterval: Duration? = null + + /** + * Whether to skip the redraw for this broadcast, e.g. a `PROVIDER_CHANGED` + * that only echoes a write the app has already redrawn for. Re-arming is + * unaffected. + */ + protected open fun skipRedraw(action: String): Boolean = false + // `by lazy`, not eager: an eager initializer runs during *this* class's // construction, before the subclass's `override val rolloverAction` // initializer has run. `internal` (not `private`) so the wiring is directly @@ -55,8 +71,8 @@ abstract class FloretWidgetUpdateReceiver : BroadcastReceiver() { final override fun onReceive(context: Context, intent: Intent) { if (intent.action !in handledActions) return val appContext = context.applicationContext - val rearm = intent.action in rearmActions - val redraw = intent.action !in rearmOnlyActions + val rearm = intent.action in rearmActions || (intent.action in extraHandledActions && extraRearmDue()) + val redraw = intent.action !in rearmOnlyActions && !skipRedraw(intent.action.orEmpty()) val pending = goAsync() CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { try { @@ -69,4 +85,25 @@ abstract class FloretWidgetUpdateReceiver : BroadcastReceiver() { } } } + + private fun extraRearmDue(): Boolean { + val interval = extraRearmInterval ?: return false + return rearmThrottle.tryAcquire(SystemClock.elapsedRealtime(), interval.inWholeMilliseconds) + } + + private companion object { + // Process-wide: the system builds a new receiver instance per broadcast. + val rearmThrottle = RearmThrottle() + } +} + +/** Lets one caller through per interval; the first call always passes. */ +internal class RearmThrottle { + private val last = AtomicLong(0L) + + fun tryAcquire(nowMillis: Long, intervalMillis: Long): Boolean { + val previous = last.get() + val due = previous == 0L || nowMillis - previous >= intervalMillis + return due && last.compareAndSet(previous, nowMillis) + } } diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetUpdates.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetUpdates.kt new file mode 100644 index 0000000..db7346d --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetUpdates.kt @@ -0,0 +1,173 @@ +package de.jeanlucmakiola.floret.glance + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.currentState +import androidx.glance.state.PreferencesGlanceStateDefinition +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +private val RefreshStampKey = longPreferencesKey("floret_refresh_stamp") + +/** + * Redraws every placed instance of this widget, resolving the ids through + * [AppWidgetManager] rather than Glance's own provider-name map. `updateAll` + * reads that map, and in a process that hasn't hosted a widget session yet (one + * woken by a tap after a reboot, or the app right after a save) it comes back + * empty, so the redraw is silently dropped. + * + * With [bumpRefreshStamp], each instance's [currentRefreshStamp] moves first. + * An `update` on a running session only recomposes and never re-runs + * `provideGlance`, so a widget that keys its data load on the stamp reloads + * there too. Needs a `PreferencesGlanceStateDefinition` widget. + */ +suspend fun GlanceAppWidget.updatePlaced( + context: Context, + receiverClass: Class<*>, + bumpRefreshStamp: Boolean = false, +) { + val appContext = context.applicationContext + val appWidgetManager = AppWidgetManager.getInstance(appContext) ?: return + val ids = appWidgetManager.getAppWidgetIds(ComponentName(appContext, receiverClass)) + if (ids.isEmpty()) return + val glanceManager = GlanceAppWidgetManager(appContext) + ids.forEach { appWidgetId -> + val glanceId = glanceManager.getGlanceIdBy(appWidgetId) + if (bumpRefreshStamp) updateWithRefresh(appContext, glanceId) else update(appContext, glanceId) + } +} + +/** Moves one instance's [currentRefreshStamp], then redraws it, so a running session reloads too. */ +suspend fun GlanceAppWidget.updateWithRefresh(context: Context, glanceId: GlanceId) { + updateAppWidgetState(context, glanceId) { it[RefreshStampKey] = nextRefreshStamp(it[RefreshStampKey]) } + update(context, glanceId) +} + +/** + * A receiver's `onUpdate`, in place of `super.onUpdate`: the system's periodic + * update also reloads each instance's data, rather than only recomposing a + * running session. Never after `super`, which has already taken the + * broadcast's [goAsync] handle. + */ +fun GlanceAppWidgetReceiver.reloadOnUpdate(context: Context, appWidgetIds: IntArray) { + val appContext = context.applicationContext + val pending = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + val glanceManager = GlanceAppWidgetManager(appContext) + appWidgetIds.forEach { glanceAppWidget.updateWithRefresh(appContext, glanceManager.getGlanceIdBy(it)) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Widget update failed", e) + } finally { + pending?.finish() + } + } +} + +private const val TAG = "FloretGlance" + +internal fun nextRefreshStamp(current: Long?): Long = (current ?: 0L) + 1 + +/** This instance's refresh stamp, moved by [updateWithRefresh]; a key for [WidgetData.current]. */ +@Composable +fun currentRefreshStamp(): Long = currentState(RefreshStampKey) ?: 0L + +/** [currentRefreshStamp] outside the composition, for `provideGlance` to read before its first load. */ +suspend fun refreshStamp(context: Context, glanceId: GlanceId): Long = + getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)[RefreshStampKey] ?: 0L + +/** + * A widget's data, created in `provideGlance` with what it loaded there and + * the keys it loaded under (typically [refreshStamp] plus whatever the load + * depends on). One instance is shared by every size a `SizeMode.Exact` or + * `Responsive` widget composes, so a resize doesn't start a size from stale + * data and a redraw loads once, not once per size. + */ +class WidgetData(initial: T, vararg loadedUnder: Any?) { + private val value = mutableStateOf(initial) + private var loadedKeys: List = loadedUnder.toList() + private val mutex = Mutex() + + /** + * The data for [keys], running [load] whenever they differ from the last + * successful load's. The last value stays on screen while a load runs or + * after one fails; the next key change tries again. + */ + @Composable + fun current(vararg keys: Any?, load: suspend () -> T): T { + val wanted = keys.toList() + LaunchedEffect(wanted) { + mutex.withLock { + if (wanted == loadedKeys) return@withLock + try { + value.value = load() + loadedKeys = wanted + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Widget data load failed", e) + } + } + } + return value.value + } +} + +/** + * Collapses bursts of "the data changed" into one [redraw] per [debounce] + * window, on [scope]. For an app's own writes, which shouldn't wait for (or + * may never get) a system broadcast. A failing redraw is logged and dropped; + * the next request tries again. + */ +@OptIn(FlowPreview::class) +class WidgetRefresher( + scope: CoroutineScope, + debounce: Duration = 300.milliseconds, + private val redraw: suspend () -> Unit, +) { + private val requests = Channel(Channel.CONFLATED) + + init { + scope.launch { + requests.receiveAsFlow().debounce(debounce).collect { + try { + redraw() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Widget redraw failed", e) + } + } + } + } + + /** Redraw soon. */ + fun request() { + requests.trySend(Unit) + } +} diff --git a/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiverTest.kt b/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiverTest.kt index 34874c3..d0ba553 100644 --- a/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiverTest.kt +++ b/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiverTest.kt @@ -49,3 +49,25 @@ class FloretWidgetUpdateReceiverTest { assertThat(receiver.rearmActions).doesNotContain("app.PROVIDER_CHANGED") } } + +class RearmThrottleTest { + + @Test + fun `the first call passes`() { + assertThat(RearmThrottle().tryAcquire(nowMillis = 1_000, intervalMillis = 60_000)).isTrue() + } + + @Test + fun `calls inside the interval are refused`() { + val throttle = RearmThrottle() + throttle.tryAcquire(1_000, 60_000) + assertThat(throttle.tryAcquire(30_000, 60_000)).isFalse() + } + + @Test + fun `a call after the interval passes again`() { + val throttle = RearmThrottle() + throttle.tryAcquire(1_000, 60_000) + assertThat(throttle.tryAcquire(61_000, 60_000)).isTrue() + } +} diff --git a/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/WidgetRefresherTest.kt b/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/WidgetRefresherTest.kt new file mode 100644 index 0000000..2316983 --- /dev/null +++ b/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/WidgetRefresherTest.kt @@ -0,0 +1,46 @@ +package de.jeanlucmakiola.floret.glance + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +class WidgetRefresherTest { + + @Test + fun `a burst of requests redraws once`() = runTest { + var redraws = 0 + val refresher = WidgetRefresher(backgroundScope, 300.milliseconds) { redraws++ } + repeat(5) { refresher.request() } + advanceTimeBy(1_000) + assertThat(redraws).isEqualTo(1) + } + + @Test + fun `requests further apart than the window redraw each time`() = runTest { + var redraws = 0 + val refresher = WidgetRefresher(backgroundScope, 300.milliseconds) { redraws++ } + refresher.request() + advanceTimeBy(500) + refresher.request() + advanceTimeBy(1_000) + assertThat(redraws).isEqualTo(2) + } + + @Test + fun `a failing redraw doesn't stop later ones`() = runTest { + var calls = 0 + val refresher = WidgetRefresher(backgroundScope, 300.milliseconds) { + calls++ + if (calls == 1) error("boom") + } + refresher.request() + advanceTimeBy(500) + refresher.request() + advanceTimeBy(1_000) + assertThat(calls).isEqualTo(2) + } +}