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:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+40
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
+22
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user