From 452c66c8efd3dda036bedae851c28ced71e9087c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 19 Aug 2026 19:04:45 -0400 Subject: [PATCH] android: sync without being asked (M12 step 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now every sync was a button press. Pull-to-refresh made asking cheaper; it did not stop the app needing to be asked, which on a phone means a note written on the bus reaches the desktop whenever you next happen to open the app. Three moments, and they are deliberately not the same job: * **Coming to the front**, if the last sync is over five minutes old or there is unsent work. Not on every foreground: stepping out to copy a link and stepping back is not a request for fresh notes, and syncing on every app switch spends someone's mobile data telling them what they are looking at. * **Going away with unsent work** — handed to WorkManager rather than run inline, because the process is about to stop being a priority and a sync started there would be killed halfway. This is the one that matters most: it is what gets a note off a phone that then goes into a pocket for the night. * **Every fifteen minutes**, network-constrained. Fifteen is not a preference, it is WorkManager's floor for periodic work; asking for less gets fifteen. **An automatic sync must not raise an error banner.** Someone who pulled the board down is owed an answer; someone who merely opened the app did not ask a question, and answering it with a red banner about an unreachable server makes their own notes look broken when nothing of theirs is. So `syncNow` and `syncQuietly` differ in exactly one thing — whether failure is announced. The quiet channel for a persistent problem is the drawer badge, from `has_pending`, which does not care how the attempt was made. **There is a switch, defaulting to on.** Linking a server IS the consent; a person who paired a device and then had to find a second toggle before anything moved would reasonably call that broken. It lives in SharedPreferences rather than the store: everything else in sync state describes the PAIRING and must survive a reinstall, while this describes how one handset behaves, and someone turning it off on their phone is not asking their laptop to stop. The copy says what "automatically" means in minutes and says that off is not off — a switch next to a Disconnect button invites exactly that misreading. The schedule is DECLARED as a function of (linked, switch) in a LaunchedEffect rather than toggled from the places that change them. There are four routes to "should not be syncing on its own" and a call at each is four chances to leave a phone quietly syncing after it was told to stop. `ON_START`/`ON_STOP`, not resume/pause — the same choice the editor's save-on- leave makes, because pause fires for anything covering the window and a sync per notification-shade pull is not automatic sync, it is a stutter. RECEIVE_BOOT_COMPLETED now appears in the merged manifest. WorkManager contributes it so the schedule survives a restart; commented in AndroidManifest because it shows in the app's permission list and nothing else in that file would explain it. Two things read from artifacts rather than recalled, both of which memory would have got wrong: `work-runtime-ktx` is an empty 6 KB stub as of 2.11 with `CoroutineWorker` and `PeriodicWorkRequestBuilder` moved into `work-runtime`, so the dependency is on the latter alone; and `Switch` is not experimental in material3 1.4.0, so no `@OptIn` — an unnecessary one is itself a warning. Also adds `android/tools/check-strings.py`, after this change added three strings: `R` is generated, so `R.string.typo` type-checks whether or not the string exists. It catches a missing name, `stringResource` on a plural or the reverse, and a format taking more arguments than the call passes. Verified against a tree with one of each fault — its first version counted Kotlin's trailing commas as arguments and called three correct sites broken, which is the failure that teaches you to ignore a tool. Two comments in this change were wrong when written and are corrected here rather than left: the flag check in SyncWorker does NOT avoid opening the store, because Application.onCreate has already run by the time any Worker starts. --- android/app/build.gradle.kts | 1 + android/app/src/main/AndroidManifest.xml | 8 ++ .../fabledsword/thoughtsync/MainActivity.kt | 133 ++++++++++++++++++ .../fabledsword/thoughtsync/SyncSchedule.kt | 94 +++++++++++++ .../fabledsword/thoughtsync/SyncSettings.kt | 44 ++++++ .../com/fabledsword/thoughtsync/SyncWorker.kt | 66 +++++++++ .../fabledsword/thoughtsync/ui/SyncScreen.kt | 53 +++++++ .../thoughtsync/ui/SyncViewModel.kt | 30 +++- .../com/fabledsword/thoughtsync/ui/Time.kt | 18 +++ android/app/src/main/res/values/strings.xml | 3 + android/config/detekt.yml | 5 + android/gradle/libs.versions.toml | 7 + android/tools/check-strings.py | 111 +++++++++++++++ ci-requirements.md | 53 +++++-- 14 files changed, 615 insertions(+), 11 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/SyncSchedule.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/SyncSettings.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/SyncWorker.kt create mode 100755 android/tools/check-strings.py diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2bf1987..97e57d3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -223,6 +223,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.work.runtime) // Required by the uniffi bindings — see the catalog note on the @aar // classifier; the plain jar builds fine and fails at runtime. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 09953ca..562c9df 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,14 @@ --> + +