AdoptVaultScreen.kt

13.9 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) "Re-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.Disclosure -> {100                    Body(101                        "This device already has a vault. Peach can re-pair only a device from " +102                            "its own paired or removed-device history.",103                    )104                    Spacer(Modifier.height(PeachSpacing.md))105                    Body(106                        "A new device must start empty, even if it looks like it contains the " +107                            "same logins. This prevents separately restored records from being " +108                            "combined and duplicated.",109                    )110                    Spacer(Modifier.height(PeachSpacing.lg))111                    PillButton("Check and re-pair", LocalPeachAccent.current, filled = true) {112                        vm.acceptDisclosure()113                    }114                }115116                AdoptVaultUiState.Password -> {117                    Body(118                        "Choose the password this phone will use. It never leaves this device; " +119                            "your existing device will share the vault only after both screens confirm the same code.",120                    )121                    Spacer(Modifier.height(PeachSpacing.lg))122                    OutlinedTextField(123                        value = password,124                        onValueChange = { password = it },125                        label = { Text("new password") },126                        singleLine = true,127                        visualTransformation =128                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),129                        isError = confirmationMismatch,130                        supportingText = {131                            if (confirmationMismatch) {132                                Text(133                                    "Passwords don’t match.",134                                    modifier = Modifier.semantics {135                                        liveRegion = LiveRegionMode.Polite136                                    },137                                )138                            }139                        },140                        modifier = Modifier.fillMaxWidth(),141                    )142                    Spacer(Modifier.height(PeachSpacing.sm))143                    OutlinedTextField(144                        value = confirmation,145                        onValueChange = { confirmation = it },146                        label = { Text("confirm password") },147                        singleLine = true,148                        visualTransformation =149                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),150                        modifier = Modifier.fillMaxWidth(),151                    )152                    Text(153                        if (reveal) "hide passwords" else "show passwords",154                        color = LocalPeachAccent.current,155                        fontFamily = PeachType.Mono,156                        fontSize = 11.sp,157                        modifier = Modifier158                            .align(Alignment.End)159                            .clickable { reveal = !reveal }160                            .padding(PeachSpacing.sm),161                    )162                    StrengthBar(score, Modifier.fillMaxWidth())163                    Spacer(Modifier.height(PeachSpacing.xs))164                    Text(165                        "strength: ${strengthLabel(score)} · at least 12 characters",166                        color = peachColors.T2,167                        fontFamily = PeachType.Mono,168                        fontSize = 10.sp,169                    )170                    Spacer(Modifier.height(PeachSpacing.lg))171                    PillButton(172                        "Continue",173                        LocalPeachAccent.current,174                        filled = true,175                        enabled = passwordReady,176                    ) {177                        if (vm.acceptPassword(password, confirmation)) {178                            password = ""179                            confirmation = ""180                        }181                    }182                    if (passwordFeedback != null && !confirmationMismatch) {183                        Spacer(Modifier.height(PeachSpacing.xs))184                        Text(185                            passwordFeedback,186                            color = peachColors.Breach,187                            fontFamily = PeachType.Mono,188                            fontSize = 10.sp,189                            textAlign = TextAlign.Center,190                            modifier = Modifier.semantics {191                                liveRegion = LiveRegionMode.Polite192                            },193                        )194                    }195                }196197                AdoptVaultUiState.Pair -> {198                    Body(199                        ADOPT_RELAY_COPY,200                    )201                    Spacer(Modifier.height(PeachSpacing.lg))202                    PairingQrCamera(onInvitation = vm::adopt)203                }204205                AdoptVaultUiState.Connecting -> {206                    Body("Opening the single-use relay room and establishing an encrypted session…")207                }208209                is AdoptVaultUiState.ConfirmSas -> {210                    Body(211                        "Only continue if this code is identical on the device sharing the vault.",212                    )213                    Spacer(Modifier.height(PeachSpacing.lg))214                    Text(215                        current.sas,216                        color = LocalPeachAccent.current,217                        fontFamily = PeachType.Mono,218                        fontSize = 38.sp,219                        letterSpacing = 6.sp,220                    )221                    Spacer(Modifier.height(PeachSpacing.lg))222                    PillButton(223                        if (vm.joining) "They match — join vault" else "They match — adopt vault",224                        LocalPeachAccent.current,225                        filled = true,226                    ) { vm.confirmSas(true) }227                    Spacer(Modifier.height(PeachSpacing.sm))228                    PillButton("Cancel", peachColors.T2, filled = false) {229                        vm.confirmSas(false)230                    }231                }232233                AdoptVaultUiState.Adopting -> {234                    Body(235                        if (vm.joining) {236                            "Checking that both devices already share the same vault…"237                        } else {238                            "Both devices confirmed. Sealing the adopted vault under this phone's password…"239                        },240                    )241                }242243                is AdoptVaultUiState.Complete -> {244                    Body(245                        if (vm.joining) {246                            "Re-paired with ${current.sourceName}. No entries or recovery words changed."247                        } else {248                            "${current.sourceName} shared the vault successfully. Encrypted sync is now active."249                        },250                    )251                    Spacer(Modifier.height(PeachSpacing.lg))252                    PillButton("Open vault", LocalPeachAccent.current, filled = true) {253                        showBackgroundSyncPrompt = true254                    }255                }256257                is AdoptVaultUiState.Failed -> {258                    Body(adoptionFailureCopy(current.reason))259                    Spacer(Modifier.height(PeachSpacing.lg))260                    PillButton("Start again", LocalPeachAccent.current, filled = true) {261                        vm.restart()262                    }263                }264            }265266            if (state !is AdoptVaultUiState.Adopting &&267                state !is AdoptVaultUiState.Complete268            ) {269                Spacer(Modifier.height(PeachSpacing.md))270                PillButton("Back", peachColors.T2, filled = false, onClick = onBack)271            }272        }273    }274275    if (showBackgroundSyncPrompt) {276        BackgroundSyncDialog { enabled ->277            showBackgroundSyncPrompt = false278            vm.setBackgroundSync(enabled)279            onComplete()280        }281    }282}283284internal const val ADOPT_RELAY_COPY =285    "Scan the QR on the device that already has your vault. That device chooses " +286        "Peach's public relay or your self-hosted relay, and this phone joins the same one."287288internal fun adoptionPasswordBlocker(289    password: String,290    confirmation: String,291    score: Int,292): String? = when {293    password.isEmpty() -> "Enter a password."294    password.length < 12 -> "Use at least 12 characters."295    confirmation.isEmpty() -> "Confirm your password."296    password != confirmation -> "Passwords don’t match."297    score < 2 -> "Use a stronger password or longer passphrase."298    else -> null299}300301@Composable302private fun Body(value: String) {303    Text(304        value,305        color = peachColors.T2,306        fontFamily = PeachType.Body,307        fontSize = 13.sp,308        textAlign = TextAlign.Center,309        modifier = Modifier.fillMaxWidth(),310    )311}312313private fun adoptionFailureCopy(reason: String): String = when (reason) {314    "invalid_pairing_invitation" ->315        "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."316    "ADOPT_SAS_REJECTED", "adoption_rejected" ->317        "The confirmation was cancelled. No vault key was accepted."318    "ADOPT_VAULT_ID_MISMATCH" ->319        "The received vault did not pass its integrity check, so Peach refused it."320    "JOIN_POPULATED_VAULT_BLOCKED" ->321        "These are different populated vaults, so Peach stopped before combining their entries. Reset or use a fresh destination, then pair again."322    "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" ->323        "Unlock this device's vault first, then try joining again."324    else ->325        "The secure adoption session ended before it completed. Start again with a fresh invitation."326}