diff --git a/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt b/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt index 7704344..e166f6e 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt @@ -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() } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/FlushOnStop.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/FlushOnStop.kt deleted file mode 100644 index 7cae009..0000000 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/FlushOnStop.kt +++ /dev/null @@ -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) } - } -} diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/ForegroundTransitions.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/ForegroundTransitions.kt index eb95196..c0f9b9e 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/ForegroundTransitions.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/ForegroundTransitions.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteEditorScreen.kt index f3d4143..339afc4 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteEditorScreen.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/ReminderNotice.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/ReminderNotice.kt index e9496e9..5f4c762 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/ReminderNotice.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/ReminderNotice.kt @@ -48,7 +48,6 @@ fun ReminderNotice() { canNotify = notificationsAllowed(context) canBeExact = exactAllowed(context) }, - onBackground = {}, ) if (canNotify && canBeExact) return diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/UpdateCard.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/UpdateCard.kt index ad88b04..062c9b2 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/UpdateCard.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/UpdateCard.kt @@ -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.