AdoptVaultScreen.kt

15.5 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    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }51    val score = scorePassword(password)52    val passwordReady =53        password.length >= 12 && password == confirmation && score >= 25455    ShellBackground {56        Column(57            modifier = Modifier58                .fillMaxSize()59                .systemBarsPadding()60                .verticalScroll(rememberScrollState())61                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg),62            horizontalAlignment = Alignment.CenterHorizontally,63            verticalArrangement = Arrangement.Center,64        ) {65            Text(66                if (vm.joining) "Join a vault" else "I already have Peach",67                color = peachColors.T0,68                fontFamily = PeachType.Display,69                fontSize = 26.sp,70            )71            Spacer(Modifier.height(PeachSpacing.sm))7273            when (val current = state) {74                AdoptVaultUiState.Disclosure -> {75                    Body(76                        "This device already has a vault. Joining merges it with the one you " +77                            "scan: nothing is deleted, and after the first sync both devices " +78                            "hold every entry from both.",79                    )80                    Spacer(Modifier.height(PeachSpacing.md))81                    Body(82                        "One thing changes for good. Both devices end up sharing a single set " +83                            "of recovery words — the ones from the vault you join. This " +84                            "device's current recovery words will stop working, and nothing " +85                            "will restore from them afterwards.",86                    )87                    Spacer(Modifier.height(PeachSpacing.lg))88                    PillButton("I understand — continue", LocalPeachAccent.current, filled = true) {89                        vm.acceptDisclosure()90                    }91                }9293                is AdoptVaultUiState.RetireWords -> {94                    Body(95                        "Both devices confirmed the same code. Nothing has been written yet.",96                    )97                    Spacer(Modifier.height(PeachSpacing.md))98                    Body(99                        "The moment you continue, this device's recovery words are retired and " +100                            "replaced by the words below — the ones for the vault you are " +101                            "joining. Write them down now, or continue if you already have them.",102                    )103                    Spacer(Modifier.height(PeachSpacing.md))104                    Text(105                        if (reveal) "hide the words" else "show the words",106                        color = LocalPeachAccent.current,107                        fontFamily = PeachType.Mono,108                        fontSize = 11.sp,109                        modifier = Modifier110                            .clickable { reveal = !reveal }111                            .padding(PeachSpacing.sm),112                    )113                    if (reveal) {114                        Text(115                            current.incomingWords116                                .mapIndexed { i, w -> "${i + 1}. $w" }117                                .joinToString("   "),118                            color = LocalPeachAccent.current,119                            fontFamily = PeachType.Mono,120                            fontSize = 13.sp,121                            lineHeight = 22.sp,122                            textAlign = TextAlign.Center,123                            modifier = Modifier.fillMaxWidth(),124                        )125                        Spacer(Modifier.height(PeachSpacing.md))126                    }127                    PillButton(128                        if (reveal) "I've written them down" else "I already have these words",129                        LocalPeachAccent.current,130                        filled = true,131                    ) {132                        reveal = false133                        vm.acknowledgeWordRetirement(true)134                    }135                    Spacer(Modifier.height(PeachSpacing.sm))136                    PillButton("Cancel — keep my words", peachColors.T2, filled = false) {137                        reveal = false138                        vm.acknowledgeWordRetirement(false)139                    }140                }141142                AdoptVaultUiState.Password -> {143                    Body(144                        "Choose the password this phone will use. It never leaves this device; " +145                            "your existing device will share the vault only after both screens confirm the same code.",146                    )147                    Spacer(Modifier.height(PeachSpacing.lg))148                    OutlinedTextField(149                        value = password,150                        onValueChange = { password = it },151                        label = { Text("new password") },152                        singleLine = true,153                        visualTransformation =154                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),155                        modifier = Modifier.fillMaxWidth(),156                    )157                    Spacer(Modifier.height(PeachSpacing.sm))158                    OutlinedTextField(159                        value = confirmation,160                        onValueChange = { confirmation = it },161                        label = { Text("confirm password") },162                        singleLine = true,163                        visualTransformation =164                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),165                        modifier = Modifier.fillMaxWidth(),166                    )167                    Text(168                        if (reveal) "hide passwords" else "show passwords",169                        color = LocalPeachAccent.current,170                        fontFamily = PeachType.Mono,171                        fontSize = 11.sp,172                        modifier = Modifier173                            .align(Alignment.End)174                            .clickable { reveal = !reveal }175                            .padding(PeachSpacing.sm),176                    )177                    StrengthBar(score, Modifier.fillMaxWidth())178                    Spacer(Modifier.height(PeachSpacing.xs))179                    Text(180                        "strength: ${strengthLabel(score)} · at least 12 characters",181                        color = peachColors.T2,182                        fontFamily = PeachType.Mono,183                        fontSize = 10.sp,184                    )185                    Spacer(Modifier.height(PeachSpacing.lg))186                    PillButton(187                        "Continue",188                        LocalPeachAccent.current,189                        filled = true,190                    ) {191                        if (passwordReady && vm.acceptPassword(password, confirmation)) {192                            password = ""193                            confirmation = ""194                        }195                    }196                    if (!passwordReady) {197                        Spacer(Modifier.height(PeachSpacing.xs))198                        Text(199                            "Continue unlocks when both passwords match and pass the strength check.",200                            color = peachColors.T2,201                            fontFamily = PeachType.Mono,202                            fontSize = 10.sp,203                            textAlign = TextAlign.Center,204                        )205                    }206                }207208                AdoptVaultUiState.Pair -> {209                    Body(210                        "Connecting via secure relay — works anywhere. Scan the QR shown by " +211                            "Peach on your existing device. It contains only the relay address and a single-use token.",212                    )213                    Spacer(Modifier.height(PeachSpacing.lg))214                    PairingQrCamera(onInvitation = vm::adopt)215                    Spacer(Modifier.height(PeachSpacing.lg))216                    Text(217                        "or enter the invitation manually",218                        color = peachColors.T2,219                        fontFamily = PeachType.Mono,220                        fontSize = 10.sp,221                    )222                    OutlinedTextField(223                        value = invitation,224                        onValueChange = { invitation = it },225                        label = { Text("peach-pair-v1:…") },226                        minLines = 3,227                        modifier = Modifier.fillMaxWidth(),228                    )229                    Spacer(Modifier.height(PeachSpacing.md))230                    PillButton(231                        "Connect",232                        LocalPeachAccent.current,233                        filled = true,234                    ) { vm.adopt(invitation) }235                }236237                AdoptVaultUiState.Connecting -> {238                    Body("Opening the single-use relay room and establishing an encrypted session…")239                }240241                is AdoptVaultUiState.ConfirmSas -> {242                    Body(243                        "Only continue if this code is identical on the device sharing the vault.",244                    )245                    Spacer(Modifier.height(PeachSpacing.lg))246                    Text(247                        current.sas,248                        color = LocalPeachAccent.current,249                        fontFamily = PeachType.Mono,250                        fontSize = 38.sp,251                        letterSpacing = 6.sp,252                    )253                    Spacer(Modifier.height(PeachSpacing.lg))254                    PillButton(255                        if (vm.joining) "They match — join vault" else "They match — adopt vault",256                        LocalPeachAccent.current,257                        filled = true,258                    ) { vm.confirmSas(true) }259                    Spacer(Modifier.height(PeachSpacing.sm))260                    PillButton("Cancel", peachColors.T2, filled = false) {261                        vm.confirmSas(false)262                    }263                }264265                AdoptVaultUiState.Adopting -> {266                    Body(267                        if (vm.joining) {268                            "Joining the vault. Your entries stay exactly where they are…"269                        } else {270                            "Both devices confirmed. Sealing the adopted vault under this phone's password…"271                        },272                    )273                }274275                is AdoptVaultUiState.Complete -> {276                    Body(277                        if (vm.joining) {278                            "Joined ${current.sourceName}. Both devices now share one vault — " +279                                "the next sync brings every entry across in both directions."280                        } else {281                            "${current.sourceName} shared the vault successfully. Encrypted sync is now active."282                        },283                    )284                    Spacer(Modifier.height(PeachSpacing.lg))285                    PillButton("Open vault", LocalPeachAccent.current, filled = true) {286                        showBackgroundSyncPrompt = true287                    }288                }289290                is AdoptVaultUiState.Failed -> {291                    Body(adoptionFailureCopy(current.reason))292                    Spacer(Modifier.height(PeachSpacing.lg))293                    PillButton("Start again", LocalPeachAccent.current, filled = true) {294                        invitation = ""295                        vm.restart()296                    }297                }298            }299300            if (state !is AdoptVaultUiState.Adopting &&301                state !is AdoptVaultUiState.Complete302            ) {303                Spacer(Modifier.height(PeachSpacing.md))304                PillButton("Back", peachColors.T2, filled = false, onClick = onBack)305            }306        }307    }308309    if (showBackgroundSyncPrompt) {310        BackgroundSyncDialog { enabled ->311            showBackgroundSyncPrompt = false312            vm.setBackgroundSync(enabled)313            onComplete()314        }315    }316}317318@Composable319private fun Body(value: String) {320    Text(321        value,322        color = peachColors.T2,323        fontFamily = PeachType.Body,324        fontSize = 13.sp,325        textAlign = TextAlign.Center,326        modifier = Modifier.fillMaxWidth(),327    )328}329330private fun adoptionFailureCopy(reason: String): String = when (reason) {331    "invalid_pairing_invitation" ->332        "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."333    "ADOPT_SAS_REJECTED", "adoption_rejected" ->334        "The confirmation was cancelled. No vault key was accepted."335    "ADOPT_VAULT_ID_MISMATCH" ->336        "The received vault did not pass its integrity check, so Peach refused it."337    "join_declined_at_word_retirement" ->338        "Cancelled before anything changed. This device kept its own vault and its recovery words still work."339    "JOIN_UNRECOVERABLE_ROOT_KEY" ->340        "That vault's recovery words cannot be represented on this device, so Peach stopped rather than leave you unable to restore. Nothing changed."341    "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" ->342        "Unlock this device's vault first, then try joining again."343    else ->344        "The secure adoption session ended before it completed. Start again with a fresh invitation."345}