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.semantics.LiveRegionMode24import androidx.compose.ui.semantics.liveRegion25import androidx.compose.ui.semantics.semantics26import androidx.compose.ui.text.input.PasswordVisualTransformation27import androidx.compose.ui.text.input.VisualTransformation28import androidx.compose.ui.text.style.TextAlign29import androidx.compose.ui.unit.dp30import androidx.compose.ui.unit.sp31import androidx.hilt.navigation.compose.hiltViewModel32import androidx.lifecycle.compose.collectAsStateWithLifecycle33import com.peachpasswords.ui.security.StrengthBar34import com.peachpasswords.ui.security.scorePassword35import com.peachpasswords.ui.security.strengthLabel36import com.peachpasswords.ui.components.BirdAnimation37import com.peachpasswords.ui.components.PairingAnimState38import com.peachpasswords.ui.theme.LocalPeachAccent39import com.peachpasswords.ui.theme.PeachSpacing40import com.peachpasswords.ui.theme.PeachType41import com.peachpasswords.ui.theme.ShellBackground42import com.peachpasswords.ui.theme.peachColors4344@Composable45fun AdoptVaultScreen(46 onBack: () -> Unit,47 onComplete: () -> Unit,48 vm: AdoptVaultViewModel = hiltViewModel(),49) {50 val state by vm.state.collectAsStateWithLifecycle()51 var password by remember { mutableStateOf("") }52 var confirmation by remember { mutableStateOf("") }53 var reveal by remember { mutableStateOf(false) }54 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }55 val score = scorePassword(password)56 val passwordBlocker = adoptionPasswordBlocker(password, confirmation, score)57 val passwordReady = passwordBlocker == null58 val passwordFeedback = if (password.isEmpty() && confirmation.isEmpty()) {59 null60 } else {61 passwordBlocker62 }63 val confirmationMismatch = confirmation.isNotEmpty() && password != confirmation6465 ShellBackground {66 Column(67 modifier = Modifier68 .fillMaxSize()69 .systemBarsPadding()70 .verticalScroll(rememberScrollState())71 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg),72 horizontalAlignment = Alignment.CenterHorizontally,73 verticalArrangement = Arrangement.Center,74 ) {75 Text(76 if (vm.joining) "Re-pair a device" else "I already have Peach",77 color = peachColors.T0,78 fontFamily = PeachType.Display,79 fontSize = 26.sp,80 )81 Spacer(Modifier.height(PeachSpacing.sm))8283 if (84 state is AdoptVaultUiState.ConfirmSas ||85 state is AdoptVaultUiState.Adopting ||86 state is AdoptVaultUiState.Complete87 ) {88 BirdAnimation(89 state = if (state is AdoptVaultUiState.Complete) {90 PairingAnimState.CONNECTED91 } else {92 PairingAnimState.LISTENING93 },94 modifier = Modifier.fillMaxWidth().height(170.dp),95 )96 }9798 when (val current = state) {99 AdoptVaultUiState.Disclosure -> {100 Body(101 "This device already has a vault. Peach can re-pair only a device from " +102 "its own paired or removed-device history.",103 )104 Spacer(Modifier.height(PeachSpacing.md))105 Body(106 "A new device must start empty, even if it looks like it contains the " +107 "same logins. This prevents separately restored records from being " +108 "combined and duplicated.",109 )110 Spacer(Modifier.height(PeachSpacing.lg))111 PillButton("Check and re-pair", LocalPeachAccent.current, filled = true) {112 vm.acceptDisclosure()113 }114 }115116 AdoptVaultUiState.Password -> {117 Body(118 "Choose the password this phone will use. It never leaves this device; " +119 "your existing device will share the vault only after both screens confirm the same code.",120 )121 Spacer(Modifier.height(PeachSpacing.lg))122 OutlinedTextField(123 value = password,124 onValueChange = { password = it },125 label = { Text("new password") },126 singleLine = true,127 visualTransformation =128 if (reveal) VisualTransformation.None else PasswordVisualTransformation(),129 isError = confirmationMismatch,130 supportingText = {131 if (confirmationMismatch) {132 Text(133 "Passwords don’t match.",134 modifier = Modifier.semantics {135 liveRegion = LiveRegionMode.Polite136 },137 )138 }139 },140 modifier = Modifier.fillMaxWidth(),141 )142 Spacer(Modifier.height(PeachSpacing.sm))143 OutlinedTextField(144 value = confirmation,145 onValueChange = { confirmation = it },146 label = { Text("confirm password") },147 singleLine = true,148 visualTransformation =149 if (reveal) VisualTransformation.None else PasswordVisualTransformation(),150 modifier = Modifier.fillMaxWidth(),151 )152 Text(153 if (reveal) "hide passwords" else "show passwords",154 color = LocalPeachAccent.current,155 fontFamily = PeachType.Mono,156 fontSize = 11.sp,157 modifier = Modifier158 .align(Alignment.End)159 .clickable { reveal = !reveal }160 .padding(PeachSpacing.sm),161 )162 StrengthBar(score, Modifier.fillMaxWidth())163 Spacer(Modifier.height(PeachSpacing.xs))164 Text(165 "strength: ${strengthLabel(score)} · at least 12 characters",166 color = peachColors.T2,167 fontFamily = PeachType.Mono,168 fontSize = 10.sp,169 )170 Spacer(Modifier.height(PeachSpacing.lg))171 PillButton(172 "Continue",173 LocalPeachAccent.current,174 filled = true,175 enabled = passwordReady,176 ) {177 if (vm.acceptPassword(password, confirmation)) {178 password = ""179 confirmation = ""180 }181 }182 if (passwordFeedback != null && !confirmationMismatch) {183 Spacer(Modifier.height(PeachSpacing.xs))184 Text(185 passwordFeedback,186 color = peachColors.Breach,187 fontFamily = PeachType.Mono,188 fontSize = 10.sp,189 textAlign = TextAlign.Center,190 modifier = Modifier.semantics {191 liveRegion = LiveRegionMode.Polite192 },193 )194 }195 }196197 AdoptVaultUiState.Pair -> {198 Body(199 ADOPT_RELAY_COPY,200 )201 Spacer(Modifier.height(PeachSpacing.lg))202 PairingQrCamera(onInvitation = vm::adopt)203 }204205 AdoptVaultUiState.Connecting -> {206 Body("Opening the single-use relay room and establishing an encrypted session…")207 }208209 is AdoptVaultUiState.ConfirmSas -> {210 Body(211 "Only continue if this code is identical on the device sharing the vault.",212 )213 Spacer(Modifier.height(PeachSpacing.lg))214 Text(215 current.sas,216 color = LocalPeachAccent.current,217 fontFamily = PeachType.Mono,218 fontSize = 38.sp,219 letterSpacing = 6.sp,220 )221 Spacer(Modifier.height(PeachSpacing.lg))222 PillButton(223 if (vm.joining) "They match — join vault" else "They match — adopt vault",224 LocalPeachAccent.current,225 filled = true,226 ) { vm.confirmSas(true) }227 Spacer(Modifier.height(PeachSpacing.sm))228 PillButton("Cancel", peachColors.T2, filled = false) {229 vm.confirmSas(false)230 }231 }232233 AdoptVaultUiState.Adopting -> {234 Body(235 if (vm.joining) {236 "Checking that both devices already share the same vault…"237 } else {238 "Both devices confirmed. Sealing the adopted vault under this phone's password…"239 },240 )241 }242243 is AdoptVaultUiState.Complete -> {244 Body(245 if (vm.joining) {246 "Re-paired with ${current.sourceName}. No entries or recovery words changed."247 } else {248 "${current.sourceName} shared the vault successfully. Encrypted sync is now active."249 },250 )251 Spacer(Modifier.height(PeachSpacing.lg))252 PillButton("Open vault", LocalPeachAccent.current, filled = true) {253 showBackgroundSyncPrompt = true254 }255 }256257 is AdoptVaultUiState.Failed -> {258 Body(adoptionFailureCopy(current.reason))259 Spacer(Modifier.height(PeachSpacing.lg))260 PillButton("Start again", LocalPeachAccent.current, filled = true) {261 vm.restart()262 }263 }264 }265266 if (state !is AdoptVaultUiState.Adopting &&267 state !is AdoptVaultUiState.Complete268 ) {269 Spacer(Modifier.height(PeachSpacing.md))270 PillButton("Back", peachColors.T2, filled = false, onClick = onBack)271 }272 }273 }274275 if (showBackgroundSyncPrompt) {276 BackgroundSyncDialog { enabled ->277 showBackgroundSyncPrompt = false278 vm.setBackgroundSync(enabled)279 onComplete()280 }281 }282}283284internal const val ADOPT_RELAY_COPY =285 "Scan the QR on the device that already has your vault. That device chooses " +286 "Peach's public relay or your self-hosted relay, and this phone joins the same one."287288internal fun adoptionPasswordBlocker(289 password: String,290 confirmation: String,291 score: Int,292): String? = when {293 password.isEmpty() -> "Enter a password."294 password.length < 12 -> "Use at least 12 characters."295 confirmation.isEmpty() -> "Confirm your password."296 password != confirmation -> "Passwords don’t match."297 score < 2 -> "Use a stronger password or longer passphrase."298 else -> null299}300301@Composable302private fun Body(value: String) {303 Text(304 value,305 color = peachColors.T2,306 fontFamily = PeachType.Body,307 fontSize = 13.sp,308 textAlign = TextAlign.Center,309 modifier = Modifier.fillMaxWidth(),310 )311}312313private fun adoptionFailureCopy(reason: String): String = when (reason) {314 "invalid_pairing_invitation" ->315 "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."316 "ADOPT_SAS_REJECTED", "adoption_rejected" ->317 "The confirmation was cancelled. No vault key was accepted."318 "ADOPT_VAULT_ID_MISMATCH" ->319 "The received vault did not pass its integrity check, so Peach refused it."320 "JOIN_POPULATED_VAULT_BLOCKED" ->321 "These are different populated vaults, so Peach stopped before combining their entries. Reset or use a fresh destination, then pair again."322 "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" ->323 "Unlock this device's vault first, then try joining again."324 else ->325 "The secure adoption session ended before it completed. Start again with a fresh invitation."326}