aeae7c03641f
feat: Let vault presence direct pairing (v2.7.0)
- time
- 2026-07-27 21:16 UTC · 2 months ago
- machine
- koh2
- parent
- e4607bb6696b
↓ peach-android-aeae7c03.tar.gzatlas at this save
files changed7 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 −0app/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 {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) {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 }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() {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+}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 {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+}