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.components.BirdAnimation34import com.peachpasswords.ui.components.PairingAnimState35import com.peachpasswords.ui.theme.LocalPeachAccent36import com.peachpasswords.ui.theme.PeachSpacing37import com.peachpasswords.ui.theme.PeachType38import com.peachpasswords.ui.theme.ShellBackground39import com.peachpasswords.ui.theme.peachColors4041@Composable42fun AdoptVaultScreen(43 onBack: () -> Unit,44 onComplete: () -> Unit,45 vm: AdoptVaultViewModel = hiltViewModel(),46) {47 val state by vm.state.collectAsStateWithLifecycle()48 var password by remember { mutableStateOf("") }49 var confirmation by remember { mutableStateOf("") }50 var reveal by remember { mutableStateOf(false) }51 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }52 val score = scorePassword(password)53 val passwordReady =54 password.length >= 12 && password == confirmation && score >= 25556 ShellBackground {57 Column(58 modifier = Modifier59 .fillMaxSize()60 .systemBarsPadding()61 .verticalScroll(rememberScrollState())62 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg),63 horizontalAlignment = Alignment.CenterHorizontally,64 verticalArrangement = Arrangement.Center,65 ) {66 Text(67 if (vm.joining) "Re-pair a device" else "I already have Peach",68 color = peachColors.T0,69 fontFamily = PeachType.Display,70 fontSize = 26.sp,71 )72 Spacer(Modifier.height(PeachSpacing.sm))7374 if (75 state is AdoptVaultUiState.ConfirmSas ||76 state is AdoptVaultUiState.Adopting ||77 state is AdoptVaultUiState.Complete78 ) {79 BirdAnimation(80 state = if (state is AdoptVaultUiState.Complete) {81 PairingAnimState.CONNECTED82 } else {83 PairingAnimState.LISTENING84 },85 modifier = Modifier.fillMaxWidth().height(170.dp),86 )87 }8889 when (val current = state) {90 AdoptVaultUiState.Disclosure -> {91 Body(92 "This device already has a vault. Peach can re-pair only a device from " +93 "its own paired or removed-device history.",94 )95 Spacer(Modifier.height(PeachSpacing.md))96 Body(97 "A new device must start empty, even if it looks like it contains the " +98 "same logins. This prevents separately restored records from being " +99 "combined and duplicated.",100 )101 Spacer(Modifier.height(PeachSpacing.lg))102 PillButton("Check and re-pair", LocalPeachAccent.current, filled = true) {103 vm.acceptDisclosure()104 }105 }106107 AdoptVaultUiState.Password -> {108 Body(109 "Choose the password this phone will use. It never leaves this device; " +110 "your existing device will share the vault only after both screens confirm the same code.",111 )112 Spacer(Modifier.height(PeachSpacing.lg))113 OutlinedTextField(114 value = password,115 onValueChange = { password = it },116 label = { Text("new password") },117 singleLine = true,118 visualTransformation =119 if (reveal) VisualTransformation.None else PasswordVisualTransformation(),120 modifier = Modifier.fillMaxWidth(),121 )122 Spacer(Modifier.height(PeachSpacing.sm))123 OutlinedTextField(124 value = confirmation,125 onValueChange = { confirmation = it },126 label = { Text("confirm password") },127 singleLine = true,128 visualTransformation =129 if (reveal) VisualTransformation.None else PasswordVisualTransformation(),130 modifier = Modifier.fillMaxWidth(),131 )132 Text(133 if (reveal) "hide passwords" else "show passwords",134 color = LocalPeachAccent.current,135 fontFamily = PeachType.Mono,136 fontSize = 11.sp,137 modifier = Modifier138 .align(Alignment.End)139 .clickable { reveal = !reveal }140 .padding(PeachSpacing.sm),141 )142 StrengthBar(score, Modifier.fillMaxWidth())143 Spacer(Modifier.height(PeachSpacing.xs))144 Text(145 "strength: ${strengthLabel(score)} · at least 12 characters",146 color = peachColors.T2,147 fontFamily = PeachType.Mono,148 fontSize = 10.sp,149 )150 Spacer(Modifier.height(PeachSpacing.lg))151 PillButton(152 "Continue",153 LocalPeachAccent.current,154 filled = true,155 ) {156 if (passwordReady && vm.acceptPassword(password, confirmation)) {157 password = ""158 confirmation = ""159 }160 }161 if (!passwordReady) {162 Spacer(Modifier.height(PeachSpacing.xs))163 Text(164 "Continue unlocks when both passwords match and pass the strength check.",165 color = peachColors.T2,166 fontFamily = PeachType.Mono,167 fontSize = 10.sp,168 textAlign = TextAlign.Center,169 )170 }171 }172173 AdoptVaultUiState.Pair -> {174 Body(175 ADOPT_RELAY_COPY,176 )177 Spacer(Modifier.height(PeachSpacing.lg))178 PairingQrCamera(onInvitation = vm::adopt)179 }180181 AdoptVaultUiState.Connecting -> {182 Body("Opening the single-use relay room and establishing an encrypted session…")183 }184185 is AdoptVaultUiState.ConfirmSas -> {186 Body(187 "Only continue if this code is identical on the device sharing the vault.",188 )189 Spacer(Modifier.height(PeachSpacing.lg))190 Text(191 current.sas,192 color = LocalPeachAccent.current,193 fontFamily = PeachType.Mono,194 fontSize = 38.sp,195 letterSpacing = 6.sp,196 )197 Spacer(Modifier.height(PeachSpacing.lg))198 PillButton(199 if (vm.joining) "They match — join vault" else "They match — adopt vault",200 LocalPeachAccent.current,201 filled = true,202 ) { vm.confirmSas(true) }203 Spacer(Modifier.height(PeachSpacing.sm))204 PillButton("Cancel", peachColors.T2, filled = false) {205 vm.confirmSas(false)206 }207 }208209 AdoptVaultUiState.Adopting -> {210 Body(211 if (vm.joining) {212 "Checking that both devices already share the same vault…"213 } else {214 "Both devices confirmed. Sealing the adopted vault under this phone's password…"215 },216 )217 }218219 is AdoptVaultUiState.Complete -> {220 Body(221 if (vm.joining) {222 "Re-paired with ${current.sourceName}. No entries or recovery words changed."223 } else {224 "${current.sourceName} shared the vault successfully. Encrypted sync is now active."225 },226 )227 Spacer(Modifier.height(PeachSpacing.lg))228 PillButton("Open vault", LocalPeachAccent.current, filled = true) {229 showBackgroundSyncPrompt = true230 }231 }232233 is AdoptVaultUiState.Failed -> {234 Body(adoptionFailureCopy(current.reason))235 Spacer(Modifier.height(PeachSpacing.lg))236 PillButton("Start again", LocalPeachAccent.current, filled = true) {237 vm.restart()238 }239 }240 }241242 if (state !is AdoptVaultUiState.Adopting &&243 state !is AdoptVaultUiState.Complete244 ) {245 Spacer(Modifier.height(PeachSpacing.md))246 PillButton("Back", peachColors.T2, filled = false, onClick = onBack)247 }248 }249 }250251 if (showBackgroundSyncPrompt) {252 BackgroundSyncDialog { enabled ->253 showBackgroundSyncPrompt = false254 vm.setBackgroundSync(enabled)255 onComplete()256 }257 }258}259260internal 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."263264@Composable265private fun Body(value: String) {266 Text(267 value,268 color = peachColors.T2,269 fontFamily = PeachType.Body,270 fontSize = 13.sp,271 textAlign = TextAlign.Center,272 modifier = Modifier.fillMaxWidth(),273 )274}275276private fun adoptionFailureCopy(reason: String): String = when (reason) {277 "invalid_pairing_invitation" ->278 "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."279 "ADOPT_SAS_REJECTED", "adoption_rejected" ->280 "The confirmation was cancelled. No vault key was accepted."281 "ADOPT_VAULT_ID_MISMATCH" ->282 "The received vault did not pass its integrity check, so Peach refused it."283 "JOIN_POPULATED_VAULT_BLOCKED" ->284 "These are different populated vaults, so Peach stopped before combining their entries. Reset or use a fresh destination, then pair again."285 "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" ->286 "Unlock this device's vault first, then try joining again."287 else ->288 "The secure adoption session ended before it completed. Start again with a fresh invitation."289}