cf0e3a2db9dd
relay changes reach existing pairs: setSyncMode/setRelayUrl push the in-force relay into every paired device through the relayInForce gate, so private-unset and off push nothing; adoption records the source's relay so the Sync card stops claiming Public on a phone dialling a private one (v2.5.10)
- time
- 2026-07-26 19:43 UTC · 2 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- android relay propagation
- parent
- 90740782e940
↓ peach-android-cf0e3a2d.tar.gzatlas at this save
files changed6 files
VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+21 −7app/src/main/java/com/peachpasswords/sync/RelaySettingsStore.kt+19 −7app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+8 −0app/src/test/java/com/peachpasswords/sync/RelaySettingsPropagationTest.kt+64 −19app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 24010920- versionName "2.5.9"19+ versionCode 24011020+ versionName "2.5.10"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+21 −7
@@ -151,11 +151,15 @@151151 }152152 153153 /**154- * Reconnect prefers each pair's relay over the global fallback. Rewrite all155- * established pairs when the user changes the mesh relay in Settings.154+ * Reconnect prefers each pair's relay over the global fallback, so a relay155+ * change in Settings only reaches established pairs by being written into156+ * them. Skips the write when nothing moved, so the observers behind the157+ * device list do not churn on a refused change.156158 */157- fun setRelayUrlForAll(relayUrl: String) {158- persist(withRelayUrl(getAll(), relayUrl))159+ fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {160+ val current = getAll()161+ val next = withRelaySetting(current, mode, storedRelayUrl)162+ if (next != current) persist(next)159163 }160164 161165 /**@@ -194,7 +198,17 @@194198 }195199 }196200 197-internal fun withRelayUrl(201+/**202+ * The mesh relay is pushed out through the same gate that decides whether to203+ * dial one: a relay this device refuses to use must not be handed to peers204+ * either — pushing Peach's own URL out under a "Private relay" label would tell205+ * the same lie one layer down.206+ */207+internal fun withRelaySetting(198208 devices: List<PairedDevice>,199- relayUrl: String,200-): List<PairedDevice> = devices.map { it.copy(relayUrl = relayUrl) }209+ mode: SyncMode,210+ storedRelayUrl: String,211+): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {212+ is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }213+ is RelayDecision.Refuse -> devices214+}app/src/main/java/com/peachpasswords/sync/RelaySettingsStore.kt+19 −7
@@ -67,6 +67,14 @@6767 fun modeForRelayUrl(normalizedRelayUrl: String): SyncMode =6868 if (normalizedRelayUrl == Rendezvous.DEFAULT_RELAY_URL) SyncMode.PUBLIC else SyncMode.PRIVATE6969 70+/**71+ * The private relay as the user should see it: blank when none is set. Never72+ * Peach's own URL wearing a private label — an empty field is the honest73+ * representation of "not configured".74+ */75+fun privateRelayUrlOf(storedRelayUrl: String): String =76+ storedRelayUrl.takeIf { it != Rendezvous.DEFAULT_RELAY_URL }.orEmpty()77+7078 fun relayInForce(mode: SyncMode, storedRelayUrl: String): RelayDecision = when {7179 mode == SyncMode.OFF -> RelayDecision.Refuse(SyncOutcomeReason.SYNC_DISABLED)7280 isPrivateRelayUnconfigured(mode, storedRelayUrl) ->@@ -83,6 +91,7 @@8391 @Singleton8492 class RelaySettingsStore @Inject constructor(8593 @ApplicationContext context: Context,94+ private val devices: PairedDeviceStore,8695 ) {8796 private val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)8897 @@ -99,20 +108,22 @@99108 }100109 }101110 111+ /**112+ * The relay is mesh state: every paired device must agree on it or a peer113+ * keeps dialling one we no longer use, and reconcile prefers the pair's own114+ * URL over this setting. So the mode change reaches the mesh before it is115+ * stored — the browser's `setSyncMode` orders it the same way.116+ */102117 fun setSyncMode(mode: SyncMode) {118+ devices.propagateRelaySetting(mode, storedRelayUrl())103119 prefs.edit().putString(KEY_MODE, mode.wire()).apply()104120 }105121 106122 /** The relay to dial, or the reason there is none. */107123 fun relayInForce(): RelayDecision = relayInForce(syncMode(), storedRelayUrl())108124 109- /**110- * The private relay as the user should see it: blank when none is set.111- * Never Peach's own URL wearing a private label — an empty field is the112- * honest representation of "not configured".113- */114- fun privateRelayUrl(): String =115- storedRelayUrl().takeIf { it != Rendezvous.DEFAULT_RELAY_URL }.orEmpty()125+ /** Blank while no private relay is set. See [privateRelayUrlOf]. */126+ fun privateRelayUrl(): String = privateRelayUrlOf(storedRelayUrl())116127 117128 /**118129 * Point this mesh at a relay by URL. A custom URL implies@@ -124,6 +135,7 @@124135 */125136 fun setRelayUrl(input: String): String {126137 val normalized = Rendezvous.normalizeRelayUrl(input)138+ devices.propagateRelaySetting(modeForRelayUrl(normalized), normalized)127139 prefs.edit()128140 .putString(KEY_URL, normalized)129141 .putString(KEY_MODE, modeForRelayUrl(normalized).wire())app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+8 −0
@@ -11,6 +11,7 @@1111 import com.peachpasswords.sync.PairedDeviceStore1212 import com.peachpasswords.sync.PeachSyncService1313 import com.peachpasswords.sync.RelayAdoptionReceiver14+import com.peachpasswords.sync.RelaySettingsStore1415 import com.peachpasswords.sync.buildLocalIdentity1516 import com.peachpasswords.sync.getDeviceName1617 import com.peachpasswords.sync.parsePairingInvitation@@ -58,6 +59,7 @@5859 private val receiver: RelayAdoptionReceiver,5960 private val vault: VaultRepository,6061 private val devices: PairedDeviceStore,62+ private val relaySettings: RelaySettingsStore,6163 private val license: LicenseRepository,6264 @ApplicationContext private val appContext: Context,6365 ) : ViewModel() {@@ -116,6 +118,12 @@116118 pending.await()117119 },118120 )121+ // The pair carries the source's relay, but the Sync card reads122+ // this device's own setting — left alone it would say "Public123+ // relay" on a phone dialling a private one. Peach's own URL124+ // still lands on PUBLIC, so joining over the public relay is125+ // not relabelled private.126+ adopted.relayUrl?.let(relaySettings::setRelayUrl)119127 _state.value = AdoptVaultUiState.Complete(120128 adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",121129 )app/src/test/java/com/peachpasswords/sync/RelaySettingsPropagationTest.kt+64 −19
@@ -1,33 +1,78 @@11 package com.peachpasswords.sync22 33 import org.junit.Assert.assertEquals4+import org.junit.Assert.assertSame45 import org.junit.Test56 7+/**8+ * What a relay change does to the existing mesh, mirroring the browser's9+ * `setSyncMode` / `setRelayUrl`. The store around this is only10+ * EncryptedSharedPreferences and a Context this suite has no reason to stand11+ * up; the decision it delegates to lives here.12+ */613 class RelaySettingsPropagationTest {14+ private val selfHosted = "wss://relay.example.com"15+16+ private val pairs = listOf(17+ PairedDevice(deviceId = "browser", relayUrl = "wss://old-relay.example", pairedAt = 1L),18+ PairedDevice(deviceId = "phone", relayUrl = null, pairedAt = 2L),19+ )20+721 @Test8- fun changing_mesh_relay_updates_every_existing_pair() {9- val devices = listOf(10- PairedDevice(11- deviceId = "browser",12- relayUrl = "wss://old-relay.example",13- pairedAt = 1L,14- ),15- PairedDevice(16- deviceId = "phone",17- relayUrl = null,18- pairedAt = 2L,19- ),20- )22+ fun `switching to private with an address moves every existing pair to it`() {23+ val updated = withRelaySetting(pairs, SyncMode.PRIVATE, selfHosted)2124 22- val updated = withRelayUrl(devices, "wss://new-relay.example/base")25+ assertEquals(listOf(selfHosted, selfHosted), updated.map { it.relayUrl })26+ assertEquals(listOf("browser", "phone"), updated.map { it.deviceId })27+ }2328 29+ @Test30+ fun `switching to public moves every existing pair back to peach's relay`() {31+ val updated = withRelaySetting(pairs, SyncMode.PUBLIC, selfHosted)32+2433 assertEquals(25- listOf(26- "wss://new-relay.example/base",27- "wss://new-relay.example/base",28- ),34+ listOf(Rendezvous.DEFAULT_RELAY_URL, Rendezvous.DEFAULT_RELAY_URL),2935 updated.map { it.relayUrl },3036 )31- assertEquals(listOf("browser", "phone"), updated.map { it.deviceId })37+ }38+39+ /**40+ * The exception. Private-with-no-address has nothing to push, and pushing41+ * the stored default would put the whole mesh on Peach's relay under a42+ * private label.43+ */44+ @Test45+ fun `private with no address moves no pair`() {46+ assertSame(pairs, withRelaySetting(pairs, SyncMode.PRIVATE, Rendezvous.DEFAULT_RELAY_URL))47+ }48+49+ @Test50+ fun `turning sync off leaves the mesh where it was`() {51+ assertSame(pairs, withRelaySetting(pairs, SyncMode.OFF, selfHosted))52+ }53+54+ /**55+ * Adoption records the source's relay through the same path as the Settings56+ * field, so what the Sync card then reads is [modeForRelayUrl] plus57+ * [privateRelayUrlOf] over the adopted URL.58+ */59+ @Test60+ fun `adoption over the public relay leaves the receiver reading public`() {61+ val adopted = Rendezvous.DEFAULT_RELAY_URL62+63+ assertEquals(SyncMode.PUBLIC, modeForRelayUrl(adopted))64+ assertEquals("", privateRelayUrlOf(adopted))65+ }66+67+ @Test68+ fun `adoption over a private relay leaves the receiver reading that url`() {69+ assertEquals(SyncMode.PRIVATE, modeForRelayUrl(selfHosted))70+ assertEquals(selfHosted, privateRelayUrlOf(selfHosted))71+ // And the receiver is not stranded in the unconfigured-private state72+ // that would pause its sync.73+ assertEquals(74+ RelayDecision.Dial(selfHosted),75+ relayInForce(modeForRelayUrl(selfHosted), selfHosted),76+ )3277 }3378 }