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.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 passwordReady = password.length >= 12 && password == confirmation && score >= 2 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. Peach can re-pair only a device from " + "its own paired or removed-device history.", ) Spacer(Modifier.height(PeachSpacing.md)) Body( "A new device must start empty, even if it looks like it contains the " + "same logins. This prevents separately restored records from being " + "combined and duplicated.", ) Spacer(Modifier.height(PeachSpacing.lg)) PillButton("Check and re-pair", 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(), 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, ) { if (passwordReady && vm.acceptPassword(password, confirmation)) { password = "" confirmation = "" } } if (!passwordReady) { Spacer(Modifier.height(PeachSpacing.xs)) Text( "Continue unlocks when both passwords match and pass the strength check.", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp, textAlign = TextAlign.Center, ) } } 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.", ) 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( if (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." @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." }