AdoptVaultScreen.kt

15.2 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.theme.LocalPeachAccent34import com.peachpasswords.ui.theme.PeachSpacing35import com.peachpasswords.ui.theme.PeachType36import com.peachpasswords.ui.theme.ShellBackground37import com.peachpasswords.ui.theme.peachColors3839@Composable40fun AdoptVaultScreen(41    onBack: () -> Unit,42    onComplete: () -> Unit,43    vm: AdoptVaultViewModel = hiltViewModel(),44) {45    val state by vm.state.collectAsStateWithLifecycle()46    var password by remember { mutableStateOf("") }47    var confirmation by remember { mutableStateOf("") }48    var reveal by remember { mutableStateOf(false) }49    var invitation by remember { mutableStateOf("") }50    val score = scorePassword(password)51    val passwordReady =52        password.length >= 12 && password == confirmation && score >= 25354    ShellBackground {55        Column(56            modifier = Modifier57                .fillMaxSize()58                .systemBarsPadding()59                .verticalScroll(rememberScrollState())60                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg),61            horizontalAlignment = Alignment.CenterHorizontally,62            verticalArrangement = Arrangement.Center,63        ) {64            Text(65                if (vm.joining) "Join a vault" else "I already have Peach",66                color = peachColors.T0,67                fontFamily = PeachType.Display,68                fontSize = 26.sp,69            )70            Spacer(Modifier.height(PeachSpacing.sm))7172            when (val current = state) {73                AdoptVaultUiState.Disclosure -> {74                    Body(75                        "This device already has a vault. Joining merges it with the one you " +76                            "scan: nothing is deleted, and after the first sync both devices " +77                            "hold every entry from both.",78                    )79                    Spacer(Modifier.height(PeachSpacing.md))80                    Body(81                        "One thing changes for good. Both devices end up sharing a single set " +82                            "of recovery words — the ones from the vault you join. This " +83                            "device's current recovery words will stop working, and nothing " +84                            "will restore from them afterwards.",85                    )86                    Spacer(Modifier.height(PeachSpacing.lg))87                    PillButton("I understand — continue", LocalPeachAccent.current, filled = true) {88                        vm.acceptDisclosure()89                    }90                }9192                is AdoptVaultUiState.RetireWords -> {93                    Body(94                        "Both devices confirmed the same code. Nothing has been written yet.",95                    )96                    Spacer(Modifier.height(PeachSpacing.md))97                    Body(98                        "The moment you continue, this device's recovery words are retired and " +99                            "replaced by the words below — the ones for the vault you are " +100                            "joining. Write them down now, or continue if you already have them.",101                    )102                    Spacer(Modifier.height(PeachSpacing.md))103                    Text(104                        if (reveal) "hide the words" else "show the words",105                        color = LocalPeachAccent.current,106                        fontFamily = PeachType.Mono,107                        fontSize = 11.sp,108                        modifier = Modifier109                            .clickable { reveal = !reveal }110                            .padding(PeachSpacing.sm),111                    )112                    if (reveal) {113                        Text(114                            current.incomingWords115                                .mapIndexed { i, w -> "${i + 1}. $w" }116                                .joinToString("   "),117                            color = LocalPeachAccent.current,118                            fontFamily = PeachType.Mono,119                            fontSize = 13.sp,120                            lineHeight = 22.sp,121                            textAlign = TextAlign.Center,122                            modifier = Modifier.fillMaxWidth(),123                        )124                        Spacer(Modifier.height(PeachSpacing.md))125                    }126                    PillButton(127                        if (reveal) "I've written them down" else "I already have these words",128                        LocalPeachAccent.current,129                        filled = true,130                    ) {131                        reveal = false132                        vm.acknowledgeWordRetirement(true)133                    }134                    Spacer(Modifier.height(PeachSpacing.sm))135                    PillButton("Cancel — keep my words", peachColors.T2, filled = false) {136                        reveal = false137                        vm.acknowledgeWordRetirement(false)138                    }139                }140141                AdoptVaultUiState.Password -> {142                    Body(143                        "Choose the password this phone will use. It never leaves this device; " +144                            "your existing device will share the vault only after both screens confirm the same code.",145                    )146                    Spacer(Modifier.height(PeachSpacing.lg))147                    OutlinedTextField(148                        value = password,149                        onValueChange = { password = it },150                        label = { Text("new password") },151                        singleLine = true,152                        visualTransformation =153                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),154                        modifier = Modifier.fillMaxWidth(),155                    )156                    Spacer(Modifier.height(PeachSpacing.sm))157                    OutlinedTextField(158                        value = confirmation,159                        onValueChange = { confirmation = it },160                        label = { Text("confirm password") },161                        singleLine = true,162                        visualTransformation =163                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),164                        modifier = Modifier.fillMaxWidth(),165                    )166                    Text(167                        if (reveal) "hide passwords" else "show passwords",168                        color = LocalPeachAccent.current,169                        fontFamily = PeachType.Mono,170                        fontSize = 11.sp,171                        modifier = Modifier172                            .align(Alignment.End)173                            .clickable { reveal = !reveal }174                            .padding(PeachSpacing.sm),175                    )176                    StrengthBar(score, Modifier.fillMaxWidth())177                    Spacer(Modifier.height(PeachSpacing.xs))178                    Text(179                        "strength: ${strengthLabel(score)} · at least 12 characters",180                        color = peachColors.T2,181                        fontFamily = PeachType.Mono,182                        fontSize = 10.sp,183                    )184                    Spacer(Modifier.height(PeachSpacing.lg))185                    PillButton(186                        "Continue",187                        LocalPeachAccent.current,188                        filled = true,189                    ) {190                        if (passwordReady && vm.acceptPassword(password, confirmation)) {191                            password = ""192                            confirmation = ""193                        }194                    }195                    if (!passwordReady) {196                        Spacer(Modifier.height(PeachSpacing.xs))197                        Text(198                            "Continue unlocks when both passwords match and pass the strength check.",199                            color = peachColors.T2,200                            fontFamily = PeachType.Mono,201                            fontSize = 10.sp,202                            textAlign = TextAlign.Center,203                        )204                    }205                }206207                AdoptVaultUiState.Pair -> {208                    Body(209                        "Connecting via secure relay — works anywhere. Scan the QR shown by " +210                            "Peach on your existing device. It contains only the relay address and a single-use token.",211                    )212                    Spacer(Modifier.height(PeachSpacing.lg))213                    PairingQrCamera(onInvitation = vm::adopt)214                    Spacer(Modifier.height(PeachSpacing.lg))215                    Text(216                        "or enter the invitation manually",217                        color = peachColors.T2,218                        fontFamily = PeachType.Mono,219                        fontSize = 10.sp,220                    )221                    OutlinedTextField(222                        value = invitation,223                        onValueChange = { invitation = it },224                        label = { Text("peach-pair-v1:…") },225                        minLines = 3,226                        modifier = Modifier.fillMaxWidth(),227                    )228                    Spacer(Modifier.height(PeachSpacing.md))229                    PillButton(230                        "Connect",231                        LocalPeachAccent.current,232                        filled = true,233                    ) { vm.adopt(invitation) }234                }235236                AdoptVaultUiState.Connecting -> {237                    Body("Opening the single-use relay room and establishing an encrypted session…")238                }239240                is AdoptVaultUiState.ConfirmSas -> {241                    Body(242                        "Only continue if this code is identical on the device sharing the vault.",243                    )244                    Spacer(Modifier.height(PeachSpacing.lg))245                    Text(246                        current.sas,247                        color = LocalPeachAccent.current,248                        fontFamily = PeachType.Mono,249                        fontSize = 38.sp,250                        letterSpacing = 6.sp,251                    )252                    Spacer(Modifier.height(PeachSpacing.lg))253                    PillButton(254                        if (vm.joining) "They match — join vault" else "They match — adopt vault",255                        LocalPeachAccent.current,256                        filled = true,257                    ) { vm.confirmSas(true) }258                    Spacer(Modifier.height(PeachSpacing.sm))259                    PillButton("Cancel", peachColors.T2, filled = false) {260                        vm.confirmSas(false)261                    }262                }263264                AdoptVaultUiState.Adopting -> {265                    Body(266                        if (vm.joining) {267                            "Joining the vault. Your entries stay exactly where they are…"268                        } else {269                            "Both devices confirmed. Sealing the adopted vault under this phone's password…"270                        },271                    )272                }273274                is AdoptVaultUiState.Complete -> {275                    Body(276                        if (vm.joining) {277                            "Joined ${current.sourceName}. Both devices now share one vault — " +278                                "the next sync brings every entry across in both directions."279                        } else {280                            "${current.sourceName} shared the vault successfully. Encrypted sync is now active."281                        },282                    )283                    Spacer(Modifier.height(PeachSpacing.lg))284                    PillButton("Open vault", LocalPeachAccent.current, filled = true, onClick = onComplete)285                }286287                is AdoptVaultUiState.Failed -> {288                    Body(adoptionFailureCopy(current.reason))289                    Spacer(Modifier.height(PeachSpacing.lg))290                    PillButton("Start again", LocalPeachAccent.current, filled = true) {291                        invitation = ""292                        vm.restart()293                    }294                }295            }296297            if (state !is AdoptVaultUiState.Adopting &&298                state !is AdoptVaultUiState.Complete299            ) {300                Spacer(Modifier.height(PeachSpacing.md))301                PillButton("Back", peachColors.T2, filled = false, onClick = onBack)302            }303        }304    }305}306307@Composable308private fun Body(value: String) {309    Text(310        value,311        color = peachColors.T2,312        fontFamily = PeachType.Body,313        fontSize = 13.sp,314        textAlign = TextAlign.Center,315        modifier = Modifier.fillMaxWidth(),316    )317}318319private fun adoptionFailureCopy(reason: String): String = when (reason) {320    "invalid_pairing_invitation" ->321        "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."322    "ADOPT_SAS_REJECTED", "adoption_rejected" ->323        "The confirmation was cancelled. No vault key was accepted."324    "ADOPT_VAULT_ID_MISMATCH" ->325        "The received vault did not pass its integrity check, so Peach refused it."326    "join_declined_at_word_retirement" ->327        "Cancelled before anything changed. This device kept its own vault and its recovery words still work."328    "JOIN_UNRECOVERABLE_ROOT_KEY" ->329        "That vault's recovery words cannot be represented on this device, so Peach stopped rather than leave you unable to restore. Nothing changed."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}