AdoptVaultScreen.kt

14.3 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. Scanning a QR from a second browser, " +102                            "tablet, or phone is allowed as long as it holds the same vault " +103                            "(same recovery words).",104                    )105                    Spacer(Modifier.height(PeachSpacing.md))106                    Body(107                        "You'll see the same short code on both screens. The codes must match " +108                            "before anything is shared — that's what proves the other device is " +109                            "really the one you pointed the camera at, not a relay or middleman.",110                    )111                    Spacer(Modifier.height(PeachSpacing.lg))112                    PillButton("Continue", LocalPeachAccent.current, filled = true) {113                        vm.acceptDisclosure()114                    }115                }116117                AdoptVaultUiState.Password -> {118                    Body(119                        "Choose the password this phone will use. It never leaves this device; " +120                            "your existing device will share the vault only after both screens confirm the same code.",121                    )122                    Spacer(Modifier.height(PeachSpacing.lg))123                    OutlinedTextField(124                        value = password,125                        onValueChange = { password = it },126                        label = { Text("new password") },127                        singleLine = true,128                        visualTransformation =129                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),130                        isError = confirmationMismatch,131                        supportingText = {132                            if (confirmationMismatch) {133                                Text(134                                    "Passwords don’t match.",135                                    modifier = Modifier.semantics {136                                        liveRegion = LiveRegionMode.Polite137                                    },138                                )139                            }140                        },141                        modifier = Modifier.fillMaxWidth(),142                    )143                    Spacer(Modifier.height(PeachSpacing.sm))144                    OutlinedTextField(145                        value = confirmation,146                        onValueChange = { confirmation = it },147                        label = { Text("confirm password") },148                        singleLine = true,149                        visualTransformation =150                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),151                        modifier = Modifier.fillMaxWidth(),152                    )153                    Text(154                        if (reveal) "hide passwords" else "show passwords",155                        color = LocalPeachAccent.current,156                        fontFamily = PeachType.Mono,157                        fontSize = 11.sp,158                        modifier = Modifier159                            .align(Alignment.End)160                            .clickable { reveal = !reveal }161                            .padding(PeachSpacing.sm),162                    )163                    StrengthBar(score, Modifier.fillMaxWidth())164                    Spacer(Modifier.height(PeachSpacing.xs))165                    Text(166                        "strength: ${strengthLabel(score)} · at least 12 characters",167                        color = peachColors.T2,168                        fontFamily = PeachType.Mono,169                        fontSize = 10.sp,170                    )171                    Spacer(Modifier.height(PeachSpacing.lg))172                    PillButton(173                        "Continue",174                        LocalPeachAccent.current,175                        filled = true,176                        enabled = passwordReady,177                    ) {178                        if (vm.acceptPassword(password, confirmation)) {179                            password = ""180                            confirmation = ""181                        }182                    }183                    if (passwordFeedback != null && !confirmationMismatch) {184                        Spacer(Modifier.height(PeachSpacing.xs))185                        Text(186                            passwordFeedback,187                            color = peachColors.Breach,188                            fontFamily = PeachType.Mono,189                            fontSize = 10.sp,190                            textAlign = TextAlign.Center,191                            modifier = Modifier.semantics {192                                liveRegion = LiveRegionMode.Polite193                            },194                        )195                    }196                }197198                AdoptVaultUiState.Pair -> {199                    Body(200                        ADOPT_RELAY_COPY,201                    )202                    Spacer(Modifier.height(PeachSpacing.lg))203                    PairingQrCamera(onInvitation = vm::adopt)204                }205206                AdoptVaultUiState.Connecting -> {207                    Body("Opening the single-use relay room and establishing an encrypted session…")208                }209210                is AdoptVaultUiState.ConfirmSas -> {211                    Body(212                        "Only continue if this code is identical on the device sharing the vault.",213                    )214                    current.peerName?.let { name ->215                        Body("Joining device: $name")216                    }217                    Spacer(Modifier.height(PeachSpacing.lg))218                    Text(219                        current.sas,220                        color = LocalPeachAccent.current,221                        fontFamily = PeachType.Mono,222                        fontSize = 38.sp,223                        letterSpacing = 6.sp,224                    )225                    Spacer(Modifier.height(PeachSpacing.lg))226                    PillButton(227                        when {228                            current.isSource -> "They match — send vault"229                            vm.joining -> "They match — join vault"230                            else -> "They match — adopt vault"231                        },232                        LocalPeachAccent.current,233                        filled = true,234                    ) { vm.confirmSas(true) }235                    Spacer(Modifier.height(PeachSpacing.sm))236                    PillButton("Cancel", peachColors.T2, filled = false) {237                        vm.confirmSas(false)238                    }239                }240241                AdoptVaultUiState.Adopting -> {242                    Body(243                        if (vm.joining) {244                            "Checking that both devices already share the same vault…"245                        } else {246                            "Both devices confirmed. Sealing the adopted vault under this phone's password…"247                        },248                    )249                }250251                is AdoptVaultUiState.Complete -> {252                    Body(253                        if (vm.joining) {254                            "Re-paired with ${current.sourceName}. No entries or recovery words changed."255                        } else {256                            "${current.sourceName} shared the vault successfully. Encrypted sync is now active."257                        },258                    )259                    Spacer(Modifier.height(PeachSpacing.lg))260                    PillButton("Open vault", LocalPeachAccent.current, filled = true) {261                        showBackgroundSyncPrompt = true262                    }263                }264265                is AdoptVaultUiState.Failed -> {266                    Body(adoptionFailureCopy(current.reason))267                    Spacer(Modifier.height(PeachSpacing.lg))268                    PillButton("Start again", LocalPeachAccent.current, filled = true) {269                        vm.restart()270                    }271                }272            }273274            if (state !is AdoptVaultUiState.Adopting &&275                state !is AdoptVaultUiState.Complete276            ) {277                Spacer(Modifier.height(PeachSpacing.md))278                PillButton("Back", peachColors.T2, filled = false, onClick = onBack)279            }280        }281    }282283    if (showBackgroundSyncPrompt) {284        BackgroundSyncDialog { enabled ->285            showBackgroundSyncPrompt = false286            vm.setBackgroundSync(enabled)287            onComplete()288        }289    }290}291292internal const val ADOPT_RELAY_COPY =293    "Scan the QR on the device that already has your vault. That device chooses " +294        "Peach's public relay or your self-hosted relay, and this phone joins the same one."295296internal fun adoptionPasswordBlocker(297    password: String,298    confirmation: String,299    score: Int,300): String? = when {301    password.isEmpty() -> "Enter a password."302    password.length < 12 -> "Use at least 12 characters."303    confirmation.isEmpty() -> "Confirm your password."304    password != confirmation -> "Passwords don’t match."305    score < 2 -> "Use a stronger password or longer passphrase."306    else -> null307}308309@Composable310private fun Body(value: String) {311    Text(312        value,313        color = peachColors.T2,314        fontFamily = PeachType.Body,315        fontSize = 13.sp,316        textAlign = TextAlign.Center,317        modifier = Modifier.fillMaxWidth(),318    )319}320321private fun adoptionFailureCopy(reason: String): String = when (reason) {322    "invalid_pairing_invitation" ->323        "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."324    "ADOPT_SAS_REJECTED", "adoption_rejected" ->325        "The confirmation was cancelled. No vault key was accepted."326    "ADOPT_VAULT_ID_MISMATCH" ->327        "The received vault did not pass its integrity check, so Peach refused it."328    "JOIN_POPULATED_VAULT_BLOCKED" ->329        "These are different populated vaults, so Peach stopped before combining their entries. Reset or use a fresh destination, then pair again."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}