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
6 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 −19
VERSION+1 −1
@@ -1 +1 @@1-2.5.91+2.5.10

view file

app/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 {

view file

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+}

view file

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())

view file

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                 )

view file

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 }

view file