ce4aae51c322

route every terminating sync path through SyncOutcomeStore: relay sessions report failures instead of swallowing them, syncNow drops the unearned LastSynced claim

time
2026-07-26 19:20 UTC · 2 months ago
machine
koh2
agent
claude-opus-5
session
android sync indicator
parent
f7bb934c823b
12 files app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+17 −1app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+75 −50app/src/main/java/com/peachpasswords/sync/SyncOutcomeReason.kt+41 −9app/src/main/java/com/peachpasswords/sync/SyncOutcomeStore.kt+2 −13app/src/main/java/com/peachpasswords/sync/SyncStatus.kt+0 −9app/src/main/java/com/peachpasswords/ui/components/SyncIndicatorDot.kt+76 −0app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+30 −15app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+67 −11app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+35 −37app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+9 −0app/src/test/java/com/peachpasswords/sync/SyncOutcomeTest.kt+207 −0app/src/test/java/com/peachpasswords/ui/vault/VaultListSyncDotTest.kt+0 −30
app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+17 −1
@@ -1,5 +1,6 @@11 package com.peachpasswords.sync22 3+import kotlinx.coroutines.CancellationException34 import org.json.JSONObject45 56 /**@@ -24,6 +25,7 @@2425     localIdentity: LocalDeviceIdentity,2526     vaultFactory: () -> SyncVaultAdapter?,2627     onSyncComplete: () -> Unit,28+    report: (SyncOutcomeSummary) -> Unit = {},2729 ) {2830     val responderKeyHex = known.longTermPublicKeyHex2931         ?: throw IllegalArgumentException("known peer missing static key")@@ -45,6 +47,9 @@4547         socket::send,4648         socket::receive,4749     )50+    // KK completed, so a peer is really there and answered. Everything past this51+    // point is a reportable outcome; everything before it is a dial into an52+    // empty room, which is the resting state of a phone, not a failure.4853     try {4954         socket.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))5055         val theirIdentity = parsePairIdentityFrame(@@ -54,7 +59,10 @@5459             "peer identity does not match authenticated KK key"5560         }5661 57-        val vault = vaultFactory() ?: return62+        val vault = vaultFactory() ?: run {63+            report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))64+            return65+        }5866         vault.bindAuthenticatedPeer(known)5967         val outcome = runSyncInitiator(6068             SyncSession(@@ -68,6 +76,14 @@6876         if (outcome.status == SyncOutcomeStatus.COMPLETED) {6977             onSyncComplete()7078         }79+        report(outcome.toSummary())80+    } catch (e: CancellationException) {81+        throw e82+    } catch (e: Exception) {83+        // Rethrown unchanged: the caller's retry loop owns what happens next.84+        // This only makes the failure sayable.85+        report(SyncOutcomeSummary(reason = classifySyncError(e.message ?: e.toString()), detail = e.message))86+        throw e7187     } finally {7288         session.close()7389     }

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+75 −50
@@ -1,6 +1,7 @@11 package com.peachpasswords.sync22 33 import com.peachpasswords.vault.VaultCrypto4+import kotlinx.coroutines.CancellationException45 import kotlinx.coroutines.CompletableDeferred56 import kotlinx.coroutines.CoroutineScope67 import kotlinx.coroutines.Dispatchers@@ -47,6 +48,7 @@4748 class CloudRelayReconnectListener @Inject constructor(4849     private val deviceStore: PairedDeviceStore,4950     private val relaySettings: RelaySettingsStore,51+    private val syncOutcomes: SyncOutcomeStore,5052 ) {5153     private var supervisor: Job? = null5254     private val watchers = mutableMapOf<String, Job>()@@ -180,6 +182,7 @@180182                                         onSyncComplete = {181183                                             deviceStore.updateLastSync(known.deviceId)182184                                         },185+                                        report = syncOutcomes::report,183186                                     )184187                                 }185188                             } else {@@ -194,6 +197,7 @@194197                                     onSyncComplete = {195198                                         deviceStore.updateLastSync(known.deviceId)196199                                     },200+                                    report = syncOutcomes::report,197201                                 )198202                             }199203                         } finally {@@ -233,65 +237,86 @@233237     localIdentity: LocalDeviceIdentity,234238     vaultFactory: () -> SyncVaultAdapter?,235239     onSyncComplete: () -> Unit,240+    report: (SyncOutcomeSummary) -> Unit = {},236241 ) {242+    // Sitting in the room is what this device does all day, so nothing is243+    // reportable until a peer has actually spoken. That frame is the line.237244     val selector = JSONObject(String(socket.receive(), Charsets.UTF_8))238-    require(selector.optString("type") == "PEACH_RECONNECT") {239-        "expected PEACH_RECONNECT"240-    }241-    val presentedId = selector.optString("rendezvous_id")242-    require(243-        MessageDigest.isEqual(244-            registeredRendezvousId.toByteArray(Charsets.US_ASCII),245-            presentedId.toByteArray(Charsets.US_ASCII),246-        ),247-    ) { "reconnect capability does not match relay room" }248-    val secret = known.rendezvousSecretHex249-        ?.let { runCatching { Rendezvous.secretFromHex(it) }.getOrNull() }250-        ?: throw IllegalArgumentException("known peer missing rendezvous secret")251-    require(252-        Rendezvous.waitingIds(secret, System.currentTimeMillis() / 1000L).any {245+    try {246+        require(selector.optString("type") == "PEACH_RECONNECT") {247+            "expected PEACH_RECONNECT"248+        }249+        val presentedId = selector.optString("rendezvous_id")250+        require(253251             MessageDigest.isEqual(254-                it.toByteArray(Charsets.US_ASCII),255252                 registeredRendezvousId.toByteArray(Charsets.US_ASCII),256-            )257-        },258-    ) { "relay room is outside the accepted clock-skew windows" }253+                presentedId.toByteArray(Charsets.US_ASCII),254+            ),255+        ) { "reconnect capability does not match relay room" }256+        val secret = known.rendezvousSecretHex257+            ?.let { runCatching { Rendezvous.secretFromHex(it) }.getOrNull() }258+            ?: throw IllegalArgumentException("known peer missing rendezvous secret")259+        require(260+            Rendezvous.waitingIds(secret, System.currentTimeMillis() / 1000L).any {261+                MessageDigest.isEqual(262+                    it.toByteArray(Charsets.US_ASCII),263+                    registeredRendezvousId.toByteArray(Charsets.US_ASCII),264+                )265+            },266+        ) { "relay room is outside the accepted clock-skew windows" }259267 260-    val initiatorKeyHex = known.longTermPublicKeyHex261-        ?: throw IllegalArgumentException("known peer missing static key")262-    val initiatorKey = hexToPubkey(initiatorKeyHex)263-    val session = noiseKKResponder(264-        ourStaticPrivateKey,265-        ourStaticPublicKey,266-        initiatorKey,267-        socket::send,268-        socket::receive,269-    )270-    try {271-        val theirIdentity = parsePairIdentityFrame(272-            SyncPadding.unpad(session.decrypt(socket.receive())),268+        val initiatorKeyHex = known.longTermPublicKeyHex269+            ?: throw IllegalArgumentException("known peer missing static key")270+        val initiatorKey = hexToPubkey(initiatorKeyHex)271+        val session = noiseKKResponder(272+            ourStaticPrivateKey,273+            ourStaticPublicKey,274+            initiatorKey,275+            socket::send,276+            socket::receive,273277         )274-        require(identityMatchesAuthenticatedPeer(known, theirIdentity, initiatorKey)) {275-            "peer identity does not match authenticated KK key"276-        }277-        socket.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))278+        try {279+            val theirIdentity = parsePairIdentityFrame(280+                SyncPadding.unpad(session.decrypt(socket.receive())),281+            )282+            require(identityMatchesAuthenticatedPeer(known, theirIdentity, initiatorKey)) {283+                "peer identity does not match authenticated KK key"284+            }285+            socket.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))278286 279-        val vault = vaultFactory() ?: return280-        vault.bindAuthenticatedPeer(known)281-        val outcome = runSyncResponder(282-            SyncSession(283-                remoteStaticPublicKey = initiatorKey.copyOf(),284-                send = { plaintext -> socket.send(session.encrypt(SyncPadding.pad(plaintext))) },285-                receive = { SyncPadding.unpad(session.decrypt(socket.receive())) },286-                close = { socket.close() },287+            val vault = vaultFactory() ?: run {288+                report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))289+                return290+            }291+            vault.bindAuthenticatedPeer(known)292+            val outcome = runSyncResponder(293+                SyncSession(294+                    remoteStaticPublicKey = initiatorKey.copyOf(),295+                    send = { plaintext -> socket.send(session.encrypt(SyncPadding.pad(plaintext))) },296+                    receive = { SyncPadding.unpad(session.decrypt(socket.receive())) },297+                    close = { socket.close() },298+                ),299+                vault,300+            )301+            if (outcome.status == SyncOutcomeStatus.COMPLETED) {302+                onSyncComplete()303+            }304+            report(outcome.toSummary())305+        } finally {306+            session.close()307+        }308+    } catch (e: CancellationException) {309+        throw e310+    } catch (e: Exception) {311+        // Rethrown unchanged: the caller's retry loop owns what happens next.312+        // This only makes the failure sayable.313+        report(314+            SyncOutcomeSummary(315+                reason = classifySyncError(e.message ?: e.toString()),316+                detail = e.message,287317             ),288-            vault,289318         )290-        if (outcome.status == SyncOutcomeStatus.COMPLETED) {291-            onSyncComplete()292-        }293-    } finally {294-        session.close()319+        throw e295320     }296321 }297322 

view file

app/src/main/java/com/peachpasswords/sync/SyncOutcomeReason.kt+41 −9
@@ -14,16 +14,23 @@1414  * swallowed by the reconnect loop's `runCatching`, so "synced fine", "the peer1515  * refused us" and "the vault was locked" were one indistinguishable state.1616  *17- * Two browser outcomes are deliberately absent:17+ * Two browser outcomes are deliberately absent, because on Android they name18+ * states that cannot occur:1819  *  - `runner_unavailable` — the browser injects its per-device sync runner and1920  *    can find it missing. Android's runner is [PeachSyncService], which is2021  *    always present; the only way it fails to run is a locked vault, which is2122  *    [VAULT_LOCKED].22- *  - `no_eligible_host` means something different here and is kept for that23- *    meaning: the browser rejects peers it cannot dial, whereas this phone24- *    usually *waits* and can talk to any peer type. What it cannot talk to is a25- *    peer with no rendezvous secret, which the reconnect loop skips in silence.26- *    See [weDialPeer] for why the roles differ.23+ *  - `private_relay_unset` — the browser treats "private mode, no address" as24+ *    terminal. `RelaySettingsStore.relayUrl()` always falls back to the public25+ *    relay instead, so here that configuration syncs rather than failing, and26+ *    a private URL equal to the public one is indistinguishable from none.27+ *    Reporting it would be a guess.28+ *29+ * `no_eligible_host` is kept but means something narrower than the browser's:30+ * the browser rejects peers it cannot dial, whereas this phone usually *waits*31+ * and can talk to any peer type. What it cannot talk to is a peer with no32+ * rendezvous secret, which the reconnect loop skips in silence. See33+ * [weDialPeer] for why the roles differ.2734  */2835 enum class SyncOutcomeReason {2936     COMPLETED_CHANGES,@@ -31,7 +38,6 @@3138     NO_PAIRED_DEVICES,3239     NO_ELIGIBLE_HOST,3340     SYNC_DISABLED,34-    PRIVATE_RELAY_UNSET,3541     VAULT_LOCKED,3642     RELAY_UNREACHABLE,3743     HANDSHAKE_FAILED,@@ -44,7 +50,6 @@4450     val isFailure: Boolean4551         get() = when (this) {4652             NO_ELIGIBLE_HOST,47-            PRIVATE_RELAY_UNSET,4853             VAULT_LOCKED,4954             RELAY_UNREACHABLE,5055             HANDSHAKE_FAILED,@@ -125,7 +130,6 @@125130         SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"126131         SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"127132         SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"128-        SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"129133         SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"130134         SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"131135         SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"@@ -159,4 +163,32 @@159163     Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE)160164         .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE161165     else -> SyncOutcomeReason.RUN_FAILED166+}167+168+/**169+ * Translate one finished session ([SyncCoordinator]'s per-round result) into the170+ * user-facing vocabulary. Tombstones count as changes: a deletion that crossed171+ * the wire is not "nothing to send or receive".172+ */173+internal fun SyncOutcome.toSummary(): SyncOutcomeSummary = when (status) {174+    SyncOutcomeStatus.COMPLETED -> SyncOutcomeSummary(175+        reason = if (entriesSent + entriesReceived + tombstonesSent + tombstonesReceived > 0) {176+            SyncOutcomeReason.COMPLETED_CHANGES177+        } else {178+            SyncOutcomeReason.COMPLETED_NO_CHANGES179+        },180+        entriesSent = entriesSent,181+        entriesReceived = entriesReceived,182+    )183+184+    SyncOutcomeStatus.VAULT_MISMATCH ->185+        SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_ID_MISMATCH, detail = error)186+187+    SyncOutcomeStatus.TIMEOUT ->188+        SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_TIMEOUT, detail = error)189+190+    SyncOutcomeStatus.ERRORED -> SyncOutcomeSummary(191+        reason = classifySyncError(error ?: "sync_failed"),192+        detail = error,193+    )162194 }

view file

app/src/main/java/com/peachpasswords/sync/SyncOutcomeStore.kt+2 −13
@@ -48,19 +48,8 @@4848      * A run ended. The counterpart of the browser's `finish()`: no sync path may4949      * terminate without passing through here.5050      */51-    fun report(52-        reason: SyncOutcomeReason,53-        detail: String? = null,54-        entriesSent: Int = 0,55-        entriesReceived: Int = 0,56-    ) {57-        val next = SyncOutcomeSummary(58-            reason = reason,59-            detail = detail,60-            entriesSent = entriesSent,61-            entriesReceived = entriesReceived,62-            finishedAt = System.currentTimeMillis(),63-        )51+    fun report(outcome: SyncOutcomeSummary) {52+        val next = outcome.copy(syncing = false, finishedAt = System.currentTimeMillis())6453         prefs.edit().putString(KEY_LAST, encodeSyncOutcome(next)).apply()6554         _summary.value = next6655     }

view file

app/src/main/java/com/peachpasswords/sync/SyncStatus.kt+0 −9
@@ -1,9 +0,0 @@1-package com.peachpasswords.sync2-3-sealed class SyncStatus {4-    data object Idle : SyncStatus()5-    data object Syncing : SyncStatus()6-    data class LastSynced(val timestamp: Long) : SyncStatus()7-    data class Error(val message: String) : SyncStatus()8-    data object Offline : SyncStatus()9-}

view file at the parent

app/src/main/java/com/peachpasswords/ui/components/SyncIndicatorDot.kt+76 −0
@@ -0,0 +1,76 @@1+package com.peachpasswords.ui.components2+3+import androidx.compose.animation.core.RepeatMode4+import androidx.compose.animation.core.animateFloat5+import androidx.compose.animation.core.infiniteRepeatable6+import androidx.compose.animation.core.rememberInfiniteTransition7+import androidx.compose.animation.core.tween8+import androidx.compose.animation.animateColorAsState9+import androidx.compose.foundation.background10+import androidx.compose.foundation.layout.Box11+import androidx.compose.foundation.layout.size12+import androidx.compose.foundation.shape.CircleShape13+import androidx.compose.runtime.Composable14+import androidx.compose.runtime.getValue15+import androidx.compose.ui.Modifier16+import androidx.compose.ui.draw.alpha17+import androidx.compose.ui.draw.clip18+import androidx.compose.ui.semantics.contentDescription19+import androidx.compose.ui.semantics.semantics20+import androidx.compose.ui.unit.Dp21+import androidx.compose.ui.unit.dp22+import com.peachpasswords.sync.SyncIndicator23+import com.peachpasswords.ui.theme.LocalReduceMotion24+import com.peachpasswords.ui.theme.peachColors25+26+/**27+ * The sync indicator light, shared by the vault list and the Sync settings card28+ * so the two can never disagree. Green pulsing while a sync is running, solid29+ * green on a healthy pair, red on a problem, unlit when there is nothing to30+ * report — the same four states as the browser's `stk-pip`.31+ *32+ * [label] is not decoration: colour alone is never the signal, so it carries33+ * the same claim in words for TalkBack and for a long-press tooltip.34+ */35+@Composable36+fun SyncIndicatorDot(37+    indicator: SyncIndicator,38+    label: String,39+    modifier: Modifier = Modifier,40+    size: Dp = 6.dp,41+) {42+    val color by animateColorAsState(43+        targetValue = when (indicator) {44+            // Busy is green too, not amber: it is the same healthy sync, in45+            // progress. The pulse carries "in progress", the hue carries "well".46+            SyncIndicator.BUSY, SyncIndicator.OK -> peachColors.SyncOk47+            SyncIndicator.ERROR -> peachColors.SyncErr48+            SyncIndicator.IDLE -> peachColors.T249+        },50+        label = "sync-indicator",51+    )52+    val pulsing = indicator == SyncIndicator.BUSY && !LocalReduceMotion.current53+    val alpha = if (pulsing) {54+        val transition = rememberInfiniteTransition(label = "sync-pulse")55+        val value by transition.animateFloat(56+            initialValue = 1f,57+            targetValue = 0.3f,58+            animationSpec = infiniteRepeatable(59+                animation = tween(900),60+                repeatMode = RepeatMode.Reverse,61+            ),62+            label = "sync-pulse-alpha",63+        )64+        value65+    } else {66+        1f67+    }68+    Box(69+        modifier70+            .size(size)71+            .alpha(alpha)72+            .clip(CircleShape)73+            .background(color)74+            .semantics { contentDescription = label },75+    )76+}

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+30 −15
@@ -31,7 +31,10 @@3131 import com.peachpasswords.feedback.FeedbackState3232 import com.peachpasswords.shared.PeachClipboard3333 import com.peachpasswords.sync.SyncMode34-import com.peachpasswords.sync.SyncStatus34+import com.peachpasswords.sync.describeSyncOutcome35+import com.peachpasswords.sync.syncIndicator36+import com.peachpasswords.sync.syncIndicatorLabel37+import com.peachpasswords.ui.components.SyncIndicatorDot3538 import com.peachpasswords.ui.theme.LocalPeachAccent3639 import com.peachpasswords.vault.DuressAction3740 import com.peachpasswords.ui.theme.peachColors@@ -693,6 +696,32 @@693696         SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }694697     }695698 699+    // The indicator light and the same claim in words, in the browser's place:700+    // directly above PAIRED DEVICES. Outside the "has devices" branch on701+    // purpose — a phone with nothing paired is exactly the phone whose owner702+    // needs to be told why nothing is syncing.703+    SubLabel("SYNC STATUS", fg)704+    val indicator = syncIndicator(syncStatus, pairedDevices.size)705+    RowLine(fg) {706+        SyncIndicatorDot(707+            indicator = indicator,708+            label = syncIndicatorLabel(indicator, syncStatus),709+            modifier = Modifier.padding(end = PeachSpacing.sm),710+            size = 8.dp,711+        )712+        Text(713+            describeSyncOutcome(syncStatus),714+            color = fg.copy(alpha = 0.7f),715+            fontFamily = PeachType.Body,716+            fontSize = 11.sp,717+            lineHeight = 16.sp,718+            modifier = Modifier.weight(1f),719+        )720+        SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {721+            if (!syncStatus.syncing) syncVm.syncNow()722+        }723+    }724+696725     SubLabel("PAIRED DEVICES", fg)697726     if (pairedDevices.isEmpty()) {698727         PeachEmptyOrEndState(@@ -742,12 +771,6 @@742771                 }743772             }744773         }745-        RowLine(fg) {746-            ValueText(syncFlashLabel(syncStatus), fg)747-            SubtleButton(if (syncStatus == SyncStatus.Syncing) "syncing..." else "sync now", fg) {748-                if (syncStatus != SyncStatus.Syncing) syncVm.syncNow()749-            }750-        }751774     }752775 753776     // Three named choices, not a toggle with an implied default. LAN sync is@@ -894,14 +917,6 @@894917 895918 private fun isSyncStale(lastSyncedAt: Long?): Boolean =896919     lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L897-898-private fun syncFlashLabel(status: SyncStatus): String = when (status) {899-    SyncStatus.Idle -> ""900-    SyncStatus.Syncing -> "syncing..."901-    SyncStatus.Offline -> "offline"902-    is SyncStatus.Error -> status.message903-    is SyncStatus.LastSynced -> "synced ${syncTimeLabel(status.timestamp)}"904-}905920 906921 /* ────────────────────────────────  Pro  ─────────────────────────────────── */907922 

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+67 −11
@@ -13,7 +13,9 @@1313 import com.peachpasswords.sync.RelaySettingsStore1414 import com.peachpasswords.sync.SyncMode1515 import com.peachpasswords.sync.SyncAlertStore16-import com.peachpasswords.sync.SyncStatus16+import com.peachpasswords.sync.SyncOutcomeReason17+import com.peachpasswords.sync.SyncOutcomeStore18+import com.peachpasswords.sync.SyncOutcomeSummary1719 import com.peachpasswords.sync.VaultSyncAdapter1820 import com.peachpasswords.sync.buildLocalIdentity1921 import com.peachpasswords.sync.deriveLocalSyncKeypair@@ -27,6 +29,8 @@2729 import kotlinx.coroutines.flow.StateFlow2830 import kotlinx.coroutines.flow.asStateFlow2931 import kotlinx.coroutines.flow.stateIn32+import kotlinx.coroutines.delay33+import kotlinx.coroutines.Job3034 import kotlinx.coroutines.launch3135 import javax.inject.Inject3236 @@ -49,10 +53,11 @@4953     private val pendingVouchedStore: PendingVouchedDeviceStore,5054     private val syncAlertStore: SyncAlertStore,5155     private val relaySettings: RelaySettingsStore,56+    private val syncOutcomes: SyncOutcomeStore,5257 ) : ViewModel() {5358 54-    private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)55-    val status: StateFlow<SyncStatus> = _status.asStateFlow()59+    /** The last sync attempt, whatever it was. Survives process death. */60+    val status: StateFlow<SyncOutcomeSummary> = syncOutcomes.summary5661 5762     private val _relayUrl = MutableStateFlow(relaySettings.relayUrl())5863     val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()@@ -126,7 +131,7 @@126131         val ok = vault.rotateSyncIdentity()127132         if (ok) {128133             deviceStore.clear()129-            _status.value = SyncStatus.Idle134+            syncOutcomes.reset()130135         }131136         return ok132137     }@@ -144,21 +149,67 @@144149     }145150 146151     /**147-     * Manual trigger. Reports Offline when no peers are paired; otherwise148-     * brings the relay service up (or nudges it, if already running) so it149-     * reconnects to every peer's room straight away.152+     * Manual trigger, and the one place a sync attempt is started on purpose.153+     *154+     * It used to claim a completed sync the instant the service was155+     * asked to start, which is a success it had not earned — the service only156+     * re-reads the device list, and the sync itself happens later in a157+     * rendezvous room or not at all. Now the attempt goes busy and the outcome158+     * comes from whoever actually finishes it.150159      */151160     fun syncNow() {161+        if (!relaySettings.isSyncEnabled()) {162+            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_DISABLED))163+            return164+        }152165         if (!vault.isUnlocked.value) {153-            _status.value = SyncStatus.Error("vault_locked")166+            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))154167             return155168         }156-        if (pairedDevices.value.isEmpty()) {157-            _status.value = SyncStatus.Offline169+        // Read the store, not `pairedDevices.value`: that flow only holds a real170+        // list while something is collecting it, so pull-to-refresh on the vault171+        // list — which collects `status` and nothing else — always saw an empty172+        // list and reported "no devices paired" against a fully paired phone.173+        val devices = deviceStore.getAll()174+        if (devices.isEmpty()) {175+            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))158176             return159177         }178+        // The same filter CloudRelayReconnectListener applies when it decides179+        // which rooms to hold — a peer with no rendezvous secret is skipped180+        // there in silence, which is precisely the case that must not look like181+        // a successful no-op.182+        if (devices.none { it.rendezvousSecretHex != null }) {183+            syncOutcomes.report(184+                SyncOutcomeSummary(185+                    reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,186+                    detail = devices.joinToString("; ") {187+                        "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"188+                    },189+                ),190+            )191+            return192+        }193+194+        syncOutcomes.begin()160195         PeachSyncService.start(context)161-        _status.value = SyncStatus.LastSynced(System.currentTimeMillis())196+        // Nothing else can close this attempt: the phone usually WAITS to be197+        // dialled, so if no peer turns up there is no session to report a198+        // failure from and the light would pulse for ever. Long enough to cover199+        // a dial round-trip (DIAL_SESSION_TIMEOUT_MS), short enough to answer200+        // the person holding the phone.201+        attemptTimeout?.cancel()202+        attemptTimeout = viewModelScope.launch {203+            delay(SYNC_ATTEMPT_TIMEOUT_MS)204+            if (syncOutcomes.summary.value.syncing) {205+                syncOutcomes.report(206+                    SyncOutcomeSummary(207+                        reason = SyncOutcomeReason.RELAY_UNREACHABLE,208+                        detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",209+                    ),210+                )211+            }212+        }162213     }163214 164215     /**@@ -167,4 +218,9 @@167218      */168219     fun triggerSync() = syncNow()169220 221+    private var attemptTimeout: Job? = null222+223+    private companion object {224+        const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L225+    }170226 }

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+35 −37
@@ -93,13 +93,19 @@9393 import com.peachpasswords.sync.SyncAlert9494 import com.peachpasswords.haptics.PeachHaptics9595 import com.peachpasswords.sync.syncAlertMessage96-import com.peachpasswords.sync.SyncStatus96+import com.peachpasswords.sync.SyncIndicator97+import com.peachpasswords.sync.SyncOutcomeReason98+import com.peachpasswords.sync.SyncOutcomeSummary99+import com.peachpasswords.sync.describeSyncOutcome100+import com.peachpasswords.sync.syncIndicator101+import com.peachpasswords.sync.syncIndicatorLabel97102 import com.peachpasswords.ui.sync.SyncViewModel98103 import com.peachpasswords.autofill.AutofillEnablement99104 import com.peachpasswords.ui.PeachNav100105 import com.peachpasswords.ui.components.PeachEmptyOrEndState101106 import com.peachpasswords.ui.components.PeachFilterPills102107 import com.peachpasswords.ui.components.PeachSegment108+import com.peachpasswords.ui.components.SyncIndicatorDot103109 import com.peachpasswords.ui.components.FaviconInitials104110 import com.peachpasswords.ui.theme.LocalPeachAccent105111 import com.peachpasswords.ui.theme.PeachSpacing@@ -156,6 +162,7 @@156162     // Pull-to-refresh drives a manual sync round against paired devices.157163     val syncVm: SyncViewModel = hiltViewModel()158164     val syncStatus by syncVm.status.collectAsState()165+    val pairedDevices by syncVm.pairedDevices.collectAsState()159166 160167     LaunchedEffect(undoEvent) {161168         val ev = undoEvent ?: return@LaunchedEffect@@ -168,16 +175,25 @@168175         if (result == SnackbarResult.ActionPerformed) vm.restoreEntries(ev.ids)169176     }170177 171-    // Pulling to refresh with nothing paired used to retract in silence, which172-    // reads as "sync ran and found nothing" rather than "there is nobody to173-    // sync with". Say which.174-    LaunchedEffect(syncStatus) {175-        if (syncStatus is SyncStatus.Offline) {176-            snackbarHostState.showSnackbar(177-                message = "No devices paired yet — add one in Settings › Sync",178-                duration = SnackbarDuration.Short,179-            )180-        }178+    // Pulling to refresh used to retract in silence on every path that was not179+    // a clean sync, which reads as "sync ran and found nothing" rather than180+    // "there is nobody to sync with" or "the peer refused us". Say which — but181+    // only for attempts that finish while she is looking, never for the182+    // restored outcome of a sync that ended days ago.183+    val openedAt = remember { System.currentTimeMillis() }184+    LaunchedEffect(syncStatus.finishedAt) {185+        val finishedAt = syncStatus.finishedAt ?: return@LaunchedEffect186+        if (finishedAt < openedAt) return@LaunchedEffect187+        val reason = syncStatus.reason ?: return@LaunchedEffect188+        if (reason == SyncOutcomeReason.COMPLETED_NO_CHANGES) return@LaunchedEffect189+        snackbarHostState.showSnackbar(190+            message = if (reason == SyncOutcomeReason.NO_PAIRED_DEVICES) {191+                "No devices paired yet — add one in Settings › Sync"192+            } else {193+                describeSyncOutcome(syncStatus)194+            },195+            duration = SnackbarDuration.Short,196+        )181197     }182198 183199     ShellBackground(modifier) {@@ -215,6 +231,7 @@215231                 // that bar used to provide.216232                 topInset = !isMultiSelect,217233                 syncStatus = syncStatus,234+                pairedCount = pairedDevices.size,218235             )219236             if (allTags.isNotEmpty()) {220237                 TagFilterStrip(@@ -236,7 +253,7 @@236253             )237254             Box(Modifier.fillMaxWidth().weight(1f)) {238255                 PeachPullToRefresh(239-                    refreshing = syncStatus is SyncStatus.Syncing,256+                    refreshing = syncStatus.syncing,240257                     onRefresh = { syncVm.syncNow() },241258                     modifier = Modifier.fillMaxSize(),242259                 ) {@@ -406,20 +423,6 @@406423     }407424 }408425 409-internal fun syncStatusDotColor(410-    status: SyncStatus,411-    idle: Color,412-    syncing: Color,413-    synced: Color,414-    error: Color,415-): Color = when (status) {416-    SyncStatus.Idle,417-    SyncStatus.Offline -> idle418-    SyncStatus.Syncing -> syncing419-    is SyncStatus.LastSynced -> synced420-    is SyncStatus.Error -> error421-}422-423426 private fun pluralEntries(total: Int): String = if (total == 1) "1 entry" else "$total entries"424427 425428 @Composable@@ -429,7 +432,8 @@429432     count: String,430433     onCollapse: (() -> Unit)? = null,431434     topInset: Boolean = false,432-    syncStatus: SyncStatus? = null,435+    syncStatus: SyncOutcomeSummary? = null,436+    pairedCount: Int = 0,433437 ) {434438     val accent = LocalPeachAccent.current435439     Column(Modifier.fillMaxWidth().then(if (topInset) Modifier.statusBarsPadding() else Modifier)) {@@ -480,17 +484,11 @@480484                 )481485             }482486             if (syncStatus != null) {483-                val syncDotColor by animateColorAsState(484-                    targetValue = syncStatusDotColor(485-                        status = syncStatus,486-                        idle = peachColors.T2,487-                        syncing = peachColors.SyncBusy,488-                        synced = peachColors.SyncOk,489-                        error = peachColors.SyncErr,490-                    ),491-                    label = "sync-dot",487+                val indicator = syncIndicator(syncStatus, pairedCount)488+                SyncIndicatorDot(489+                    indicator = indicator,490+                    label = syncIndicatorLabel(indicator, syncStatus),492491                 )493-                Box(Modifier.size(6.dp).clip(CircleShape).background(syncDotColor))494492                 Spacer(Modifier.width(PeachSpacing.sm))495493             }496494             Text(count, color = peachColors.T2, style = PeachType.tinyMono)

view file

app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+9 −0
@@ -70,6 +70,7 @@7070         )7171         val (browserSocket, phoneSocket) = socketPair()7272         var syncMarked = false73+        val reported = mutableListOf<SyncOutcomeSummary>()7374 7475         val responder = async(Dispatchers.Default) {7576             runCloudRelayResponderSession(@@ -81,6 +82,7 @@8182                 localIdentity = phoneIdentity,8283                 vaultFactory = { null },8384                 onSyncComplete = { syncMarked = true },85+                report = { reported += it },8486             )8587         }8688         browserSocket.send(@@ -104,6 +106,9 @@104106         assertEquals(phoneIdentity.deviceId, theirIdentity.deviceId)105107         assertEquals(phoneIdentity.longTermPublicKeyHex, theirIdentity.longTermPublicKeyHex)106108         assertFalse(syncMarked)109+        // A locked vault used to be a bare `return` here — the peer got as far110+        // as an authenticated session and then nothing was said to anyone.111+        assertEquals(listOf(SyncOutcomeReason.VAULT_LOCKED), reported.map { it.reason })107112     }108113 109114     @Test@@ -121,6 +126,7 @@121126             pairedAt = 1L,122127         )123128         val (sender, receiver) = socketPair()129+        val reported = mutableListOf<SyncOutcomeSummary>()124130         sender.send(125131             """{"type":"PEACH_RECONNECT","rendezvous_id":"${"Z".repeat(43)}"}"""126132                 .toByteArray(),@@ -142,9 +148,12 @@142148                 ),143149                 vaultFactory = { null },144150                 onSyncComplete = {},151+                report = { reported += it },145152             )146153         }.exceptionOrNull()147154 148155         assertTrue(failure?.message?.contains("does not match relay room") == true)156+        // Still rethrown for the retry loop, but no longer swallowed silently.157+        assertEquals(listOf(SyncOutcomeReason.HANDSHAKE_FAILED), reported.map { it.reason })149158     }150159 }

view file

app/src/test/java/com/peachpasswords/sync/SyncOutcomeTest.kt+207 −0
@@ -0,0 +1,207 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNotEquals5+import org.junit.Assert.assertNull6+import org.junit.Assert.assertTrue7+import org.junit.Test8+9+/**10+ * The indicator light is the whole point of this vocabulary, so the mapping is11+ * swept exhaustively rather than sampled: a reason added later has to be given12+ * a place here or the sweep fails.13+ */14+class SyncOutcomeTest {15+16+    private val succeeded = setOf(17+        SyncOutcomeReason.COMPLETED_CHANGES,18+        SyncOutcomeReason.COMPLETED_NO_CHANGES,19+    )20+21+    @Test fun `only a real success is ever green, even with devices paired`() {22+        for (reason in SyncOutcomeReason.entries) {23+            val indicator = syncIndicator(SyncOutcomeSummary(reason = reason), pairedCount = 5)24+            if (reason in succeeded) {25+                assertEquals("$reason must be ok", SyncIndicator.OK, indicator)26+            } else {27+                assertNotEquals("$reason must never resolve to ok", SyncIndicator.OK, indicator)28+            }29+        }30+    }31+32+    /**33+     * The set itself, not just its self-consistency with the light: moving a34+     * reason out of it would otherwise flip red to unlit with every other test35+     * still green. "Paired, but nothing here can accept a sync" in particular36+     * is a problem, not a quiet state.37+     */38+    @Test fun `the failing outcomes are exactly these`() {39+        assertEquals(40+            setOf(41+                SyncOutcomeReason.NO_ELIGIBLE_HOST,42+                SyncOutcomeReason.VAULT_LOCKED,43+                SyncOutcomeReason.RELAY_UNREACHABLE,44+                SyncOutcomeReason.HANDSHAKE_FAILED,45+                SyncOutcomeReason.VAULT_ID_MISMATCH,46+                SyncOutcomeReason.SYNC_TIMEOUT,47+                SyncOutcomeReason.RUN_FAILED,48+            ),49+            SyncOutcomeReason.entries.filter { it.isFailure }.toSet(),50+        )51+    }52+53+    @Test fun `every failure is red and nothing else is`() {54+        for (reason in SyncOutcomeReason.entries) {55+            val indicator = syncIndicator(SyncOutcomeSummary(reason = reason), pairedCount = 1)56+            assertEquals(57+                "$reason: isFailure and the red light must agree",58+                reason.isFailure,59+                indicator == SyncIndicator.ERROR,60+            )61+        }62+    }63+64+    @Test fun `a run in flight pulses whatever the last outcome was`() {65+        for (reason in SyncOutcomeReason.entries + null) {66+            assertEquals(67+                SyncIndicator.BUSY,68+                syncIndicator(SyncOutcomeSummary(reason = reason, syncing = true), pairedCount = 0),69+            )70+        }71+    }72+73+    @Test fun `a successful pair with nothing attempted is green, and nothing paired is unlit`() {74+        assertEquals(SyncIndicator.OK, syncIndicator(SyncOutcomeSummary(), pairedCount = 1))75+        assertEquals(SyncIndicator.IDLE, syncIndicator(SyncOutcomeSummary(), pairedCount = 0))76+        // Paired but switched off is not a problem and not health — unlit.77+        assertEquals(78+            SyncIndicator.IDLE,79+            syncIndicator(SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_DISABLED), pairedCount = 1),80+        )81+    }82+83+    @Test fun `a sync that could not run reads differently from one that found nothing`() {84+        val ranAndFoundNothing = describeSyncOutcome(85+            SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_NO_CHANGES),86+        )87+        val couldNotRun = describeSyncOutcome(88+            SyncOutcomeSummary(reason = SyncOutcomeReason.NO_ELIGIBLE_HOST, detail = "Pixel [type=android]"),89+        )90+        assertEquals("Up to date — nothing to send or receive", ranAndFoundNothing)91+        assertEquals(92+            "Paired, but no device here can accept a sync (Pixel [type=android])",93+            couldNotRun,94+        )95+    }96+97+    /** Word for word the browser's `COPY` table — the two clients sit side by side. */98+    @Test fun `copy matches the browser`() {99+        fun copy(reason: SyncOutcomeReason) = describeSyncOutcome(SyncOutcomeSummary(reason = reason))100+        assertEquals("Not synced yet", describeSyncOutcome(SyncOutcomeSummary()))101+        assertEquals("Synced", copy(SyncOutcomeReason.COMPLETED_CHANGES))102+        assertEquals("Up to date — nothing to send or receive", copy(SyncOutcomeReason.COMPLETED_NO_CHANGES))103+        assertEquals("No devices paired yet", copy(SyncOutcomeReason.NO_PAIRED_DEVICES))104+        assertEquals("Paired, but no device here can accept a sync", copy(SyncOutcomeReason.NO_ELIGIBLE_HOST))105+        assertEquals("Sync is switched off", copy(SyncOutcomeReason.SYNC_DISABLED))106+        assertEquals("Vault was locked — unlock and sync again", copy(SyncOutcomeReason.VAULT_LOCKED))107+        assertEquals("Couldn't reach the relay", copy(SyncOutcomeReason.RELAY_UNREACHABLE))108+        assertEquals("Reached the relay, but the device didn't answer", copy(SyncOutcomeReason.HANDSHAKE_FAILED))109+        assertEquals("That device holds a different vault", copy(SyncOutcomeReason.VAULT_ID_MISMATCH))110+        assertEquals("The device stopped responding mid-sync", copy(SyncOutcomeReason.SYNC_TIMEOUT))111+        assertEquals("Sync failed", copy(SyncOutcomeReason.RUN_FAILED))112+    }113+114+    @Test fun `a completed sync says what moved`() {115+        assertEquals(116+            "Synced — 3 sent, 1 received",117+            describeSyncOutcome(118+                SyncOutcomeSummary(119+                    reason = SyncOutcomeReason.COMPLETED_CHANGES,120+                    entriesSent = 3,121+                    entriesReceived = 1,122+                ),123+            ),124+        )125+    }126+127+    @Test fun `the label always carries the colour's claim in words`() {128+        assertEquals(129+            "Sync problem — Couldn't reach the relay",130+            syncIndicatorLabel(131+                SyncIndicator.ERROR,132+                SyncOutcomeSummary(reason = SyncOutcomeReason.RELAY_UNREACHABLE),133+            ),134+        )135+        assertEquals("Not syncing — Not synced yet", syncIndicatorLabel(SyncIndicator.IDLE, SyncOutcomeSummary()))136+        assertTrue(syncIndicatorLabel(SyncIndicator.BUSY, SyncOutcomeSummary()).startsWith("Syncing"))137+        assertTrue(syncIndicatorLabel(SyncIndicator.OK, SyncOutcomeSummary()).startsWith("Sync healthy"))138+    }139+140+    @Test fun `opaque session errors are classified, not lumped into one failure`() {141+        assertEquals(SyncOutcomeReason.VAULT_LOCKED, classifySyncError("VAULT_LOCKED"))142+        assertEquals(SyncOutcomeReason.VAULT_ID_MISMATCH, classifySyncError("vault_id_mismatch"))143+        assertEquals(SyncOutcomeReason.SYNC_TIMEOUT, classifySyncError("session timed out after 30s"))144+        assertEquals(145+            SyncOutcomeReason.HANDSHAKE_FAILED,146+            classifySyncError("peer identity does not match authenticated KK key"),147+        )148+        assertEquals(149+            SyncOutcomeReason.HANDSHAKE_FAILED,150+            classifySyncError("reconnect capability does not match relay room"),151+        )152+        assertEquals(SyncOutcomeReason.RELAY_UNREACHABLE, classifySyncError("relay_closed_1006"))153+        assertEquals(SyncOutcomeReason.RUN_FAILED, classifySyncError("something nobody predicted"))154+    }155+156+    @Test fun `a session outcome becomes the user-facing one`() {157+        assertEquals(158+            SyncOutcomeReason.COMPLETED_NO_CHANGES,159+            SyncOutcome(SyncOutcomeStatus.COMPLETED).toSummary().reason,160+        )161+        assertEquals(162+            SyncOutcomeReason.COMPLETED_CHANGES,163+            SyncOutcome(SyncOutcomeStatus.COMPLETED, entriesReceived = 2).toSummary().reason,164+        )165+        // A deletion that crossed the wire is a change, not "nothing to do".166+        assertEquals(167+            SyncOutcomeReason.COMPLETED_CHANGES,168+            SyncOutcome(SyncOutcomeStatus.COMPLETED, tombstonesSent = 1).toSummary().reason,169+        )170+        assertEquals(171+            SyncOutcomeReason.VAULT_ID_MISMATCH,172+            SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH).toSummary().reason,173+        )174+        assertEquals(175+            SyncOutcomeReason.SYNC_TIMEOUT,176+            SyncOutcome(SyncOutcomeStatus.TIMEOUT, error = "session timed out after 30s").toSummary().reason,177+        )178+        assertEquals(179+            SyncOutcomeReason.RELAY_UNREACHABLE,180+            SyncOutcome(SyncOutcomeStatus.ERRORED, error = "relay_receive_queue_full").toSummary().reason,181+        )182+    }183+184+    @Test fun `the reason for a failure survives being killed`() {185+        val failure = SyncOutcomeSummary(186+            reason = SyncOutcomeReason.HANDSHAKE_FAILED,187+            detail = "peer identity does not match authenticated KK key",188+            entriesSent = 4,189+            entriesReceived = 2,190+            finishedAt = 1_700_000_000_000L,191+        )192+        assertEquals(failure, decodeSyncOutcome(encodeSyncOutcome(failure)))193+    }194+195+    @Test fun `a process killed mid-sync does not come back claiming to be syncing`() {196+        val inFlight = SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_CHANGES, syncing = true)197+        val restored = decodeSyncOutcome(encodeSyncOutcome(inFlight))198+        assertEquals(false, restored.syncing)199+        assertEquals(SyncOutcomeReason.COMPLETED_CHANGES, restored.reason)200+    }201+202+    @Test fun `nothing stored, or something unreadable, restores as nothing attempted`() {203+        assertNull(decodeSyncOutcome(null).reason)204+        assertNull(decodeSyncOutcome("{not json").reason)205+        assertNull(decodeSyncOutcome("""{"reason":"A_REASON_FROM_THE_FUTURE"}""").reason)206+    }207+}

view file

app/src/test/java/com/peachpasswords/ui/vault/VaultListSyncDotTest.kt+0 −30
@@ -1,30 +0,0 @@1-package com.peachpasswords.ui.vault2-3-import androidx.compose.ui.graphics.Color4-import com.peachpasswords.sync.SyncStatus5-import org.junit.Assert.assertEquals6-import org.junit.Test7-8-class VaultListSyncDotTest {9-    private val idle = Color(0xFF111111)10-    private val syncing = Color(0xFF222222)11-    private val synced = Color(0xFF333333)12-    private val error = Color(0xFF444444)13-14-    @Test fun sync_dot_reflects_live_sync_status() {15-        assertEquals(idle, dot(SyncStatus.Idle))16-        assertEquals(idle, dot(SyncStatus.Offline))17-        assertEquals(syncing, dot(SyncStatus.Syncing))18-        assertEquals(synced, dot(SyncStatus.LastSynced(123L)))19-        assertEquals(error, dot(SyncStatus.Error("timeout")))20-    }21-22-    private fun dot(status: SyncStatus): Color =23-        syncStatusDotColor(24-            status = status,25-            idle = idle,26-            syncing = syncing,27-            synced = synced,28-            error = error,29-        )30-}

view file at the parent