aeae7c03641f

feat: Let vault presence direct pairing (v2.7.0)

time
2026-07-27 21:16 UTC · 2 months ago
machine
koh2
parent
e4607bb6696b
7 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+5 −2app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+32 −7app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+31 −0app/src/main/java/com/peachpasswords/ui/pairing/PairingRelayChoice.kt+166 −0app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+14 −14app/src/test/java/com/peachpasswords/ui/pairing/PairingDirectionTest.kt+20 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24012620-        versionName "2.6.2"19+        versionCode 24012720+        versionName "2.7.0"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+5 −2
@@ -172,8 +172,7 @@172172 173173                 AdoptVaultUiState.Pair -> {174174                     Body(175-                        "Connecting via secure relay — works anywhere. Scan the QR shown by " +176-                            "Peach on your existing device. It contains only the relay address and a single-use token.",175+                        ADOPT_RELAY_COPY,177176                     )178177                     Spacer(Modifier.height(PeachSpacing.lg))179178                     PairingQrCamera(onInvitation = vm::adopt)@@ -257,6 +256,10 @@257256         }258257     }259258 }259+260+internal const val ADOPT_RELAY_COPY =261+    "Scan the QR on the device that already has your vault. That device chooses " +262+        "Peach's public relay or your self-hosted relay, and this phone joins the same one."260263 261264 @Composable262265 private fun Body(value: String) {

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+32 −7
@@ -56,9 +56,12 @@5656     vm: CodePairViewModel = hiltViewModel(),5757 ) {5858     val state by vm.state.collectAsStateWithLifecycle()59+    val syncMode by vm.syncMode.collectAsStateWithLifecycle()60+    val privateRelayUrl by vm.privateRelayUrl.collectAsStateWithLifecycle()5961     val context = LocalContext.current6062 6163     var showBackgroundSyncPrompt by remember { mutableStateOf(false) }64+    var relayExpanded by remember { mutableStateOf(false) }6265 6366     LaunchedEffect(Unit) { vm.start() }6467 @@ -100,16 +103,26 @@100103 101104                 is CodePairUiState.ShowingInvitation -> {102105                     Headline(103-                        "Scan this in Peach",104-                        "Connecting via secure relay — works anywhere. No vault or identity information is in this QR.",106+                        "Pair a device",107+                        "Scan this QR on the empty device. Peach will send this vault only after the 8-digit code matches.",105108                     )106109                     Spacer(Modifier.height(PeachSpacing.md))107-                    PairingQrCode(108-                        value = s.invitation,109-                        modifier = Modifier110-                            .fillMaxWidth()111-                            .height(260.dp),110+                    PairingRelayChoice(111+                        mode = syncMode,112+                        configuredPrivateUrl = privateRelayUrl,113+                        expanded = relayExpanded,114+                        onExpandedChange = { relayExpanded = it },115+                        onSelectMode = vm::selectRelayMode,116+                        onSavePrivateUrl = vm::savePrivateRelayUrl,112117                     )118+                    if (!relayExpanded) {119+                        PairingQrCode(120+                            value = s.invitation,121+                            modifier = Modifier122+                                .fillMaxWidth()123+                                .height(260.dp),124+                        )125+                    }113126                 }114127 115128                 is CodePairUiState.AwaitingPeerConfirmation -> {@@ -148,6 +161,18 @@148161                 is CodePairUiState.Failed -> {149162                     Headline("Couldn't pair", failureCopy(s.reason))150163                     Spacer(Modifier.height(PeachSpacing.lg))164+                    if (s.reason == "sync_disabled" || s.reason == "private_relay_unset") {165+                        PairingRelayChoice(166+                            mode = syncMode,167+                            configuredPrivateUrl = privateRelayUrl,168+                            expanded = true,169+                            collapsible = false,170+                            onExpandedChange = { relayExpanded = it },171+                            onSelectMode = vm::selectRelayMode,172+                            onSavePrivateUrl = vm::savePrivateRelayUrl,173+                        )174+                        Spacer(Modifier.height(PeachSpacing.md))175+                    }151176                     PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)152177                 }153178             }

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+31 −0
@@ -13,6 +13,7 @@1313 import com.peachpasswords.sync.RelayAdoptionHost1414 import com.peachpasswords.sync.RelaySettingsStore1515 import com.peachpasswords.sync.RelayDecision16+import com.peachpasswords.sync.SyncMode1617 import com.peachpasswords.sync.AdoptionSourceDeps1718 import com.peachpasswords.sync.AdoptionFrame1819 import com.peachpasswords.sync.LocalDeviceIdentity@@ -64,6 +65,12 @@6465     private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)6566     val state: StateFlow<CodePairUiState> = _state.asStateFlow()6667 68+    private val _syncMode = MutableStateFlow(relaySettings.syncMode())69+    val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()70+71+    private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())72+    val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()73+6774     /**6875      * The single live transport collector. Switching between relay and LAN6976      * restarts the screen, and without cancelling the previous collector both@@ -175,6 +182,30 @@175182     fun setBackgroundSync(enabled: Boolean) {176183         backgroundSync.setEnabled(enabled)177184         if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)185+    }186+187+    fun selectRelayMode(mode: SyncMode) {188+        require(mode != SyncMode.OFF) { "Pairing requires a relay" }189+        relaySettings.setSyncMode(mode)190+        refreshRelayState()191+        if (relaySettings.relayInForce() is RelayDecision.Dial) {192+            restart()193+        } else {194+            stop()195+            _state.value = CodePairUiState.Failed("private_relay_unset")196+        }197+    }198+199+    fun savePrivateRelayUrl(input: String): Boolean = runCatching {200+        relaySettings.setRelayUrl(input)201+        refreshRelayState()202+        restart()203+        true204+    }.getOrDefault(false)205+206+    private fun refreshRelayState() {207+        _syncMode.value = relaySettings.syncMode()208+        _privateRelayUrl.value = relaySettings.privateRelayUrl()178209     }179210 180211     fun restart() {

view file

app/src/main/java/com/peachpasswords/ui/pairing/PairingRelayChoice.kt+166 −0
@@ -0,0 +1,166 @@1+package com.peachpasswords.ui.pairing2+3+import androidx.compose.foundation.clickable4+import androidx.compose.foundation.layout.Column5+import androidx.compose.foundation.layout.Row6+import androidx.compose.foundation.layout.Spacer7+import androidx.compose.foundation.layout.fillMaxWidth8+import androidx.compose.foundation.layout.height9+import androidx.compose.foundation.layout.padding10+import androidx.compose.material3.OutlinedTextField11+import androidx.compose.material3.RadioButton12+import androidx.compose.material3.Text13+import androidx.compose.runtime.Composable14+import androidx.compose.runtime.getValue15+import androidx.compose.runtime.mutableStateOf16+import androidx.compose.runtime.remember17+import androidx.compose.runtime.setValue18+import androidx.compose.ui.Alignment19+import androidx.compose.ui.Modifier20+import androidx.compose.ui.text.style.TextAlign21+import androidx.compose.ui.unit.dp22+import androidx.compose.ui.unit.sp23+import com.peachpasswords.sync.SyncMode24+import com.peachpasswords.ui.theme.LocalPeachAccent25+import com.peachpasswords.ui.theme.PeachSpacing26+import com.peachpasswords.ui.theme.PeachType27+import com.peachpasswords.ui.theme.peachColors28+29+internal fun pairingRelaySummary(mode: SyncMode): String = when (mode) {30+    SyncMode.PUBLIC -> "Using Public relay"31+    SyncMode.PRIVATE -> "Using Private relay"32+    SyncMode.OFF -> "Choose a relay to pair"33+}34+35+/**36+ * Pairing can use the public relay or a self-hosted peach-relay. No-sync is37+ * deliberately absent because this control exists only while creating a38+ * single-use pairing invitation.39+ */40+@Composable41+internal fun PairingRelayChoice(42+    mode: SyncMode,43+    configuredPrivateUrl: String,44+    expanded: Boolean,45+    collapsible: Boolean = true,46+    onExpandedChange: (Boolean) -> Unit,47+    onSelectMode: (SyncMode) -> Unit,48+    onSavePrivateUrl: (String) -> Boolean,49+) {50+    var draft by remember(configuredPrivateUrl) { mutableStateOf(configuredPrivateUrl) }51+    var status by remember { mutableStateOf<String?>(null) }52+53+    if (!expanded) {54+        Row(55+            modifier = Modifier56+                .fillMaxWidth()57+                .clickable { onExpandedChange(true) }58+                .padding(vertical = PeachSpacing.sm),59+            verticalAlignment = Alignment.CenterVertically,60+        ) {61+            Text(62+                pairingRelaySummary(mode),63+                color = peachColors.T2,64+                fontFamily = PeachType.Mono,65+                fontSize = 10.sp,66+                modifier = Modifier.weight(1f),67+            )68+            Text(69+                "change",70+                color = LocalPeachAccent.current,71+                fontFamily = PeachType.Mono,72+                fontSize = 10.sp,73+            )74+        }75+        return76+    }77+78+    Column(Modifier.fillMaxWidth()) {79+        Text(80+            "Choose the relay this vault uses",81+            color = peachColors.T0,82+            fontFamily = PeachType.Body,83+            fontSize = 13.sp,84+        )85+        Spacer(Modifier.height(PeachSpacing.xs))86+        RelayChoiceRow(87+            title = "Public relay",88+            detail = "Peach's relay. Nothing to set up.",89+            selected = mode == SyncMode.PUBLIC,90+        ) {91+            status = null92+            onSelectMode(SyncMode.PUBLIC)93+        }94+        RelayChoiceRow(95+            title = "Private relay",96+            detail = "Your own peach-relay on your domain.",97+            selected = mode == SyncMode.PRIVATE,98+        ) {99+            status = null100+            onSelectMode(SyncMode.PRIVATE)101+        }102+        if (mode == SyncMode.PRIVATE) {103+            OutlinedTextField(104+                value = draft,105+                onValueChange = {106+                    draft = it107+                    status = null108+                },109+                label = { Text("relay URL") },110+                placeholder = { Text("wss://relay.example.com") },111+                singleLine = true,112+                modifier = Modifier.fillMaxWidth(),113+            )114+            Text(115+                status ?: if (configuredPrivateUrl.isBlank()) {116+                    "Pairing waits until a valid self-hosted relay is saved."117+                } else {118+                    "Only secure wss:// relay URLs are accepted."119+                },120+                color = if (status?.startsWith("Use") == true || configuredPrivateUrl.isBlank()) {121+                    peachColors.Breach122+                } else {123+                    peachColors.T2124+                },125+                fontFamily = PeachType.Mono,126+                fontSize = 9.sp,127+                modifier = Modifier128+                    .fillMaxWidth()129+                    .padding(top = PeachSpacing.xs),130+                textAlign = TextAlign.Start,131+            )132+            Spacer(Modifier.height(PeachSpacing.sm))133+            PillButton("Save private relay", LocalPeachAccent.current, filled = true) {134+                status = if (onSavePrivateUrl(draft)) "Saved" else "Use a valid wss:// relay URL"135+            }136+        }137+        if (collapsible) {138+            Spacer(Modifier.height(PeachSpacing.xs))139+            PillButton("Done", peachColors.T2, filled = false) {140+                onExpandedChange(false)141+            }142+        }143+    }144+}145+146+@Composable147+private fun RelayChoiceRow(148+    title: String,149+    detail: String,150+    selected: Boolean,151+    onSelect: () -> Unit,152+) {153+    Row(154+        modifier = Modifier155+            .fillMaxWidth()156+            .clickable(onClick = onSelect)157+            .padding(vertical = PeachSpacing.xs),158+        verticalAlignment = Alignment.CenterVertically,159+    ) {160+        Column(Modifier.weight(1f)) {161+            Text(title, color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 12.sp)162+            Text(detail, color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 10.sp)163+        }164+        RadioButton(selected = selected, onClick = onSelect)165+    }166+}

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+14 −14
@@ -707,26 +707,15 @@707707     SubLabel("ADD A DEVICE", fg, top = 0.dp)708708     RowLine(fg) {709709         Column(Modifier.weight(1f)) {710-            Text("Show a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)710+            Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)711711             Text(712-                "Let another Peach device scan this phone.",712+                "This vault shows a QR. An empty device scans it and receives the vault after the codes match.",713713                 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,714714                 modifier = Modifier.padding(top = 2.dp),715715             )716716         }717-        SubtleButton("show", fg) { onNavigate(PeachNav.CodePair) }717+        SubtleButton("pair", fg) { onNavigate(PeachNav.CodePair) }718718     }719-    RowLine(fg) {720-        Column(Modifier.weight(1f)) {721-            Text("Scan a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)722-            Text(723-                "For a new empty phone, or to re-pair a device this vault already knows.",724-                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,725-                modifier = Modifier.padding(top = 2.dp),726-            )727-        }728-        SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }729-    }730719 731720     SubLabel("SYNC STATUS", fg)732721     val indicator = syncIndicator(syncStatus, pairedDevices.size)@@ -922,6 +911,17 @@922911         onToggle = { showRecovery = !showRecovery },923912     )924913     if (showRecovery) {914+        RowLine(fg) {915+            Column(Modifier.weight(1f)) {916+                Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)917+                Text(918+                    "Scan only when this populated vault previously trusted the source device.",919+                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,920+                    modifier = Modifier.padding(top = 2.dp),921+                )922+            }923+            SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }924+        }925925         if (rotateConfirmOpen) {926926             Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)927927             rotateError?.let {

view file

app/src/test/java/com/peachpasswords/ui/pairing/PairingDirectionTest.kt+20 −0
@@ -0,0 +1,20 @@1+package com.peachpasswords.ui.pairing2+3+import com.peachpasswords.sync.SyncMode4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+class PairingDirectionTest {9+    @Test fun receiving_phone_explains_that_the_source_qr_selects_public_or_self_hosted_relay() {10+        assertTrue(ADOPT_RELAY_COPY.contains("device that already has your vault"))11+        assertTrue(ADOPT_RELAY_COPY.contains("public relay"))12+        assertTrue(ADOPT_RELAY_COPY.contains("self-hosted relay"))13+    }14+15+    @Test fun source_pairing_names_the_relay_in_force() {16+        assertEquals("Using Public relay", pairingRelaySummary(SyncMode.PUBLIC))17+        assertEquals("Using Private relay", pairingRelaySummary(SyncMode.PRIVATE))18+        assertEquals("Choose a relay to pair", pairingRelaySummary(SyncMode.OFF))19+    }20+}

view file