DRY pass #2, batch 8, F20: one foreground observer, one foreground latch (#5372)

FlushOnStop was ForegroundTransitions with only its ON_STOP half, so it goes
and the editor calls ForegroundTransitions(onBackground = flush); both halves
now default to nothing, dropping three onBackground = {}. AutomaticUpdate and
AutomaticSync each kept a wanted flag set on the way in and consumed in a
LaunchedEffect once ready; that latch is OnEachForeground(ready, onBackground,
act), with each caller's ready and its reason kept where they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:07:02 -04:00
co-authored by Claude Opus 5.5
parent 63abff8681
commit fb95c9b66f
6 changed files with 56 additions and 78 deletions
@@ -430,7 +430,7 @@ private fun ReminderAlarms(core: Inkwell) {
refresh()
}
ForegroundTransitions(onForeground = { refresh() }, onBackground = {})
ForegroundTransitions(onForeground = { refresh() })
LaunchedEffect(Unit) {
// TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below
@@ -461,17 +461,9 @@ private fun AutomaticUpdate(
linked: Boolean,
onCheck: () -> Unit,
) {
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(onForeground = { wanted = true }, onBackground = {})
LaunchedEffect(wanted, linked) {
if (!wanted || !linked) return@LaunchedEffect
// Consumed here, so this fires once per trip to the foreground however many
// times the effect restarts. There is no suspension point before the call, so
// the block completes before the recomposition that would cancel it.
wanted = false
onCheck()
}
// Waits for a link rather than dropping the trip: linking a device while the app is
// in front checks as soon as it is linked.
OnEachForeground(ready = linked, act = onCheck)
}
/**
@@ -508,28 +500,18 @@ private fun AutomaticSync(
if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context)
}
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(
onForeground = { wanted = true },
// Ready once `loading` is done, so the decision waits for the stored link to be
// READ. At first composition `linked` is still false because nothing has looked in
// the database yet, and acting on that would skip the sync on every cold start.
OnEachForeground(
ready = !state.loading,
onBackground = {
// Unsent work follows the person out of the app. Without this, a note
// written on a phone that then goes into a pocket for the night does
// not reach the desktop until the app is opened again by hand.
if (enabled && state.linked && state.pending) SyncSchedule.pushSoon(context)
},
)
// Keyed on `loading` so the decision waits for the stored link to be READ. At
// first composition `linked` is still false because nothing has looked in the
// database yet, and acting on that would skip the sync on every cold start.
LaunchedEffect(wanted, state.loading) {
if (!wanted || state.loading) return@LaunchedEffect
// Consumed here, so this fires exactly once per trip to the foreground
// however many times the effect restarts. Writing a key from inside the
// effect does restart it — but there is no suspension point between here
// and the call below, so the block runs to completion before the
// recomposition that would cancel it can be scheduled.
wanted = false
) {
val worthIt = state.pending || olderThan(state.status?.lastSyncAt, STALE_MINUTES)
if (enabled && state.linked && worthIt) onSync()
}
@@ -1,36 +0,0 @@
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Run [flush] when the app goes to the background.
*
* `ON_STOP` rather than `ON_PAUSE`: pause also fires when a dialog opens over the
* activity, which would save mid-sentence for no reason. The lambda goes through
* `rememberUpdatedState` so the observer — registered once — always calls the
* CURRENT one; captured directly it would hold the first composition's empty text
* forever and save that over a full note.
*
* Shared by the editor and the capture sheet. Both are places where text exists
* only in a composable until something writes it down, and the process can be
* killed while backgrounded without either of them being told again.
*/
@Composable
fun FlushOnStop(flush: () -> Unit) {
val current by rememberUpdatedState(flush)
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) current()
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
@@ -2,8 +2,12 @@ package com.fabledsword.inkwell.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -11,28 +15,31 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Calls back when the app comes to the front and when it leaves.
*
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`, which is the same choice
* the editor's save-on-leave makes for the same reason: resume and pause fire for
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`: resume and pause fire for
* anything that merely covers the window — a permission dialog, the notification
* shade — and a sync per shade-pull is not automatic sync, it is a stutter.
* shade — and a sync per shade-pull is not automatic sync, it is a stutter, just as
* a save per dialog would save the editor mid-sentence.
*
* A single-Activity app, so the Activity's lifecycle is the app's. If a second
* Activity is ever added this needs `ProcessLifecycleOwner` instead, or rotating
* between them will read as leaving and returning.
*
* Two callers, wanting opposite halves of it: automatic sync uses the return to
* decide whether to fetch, and the reminder notice uses it to re-read a
* permission the person may have just changed in the system settings.
* Callers want different halves of it: automatic sync uses both, the reminder
* notice and the update card use the return to re-read a permission the person may
* have just changed in the system settings, and the editor uses the leaving to
* write down text that otherwise exists only in a composable — the process can be
* killed while backgrounded without it being told again.
*
* Both callbacks go through [rememberUpdatedState]: the observer is registered
* once, and without it the lambda would keep reading the first composition's
* state forever — deciding whether to push unsent notes from a snapshot taken
* before any note existed.
* before any note existed, or saving the first composition's empty text over a
* full note.
*/
@Composable
fun ForegroundTransitions(
onForeground: () -> Unit,
onBackground: () -> Unit,
onForeground: () -> Unit = {},
onBackground: () -> Unit = {},
) {
val forward by rememberUpdatedState(onForeground)
val away by rememberUpdatedState(onBackground)
@@ -50,3 +57,29 @@ fun ForegroundTransitions(
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
/**
* Run [act] once per trip to the front, as soon as [ready] holds.
*
* A trip that arrives before [ready] is held, not dropped: it runs when [ready]
* turns true. [onBackground] is passed through to [ForegroundTransitions].
*
* The trip is consumed inside the effect, so [act] fires once however many times
* the effect restarts. Writing a key from inside the effect does restart it — but
* there is no suspension point between consuming it and the call, so the block
* runs to completion before the recomposition that would cancel it is scheduled.
*/
@Composable
fun OnEachForeground(
ready: Boolean,
onBackground: () -> Unit = {},
act: () -> Unit,
) {
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(onForeground = { wanted = true }, onBackground = onBackground)
LaunchedEffect(wanted, ready) {
if (!wanted || !ready) return@LaunchedEffect
wanted = false
act()
}
}
@@ -142,7 +142,7 @@ fun NoteEditorScreen(
BackHandler(onBack = leave)
// The system picker, for any type: a note can carry a PDF as readily as a photo.
// Leaving for it stops this screen, so FlushOnStop has already saved the text by
// Leaving for it stops this screen, so the flush on leaving has already saved the text by
// the time the files come back.
val pickFiles =
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
@@ -152,7 +152,7 @@ fun NoteEditorScreen(
// Leaving the APP is not closing the editor, so the text has to be saved
// without the screen being torn down. Losing a paragraph to an incoming call
// is exactly the failure that makes someone stop trusting a notes app.
FlushOnStop(flush)
ForegroundTransitions(onBackground = flush)
// The sheet shape, kept. `windowInsetsPadding` both insets the card below the
// status bar AND consumes that inset, so the bar inside adds no second gap of
@@ -340,7 +340,7 @@ private fun EditorOverlays(
* How long typing has to stop before the note is written.
*
* Long enough that a normal sentence is one write, short enough that nothing
* meaningful is at risk if the app dies. The flush on close and [FlushOnStop] still
* meaningful is at risk if the app dies. The flush on close and on leaving the app still
* cover the window between the last keystroke and this elapsing.
*/
private const val AUTOSAVE_IDLE_MS = 1_000L
@@ -48,7 +48,6 @@ fun ReminderNotice() {
canNotify = notificationsAllowed(context)
canBeExact = exactAllowed(context)
},
onBackground = {},
)
if (canNotify && canBeExact) return
@@ -53,7 +53,7 @@ fun UpdateCard(
// be up after someone granted it and came back, and the offer below it would
// look blocked when it is not (family idea #5103, practice 9).
var canInstall by remember { mutableStateOf(AppUpdate.canInstall(context)) }
ForegroundTransitions(onForeground = { canInstall = AppUpdate.canInstall(context) }, onBackground = {})
ForegroundTransitions(onForeground = { canInstall = AppUpdate.canInstall(context) })
// The system answers an install through a BroadcastReceiver, which has no way
// back into a view model. This is the seam.