1package com.peachpasswords.ui.pairing23import androidx.compose.foundation.clickable4import androidx.compose.foundation.layout.Arrangement5import androidx.compose.foundation.layout.Column6import androidx.compose.foundation.layout.Spacer7import androidx.compose.foundation.layout.fillMaxSize8import androidx.compose.foundation.layout.fillMaxWidth9import androidx.compose.foundation.layout.height10import androidx.compose.foundation.layout.padding11import androidx.compose.foundation.layout.systemBarsPadding12import androidx.compose.foundation.rememberScrollState13import androidx.compose.foundation.verticalScroll14import androidx.compose.material3.OutlinedTextField15import androidx.compose.material3.Text16import androidx.compose.runtime.Composable17import androidx.compose.runtime.getValue18import androidx.compose.runtime.mutableStateOf19import androidx.compose.runtime.remember20import androidx.compose.runtime.setValue21import androidx.compose.ui.Alignment22import androidx.compose.ui.Modifier23import androidx.compose.ui.text.input.PasswordVisualTransformation24import androidx.compose.ui.text.input.VisualTransformation25import androidx.compose.ui.text.style.TextAlign26import androidx.compose.ui.unit.dp27import androidx.compose.ui.unit.sp28import androidx.hilt.navigation.compose.hiltViewModel29import androidx.lifecycle.compose.collectAsStateWithLifecycle30import com.peachpasswords.ui.security.StrengthBar31import com.peachpasswords.ui.security.scorePassword32import com.peachpasswords.ui.security.strengthLabel33import com.peachpasswords.ui.theme.LocalPeachAccent34import com.peachpasswords.ui.theme.PeachSpacing35import com.peachpasswords.ui.theme.PeachType36import com.peachpasswords.ui.theme.ShellBackground37import com.peachpasswords.ui.theme.peachColors3839@Composable40fun AdoptVaultScreen(41 onBack: () -> Unit,42 onComplete: () -> Unit,43 vm: AdoptVaultViewModel = hiltViewModel(),44) {45 val state by vm.state.collectAsStateWithLifecycle()46 var password by remember { mutableStateOf("") }47 var confirmation by remember { mutableStateOf("") }48 var reveal by remember { mutableStateOf(false) }49 var invitation by remember { mutableStateOf("") }50 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }51 val score = scorePassword(password)52 val passwordReady =53 password.length >= 12 && password == confirmation && score >= 25455 ShellBackground {56 Column(57 modifier = Modifier58 .fillMaxSize()59 .systemBarsPadding()60 .verticalScroll(rememberScrollState())61 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg),62 horizontalAlignment = Alignment.CenterHorizontally,63 verticalArrangement = Arrangement.Center,64 ) {65 Text(66 if (vm.joining) "Join a vault" else "I already have Peach",67 color = peachColors.T0,68 fontFamily = PeachType.Display,69 fontSize = 26.sp,70 )71 Spacer(Modifier.height(PeachSpacing.sm))7273 when (val current = state) {74 AdoptVaultUiState.Disclosure -> {75 Body(76 "This device already has a vault. Joining merges it with the one you " +77 "scan: nothing is deleted, and after the first sync both devices " +78 "hold every entry from both.",79 )80 Spacer(Modifier.height(PeachSpacing.md))81 Body(82 "One thing changes for good. Both devices end up sharing a single set " +83 "of recovery words — the ones from the vault you join. This " +84 "device's current recovery words will stop working, and nothing " +85 "will restore from them afterwards.",86 )87 Spacer(Modifier.height(PeachSpacing.lg))88 PillButton("I understand — continue", LocalPeachAccent.current, filled = true) {89 vm.acceptDisclosure()90 }91 }9293 is AdoptVaultUiState.RetireWords -> {94 Body(95 "Both devices confirmed the same code. Nothing has been written yet.",96 )97 Spacer(Modifier.height(PeachSpacing.md))98 Body(99 "The moment you continue, this device's recovery words are retired and " +100 "replaced by the words below — the ones for the vault you are " +101 "joining. Write them down now, or continue if you already have them.",102 )103 Spacer(Modifier.height(PeachSpacing.md))104 Text(105 if (reveal) "hide the words" else "show the words",106 color = LocalPeachAccent.current,107 fontFamily = PeachType.Mono,108 fontSize = 11.sp,109 modifier = Modifier110 .clickable { reveal = !reveal }111 .padding(PeachSpacing.sm),112 )113 if (reveal) {114 Text(115 current.incomingWords116 .mapIndexed { i, w -> "${i + 1}. $w" }117 .joinToString(" "),118 color = LocalPeachAccent.current,119 fontFamily = PeachType.Mono,120 fontSize = 13.sp,121 lineHeight = 22.sp,122 textAlign = TextAlign.Center,123 modifier = Modifier.fillMaxWidth(),124 )125 Spacer(Modifier.height(PeachSpacing.md))126 }127 PillButton(128 if (reveal) "I've written them down" else "I already have these words",129 LocalPeachAccent.current,130 filled = true,131 ) {132 reveal = false133 vm.acknowledgeWordRetirement(true)134 }135 Spacer(Modifier.height(PeachSpacing.sm))136 PillButton("Cancel — keep my words", peachColors.T2, filled = false) {137 reveal = false138 vm.acknowledgeWordRetirement(false)139 }140 }141142 AdoptVaultUiState.Password -> {143 Body(144 "Choose the password this phone will use. It never leaves this device; " +145 "your existing device will share the vault only after both screens confirm the same code.",146 )147 Spacer(Modifier.height(PeachSpacing.lg))148 OutlinedTextField(149 value = password,150 onValueChange = { password = it },151 label = { Text("new password") },152 singleLine = true,153 visualTransformation =154 if (reveal) VisualTransformation.None else PasswordVisualTransformation(),155 modifier = Modifier.fillMaxWidth(),156 )157 Spacer(Modifier.height(PeachSpacing.sm))158 OutlinedTextField(159 value = confirmation,160 onValueChange = { confirmation = it },161 label = { Text("confirm password") },162 singleLine = true,163 visualTransformation =164 if (reveal) VisualTransformation.None else PasswordVisualTransformation(),165 modifier = Modifier.fillMaxWidth(),166 )167 Text(168 if (reveal) "hide passwords" else "show passwords",169 color = LocalPeachAccent.current,170 fontFamily = PeachType.Mono,171 fontSize = 11.sp,172 modifier = Modifier173 .align(Alignment.End)174 .clickable { reveal = !reveal }175 .padding(PeachSpacing.sm),176 )177 StrengthBar(score, Modifier.fillMaxWidth())178 Spacer(Modifier.height(PeachSpacing.xs))179 Text(180 "strength: ${strengthLabel(score)} · at least 12 characters",181 color = peachColors.T2,182 fontFamily = PeachType.Mono,183 fontSize = 10.sp,184 )185 Spacer(Modifier.height(PeachSpacing.lg))186 PillButton(187 "Continue",188 LocalPeachAccent.current,189 filled = true,190 ) {191 if (passwordReady && vm.acceptPassword(password, confirmation)) {192 password = ""193 confirmation = ""194 }195 }196 if (!passwordReady) {197 Spacer(Modifier.height(PeachSpacing.xs))198 Text(199 "Continue unlocks when both passwords match and pass the strength check.",200 color = peachColors.T2,201 fontFamily = PeachType.Mono,202 fontSize = 10.sp,203 textAlign = TextAlign.Center,204 )205 }206 }207208 AdoptVaultUiState.Pair -> {209 Body(210 "Connecting via secure relay — works anywhere. Scan the QR shown by " +211 "Peach on your existing device. It contains only the relay address and a single-use token.",212 )213 Spacer(Modifier.height(PeachSpacing.lg))214 PairingQrCamera(onInvitation = vm::adopt)215 Spacer(Modifier.height(PeachSpacing.lg))216 Text(217 "or enter the invitation manually",218 color = peachColors.T2,219 fontFamily = PeachType.Mono,220 fontSize = 10.sp,221 )222 OutlinedTextField(223 value = invitation,224 onValueChange = { invitation = it },225 label = { Text("peach-pair-v1:…") },226 minLines = 3,227 modifier = Modifier.fillMaxWidth(),228 )229 Spacer(Modifier.height(PeachSpacing.md))230 PillButton(231 "Connect",232 LocalPeachAccent.current,233 filled = true,234 ) { vm.adopt(invitation) }235 }236237 AdoptVaultUiState.Connecting -> {238 Body("Opening the single-use relay room and establishing an encrypted session…")239 }240241 is AdoptVaultUiState.ConfirmSas -> {242 Body(243 "Only continue if this code is identical on the device sharing the vault.",244 )245 Spacer(Modifier.height(PeachSpacing.lg))246 Text(247 current.sas,248 color = LocalPeachAccent.current,249 fontFamily = PeachType.Mono,250 fontSize = 38.sp,251 letterSpacing = 6.sp,252 )253 Spacer(Modifier.height(PeachSpacing.lg))254 PillButton(255 if (vm.joining) "They match — join vault" else "They match — adopt vault",256 LocalPeachAccent.current,257 filled = true,258 ) { vm.confirmSas(true) }259 Spacer(Modifier.height(PeachSpacing.sm))260 PillButton("Cancel", peachColors.T2, filled = false) {261 vm.confirmSas(false)262 }263 }264265 AdoptVaultUiState.Adopting -> {266 Body(267 if (vm.joining) {268 "Joining the vault. Your entries stay exactly where they are…"269 } else {270 "Both devices confirmed. Sealing the adopted vault under this phone's password…"271 },272 )273 }274275 is AdoptVaultUiState.Complete -> {276 Body(277 if (vm.joining) {278 "Joined ${current.sourceName}. Both devices now share one vault — " +279 "the next sync brings every entry across in both directions."280 } else {281 "${current.sourceName} shared the vault successfully. Encrypted sync is now active."282 },283 )284 Spacer(Modifier.height(PeachSpacing.lg))285 PillButton("Open vault", LocalPeachAccent.current, filled = true) {286 showBackgroundSyncPrompt = true287 }288 }289290 is AdoptVaultUiState.Failed -> {291 Body(adoptionFailureCopy(current.reason))292 Spacer(Modifier.height(PeachSpacing.lg))293 PillButton("Start again", LocalPeachAccent.current, filled = true) {294 invitation = ""295 vm.restart()296 }297 }298 }299300 if (state !is AdoptVaultUiState.Adopting &&301 state !is AdoptVaultUiState.Complete302 ) {303 Spacer(Modifier.height(PeachSpacing.md))304 PillButton("Back", peachColors.T2, filled = false, onClick = onBack)305 }306 }307 }308309 if (showBackgroundSyncPrompt) {310 BackgroundSyncDialog { enabled ->311 showBackgroundSyncPrompt = false312 vm.setBackgroundSync(enabled)313 onComplete()314 }315 }316}317318@Composable319private fun Body(value: String) {320 Text(321 value,322 color = peachColors.T2,323 fontFamily = PeachType.Body,324 fontSize = 13.sp,325 textAlign = TextAlign.Center,326 modifier = Modifier.fillMaxWidth(),327 )328}329330private fun adoptionFailureCopy(reason: String): String = when (reason) {331 "invalid_pairing_invitation" ->332 "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."333 "ADOPT_SAS_REJECTED", "adoption_rejected" ->334 "The confirmation was cancelled. No vault key was accepted."335 "ADOPT_VAULT_ID_MISMATCH" ->336 "The received vault did not pass its integrity check, so Peach refused it."337 "join_declined_at_word_retirement" ->338 "Cancelled before anything changed. This device kept its own vault and its recovery words still work."339 "JOIN_UNRECOVERABLE_ROOT_KEY" ->340 "That vault's recovery words cannot be represented on this device, so Peach stopped rather than leave you unable to restore. Nothing changed."341 "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" ->342 "Unlock this device's vault first, then try joining again."343 else ->344 "The secure adoption session ended before it completed. Start again with a fresh invitation."345}