package com.peachpasswords.ui.pairing import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.peachpasswords.ui.security.StrengthBar import com.peachpasswords.ui.security.scorePassword import com.peachpasswords.ui.security.strengthLabel import com.peachpasswords.ui.components.BirdAnimation import com.peachpasswords.ui.components.PairingAnimState import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import com.peachpasswords.ui.theme.peachColors @Composable fun AdoptVaultScreen( onBack: () -> Unit, onComplete: () -> Unit, vm: AdoptVaultViewModel = hiltViewModel(), ) { val state by vm.state.collectAsStateWithLifecycle() var password by remember { mutableStateOf("") } var confirmation by remember { mutableStateOf("") } var reveal by remember { mutableStateOf(false) } var showBackgroundSyncPrompt by remember { mutableStateOf(false) } val score = scorePassword(password) val passwordBlocker = adoptionPasswordBlocker(password, confirmation, score) val passwordReady = passwordBlocker == null val passwordFeedback = if (password.isEmpty() && confirmation.isEmpty()) { null } else { passwordBlocker } val confirmationMismatch = confirmation.isNotEmpty() && password != confirmation ShellBackground { Column( modifier = Modifier .fillMaxSize() .systemBarsPadding() .verticalScroll(rememberScrollState()) .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( if (vm.joining) "Re-pair a device" else "I already have Peach", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp, ) Spacer(Modifier.height(PeachSpacing.sm)) if ( state is AdoptVaultUiState.ConfirmSas || state is AdoptVaultUiState.Adopting || state is AdoptVaultUiState.Complete ) { BirdAnimation( state = if (state is AdoptVaultUiState.Complete) { PairingAnimState.CONNECTED } else { PairingAnimState.LISTENING }, modifier = Modifier.fillMaxWidth().height(170.dp), ) } when (val current = state) { AdoptVaultUiState.Disclosure -> { Body( "This device already has a vault. Scanning a QR from a second browser, " + "tablet, or phone is allowed as long as it holds the same vault " + "(same recovery words).", ) Spacer(Modifier.height(PeachSpacing.md)) Body( "You'll see the same short code on both screens. The codes must match " + "before anything is shared — that's what proves the other device is " + "really the one you pointed the camera at, not a relay or middleman.", ) Spacer(Modifier.height(PeachSpacing.lg)) PillButton("Continue", LocalPeachAccent.current, filled = true) { vm.acceptDisclosure() } } AdoptVaultUiState.Password -> { Body( "Choose the password this phone will use. It never leaves this device; " + "your existing device will share the vault only after both screens confirm the same code.", ) Spacer(Modifier.height(PeachSpacing.lg)) OutlinedTextField( value = password, onValueChange = { password = it }, label = { Text("new password") }, singleLine = true, visualTransformation = if (reveal) VisualTransformation.None else PasswordVisualTransformation(), isError = confirmationMismatch, supportingText = { if (confirmationMismatch) { Text( "Passwords don’t match.", modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, ) } }, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(PeachSpacing.sm)) OutlinedTextField( value = confirmation, onValueChange = { confirmation = it }, label = { Text("confirm password") }, singleLine = true, visualTransformation = if (reveal) VisualTransformation.None else PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), ) Text( if (reveal) "hide passwords" else "show passwords", color = LocalPeachAccent.current, fontFamily = PeachType.Mono, fontSize = 11.sp, modifier = Modifier .align(Alignment.End) .clickable { reveal = !reveal } .padding(PeachSpacing.sm), ) StrengthBar(score, Modifier.fillMaxWidth()) Spacer(Modifier.height(PeachSpacing.xs)) Text( "strength: ${strengthLabel(score)} · at least 12 characters", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp, ) Spacer(Modifier.height(PeachSpacing.lg)) PillButton( "Continue", LocalPeachAccent.current, filled = true, enabled = passwordReady, ) { if (vm.acceptPassword(password, confirmation)) { password = "" confirmation = "" } } if (passwordFeedback != null && !confirmationMismatch) { Spacer(Modifier.height(PeachSpacing.xs)) Text( passwordFeedback, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, textAlign = TextAlign.Center, modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, ) } } AdoptVaultUiState.Pair -> { Body( ADOPT_RELAY_COPY, ) Spacer(Modifier.height(PeachSpacing.lg)) PairingQrCamera(onInvitation = vm::adopt) } AdoptVaultUiState.Connecting -> { Body("Opening the single-use relay room and establishing an encrypted session…") } is AdoptVaultUiState.ConfirmSas -> { Body( "Only continue if this code is identical on the device sharing the vault.", ) current.peerName?.let { name -> Body("Joining device: $name") } Spacer(Modifier.height(PeachSpacing.lg)) Text( current.sas, color = LocalPeachAccent.current, fontFamily = PeachType.Mono, fontSize = 38.sp, letterSpacing = 6.sp, ) Spacer(Modifier.height(PeachSpacing.lg)) PillButton( when { current.isSource -> "They match — send vault" vm.joining -> "They match — join vault" else -> "They match — adopt vault" }, LocalPeachAccent.current, filled = true, ) { vm.confirmSas(true) } Spacer(Modifier.height(PeachSpacing.sm)) PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false) } } AdoptVaultUiState.Adopting -> { Body( if (vm.joining) { "Checking that both devices already share the same vault…" } else { "Both devices confirmed. Sealing the adopted vault under this phone's password…" }, ) } is AdoptVaultUiState.Complete -> { Body( if (vm.joining) { "Re-paired with ${current.sourceName}. No entries or recovery words changed." } else { "${current.sourceName} shared the vault successfully. Encrypted sync is now active." }, ) Spacer(Modifier.height(PeachSpacing.lg)) PillButton("Open vault", LocalPeachAccent.current, filled = true) { showBackgroundSyncPrompt = true } } is AdoptVaultUiState.Failed -> { Body(adoptionFailureCopy(current.reason)) Spacer(Modifier.height(PeachSpacing.lg)) PillButton("Start again", LocalPeachAccent.current, filled = true) { vm.restart() } } } if (state !is AdoptVaultUiState.Adopting && state !is AdoptVaultUiState.Complete ) { Spacer(Modifier.height(PeachSpacing.md)) PillButton("Back", peachColors.T2, filled = false, onClick = onBack) } } } if (showBackgroundSyncPrompt) { BackgroundSyncDialog { enabled -> showBackgroundSyncPrompt = false vm.setBackgroundSync(enabled) onComplete() } } } internal const val ADOPT_RELAY_COPY = "Scan the QR on the device that already has your vault. That device chooses " + "Peach's public relay or your self-hosted relay, and this phone joins the same one." internal fun adoptionPasswordBlocker( password: String, confirmation: String, score: Int, ): String? = when { password.isEmpty() -> "Enter a password." password.length < 12 -> "Use at least 12 characters." confirmation.isEmpty() -> "Confirm your password." password != confirmation -> "Passwords don’t match." score < 2 -> "Use a stronger password or longer passphrase." else -> null } @Composable private fun Body(value: String) { Text( value, color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) } private fun adoptionFailureCopy(reason: String): String = when (reason) { "invalid_pairing_invitation" -> "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again." "ADOPT_SAS_REJECTED", "adoption_rejected" -> "The confirmation was cancelled. No vault key was accepted." "ADOPT_VAULT_ID_MISMATCH" -> "The received vault did not pass its integrity check, so Peach refused it." "JOIN_POPULATED_VAULT_BLOCKED" -> "These are different populated vaults, so Peach stopped before combining their entries. Reset or use a fresh destination, then pair again." "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" -> "Unlock this device's vault first, then try joining again." else -> "The secure adoption session ended before it completed. Start again with a fresh invitation." }