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 val score = scorePassword(password)51 val passwordReady =52 password.length >= 12 && password == confirmation && score >= 25354 ShellBackground {55 Column(56 modifier = Modifier57 .fillMaxSize()58 .systemBarsPadding()59 .verticalScroll(rememberScrollState())60 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg),61 horizontalAlignment = Alignment.CenterHorizontally,62 verticalArrangement = Arrangement.Center,63 ) {64 Text(65 if (vm.joining) "Join a vault" else "I already have Peach",66 color = peachColors.T0,67 fontFamily = PeachType.Display,68 fontSize = 26.sp,69 )70 Spacer(Modifier.height(PeachSpacing.sm))7172 when (val current = state) {73 AdoptVaultUiState.Disclosure -> {74 Body(75 "This device already has a vault. Joining merges it with the one you " +76 "scan: nothing is deleted, and after the first sync both devices " +77 "hold every entry from both.",78 )79 Spacer(Modifier.height(PeachSpacing.md))80 Body(81 "One thing changes for good. Both devices end up sharing a single set " +82 "of recovery words — the ones from the vault you join. This " +83 "device's current recovery words will stop working, and nothing " +84 "will restore from them afterwards.",85 )86 Spacer(Modifier.height(PeachSpacing.lg))87 PillButton("I understand — continue", LocalPeachAccent.current, filled = true) {88 vm.acceptDisclosure()89 }90 }9192 is AdoptVaultUiState.RetireWords -> {93 Body(94 "Both devices confirmed the same code. Nothing has been written yet.",95 )96 Spacer(Modifier.height(PeachSpacing.md))97 Body(98 "The moment you continue, this device's recovery words are retired and " +99 "replaced by the words below — the ones for the vault you are " +100 "joining. Write them down now, or continue if you already have them.",101 )102 Spacer(Modifier.height(PeachSpacing.md))103 Text(104 if (reveal) "hide the words" else "show the words",105 color = LocalPeachAccent.current,106 fontFamily = PeachType.Mono,107 fontSize = 11.sp,108 modifier = Modifier109 .clickable { reveal = !reveal }110 .padding(PeachSpacing.sm),111 )112 if (reveal) {113 Text(114 current.incomingWords115 .mapIndexed { i, w -> "${i + 1}. $w" }116 .joinToString(" "),117 color = LocalPeachAccent.current,118 fontFamily = PeachType.Mono,119 fontSize = 13.sp,120 lineHeight = 22.sp,121 textAlign = TextAlign.Center,122 modifier = Modifier.fillMaxWidth(),123 )124 Spacer(Modifier.height(PeachSpacing.md))125 }126 PillButton(127 if (reveal) "I've written them down" else "I already have these words",128 LocalPeachAccent.current,129 filled = true,130 ) {131 reveal = false132 vm.acknowledgeWordRetirement(true)133 }134 Spacer(Modifier.height(PeachSpacing.sm))135 PillButton("Cancel — keep my words", peachColors.T2, filled = false) {136 reveal = false137 vm.acknowledgeWordRetirement(false)138 }139 }140141 AdoptVaultUiState.Password -> {142 Body(143 "Choose the password this phone will use. It never leaves this device; " +144 "your existing device will share the vault only after both screens confirm the same code.",145 )146 Spacer(Modifier.height(PeachSpacing.lg))147 OutlinedTextField(148 value = password,149 onValueChange = { password = it },150 label = { Text("new password") },151 singleLine = true,152 visualTransformation =153 if (reveal) VisualTransformation.None else PasswordVisualTransformation(),154 modifier = Modifier.fillMaxWidth(),155 )156 Spacer(Modifier.height(PeachSpacing.sm))157 OutlinedTextField(158 value = confirmation,159 onValueChange = { confirmation = it },160 label = { Text("confirm password") },161 singleLine = true,162 visualTransformation =163 if (reveal) VisualTransformation.None else PasswordVisualTransformation(),164 modifier = Modifier.fillMaxWidth(),165 )166 Text(167 if (reveal) "hide passwords" else "show passwords",168 color = LocalPeachAccent.current,169 fontFamily = PeachType.Mono,170 fontSize = 11.sp,171 modifier = Modifier172 .align(Alignment.End)173 .clickable { reveal = !reveal }174 .padding(PeachSpacing.sm),175 )176 StrengthBar(score, Modifier.fillMaxWidth())177 Spacer(Modifier.height(PeachSpacing.xs))178 Text(179 "strength: ${strengthLabel(score)} · at least 12 characters",180 color = peachColors.T2,181 fontFamily = PeachType.Mono,182 fontSize = 10.sp,183 )184 Spacer(Modifier.height(PeachSpacing.lg))185 PillButton(186 "Continue",187 LocalPeachAccent.current,188 filled = true,189 ) {190 if (passwordReady && vm.acceptPassword(password, confirmation)) {191 password = ""192 confirmation = ""193 }194 }195 if (!passwordReady) {196 Spacer(Modifier.height(PeachSpacing.xs))197 Text(198 "Continue unlocks when both passwords match and pass the strength check.",199 color = peachColors.T2,200 fontFamily = PeachType.Mono,201 fontSize = 10.sp,202 textAlign = TextAlign.Center,203 )204 }205 }206207 AdoptVaultUiState.Pair -> {208 Body(209 "Connecting via secure relay — works anywhere. Scan the QR shown by " +210 "Peach on your existing device. It contains only the relay address and a single-use token.",211 )212 Spacer(Modifier.height(PeachSpacing.lg))213 PairingQrCamera(onInvitation = vm::adopt)214 Spacer(Modifier.height(PeachSpacing.lg))215 Text(216 "or enter the invitation manually",217 color = peachColors.T2,218 fontFamily = PeachType.Mono,219 fontSize = 10.sp,220 )221 OutlinedTextField(222 value = invitation,223 onValueChange = { invitation = it },224 label = { Text("peach-pair-v1:…") },225 minLines = 3,226 modifier = Modifier.fillMaxWidth(),227 )228 Spacer(Modifier.height(PeachSpacing.md))229 PillButton(230 "Connect",231 LocalPeachAccent.current,232 filled = true,233 ) { vm.adopt(invitation) }234 }235236 AdoptVaultUiState.Connecting -> {237 Body("Opening the single-use relay room and establishing an encrypted session…")238 }239240 is AdoptVaultUiState.ConfirmSas -> {241 Body(242 "Only continue if this code is identical on the device sharing the vault.",243 )244 Spacer(Modifier.height(PeachSpacing.lg))245 Text(246 current.sas,247 color = LocalPeachAccent.current,248 fontFamily = PeachType.Mono,249 fontSize = 38.sp,250 letterSpacing = 6.sp,251 )252 Spacer(Modifier.height(PeachSpacing.lg))253 PillButton(254 if (vm.joining) "They match — join vault" else "They match — adopt vault",255 LocalPeachAccent.current,256 filled = true,257 ) { vm.confirmSas(true) }258 Spacer(Modifier.height(PeachSpacing.sm))259 PillButton("Cancel", peachColors.T2, filled = false) {260 vm.confirmSas(false)261 }262 }263264 AdoptVaultUiState.Adopting -> {265 Body(266 if (vm.joining) {267 "Joining the vault. Your entries stay exactly where they are…"268 } else {269 "Both devices confirmed. Sealing the adopted vault under this phone's password…"270 },271 )272 }273274 is AdoptVaultUiState.Complete -> {275 Body(276 if (vm.joining) {277 "Joined ${current.sourceName}. Both devices now share one vault — " +278 "the next sync brings every entry across in both directions."279 } else {280 "${current.sourceName} shared the vault successfully. Encrypted sync is now active."281 },282 )283 Spacer(Modifier.height(PeachSpacing.lg))284 PillButton("Open vault", LocalPeachAccent.current, filled = true, onClick = onComplete)285 }286287 is AdoptVaultUiState.Failed -> {288 Body(adoptionFailureCopy(current.reason))289 Spacer(Modifier.height(PeachSpacing.lg))290 PillButton("Start again", LocalPeachAccent.current, filled = true) {291 invitation = ""292 vm.restart()293 }294 }295 }296297 if (state !is AdoptVaultUiState.Adopting &&298 state !is AdoptVaultUiState.Complete299 ) {300 Spacer(Modifier.height(PeachSpacing.md))301 PillButton("Back", peachColors.T2, filled = false, onClick = onBack)302 }303 }304 }305}306307@Composable308private fun Body(value: String) {309 Text(310 value,311 color = peachColors.T2,312 fontFamily = PeachType.Body,313 fontSize = 13.sp,314 textAlign = TextAlign.Center,315 modifier = Modifier.fillMaxWidth(),316 )317}318319private fun adoptionFailureCopy(reason: String): String = when (reason) {320 "invalid_pairing_invitation" ->321 "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."322 "ADOPT_SAS_REJECTED", "adoption_rejected" ->323 "The confirmation was cancelled. No vault key was accepted."324 "ADOPT_VAULT_ID_MISMATCH" ->325 "The received vault did not pass its integrity check, so Peach refused it."326 "join_declined_at_word_retirement" ->327 "Cancelled before anything changed. This device kept its own vault and its recovery words still work."328 "JOIN_UNRECOVERABLE_ROOT_KEY" ->329 "That vault's recovery words cannot be represented on this device, so Peach stopped rather than leave you unable to restore. Nothing changed."330 "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" ->331 "Unlock this device's vault first, then try joining again."332 else ->333 "The secure adoption session ended before it completed. Start again with a fresh invitation."334}