AdoptVaultScreen.kt

12.4 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.components.BirdAnimation34import com.peachpasswords.ui.components.PairingAnimState35import com.peachpasswords.ui.theme.LocalPeachAccent36import com.peachpasswords.ui.theme.PeachSpacing37import com.peachpasswords.ui.theme.PeachType38import com.peachpasswords.ui.theme.ShellBackground39import com.peachpasswords.ui.theme.peachColors4041@Composable42fun AdoptVaultScreen(43    onBack: () -> Unit,44    onComplete: () -> Unit,45    vm: AdoptVaultViewModel = hiltViewModel(),46) {47    val state by vm.state.collectAsStateWithLifecycle()48    var password by remember { mutableStateOf("") }49    var confirmation by remember { mutableStateOf("") }50    var reveal by remember { mutableStateOf(false) }51    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }52    val score = scorePassword(password)53    val passwordReady =54        password.length >= 12 && password == confirmation && score >= 25556    ShellBackground {57        Column(58            modifier = Modifier59                .fillMaxSize()60                .systemBarsPadding()61                .verticalScroll(rememberScrollState())62                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg),63            horizontalAlignment = Alignment.CenterHorizontally,64            verticalArrangement = Arrangement.Center,65        ) {66            Text(67                if (vm.joining) "Re-pair a device" else "I already have Peach",68                color = peachColors.T0,69                fontFamily = PeachType.Display,70                fontSize = 26.sp,71            )72            Spacer(Modifier.height(PeachSpacing.sm))7374            if (75                state is AdoptVaultUiState.ConfirmSas ||76                state is AdoptVaultUiState.Adopting ||77                state is AdoptVaultUiState.Complete78            ) {79                BirdAnimation(80                    state = if (state is AdoptVaultUiState.Complete) {81                        PairingAnimState.CONNECTED82                    } else {83                        PairingAnimState.LISTENING84                    },85                    modifier = Modifier.fillMaxWidth().height(170.dp),86                )87            }8889            when (val current = state) {90                AdoptVaultUiState.Disclosure -> {91                    Body(92                        "This device already has a vault. Peach can re-pair only a device from " +93                            "its own paired or removed-device history.",94                    )95                    Spacer(Modifier.height(PeachSpacing.md))96                    Body(97                        "A new device must start empty, even if it looks like it contains the " +98                            "same logins. This prevents separately restored records from being " +99                            "combined and duplicated.",100                    )101                    Spacer(Modifier.height(PeachSpacing.lg))102                    PillButton("Check and re-pair", LocalPeachAccent.current, filled = true) {103                        vm.acceptDisclosure()104                    }105                }106107                AdoptVaultUiState.Password -> {108                    Body(109                        "Choose the password this phone will use. It never leaves this device; " +110                            "your existing device will share the vault only after both screens confirm the same code.",111                    )112                    Spacer(Modifier.height(PeachSpacing.lg))113                    OutlinedTextField(114                        value = password,115                        onValueChange = { password = it },116                        label = { Text("new password") },117                        singleLine = true,118                        visualTransformation =119                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),120                        modifier = Modifier.fillMaxWidth(),121                    )122                    Spacer(Modifier.height(PeachSpacing.sm))123                    OutlinedTextField(124                        value = confirmation,125                        onValueChange = { confirmation = it },126                        label = { Text("confirm password") },127                        singleLine = true,128                        visualTransformation =129                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),130                        modifier = Modifier.fillMaxWidth(),131                    )132                    Text(133                        if (reveal) "hide passwords" else "show passwords",134                        color = LocalPeachAccent.current,135                        fontFamily = PeachType.Mono,136                        fontSize = 11.sp,137                        modifier = Modifier138                            .align(Alignment.End)139                            .clickable { reveal = !reveal }140                            .padding(PeachSpacing.sm),141                    )142                    StrengthBar(score, Modifier.fillMaxWidth())143                    Spacer(Modifier.height(PeachSpacing.xs))144                    Text(145                        "strength: ${strengthLabel(score)} · at least 12 characters",146                        color = peachColors.T2,147                        fontFamily = PeachType.Mono,148                        fontSize = 10.sp,149                    )150                    Spacer(Modifier.height(PeachSpacing.lg))151                    PillButton(152                        "Continue",153                        LocalPeachAccent.current,154                        filled = true,155                    ) {156                        if (passwordReady && vm.acceptPassword(password, confirmation)) {157                            password = ""158                            confirmation = ""159                        }160                    }161                    if (!passwordReady) {162                        Spacer(Modifier.height(PeachSpacing.xs))163                        Text(164                            "Continue unlocks when both passwords match and pass the strength check.",165                            color = peachColors.T2,166                            fontFamily = PeachType.Mono,167                            fontSize = 10.sp,168                            textAlign = TextAlign.Center,169                        )170                    }171                }172173                AdoptVaultUiState.Pair -> {174                    Body(175                        ADOPT_RELAY_COPY,176                    )177                    Spacer(Modifier.height(PeachSpacing.lg))178                    PairingQrCamera(onInvitation = vm::adopt)179                }180181                AdoptVaultUiState.Connecting -> {182                    Body("Opening the single-use relay room and establishing an encrypted session…")183                }184185                is AdoptVaultUiState.ConfirmSas -> {186                    Body(187                        "Only continue if this code is identical on the device sharing the vault.",188                    )189                    Spacer(Modifier.height(PeachSpacing.lg))190                    Text(191                        current.sas,192                        color = LocalPeachAccent.current,193                        fontFamily = PeachType.Mono,194                        fontSize = 38.sp,195                        letterSpacing = 6.sp,196                    )197                    Spacer(Modifier.height(PeachSpacing.lg))198                    PillButton(199                        if (vm.joining) "They match — join vault" else "They match — adopt vault",200                        LocalPeachAccent.current,201                        filled = true,202                    ) { vm.confirmSas(true) }203                    Spacer(Modifier.height(PeachSpacing.sm))204                    PillButton("Cancel", peachColors.T2, filled = false) {205                        vm.confirmSas(false)206                    }207                }208209                AdoptVaultUiState.Adopting -> {210                    Body(211                        if (vm.joining) {212                            "Checking that both devices already share the same vault…"213                        } else {214                            "Both devices confirmed. Sealing the adopted vault under this phone's password…"215                        },216                    )217                }218219                is AdoptVaultUiState.Complete -> {220                    Body(221                        if (vm.joining) {222                            "Re-paired with ${current.sourceName}. No entries or recovery words changed."223                        } else {224                            "${current.sourceName} shared the vault successfully. Encrypted sync is now active."225                        },226                    )227                    Spacer(Modifier.height(PeachSpacing.lg))228                    PillButton("Open vault", LocalPeachAccent.current, filled = true) {229                        showBackgroundSyncPrompt = true230                    }231                }232233                is AdoptVaultUiState.Failed -> {234                    Body(adoptionFailureCopy(current.reason))235                    Spacer(Modifier.height(PeachSpacing.lg))236                    PillButton("Start again", LocalPeachAccent.current, filled = true) {237                        vm.restart()238                    }239                }240            }241242            if (state !is AdoptVaultUiState.Adopting &&243                state !is AdoptVaultUiState.Complete244            ) {245                Spacer(Modifier.height(PeachSpacing.md))246                PillButton("Back", peachColors.T2, filled = false, onClick = onBack)247            }248        }249    }250251    if (showBackgroundSyncPrompt) {252        BackgroundSyncDialog { enabled ->253            showBackgroundSyncPrompt = false254            vm.setBackgroundSync(enabled)255            onComplete()256        }257    }258}259260internal const val ADOPT_RELAY_COPY =261    "Scan the QR on the device that already has your vault. That device chooses " +262        "Peach's public relay or your self-hosted relay, and this phone joins the same one."263264@Composable265private fun Body(value: String) {266    Text(267        value,268        color = peachColors.T2,269        fontFamily = PeachType.Body,270        fontSize = 13.sp,271        textAlign = TextAlign.Center,272        modifier = Modifier.fillMaxWidth(),273    )274}275276private fun adoptionFailureCopy(reason: String): String = when (reason) {277    "invalid_pairing_invitation" ->278        "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."279    "ADOPT_SAS_REJECTED", "adoption_rejected" ->280        "The confirmation was cancelled. No vault key was accepted."281    "ADOPT_VAULT_ID_MISMATCH" ->282        "The received vault did not pass its integrity check, so Peach refused it."283    "JOIN_POPULATED_VAULT_BLOCKED" ->284        "These are different populated vaults, so Peach stopped before combining their entries. Reset or use a fresh destination, then pair again."285    "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" ->286        "Unlock this device's vault first, then try joining again."287    else ->288        "The secure adoption session ended before it completed. Start again with a fresh invitation."289}