From 750d11d32e8cc6369adae4fb55210d153398745b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 19 Aug 2026 15:41:02 -0400 Subject: [PATCH] android: connect a server from the phone (M12 step 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plumbing has been bound since step 4 — probe, link by password or token, unlink, sync — with nothing on top of it. Until this commit the phone was a good standalone notes app that could not be the SAME notes as the desktop, which is the point of the project. Structurally a port of the desktop's SyncView.vue: same probe-then-link order, same copy wherever the copy was already right. The two surfaces pair with the same servers, and a difference in wording here would read as a difference in behaviour. BEING UNLINKED IS NOT A PROBLEM, and the screen is written around that. It leads with "Working offline on this device" and says what connecting would ADD. A local-first app that frames its resting state as unfinished setup is lying about what it is. The drawer badge follows the same rule: it says nothing at all when unlinked, rather than "Off". Probe before credentials. A typo that reaches a stranger's server should cost a round trip, not a password — so the address is checked first, what answered is shown (name, version, compatibility), and only then does a sign-in form appear. An incompatible server never gets one; the core would refuse the link anyway, and collecting a password to throw away is worse than not asking. CLEARTEXT IS NOW PERMITTED, deliberately and not silently. Android blocks plain http from API 28, and the core explicitly supports a self-hosted server on a LAN — `http://192.168.1.10:8000` is a case it has a test for. The platform default would make this app unusable for exactly the people it is built for, with a transport error they could do nothing about. A network-security-config would be tighter in principle but matches domains and IP literals, not CIDR ranges, so it cannot express "my own network". The other half of the trade is a warning that appears the moment a probed address starts with http:// and BEFORE any credential field: anyone on the same network can read your password and your notes. Credentials never enter the view model. The address, email and device name are `rememberSaveable` so a rotation doesn't cost a retype; the password and the token are plain `remember` on purpose — rememberSaveable persists into the instance-state bundle, and a secret has no business being written there to save four seconds of typing. They reach the core as a `Credentials` sealed type and die with the composable. That sealed type also fixed a bug detekt surfaced by complaining about a six-parameter function: `link_with_token` takes NO device name (the token was already minted against a named device in the web app), so the flat argument list meant the form collected one in token mode and silently dropped it. The field now exists only on the password path. Threading, which differs by call and is easy to get wrong in one direction: probe / linkWithPassword / linkWithToken / unlink / syncNow are Rust async through uniffi, so Kotlin sees suspend functions already driven by tokio and awaits them directly — wrapping them in Dispatchers.IO would park a thread to wait on something that never blocks one. syncStatus and hasPending are ordinary blocking FFI into SQLite and do need it. A sync that changed anything tells the board to reload, because a pull can have rewritten every note it is holding. Wired explicitly at the one place that owns both view models rather than through a shared event bus. A no-op sync deliberately does not, so the board never flashes its loading state for nothing. Sync results are kept RAW in state and turned into sentences in the UI, where stringResource is in scope — the same split Time.kt draws for timestamps. The summary counts what MOVED; batches, pages, noop and cursor are all real numbers and none of them answer "are my notes in step". Rejections are surfaced rather than swallowed: only a person can resolve them. So is a revoke that didn't land — someone disconnecting to retire a phone has to be told a live credential is still out there, and has to still find it when they come back to check, so it is a persistent notice and not a toast. Also here: `Panel`/`Notice` extracted as shared tinted chrome, drawn from the same note palette the cards use rather than Material's errorContainer, so a warning is the same yellow a note can be. `PlainTextField` gained a visual transformation for the password field. `formatReminder` became `formatInstant` now that "last synced" reads it too. Verified locally per ci-requirements.md: ktlint and detekt clean in ci-rust-android:1.97, uniffi bindings generated from a host build and read to confirm ULong on the summary counters, `Compatibility.Ok`/`RevokeOutcome. Unsupported` being objects, and all five sync calls being suspend. Every R.string/R.plurals reference cross-checked for existence, kind and format arity. A symbol-resolution pass over the whole package caught a composable a bad edit had deleted — ktlint and detekt both parse without resolving, so neither could see it. Not done: no automatic sync. The desktop is manual-only too, so this is parity rather than a gap, but pull-to-refresh on the board is the obvious phone-native follow-up. Worth an operator decision, not changed here: allowBackup is still true, so Android's cloud backup now includes a device token as well as the notes. Good for restoring to a new phone, and a wider blast radius than before this commit. Scribe #2777 Co-Authored-By: Claude Opus 5 (1M context) --- android/app/src/main/AndroidManifest.xml | 22 +- .../fabledsword/thoughtsync/MainActivity.kt | 139 +++++-- .../fabledsword/thoughtsync/ui/BoardScreen.kt | 28 ++ .../thoughtsync/ui/EditorPickers.kt | 2 +- .../com/fabledsword/thoughtsync/ui/Panel.kt | 80 ++++ .../fabledsword/thoughtsync/ui/PlainField.kt | 3 + .../fabledsword/thoughtsync/ui/SyncPairing.kt | 363 ++++++++++++++++++ .../fabledsword/thoughtsync/ui/SyncScreen.kt | 235 ++++++++++++ .../fabledsword/thoughtsync/ui/SyncText.kt | 71 ++++ .../thoughtsync/ui/SyncViewModel.kt | 314 +++++++++++++++ .../com/fabledsword/thoughtsync/ui/Time.kt | 7 +- android/app/src/main/res/values/strings.xml | 80 ++++ 12 files changed, 1300 insertions(+), 44 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/Panel.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncPairing.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncScreen.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncText.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncViewModel.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f1c66d5..09953ca 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,25 @@ --> + + android:theme="@style/Theme.ThoughtSync" + android:usesCleartextTraffic="true"> Screen.SYNC + editing != null -> Screen.EDITOR + else -> Screen.BOARD + } + + when (screen) { + Screen.SYNC -> + SyncScreen( + state = sync.state, + onClose = { showingSync = false }, + onProbe = sync::probe, + onClearProbe = sync::clearProbe, + onLink = sync::link, + onSyncNow = sync::syncNow, + onUnlink = sync::unlink, + onDismissRevokeNotice = sync::dismissRevokeNotice, + ) + + Screen.EDITOR -> + NoteEditorScreen( + // Non-null by construction: `screen` is EDITOR only when it is. + note = requireNotNull(editing) { "the editor screen needs a note" }, + labels = board.state.labels, + saving = board.state.saving, + error = board.state.error, + // The one seam between the editor and the store. Exhaustive at the + // other end, so a new action cannot be added without being handled. + onAction = { board.onEditorAction(editing, it) }, + ) + + Screen.BOARD -> { + BoardScreen( + state = board.state, + onOpen = board::open, + onOpenNote = board::openNote, + syncSummary = syncSummary(sync), + onOpenSync = { showingSync = true }, + onSearch = board::search, + onCompose = { composing = true }, + onDismissError = board::dismissError, + ) + + if (composing) { + ComposeSheet( + saving = board.state.saving, + onDismiss = { composing = false }, + onSave = { kind, title, content -> + board.create(kind, title, content) + composing = false + }, + ) + } + } } - BoardScreen( - state = model.state, - onOpen = model::open, - onOpenNote = model::openNote, - onSearch = model::search, - onCompose = { composing = true }, - onDismissError = model::dismissError, - ) + // The sync screen has no back handler of its own, so one lives here. The + // editor keeps its own, because it has to save the open note before leaving. + BackHandler(enabled = showingSync) { showingSync = false } +} - if (composing) { - ComposeSheet( - saving = model.state.saving, - onDismiss = { composing = false }, - onSave = { kind, title, content -> - model.create(kind, title, content) - composing = false - }, - ) +/** + * One line of sync state for the drawer, or null when there is nothing to say. + * + * Deliberately silent while the status is still loading and when the device is + * simply unlinked-and-idle — an "Off" badge on a local-first app would frame its + * normal resting state as something switched off. + */ +@Composable +private fun syncSummary(sync: SyncViewModel): String? { + val state = sync.state + return when { + state.loading -> null + !state.linked -> null + state.pending -> stringResource(R.string.sync_badge_unsent) + else -> stringResource(R.string.sync_badge_on) } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt index 46db948..c490a6f 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt @@ -56,6 +56,14 @@ fun BoardScreen( state: BoardState, onOpen: (Destination) -> Unit, onOpenNote: (Note) -> Unit, + /** + * One line of sync state for the drawer, or null when there is nothing worth + * saying. Passed in rather than read from [BoardState]: sync has its own view + * model, and giving the board a copy of it would be two sources of truth for + * whether this device is linked. + */ + syncSummary: String?, + onOpenSync: () -> Unit, onSearch: (String) -> Unit, onCompose: () -> Unit, onDismissError: () -> Unit, @@ -69,10 +77,15 @@ fun BoardScreen( NavigationDrawer( current = state.destination, labels = state.labels, + syncSummary = syncSummary, onOpen = { onOpen(it) scope.launch { drawerState.close() } }, + onOpenSync = { + onOpenSync() + scope.launch { drawerState.close() } + }, ) }, ) { @@ -172,7 +185,9 @@ private fun SearchBar( private fun NavigationDrawer( current: Destination, labels: List