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
↓ peach-android-ce4aae51.tar.gzatlas at this save
files changed12 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 −30app/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 }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 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 }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 }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-}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+}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 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 }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)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 }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+}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-}