AdoptVaultScreen.kt

12.4 KB · raw

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.text.input.PasswordVisualTransformation24import androidx.compose.ui.text.input.VisualTransformation25import androidx.compose.ui.text.style.TextAlign26import androidx.compose.ui.unit.dp27import androidx.compose.ui.unit.sp28import androidx.hilt.navigation.compose.hiltViewModel29import androidx.lifecycle.compose.collectAsStateWithLifecycle30import com.peachpasswords.ui.security.StrengthBar31import com.peachpasswords.ui.security.scorePassword32import com.peachpasswords.ui.security.strengthLabel33import com.peachpasswords.ui.components.BirdAnimation34import com.peachpasswords.ui.components.PairingAnimState35import com.peachpasswords.ui.theme.LocalPeachAccent36import com.peachpasswords.ui.theme.PeachSpacing37import com.peachpasswords.ui.theme.PeachType38import com.peachpasswords.ui.theme.ShellBackground39import com.peachpasswords.ui.theme.peachColors4041@Composable42fun AdoptVaultScreen(43    onBack: () -> Unit,44    onComplete: () -> Unit,45    vm: AdoptVaultViewModel = hiltViewModel(),46) {47    val state by vm.state.collectAsStateWithLifecycle()48    var password by remember { mutableStateOf("") }49    var confirmation by remember { mutableStateOf("") }50    var reveal by remember { mutableStateOf(false) }51    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }52    val score = scorePassword(password)53    val passwordReady =54        password.length >= 12 && password == confirmation && score >= 25556    ShellBackground {57        Column(58            modifier = Modifier59                .fillMaxSize()60                .systemBarsPadding()61                .verticalScroll(rememberScrollState())62                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg),63            horizontalAlignment = Alignment.CenterHorizontally,64            verticalArrangement = Arrangement.Center,65        ) {66            Text(67                if (vm.joining) "Re-pair a device" else "I already have Peach",68                color = peachColors.T0,69                fontFamily = PeachType.Display,70                fontSize = 26.sp,71            )72            Spacer(Modifier.height(PeachSpacing.sm))7374            if (75                state is AdoptVaultUiState.ConfirmSas ||76                state is AdoptVaultUiState.Adopting ||77                state is AdoptVaultUiState.Complete78            ) {79                BirdAnimation(80                    state = if (state is AdoptVaultUiState.Complete) {81                        PairingAnimState.CONNECTED82                    } else {83                        PairingAnimState.LISTENING84                    },85                    modifier = Modifier.fillMaxWidth().height(170.dp),86                )87            }8889            when (val current = state) {90                AdoptVaultUiState.Disclosure -> {91                    Body(92                        "This device already has a vault. Peach can re-pair only a device from " +93                            "its own paired or removed-device history.",94                    )95                    Spacer(Modifier.height(PeachSpacing.md))96                    Body(97                        "A new device must start empty, even if it looks like it contains the " +98                            "same logins. This prevents separately restored records from being " +99                            "combined and duplicated.",100                    )101                    Spacer(Modifier.height(PeachSpacing.lg))102                    PillButton("Check and re-pair", LocalPeachAccent.current, filled = true) {103                        vm.acceptDisclosure()104                    }105                }106107                AdoptVaultUiState.Password -> {108                    Body(109                        "Choose the password this phone will use. It never leaves this device; " +110                            "your existing device will share the vault only after both screens confirm the same code.",111                    )112                    Spacer(Modifier.height(PeachSpacing.lg))113                    OutlinedTextField(114                        value = password,115                        onValueChange = { password = it },116                        label = { Text("new password") },117                        singleLine = true,118                        visualTransformation =119                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),120                        modifier = Modifier.fillMaxWidth(),121                    )122                    Spacer(Modifier.height(PeachSpacing.sm))123                    OutlinedTextField(124                        value = confirmation,125                        onValueChange = { confirmation = it },126                        label = { Text("confirm password") },127                        singleLine = true,128                        visualTransformation =129                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),130                        modifier = Modifier.fillMaxWidth(),131                    )132                    Text(133                        if (reveal) "hide passwords" else "show passwords",134                        color = LocalPeachAccent.current,135                        fontFamily = PeachType.Mono,136                        fontSize = 11.sp,137                        modifier = Modifier138                            .align(Alignment.End)139                            .clickable { reveal = !reveal }140                            .padding(PeachSpacing.sm),141                    )142                    StrengthBar(score, Modifier.fillMaxWidth())143                    Spacer(Modifier.height(PeachSpacing.xs))144                    Text(145                        "strength: ${strengthLabel(score)} · at least 12 characters",146                        color = peachColors.T2,147                        fontFamily = PeachType.Mono,148                        fontSize = 10.sp,149                    )150                    Spacer(Modifier.height(PeachSpacing.lg))151                    PillButton(152                        "Continue",153                        LocalPeachAccent.current,154                        filled = true,155                    ) {156                        if (passwordReady && vm.acceptPassword(password, confirmation)) {157                            password = ""158                            confirmation = ""159                        }160                    }161                    if (!passwordReady) {162                        Spacer(Modifier.height(PeachSpacing.xs))163                        Text(164                            "Continue unlocks when both passwords match and pass the strength check.",165                            color = peachColors.T2,166                            fontFamily = PeachType.Mono,167                            fontSize = 10.sp,168                            textAlign = TextAlign.Center,169                        )170                    }171                }172173                AdoptVaultUiState.Pair -> {174                    Body(175                        "Connecting via secure relay — works anywhere. Scan the QR shown by " +176                            "Peach on your existing device. It contains only the relay address and a single-use token.",177                    )178                    Spacer(Modifier.height(PeachSpacing.lg))179                    PairingQrCamera(onInvitation = vm::adopt)180                }181182                AdoptVaultUiState.Connecting -> {183                    Body("Opening the single-use relay room and establishing an encrypted session…")184                }185186                is AdoptVaultUiState.ConfirmSas -> {187                    Body(188                        "Only continue if this code is identical on the device sharing the vault.",189                    )190                    Spacer(Modifier.height(PeachSpacing.lg))191                    Text(192                        current.sas,193                        color = LocalPeachAccent.current,194                        fontFamily = PeachType.Mono,195                        fontSize = 38.sp,196                        letterSpacing = 6.sp,197                    )198                    Spacer(Modifier.height(PeachSpacing.lg))199                    PillButton(200                        if (vm.joining) "They match — join vault" else "They match — adopt vault",201                        LocalPeachAccent.current,202                        filled = true,203                    ) { vm.confirmSas(true) }204                    Spacer(Modifier.height(PeachSpacing.sm))205                    PillButton("Cancel", peachColors.T2, filled = false) {206                        vm.confirmSas(false)207                    }208                }209210                AdoptVaultUiState.Adopting -> {211                    Body(212                        if (vm.joining) {213                            "Checking that both devices already share the same vault…"214                        } else {215                            "Both devices confirmed. Sealing the adopted vault under this phone's password…"216                        },217                    )218                }219220                is AdoptVaultUiState.Complete -> {221                    Body(222                        if (vm.joining) {223                            "Re-paired with ${current.sourceName}. No entries or recovery words changed."224                        } else {225                            "${current.sourceName} shared the vault successfully. Encrypted sync is now active."226                        },227                    )228                    Spacer(Modifier.height(PeachSpacing.lg))229                    PillButton("Open vault", LocalPeachAccent.current, filled = true) {230                        showBackgroundSyncPrompt = true231                    }232                }233234                is AdoptVaultUiState.Failed -> {235                    Body(adoptionFailureCopy(current.reason))236                    Spacer(Modifier.height(PeachSpacing.lg))237                    PillButton("Start again", LocalPeachAccent.current, filled = true) {238                        vm.restart()239                    }240                }241            }242243            if (state !is AdoptVaultUiState.Adopting &&244                state !is AdoptVaultUiState.Complete245            ) {246                Spacer(Modifier.height(PeachSpacing.md))247                PillButton("Back", peachColors.T2, filled = false, onClick = onBack)248            }249        }250    }251252    if (showBackgroundSyncPrompt) {253        BackgroundSyncDialog { enabled ->254            showBackgroundSyncPrompt = false255            vm.setBackgroundSync(enabled)256            onComplete()257        }258    }259}260261@Composable262private fun Body(value: String) {263    Text(264        value,265        color = peachColors.T2,266        fontFamily = PeachType.Body,267        fontSize = 13.sp,268        textAlign = TextAlign.Center,269        modifier = Modifier.fillMaxWidth(),270    )271}272273private fun adoptionFailureCopy(reason: String): String = when (reason) {274    "invalid_pairing_invitation" ->275        "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."276    "ADOPT_SAS_REJECTED", "adoption_rejected" ->277        "The confirmation was cancelled. No vault key was accepted."278    "ADOPT_VAULT_ID_MISMATCH" ->279        "The received vault did not pass its integrity check, so Peach refused it."280    "JOIN_POPULATED_VAULT_BLOCKED" ->281        "These are different populated vaults, so Peach stopped before combining their entries. Reset or use a fresh destination, then pair again."282    "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" ->283        "Unlock this device's vault first, then try joining again."284    else ->285        "The secure adoption session ended before it completed. Start again with a fresh invitation."286}