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. Scanning a QR from a second browser, " +102 "tablet, or phone is allowed as long as it holds the same vault " +103 "(same recovery words).",104 )105 Spacer(Modifier.height(PeachSpacing.md))106 Body(107 "You'll see the same short code on both screens. The codes must match " +108 "before anything is shared — that's what proves the other device is " +109 "really the one you pointed the camera at, not a relay or middleman.",110 )111 Spacer(Modifier.height(PeachSpacing.lg))112 PillButton("Continue", LocalPeachAccent.current, filled = true) {113 vm.acceptDisclosure()114 }115 }116117 AdoptVaultUiState.Password -> {118 Body(119 "Choose the password this phone will use. It never leaves this device; " +120 "your existing device will share the vault only after both screens confirm the same code.",121 )122 Spacer(Modifier.height(PeachSpacing.lg))123 OutlinedTextField(124 value = password,125 onValueChange = { password = it },126 label = { Text("new password") },127 singleLine = true,128 visualTransformation =129 if (reveal) VisualTransformation.None else PasswordVisualTransformation(),130 isError = confirmationMismatch,131 supportingText = {132 if (confirmationMismatch) {133 Text(134 "Passwords don’t match.",135 modifier = Modifier.semantics {136 liveRegion = LiveRegionMode.Polite137 },138 )139 }140 },141 modifier = Modifier.fillMaxWidth(),142 )143 Spacer(Modifier.height(PeachSpacing.sm))144 OutlinedTextField(145 value = confirmation,146 onValueChange = { confirmation = it },147 label = { Text("confirm password") },148 singleLine = true,149 visualTransformation =150 if (reveal) VisualTransformation.None else PasswordVisualTransformation(),151 modifier = Modifier.fillMaxWidth(),152 )153 Text(154 if (reveal) "hide passwords" else "show passwords",155 color = LocalPeachAccent.current,156 fontFamily = PeachType.Mono,157 fontSize = 11.sp,158 modifier = Modifier159 .align(Alignment.End)160 .clickable { reveal = !reveal }161 .padding(PeachSpacing.sm),162 )163 StrengthBar(score, Modifier.fillMaxWidth())164 Spacer(Modifier.height(PeachSpacing.xs))165 Text(166 "strength: ${strengthLabel(score)} · at least 12 characters",167 color = peachColors.T2,168 fontFamily = PeachType.Mono,169 fontSize = 10.sp,170 )171 Spacer(Modifier.height(PeachSpacing.lg))172 PillButton(173 "Continue",174 LocalPeachAccent.current,175 filled = true,176 enabled = passwordReady,177 ) {178 if (vm.acceptPassword(password, confirmation)) {179 password = ""180 confirmation = ""181 }182 }183 if (passwordFeedback != null && !confirmationMismatch) {184 Spacer(Modifier.height(PeachSpacing.xs))185 Text(186 passwordFeedback,187 color = peachColors.Breach,188 fontFamily = PeachType.Mono,189 fontSize = 10.sp,190 textAlign = TextAlign.Center,191 modifier = Modifier.semantics {192 liveRegion = LiveRegionMode.Polite193 },194 )195 }196 }197198 AdoptVaultUiState.Pair -> {199 Body(200 ADOPT_RELAY_COPY,201 )202 Spacer(Modifier.height(PeachSpacing.lg))203 PairingQrCamera(onInvitation = vm::adopt)204 }205206 AdoptVaultUiState.Connecting -> {207 Body("Opening the single-use relay room and establishing an encrypted session…")208 }209210 is AdoptVaultUiState.ConfirmSas -> {211 Body(212 "Only continue if this code is identical on the device sharing the vault.",213 )214 current.peerName?.let { name ->215 Body("Joining device: $name")216 }217 Spacer(Modifier.height(PeachSpacing.lg))218 Text(219 current.sas,220 color = LocalPeachAccent.current,221 fontFamily = PeachType.Mono,222 fontSize = 38.sp,223 letterSpacing = 6.sp,224 )225 Spacer(Modifier.height(PeachSpacing.lg))226 PillButton(227 when {228 current.isSource -> "They match — send vault"229 vm.joining -> "They match — join vault"230 else -> "They match — adopt vault"231 },232 LocalPeachAccent.current,233 filled = true,234 ) { vm.confirmSas(true) }235 Spacer(Modifier.height(PeachSpacing.sm))236 PillButton("Cancel", peachColors.T2, filled = false) {237 vm.confirmSas(false)238 }239 }240241 AdoptVaultUiState.Adopting -> {242 Body(243 if (vm.joining) {244 "Checking that both devices already share the same vault…"245 } else {246 "Both devices confirmed. Sealing the adopted vault under this phone's password…"247 },248 )249 }250251 is AdoptVaultUiState.Complete -> {252 Body(253 if (vm.joining) {254 "Re-paired with ${current.sourceName}. No entries or recovery words changed."255 } else {256 "${current.sourceName} shared the vault successfully. Encrypted sync is now active."257 },258 )259 Spacer(Modifier.height(PeachSpacing.lg))260 PillButton("Open vault", LocalPeachAccent.current, filled = true) {261 showBackgroundSyncPrompt = true262 }263 }264265 is AdoptVaultUiState.Failed -> {266 Body(adoptionFailureCopy(current.reason))267 Spacer(Modifier.height(PeachSpacing.lg))268 PillButton("Start again", LocalPeachAccent.current, filled = true) {269 vm.restart()270 }271 }272 }273274 if (state !is AdoptVaultUiState.Adopting &&275 state !is AdoptVaultUiState.Complete276 ) {277 Spacer(Modifier.height(PeachSpacing.md))278 PillButton("Back", peachColors.T2, filled = false, onClick = onBack)279 }280 }281 }282283 if (showBackgroundSyncPrompt) {284 BackgroundSyncDialog { enabled ->285 showBackgroundSyncPrompt = false286 vm.setBackgroundSync(enabled)287 onComplete()288 }289 }290}291292internal const val ADOPT_RELAY_COPY =293 "Scan the QR on the device that already has your vault. That device chooses " +294 "Peach's public relay or your self-hosted relay, and this phone joins the same one."295296internal fun adoptionPasswordBlocker(297 password: String,298 confirmation: String,299 score: Int,300): String? = when {301 password.isEmpty() -> "Enter a password."302 password.length < 12 -> "Use at least 12 characters."303 confirmation.isEmpty() -> "Confirm your password."304 password != confirmation -> "Passwords don’t match."305 score < 2 -> "Use a stronger password or longer passphrase."306 else -> null307}308309@Composable310private fun Body(value: String) {311 Text(312 value,313 color = peachColors.T2,314 fontFamily = PeachType.Body,315 fontSize = 13.sp,316 textAlign = TextAlign.Center,317 modifier = Modifier.fillMaxWidth(),318 )319}320321private fun adoptionFailureCopy(reason: String): String = when (reason) {322 "invalid_pairing_invitation" ->323 "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."324 "ADOPT_SAS_REJECTED", "adoption_rejected" ->325 "The confirmation was cancelled. No vault key was accepted."326 "ADOPT_VAULT_ID_MISMATCH" ->327 "The received vault did not pass its integrity check, so Peach refused it."328 "JOIN_POPULATED_VAULT_BLOCKED" ->329 "These are different populated vaults, so Peach stopped before combining their entries. Reset or use a fresh destination, then pair again."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}