AdoptVaultScreen.kt

13.6 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.semantics.LiveRegionMode24import androidx.compose.ui.semantics.liveRegion25import androidx.compose.ui.semantics.semantics26import androidx.compose.ui.text.input.PasswordVisualTransformation27import androidx.compose.ui.text.input.VisualTransformation28import androidx.compose.ui.text.style.TextAlign29import androidx.compose.ui.unit.dp30import androidx.compose.ui.unit.sp31import androidx.hilt.navigation.compose.hiltViewModel32import androidx.lifecycle.compose.collectAsStateWithLifecycle33import com.peachpasswords.ui.security.StrengthBar34import com.peachpasswords.ui.security.scorePassword35import com.peachpasswords.ui.security.strengthLabel36import com.peachpasswords.ui.components.BirdAnimation37import com.peachpasswords.ui.components.PairingAnimState38import com.peachpasswords.ui.theme.LocalPeachAccent39import com.peachpasswords.ui.theme.PeachSpacing40import com.peachpasswords.ui.theme.PeachType41import com.peachpasswords.ui.theme.ShellBackground42import com.peachpasswords.ui.theme.peachColors4344@Composable45fun AdoptVaultScreen(46    onBack: () -> Unit,47    onComplete: () -> Unit,48    vm: AdoptVaultViewModel = hiltViewModel(),49) {50    val state by vm.state.collectAsStateWithLifecycle()51    var password by remember { mutableStateOf("") }52    var confirmation by remember { mutableStateOf("") }53    var reveal by remember { mutableStateOf(false) }54    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }55    val score = scorePassword(password)56    val passwordBlocker = adoptionPasswordBlocker(password, confirmation, score)57    val passwordReady = passwordBlocker == null58    val passwordFeedback = if (password.isEmpty() && confirmation.isEmpty()) {59        null60    } else {61        passwordBlocker62    }63    val confirmationMismatch = confirmation.isNotEmpty() && password != confirmation6465    ShellBackground {66        Column(67            modifier = Modifier68                .fillMaxSize()69                .systemBarsPadding()70                .verticalScroll(rememberScrollState())71                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg),72            horizontalAlignment = Alignment.CenterHorizontally,73            verticalArrangement = Arrangement.Center,74        ) {75            Text(76                if (vm.joining) "Pair a device" else "I already have Peach",77                color = peachColors.T0,78                fontFamily = PeachType.Display,79                fontSize = 26.sp,80            )81            Spacer(Modifier.height(PeachSpacing.sm))8283            if (84                state is AdoptVaultUiState.ConfirmSas ||85                state is AdoptVaultUiState.Adopting ||86                state is AdoptVaultUiState.Complete87            ) {88                BirdAnimation(89                    state = if (state is AdoptVaultUiState.Complete) {90                        PairingAnimState.CONNECTED91                    } else {92                        PairingAnimState.LISTENING93                    },94                    modifier = Modifier.fillMaxWidth().height(170.dp),95                )96            }9798            when (val current = state) {99                AdoptVaultUiState.Password -> {100                    Body(101                        "Choose the password this phone will use. It never leaves this device; " +102                            "your existing device will share the vault only after both screens confirm the same code.",103                    )104                    Spacer(Modifier.height(PeachSpacing.lg))105                    OutlinedTextField(106                        value = password,107                        onValueChange = { password = it },108                        label = { Text("new password") },109                        singleLine = true,110                        visualTransformation =111                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),112                        isError = confirmationMismatch,113                        supportingText = {114                            if (confirmationMismatch) {115                                Text(116                                    "Passwords don’t match.",117                                    modifier = Modifier.semantics {118                                        liveRegion = LiveRegionMode.Polite119                                    },120                                )121                            }122                        },123                        modifier = Modifier.fillMaxWidth(),124                    )125                    Spacer(Modifier.height(PeachSpacing.sm))126                    OutlinedTextField(127                        value = confirmation,128                        onValueChange = { confirmation = it },129                        label = { Text("confirm password") },130                        singleLine = true,131                        visualTransformation =132                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),133                        modifier = Modifier.fillMaxWidth(),134                    )135                    Text(136                        if (reveal) "hide passwords" else "show passwords",137                        color = LocalPeachAccent.current,138                        fontFamily = PeachType.Mono,139                        fontSize = 11.sp,140                        modifier = Modifier141                            .align(Alignment.End)142                            .clickable { reveal = !reveal }143                            .padding(PeachSpacing.sm),144                    )145                    StrengthBar(score, Modifier.fillMaxWidth())146                    Spacer(Modifier.height(PeachSpacing.xs))147                    Text(148                        "strength: ${strengthLabel(score)} · at least 12 characters",149                        color = peachColors.T2,150                        fontFamily = PeachType.Mono,151                        fontSize = 10.sp,152                    )153                    Spacer(Modifier.height(PeachSpacing.lg))154                    PillButton(155                        "Continue",156                        LocalPeachAccent.current,157                        filled = true,158                        enabled = passwordReady,159                    ) {160                        if (vm.acceptPassword(password, confirmation)) {161                            password = ""162                            confirmation = ""163                        }164                    }165                    if (passwordFeedback != null && !confirmationMismatch) {166                        Spacer(Modifier.height(PeachSpacing.xs))167                        Text(168                            passwordFeedback,169                            color = peachColors.Breach,170                            fontFamily = PeachType.Mono,171                            fontSize = 10.sp,172                            textAlign = TextAlign.Center,173                            modifier = Modifier.semantics {174                                liveRegion = LiveRegionMode.Polite175                            },176                        )177                    }178                }179180                AdoptVaultUiState.Pair -> {181                    Body(182                        ADOPT_RELAY_COPY,183                    )184                    Spacer(Modifier.height(PeachSpacing.lg))185                    PairingQrCamera(onInvitation = vm::adopt)186                }187188                AdoptVaultUiState.Connecting -> {189                    Body("Opening the single-use relay room and establishing an encrypted session…")190                }191192                is AdoptVaultUiState.ConfirmSas -> {193                    Body(194                        "Only continue if this code is identical on the device sharing the vault.",195                    )196                    current.peerName?.let { name ->197                        Body("Joining device: $name")198                    }199                    Spacer(Modifier.height(PeachSpacing.lg))200                    Text(201                        current.sas,202                        color = LocalPeachAccent.current,203                        fontFamily = PeachType.Mono,204                        fontSize = 38.sp,205                        letterSpacing = 6.sp,206                    )207                    Spacer(Modifier.height(PeachSpacing.lg))208                    PillButton(209                        when {210                            current.isSource -> "They match — send vault"211                            vm.joining -> "They match — join vault"212                            else -> "They match — adopt vault"213                        },214                        LocalPeachAccent.current,215                        filled = true,216                    ) { vm.confirmSas(true) }217                    Spacer(Modifier.height(PeachSpacing.sm))218                    PillButton("Cancel", peachColors.T2, filled = false) {219                        vm.confirmSas(false)220                    }221                }222223                AdoptVaultUiState.Adopting -> {224                    Body(225                        if (vm.joining) {226                            "Checking that both devices already share the same vault…"227                        } else {228                            "Both devices confirmed. Sealing the adopted vault under this phone's password…"229                        },230                    )231                }232233                is AdoptVaultUiState.Complete -> {234                    Body(235                        if (vm.joining) {236                            "Re-paired with ${current.sourceName}. No entries or recovery words changed."237                        } else {238                            "${current.sourceName} shared the vault successfully. Encrypted sync is now active."239                        },240                    )241                    Spacer(Modifier.height(PeachSpacing.lg))242                    PillButton("Open vault", LocalPeachAccent.current, filled = true) {243                        showBackgroundSyncPrompt = true244                    }245                }246247                is AdoptVaultUiState.Failed -> {248                    if (current.reason == "ADOPT_PRO_REQUIRED") {249                        PairingProRequired(onRetry = vm::restart)250                    } else {251                        Body(adoptionFailureCopy(current.reason))252                        Spacer(Modifier.height(PeachSpacing.lg))253                        PillButton("Start again", LocalPeachAccent.current, filled = true) {254                            vm.restart()255                        }256                    }257                }258            }259260            if (state !is AdoptVaultUiState.Adopting &&261                state !is AdoptVaultUiState.Complete262            ) {263                Spacer(Modifier.height(PeachSpacing.md))264                PillButton("Back", peachColors.T2, filled = false, onClick = onBack)265            }266        }267    }268269    if (showBackgroundSyncPrompt) {270        BackgroundSyncDialog { enabled ->271            showBackgroundSyncPrompt = false272            vm.setBackgroundSync(enabled)273            onComplete()274        }275    }276}277278internal const val ADOPT_RELAY_COPY =279    "Scan the QR on the device that already has your vault. That device chooses " +280        "Peach's public relay or your self-hosted relay, and this phone joins the same one."281282internal fun adoptionPasswordBlocker(283    password: String,284    confirmation: String,285    score: Int,286): String? = when {287    password.isEmpty() -> "Enter a password."288    password.length < 12 -> "Use at least 12 characters."289    confirmation.isEmpty() -> "Confirm your password."290    password != confirmation -> "Passwords don’t match."291    score < 2 -> "Use a stronger password or longer passphrase."292    else -> null293}294295@Composable296private fun Body(value: String) {297    Text(298        value,299        color = peachColors.T2,300        fontFamily = PeachType.Body,301        fontSize = 13.sp,302        textAlign = TextAlign.Center,303        modifier = Modifier.fillMaxWidth(),304    )305}306307private fun adoptionFailureCopy(reason: String): String = when (reason) {308    "ADOPT_PRO_REQUIRED" ->309        "Peach Pro is required on at least one of the two devices before encrypted sync can be paired."310    "invalid_pairing_invitation" ->311        "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."312    "ADOPT_SAS_REJECTED", "adoption_rejected" ->313        "The confirmation was cancelled. No vault key was accepted."314    "ADOPT_VAULT_ID_MISMATCH" ->315        "The received vault did not pass its integrity check, so Peach refused it."316    "JOIN_POPULATED_VAULT_BLOCKED" ->317        "These are different populated vaults, so Peach stopped before combining their entries. Reset or use a fresh destination, then pair again."318    "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" ->319        "Unlock this device's vault first, then try joining again."320    else ->321        "The secure adoption session ended before it completed. Start again with a fresh invitation."322}