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 <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/18
This commit is contained in:
Jean-Luc Makiola
2026-10-21 17:56:00 +02:00
co-authored by makiolaj
parent 68d6432434
commit 96509d0f9d
7 changed files with 304 additions and 4 deletions
+20
View File
@@ -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).
+1 -1
View File
@@ -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
+2
View File
@@ -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)
}
@@ -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<String> = 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)
}
}
@@ -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<T>(initial: T, vararg loadedUnder: Any?) {
private val value = mutableStateOf(initial)
private var loadedKeys: List<Any?> = 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<Unit>(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)
}
}
@@ -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()
}
}
@@ -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)
}
}