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() refresh()
} }
ForegroundTransitions(onForeground = { refresh() }, onBackground = {}) ForegroundTransitions(onForeground = { refresh() })
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below // TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below
@@ -461,17 +461,9 @@ private fun AutomaticUpdate(
linked: Boolean, linked: Boolean,
onCheck: () -> Unit, onCheck: () -> Unit,
) { ) {
var wanted by remember { mutableStateOf(false) } // Waits for a link rather than dropping the trip: linking a device while the app is
ForegroundTransitions(onForeground = { wanted = true }, onBackground = {}) // in front checks as soon as it is linked.
OnEachForeground(ready = linked, act = onCheck)
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()
}
} }
/** /**
@@ -508,28 +500,18 @@ private fun AutomaticSync(
if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context) if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context)
} }
var wanted by remember { mutableStateOf(false) } // Ready once `loading` is done, so the decision waits for the stored link to be
ForegroundTransitions( // READ. At first composition `linked` is still false because nothing has looked in
onForeground = { wanted = true }, // the database yet, and acting on that would skip the sync on every cold start.
OnEachForeground(
ready = !state.loading,
onBackground = { onBackground = {
// Unsent work follows the person out of the app. Without this, a note // 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 // 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. // not reach the desktop until the app is opened again by hand.
if (enabled && state.linked && state.pending) SyncSchedule.pushSoon(context) 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) val worthIt = state.pending || olderThan(state.status?.lastSyncAt, STALE_MINUTES)
if (enabled && state.linked && worthIt) onSync() 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner 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. * 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 * `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`: resume and pause fire for
* the editor's save-on-leave makes for the same reason: resume and pause fire for
* anything that merely covers the window — a permission dialog, the notification * 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 * 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 * Activity is ever added this needs `ProcessLifecycleOwner` instead, or rotating
* between them will read as leaving and returning. * between them will read as leaving and returning.
* *
* Two callers, wanting opposite halves of it: automatic sync uses the return to * Callers want different halves of it: automatic sync uses both, the reminder
* decide whether to fetch, and the reminder notice uses it to re-read a * notice and the update card use the return to re-read a permission the person may
* permission the person may have just changed in the system settings. * 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 * Both callbacks go through [rememberUpdatedState]: the observer is registered
* once, and without it the lambda would keep reading the first composition's * 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 * 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 @Composable
fun ForegroundTransitions( fun ForegroundTransitions(
onForeground: () -> Unit, onForeground: () -> Unit = {},
onBackground: () -> Unit, onBackground: () -> Unit = {},
) { ) {
val forward by rememberUpdatedState(onForeground) val forward by rememberUpdatedState(onForeground)
val away by rememberUpdatedState(onBackground) val away by rememberUpdatedState(onBackground)
@@ -50,3 +57,29 @@ fun ForegroundTransitions(
onDispose { owner.lifecycle.removeObserver(observer) } 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) BackHandler(onBack = leave)
// The system picker, for any type: a note can carry a PDF as readily as a photo. // 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. // the time the files come back.
val pickFiles = val pickFiles =
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> 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 // 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 // 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. // 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 // 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 // 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. * How long typing has to stop before the note is written.
* *
* Long enough that a normal sentence is one write, short enough that nothing * 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. * cover the window between the last keystroke and this elapsing.
*/ */
private const val AUTOSAVE_IDLE_MS = 1_000L private const val AUTOSAVE_IDLE_MS = 1_000L
@@ -48,7 +48,6 @@ fun ReminderNotice() {
canNotify = notificationsAllowed(context) canNotify = notificationsAllowed(context)
canBeExact = exactAllowed(context) canBeExact = exactAllowed(context)
}, },
onBackground = {},
) )
if (canNotify && canBeExact) return 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 // 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). // look blocked when it is not (family idea #5103, practice 9).
var canInstall by remember { mutableStateOf(AppUpdate.canInstall(context)) } 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 // The system answers an install through a BroadcastReceiver, which has no way
// back into a view model. This is the seam. // back into a view model. This is the seam.