android: sync without being asked (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (debug APK) (push) Canceled after 6m32s

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.
This commit is contained in:
2026-08-19 19:04:45 -04:00
parent 64542ed6cb
commit 452c66c8ef
14 changed files with 615 additions and 11 deletions
+1
View File
@@ -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.
+8
View File
@@ -8,6 +8,14 @@
-->
<uses-permission android:name="android.permission.INTERNET" />
<!--
RECEIVE_BOOT_COMPLETED is NOT declared here and still ends up in the merged
manifest: WorkManager contributes it, so the periodic sync survives a
restart instead of silently stopping until the app is next opened. Noted
because it shows in the app's permission list and there is otherwise
nothing in this file to explain where it came from.
-->
<!--
usesCleartextTraffic, deliberately.
@@ -6,11 +6,18 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
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.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.thoughtsync.ui.BoardScreen
@@ -20,8 +27,10 @@ import com.fabledsword.thoughtsync.ui.ComposeSheet
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
import com.fabledsword.thoughtsync.ui.SyncScreen
import com.fabledsword.thoughtsync.ui.SyncState
import com.fabledsword.thoughtsync.ui.SyncViewModel
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
import com.fabledsword.thoughtsync.ui.olderThan
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -63,6 +72,7 @@ private enum class Screen { BOARD, EDITOR, SYNC }
*/
@Composable
private fun App(core: ThoughtSync) {
val context = LocalContext.current
val board: BoardViewModel = viewModel(factory = BoardViewModel.factory(core))
// A pull can rewrite every note the board is holding, so a sync that changed
// anything tells it to reload. Wired here, at the one place that owns both.
@@ -74,6 +84,11 @@ private fun App(core: ThoughtSync) {
var composing by remember { mutableStateOf(false) }
var showingSync by remember { mutableStateOf(false) }
val settings = remember(context) { SyncSettings(context) }
var automatic by remember { mutableStateOf(settings.automatic) }
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
val editing = board.state.editing
val screen =
when {
@@ -93,6 +108,11 @@ private fun App(core: ThoughtSync) {
onSyncNow = sync::syncNow,
onUnlink = sync::unlink,
onDismissRevokeNotice = sync::dismissRevokeNotice,
automatic = automatic,
onAutomaticChange = {
automatic = it
settings.automatic = it
},
)
Screen.EDITOR ->
@@ -147,6 +167,119 @@ private fun App(core: ThoughtSync) {
BackHandler(enabled = showingSync) { showingSync = false }
}
/**
* Syncing without being asked.
*
* Three moments, and they are not the same job:
*
* - **Coming to the front.** Someone opening the app expects what they are
* looking at to be true. Rate-limited by [STALE_MINUTES] so flicking between
* two apps is not a request for fresh notes.
* - **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 here would be killed halfway.
* - **Every fifteen minutes.** The background heartbeat, so a phone in a pocket
* is roughly current before it is picked up.
*
* Being unlinked or having the switch off makes all three no-ops, and cancels the
* scheduled work rather than merely skipping it.
*/
@Composable
private fun AutomaticSync(
state: SyncState,
enabled: Boolean,
onSync: () -> Unit,
) {
val context = LocalContext.current
// DECLARED as a function of two facts rather than toggled from the places
// that change them. There are four routes to "should not be syncing on its
// own" — never linked, just unlinked, switch off, switch off then unlink —
// and a call at each is four chances to leave a phone quietly syncing after
// it was told to stop.
LaunchedEffect(state.linked, enabled) {
if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context)
}
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(
onForeground = { wanted = true },
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()
}
}
/**
* 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
* 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.
*
* 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.
*
* 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.
*/
@Composable
private fun ForegroundTransitions(
onForeground: () -> Unit,
onBackground: () -> Unit,
) {
val forward by rememberUpdatedState(onForeground)
val away by rememberUpdatedState(onBackground)
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> forward()
Lifecycle.Event.ON_STOP -> away()
else -> Unit
}
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
/**
* How stale the last sync has to be before opening the app triggers another.
*
* Not zero. 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 to
* tell them what they are already looking at. Five minutes is short enough that
* coming back to the phone after doing something else gets current data, and
* long enough that flicking between two apps does not.
*
* Unsent local changes bypass this entirely — those go out at the first chance.
*/
private const val STALE_MINUTES = 5L
/**
* One line of sync state for the drawer, or null when there is nothing to say.
*
@@ -0,0 +1,94 @@
package com.fabledsword.thoughtsync
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
/**
* When the system should sync on its own.
*
* All of the policy lives here rather than being spread across the call sites,
* because the interesting question is not "how do I enqueue work" but "how often
* is often enough" — and that answer should be readable in one place.
*
* Two jobs, deliberately different:
*
* - **[enable]** is the heartbeat. Fifteen minutes is not a preference, it is
* WorkManager's floor for periodic work; asking for less silently gets you
* fifteen anyway. It keeps a phone that is sitting in a pocket roughly current
* so that opening the app is not a wait.
* - **[pushSoon]** is for the moment a person walks away from a note they just
* wrote. Waiting up to fifteen minutes to hand that to the server is the
* difference between "my notes are everywhere" and "my notes are on whichever
* device I used last", which is the whole point of the product.
*
* Both require a network. Without that constraint every run on a phone with no
* signal would wake the process, open SQLite, fail a connection and burn the
* retry budget for nothing.
*/
object SyncSchedule {
/** Every 15 minutes while linked. */
fun enable(context: Context) {
val request =
PeriodicWorkRequestBuilder<SyncWorker>(PERIOD_MINUTES, TimeUnit.MINUTES)
.setConstraints(networkRequired())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
.build()
// UPDATE, not KEEP: this is called on every launch, and KEEP would ignore
// a changed interval forever on any device that had ever enqueued the old
// one. UPDATE applies the change WITHOUT resetting the next run, so
// opening the app repeatedly cannot push the sync further away each time.
WorkManager
.getInstance(context)
.enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.UPDATE, request)
}
/** Stop syncing on our own: unlinked, or the person turned it off. */
fun disable(context: Context) {
WorkManager.getInstance(context).apply {
cancelUniqueWork(PERIODIC)
cancelUniqueWork(PUSH)
}
}
/**
* Get whatever is unsent off this device, as soon as there is a network.
*
* Enqueued when the app goes to the background holding unsent changes, so a
* note survives being written on a phone that is then put away for the night.
*/
fun pushSoon(context: Context) {
val request =
OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(networkRequired())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
.build()
// REPLACE rather than KEEP: if an earlier attempt is sitting in a long
// backoff, the person has just given us a reason to try again sooner.
WorkManager
.getInstance(context)
.enqueueUniqueWork(PUSH, ExistingWorkPolicy.REPLACE, request)
}
private fun networkRequired(): Constraints =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
private const val PERIODIC = "thoughtsync-periodic-sync"
private const val PUSH = "thoughtsync-push-pending"
/** WorkManager's own minimum for periodic work. Asking for less gets this. */
private const val PERIOD_MINUTES = 15L
private const val BACKOFF_SECONDS = 30L
}
@@ -0,0 +1,44 @@
package com.fabledsword.thoughtsync
import android.content.Context
/**
* Whether this device syncs on its own, and nothing else.
*
* Device-local on purpose. Every other piece of sync state — the server, the
* token, the cursor — lives in the core's SQLite file because it describes the
* PAIRING and has to survive a reinstall to the same account. This describes
* how one phone behaves, and a person who turns it off on their handset is not
* asking their laptop to stop.
*
* `SharedPreferences` rather than the store because the background worker reads
* it on a process the system started, where reaching for the core would mean
* depending on the store having opened successfully to answer a question that
* has nothing to do with the store.
*/
class SyncSettings(
context: Context,
) {
// applicationContext: this outlives any Activity, and holding one here would
// leak the whole window when the phone rotates.
private val prefs =
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
/**
* Defaults to ON.
*
* Linking a server IS the consent — a person who paired this device and then
* had to find a second switch before anything moved would reasonably call
* that broken. Turning it off leaves manual sync working exactly as before.
*/
var automatic: Boolean
get() = prefs.getBoolean(KEY_AUTOMATIC, true)
set(value) {
prefs.edit().putBoolean(KEY_AUTOMATIC, value).apply()
}
private companion object {
const val FILE = "thoughtsync-sync"
const val KEY_AUTOMATIC = "automatic"
}
}
@@ -0,0 +1,66 @@
package com.fabledsword.thoughtsync
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
/**
* One sync cycle, run by the system rather than by a person.
*
* WorkManager may start the process to run this, which means [ThoughtSyncApplication.onCreate]
* has already opened the store by the time [doWork] is called — the same handle
* the UI uses, so there is never a second SQLite connection racing the first.
*
* The automatic-sync switch is checked here as well as at scheduling time. That
* does NOT avoid opening the store — `onCreate` has already done it by the time
* any Worker runs — it avoids the network call and the writes.
*
* ## Why the outcome is thrown away
*
* A run that nobody asked for must not become a notification, a banner, or
* anything else that interrupts. If it pulled changes, the board reloads next
* time it is looked at; if it pushed them, they are gone from the outbox. The
* one thing the person can act on — "there are unsent notes" — is already told
* by the drawer badge, from `has_pending`, which does not care how the attempt
* was made.
*/
class SyncWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val core = (applicationContext as? ThoughtSyncApplication)?.core
// Both of these are "nothing to do", not "something went wrong", so both
// report success and let the run retire quietly:
// * automatic sync was switched off after this was enqueued — the
// schedule is cancelled then, but a run already handed to the system
// can still land;
// * the store never opened, which no retry fixes this launch and which
// the UI is already reporting to whoever is looking.
if (!SyncSettings(applicationContext).automatic || core == null) return Result.success()
return try {
// Unlinked since this was enqueued. Not a failure — retrying would
// burn the backoff schedule on a device that has no server.
if (core.syncStatus().linked) {
val outcome = core.syncNow()
Log.i(TAG, "background sync at ${outcome.status.lastSyncAt}")
}
Result.success()
} catch (e: Exception) {
// Deliberately broad, and deliberately `retry` rather than `failure`:
// almost everything that goes wrong here is a flat tyre — no route to
// the server, a laptop asleep, a token being rotated. Retry hands it
// to WorkManager's exponential backoff; `failure` would drop the run
// for good and strand the notes until someone opens the app by hand.
Log.w(TAG, "background sync failed, will retry", e)
Result.retry()
}
}
private companion object {
const val TAG = "ThoughtSyncWorker"
}
}
@@ -20,6 +20,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -28,6 +29,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@@ -60,6 +62,8 @@ fun SyncScreen(
onSyncNow: () -> Unit,
onUnlink: () -> Unit,
onDismissRevokeNotice: () -> Unit,
automatic: Boolean,
onAutomaticChange: (Boolean) -> Unit,
) {
Scaffold(
topBar = {
@@ -93,6 +97,8 @@ fun SyncScreen(
state = state,
onSyncNow = onSyncNow,
onUnlink = onUnlink,
automatic = automatic,
onAutomaticChange = onAutomaticChange,
)
else ->
UnlinkedPanel(
@@ -114,6 +120,8 @@ private fun LinkedPanel(
state: SyncState,
onSyncNow: () -> Unit,
onUnlink: () -> Unit,
automatic: Boolean,
onAutomaticChange: (Boolean) -> Unit,
) {
var confirmingUnlink by remember { mutableStateOf(false) }
@@ -154,6 +162,8 @@ private fun LinkedPanel(
}
}
AutomaticRow(automatic = automatic, enabled = !state.busy, onChange = onAutomaticChange)
if (state.syncing) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
@@ -233,3 +243,46 @@ private fun LinkedPanel(
)
}
}
/**
* The one setting this screen has.
*
* Reads as a statement of what the phone does rather than a feature name, and
* says what "automatically" means in minutes — an interval a person cannot see is
* one they cannot trust, and "syncs automatically" covers everything from every
* keystroke to once a day.
*
* Turning it off is not turning sync off. The copy says so, because a switch next
* to a Disconnect button invites exactly that reading.
*/
@Composable
private fun AutomaticRow(
automatic: Boolean,
enabled: Boolean,
onChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.sync_automatic),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text =
stringResource(
if (automatic) {
R.string.sync_automatic_on
} else {
R.string.sync_automatic_off
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = automatic, onCheckedChange = onChange, enabled = enabled)
}
}
@@ -223,20 +223,44 @@ class SyncViewModel(
}
}
fun syncNow() {
/** A sync the person asked for. Failures are reported. */
fun syncNow() = sync(announce = true)
/**
* A sync nothing asked for — app resume, or the periodic worker.
*
* The difference is entirely in how FAILURE is treated. 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 a server being
* unreachable makes their own notes look broken when nothing of theirs is.
* The quiet channel for a persistent problem is the drawer badge, which reads
* `has_pending` and does not care how the attempt was made.
*
* It does NOT clear an existing error either: a failure the person was already
* shown stays shown until they dismiss it or a real sync succeeds.
*/
fun syncQuietly() = sync(announce = false)
private fun sync(announce: Boolean) {
viewModelScope.launch {
state = state.copy(syncing = true, syncError = null)
state = state.copy(syncing = true, syncError = if (announce) null else state.syncError)
state =
try {
val outcome = core.syncNow()
state.copy(
syncing = false,
// A success clears the error whoever started it: the
// condition it described is demonstrably over.
syncError = null,
lastOutcome = outcome,
status = outcome.status,
pending = withContext(Dispatchers.IO) { core.hasPending() },
)
} catch (e: Exception) {
state.copy(syncing = false, syncError = e.describe())
state.copy(
syncing = false,
syncError = if (announce) e.describe() else state.syncError,
)
}
// Only when something actually arrived: a no-op sync must not make the
// board flash its loading state for nothing.
@@ -70,3 +70,21 @@ fun reminderLabel(
/** Whether a stored reminder has already passed, for showing it as overdue. */
fun isPast(raw: String): Boolean =
runCatching { OffsetDateTime.parse(raw).toInstant() < Instant.now() }.getOrDefault(false)
/**
* Whether a timestamp is older than [minutes] ago — or absent entirely.
*
* Null reads as stale, which is the answer that matters at the one call site:
* a device that has never completed a sync has the most to gain from one.
* Unparseable reads as stale too, for the same reason — guessing "recent" from a
* value we could not understand would suppress the sync that might fix it.
*/
fun olderThan(
raw: String?,
minutes: Long,
): Boolean {
val at = raw?.let { runCatching { OffsetDateTime.parse(it).toInstant() }.getOrNull() }
return at == null || at < Instant.now().minusSeconds(minutes * SECONDS_PER_MINUTE)
}
private const val SECONDS_PER_MINUTE = 60L
@@ -112,6 +112,9 @@
<string name="sync_disconnect">Disconnect</string>
<string name="sync_disconnect_title">Stop syncing with this server?</string>
<string name="sync_disconnect_body">Your notes stay on this device, and the copy on the server is left alone. This device\'s access token is revoked, so it can\'t be used to reach the server again.</string>
<string name="sync_automatic">Sync automatically</string>
<string name="sync_automatic_on">Checks about every 15 minutes, and whenever you open the app.</string>
<string name="sync_automatic_off">Only when you pull the board down or tap Sync now.</string>
<string name="sync_footer">Your notes live on this device either way — syncing just keeps a server copy in step, so your other devices can catch up.</string>
<string name="sync_failed_title">Sync failed</string>
<string name="sync_rejected_title">The server wouldn\'t accept some changes</string>
+5
View File
@@ -57,9 +57,14 @@ exceptions:
# worse for the user.
# * the Application — the store failing to open is the one thing that must
# still let the app start, so it can explain itself.
# * the background Worker — it runs with nobody present, so an escaping
# exception is a crash report for a job the person never asked for. Every
# realistic failure there (no route, server down, token rotating) has the
# same right answer, which is Result.retry().
#
# Scoped to those paths rather than disabled globally: elsewhere the rule is
# right and still applies.
excludes:
- "**/ui/**"
- "**/ThoughtSyncApplication.kt"
- "**/SyncWorker.kt"
+7
View File
@@ -12,6 +12,12 @@ lifecycle = "2.8.7"
activity-compose = "1.9.3"
coroutines = "1.9.0"
# WorkManager runs the background sync. `work-runtime-ktx` is NOT used: as of
# 2.11 it is a 6 KB stub and every Kotlin extension (`PeriodicWorkRequestBuilder`,
# `CoroutineWorker`) has moved into `work-runtime` itself. Verified by unpacking
# both artifacts, not from memory.
work = "2.11.2"
# ktlint and detekt are NOT Gradle plugins here. ci-rust-android already ships
# both as pinned CLIs (M12 step 3), and the CI lane invokes those directly. Adding
# the Gradle plugins would mean a SECOND pinned version of each tool, resolved at
@@ -43,6 +49,7 @@ compose-material3 = { module = "androidx.compose.material3:material3" }
compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "work" }
junit = { module = "junit:junit", version.ref = "junit" }
[plugins]
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Check every R.string / R.plurals reference against strings.xml.
Three ways a resource reference compiles and then fails, none of which ktlint,
detekt or the Kotlin compiler will catch:
1. the name does not exist -> resource-not-found at runtime
2. `stringResource` on a plural (or the reverse) -> wrong overload, wrong text
3. the format string takes more arguments than the call passes -> the format
silently renders `%2$s` as literal text, or throws
python3 android/tools/check-strings.py
Exits non-zero on any problem.
"""
import glob
import os
import re
import sys
import xml.etree.ElementTree as ET
BASE = (
sys.argv[1]
if len(sys.argv) > 1
else os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
)
STRINGS = os.path.join(BASE, "app", "src", "main", "res", "values", "strings.xml")
SOURCES = os.path.join(BASE, "app", "src", "main", "java", "**", "*.kt")
CALL = re.compile(
r"(stringResource|pluralStringResource)\(\s*R\.(string|plurals)\.(\w+)"
r"((?:[^()]|\([^()]*\))*)\)"
)
def arity(text):
"""How many distinct arguments a format string consumes."""
numbered = set(re.findall(r"%(\d)\$", text))
return len(numbered) if numbered else len(re.findall(r"%[sd]", text))
def supplied_args(rest):
"""Count top-level commas in an argument tail.
Kotlin permits a TRAILING comma before the closing paren, which is not an
argument — counting it inflated every multi-line call by one the first time
this was written, and made three correct call sites look broken. Braces count
toward depth as well as parens, or a comma inside a lambda would be read as
another argument.
"""
rest = rest.rstrip()
if rest.endswith(","):
rest = rest[:-1]
depth = 0
count = 0
for ch in rest:
if ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
elif ch == "," and depth == 0:
count += 1
return count
def main():
root = ET.parse(STRINGS).getroot()
strings = {e.get("name"): "".join(e.itertext()) for e in root.findall("string")}
plurals = {
e.get("name"): max(
(arity("".join(i.itertext())) for i in e.findall("item")), default=0
)
for e in root.findall("plurals")
}
problems = 0
for path in glob.glob(SOURCES, recursive=True):
with open(path, encoding="utf-8") as fh:
src = fh.read()
for match in CALL.finditer(src):
fn, kind, name, rest = match.groups()
line = src[: match.start()].count("\n") + 1
where = f"{os.path.basename(path)}:{line} {name}"
if kind == "string" and name not in strings:
print(f"MISSING {where}: no such string")
problems += 1
continue
if kind == "plurals" and name not in plurals:
print(f"MISSING {where}: no such plural")
problems += 1
continue
if (fn == "pluralStringResource") != (kind == "plurals"):
print(f"KIND {where}: {fn} used on R.{kind}")
problems += 1
continue
passed = supplied_args(rest)
# A plural call passes the count first, then the format arguments.
wanted = arity(strings[name]) if kind == "string" else plurals[name] + 1
if passed != wanted:
print(f"ARITY {where}: wants {wanted}, call passes {passed}")
problems += 1
print(f"\n{len(strings)} strings, {len(plurals)} plurals, {problems} problems")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())
+45 -8
View File
@@ -211,25 +211,62 @@ local run says nothing about whether the code builds. It cost a red CI run on
`750d11d`, where `android.os.Build` was lost in a file split and both analyzers
were happy.
So there is a third local check, covering exactly that one blind spot:
So there are two more local checks, each covering one blind spot:
```
python3 android/tools/check-symbols.py
python3 android/tools/check-strings.py
```
It flags any capitalised identifier that is neither imported, declared in the
same package, a type parameter, nor implicitly available. Not a type checker —
`compileDebugKotlin` in CI remains the only real one, and it is also the ONLY
lane that type-checks at all, since there is no Android SDK on the workstation.
Run all three before a push that touches Kotlin.
`check-symbols.py` flags any capitalised identifier that is neither imported,
declared in the same package, a type parameter, nor implicitly available. Not a
type checker — `compileDebugKotlin` in CI remains the only real one, and it is
also the ONLY lane that type-checks at all, since there is no Android SDK on the
workstation.
`check-strings.py` covers resources, where the compiler is no help either: `R`
is generated, so `R.string.whatever` type-checks whether or not the string
exists. It catches a missing name, `stringResource` used on a plural or the
reverse, and a format string that takes more arguments than the call passes —
the last of which renders `%2$s` as literal text rather than failing.
Run all four before a push that touches Kotlin.
A caution worth keeping, because it bit twice: a checker of this shape is itself
easy to get vacuously right. The first version stripped line comments with
`re.sub(r'//.*', src, flags=re.S)`, and DOTALL makes `//.*` swallow each file
from its first comment to EOF — so it reported everything clean by examining
almost nothing. **Test a checker against a known-bad tree before trusting a
green from it**; this one is verified by deleting the `Build` import from a copy
of the source and confirming it fails.
green from it**. `check-symbols.py` is verified by deleting the `Build` import
from a copy of the source; `check-strings.py` by introducing one of each of its
three fault kinds. Its own first version counted Kotlin's trailing commas as
arguments and reported three correct call sites as broken — the opposite failure,
and the one that teaches you to ignore the tool.
## A fourth Kotlin check: read the artifact, don't recall the API
Compose comes from a BOM (`compose-bom` in `libs.versions.toml`), so no file in
this repo states which `material3` a build actually gets. Guessing its API and
finding out from CI costs eight minutes a try. Resolve and read it instead:
```
# androidx is on Google's Maven, NOT Maven Central — repo1 returns 404
BOM=https://dl.google.com/dl/android/maven2/androidx/compose/compose-bom
curl -sS $BOM/2026.05.01/compose-bom-2026.05.01.pom | grep -A3 'material3</artifactId>'
M3=https://dl.google.com/dl/android/maven2/androidx/compose/material3/material3-android
curl -sS -o m3-src.jar $M3/1.4.0/material3-android-1.4.0-sources.jar
```
The sources jar answers what javap cannot: default arguments, parameter names,
and whether a declaration carries `@ExperimentalMaterial3Api`. That last one is
not optional trivia — an unnecessary `@OptIn` is itself a Kotlin warning, so
guessing "safely" breaks the build's zero-warning record just as surely as
omitting a required one breaks the build.
Same technique for any dependency. It is how `work-runtime-ktx` was found to be
an empty 6 KB stub as of 2.11, with `CoroutineWorker` and
`PeriodicWorkRequestBuilder` moved into `work-runtime` itself.
## Checking the Rust lane before pushing