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) "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.Password -> {100 Body(101 "Choose the password this phone will use. It never leaves this device; " +102 "your existing device will share the vault only after both screens confirm the same code.",103 )104 Spacer(Modifier.height(PeachSpacing.lg))105 OutlinedTextField(106 value = password,107 onValueChange = { password = it },108 label = { Text("new password") },109 singleLine = true,110 visualTransformation =111 if (reveal) VisualTransformation.None else PasswordVisualTransformation(),112 isError = confirmationMismatch,113 supportingText = {114 if (confirmationMismatch) {115 Text(116 "Passwords don’t match.",117 modifier = Modifier.semantics {118 liveRegion = LiveRegionMode.Polite119 },120 )121 }122 },123 modifier = Modifier.fillMaxWidth(),124 )125 Spacer(Modifier.height(PeachSpacing.sm))126 OutlinedTextField(127 value = confirmation,128 onValueChange = { confirmation = it },129 label = { Text("confirm password") },130 singleLine = true,131 visualTransformation =132 if (reveal) VisualTransformation.None else PasswordVisualTransformation(),133 modifier = Modifier.fillMaxWidth(),134 )135 Text(136 if (reveal) "hide passwords" else "show passwords",137 color = LocalPeachAccent.current,138 fontFamily = PeachType.Mono,139 fontSize = 11.sp,140 modifier = Modifier141 .align(Alignment.End)142 .clickable { reveal = !reveal }143 .padding(PeachSpacing.sm),144 )145 StrengthBar(score, Modifier.fillMaxWidth())146 Spacer(Modifier.height(PeachSpacing.xs))147 Text(148 "strength: ${strengthLabel(score)} · at least 12 characters",149 color = peachColors.T2,150 fontFamily = PeachType.Mono,151 fontSize = 10.sp,152 )153 Spacer(Modifier.height(PeachSpacing.lg))154 PillButton(155 "Continue",156 LocalPeachAccent.current,157 filled = true,158 enabled = passwordReady,159 ) {160 if (vm.acceptPassword(password, confirmation)) {161 password = ""162 confirmation = ""163 }164 }165 if (passwordFeedback != null && !confirmationMismatch) {166 Spacer(Modifier.height(PeachSpacing.xs))167 Text(168 passwordFeedback,169 color = peachColors.Breach,170 fontFamily = PeachType.Mono,171 fontSize = 10.sp,172 textAlign = TextAlign.Center,173 modifier = Modifier.semantics {174 liveRegion = LiveRegionMode.Polite175 },176 )177 }178 }179180 AdoptVaultUiState.Pair -> {181 Body(182 ADOPT_RELAY_COPY,183 )184 Spacer(Modifier.height(PeachSpacing.lg))185 PairingQrCamera(onInvitation = vm::adopt)186 }187188 AdoptVaultUiState.Connecting -> {189 Body("Opening the single-use relay room and establishing an encrypted session…")190 }191192 is AdoptVaultUiState.ConfirmSas -> {193 Body(194 "Only continue if this code is identical on the device sharing the vault.",195 )196 current.peerName?.let { name ->197 Body("Joining device: $name")198 }199 Spacer(Modifier.height(PeachSpacing.lg))200 Text(201 current.sas,202 color = LocalPeachAccent.current,203 fontFamily = PeachType.Mono,204 fontSize = 38.sp,205 letterSpacing = 6.sp,206 )207 Spacer(Modifier.height(PeachSpacing.lg))208 PillButton(209 when {210 current.isSource -> "They match — send vault"211 vm.joining -> "They match — join vault"212 else -> "They match — adopt vault"213 },214 LocalPeachAccent.current,215 filled = true,216 ) { vm.confirmSas(true) }217 Spacer(Modifier.height(PeachSpacing.sm))218 PillButton("Cancel", peachColors.T2, filled = false) {219 vm.confirmSas(false)220 }221 }222223 AdoptVaultUiState.Adopting -> {224 Body(225 if (vm.joining) {226 "Checking that both devices already share the same vault…"227 } else {228 "Both devices confirmed. Sealing the adopted vault under this phone's password…"229 },230 )231 }232233 is AdoptVaultUiState.Complete -> {234 Body(235 if (vm.joining) {236 "Re-paired with ${current.sourceName}. No entries or recovery words changed."237 } else {238 "${current.sourceName} shared the vault successfully. Encrypted sync is now active."239 },240 )241 Spacer(Modifier.height(PeachSpacing.lg))242 PillButton("Open vault", LocalPeachAccent.current, filled = true) {243 showBackgroundSyncPrompt = true244 }245 }246247 is AdoptVaultUiState.Failed -> {248 if (current.reason == "ADOPT_PRO_REQUIRED") {249 PairingProRequired(onRetry = vm::restart)250 } else {251 Body(adoptionFailureCopy(current.reason))252 Spacer(Modifier.height(PeachSpacing.lg))253 PillButton("Start again", LocalPeachAccent.current, filled = true) {254 vm.restart()255 }256 }257 }258 }259260 if (state !is AdoptVaultUiState.Adopting &&261 state !is AdoptVaultUiState.Complete262 ) {263 Spacer(Modifier.height(PeachSpacing.md))264 PillButton("Back", peachColors.T2, filled = false, onClick = onBack)265 }266 }267 }268269 if (showBackgroundSyncPrompt) {270 BackgroundSyncDialog { enabled ->271 showBackgroundSyncPrompt = false272 vm.setBackgroundSync(enabled)273 onComplete()274 }275 }276}277278internal const val ADOPT_RELAY_COPY =279 "Scan the QR on the device that already has your vault. That device chooses " +280 "Peach's public relay or your self-hosted relay, and this phone joins the same one."281282internal fun adoptionPasswordBlocker(283 password: String,284 confirmation: String,285 score: Int,286): String? = when {287 password.isEmpty() -> "Enter a password."288 password.length < 12 -> "Use at least 12 characters."289 confirmation.isEmpty() -> "Confirm your password."290 password != confirmation -> "Passwords don’t match."291 score < 2 -> "Use a stronger password or longer passphrase."292 else -> null293}294295@Composable296private fun Body(value: String) {297 Text(298 value,299 color = peachColors.T2,300 fontFamily = PeachType.Body,301 fontSize = 13.sp,302 textAlign = TextAlign.Center,303 modifier = Modifier.fillMaxWidth(),304 )305}306307private fun adoptionFailureCopy(reason: String): String = when (reason) {308 "ADOPT_PRO_REQUIRED" ->309 "Peach Pro is required on at least one of the two devices before encrypted sync can be paired."310 "invalid_pairing_invitation" ->311 "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."312 "ADOPT_SAS_REJECTED", "adoption_rejected" ->313 "The confirmation was cancelled. No vault key was accepted."314 "ADOPT_VAULT_ID_MISMATCH" ->315 "The received vault did not pass its integrity check, so Peach refused it."316 "JOIN_POPULATED_VAULT_BLOCKED" ->317 "These are different populated vaults, so Peach stopped before combining their entries. Reset or use a fresh destination, then pair again."318 "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" ->319 "Unlock this device's vault first, then try joining again."320 else ->321 "The secure adoption session ended before it completed. Start again with a fresh invitation."322}