CodePairScreen.kt

8.3 KB · raw

1package com.peachpasswords.ui.pairing23import androidx.compose.foundation.layout.Arrangement4import androidx.compose.foundation.layout.Box5import 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.material3.Text13import androidx.compose.runtime.Composable14import androidx.compose.runtime.LaunchedEffect15import androidx.compose.runtime.getValue16import androidx.compose.ui.platform.LocalContext17import androidx.compose.ui.Alignment18import androidx.compose.ui.Modifier19import androidx.compose.ui.text.font.FontStyle20import androidx.compose.ui.text.style.TextAlign21import androidx.compose.ui.unit.dp22import androidx.compose.ui.unit.sp23import androidx.hilt.navigation.compose.hiltViewModel24import androidx.lifecycle.compose.collectAsStateWithLifecycle25import com.peachpasswords.ui.components.BirdAnimation26import com.peachpasswords.ui.components.PairingAnimState27import com.peachpasswords.ui.theme.LocalPeachAccent28import com.peachpasswords.ui.theme.PeachSpacing29import com.peachpasswords.ui.theme.PeachType30import com.peachpasswords.ui.theme.ShellBackground31import com.peachpasswords.ui.theme.peachColors3233/**34 * Pair a device by code. The phone shows the code; the browser types it in and35 * dials. Mirrors the extension's own phase structure (enter → connecting → sas36 * → paired), minus the entry field — the phone never types a code, it publishes37 * one.38 */39@Composable40fun CodePairScreen(41    onBack: () -> Unit,42    vm: CodePairViewModel = hiltViewModel(),43) {44    val state by vm.state.collectAsStateWithLifecycle()45    val context = LocalContext.current4647    LaunchedEffect(Unit) { vm.start() }4849    // Asked after a successful pairing, not before: at that point the user has50    // just chosen to rely on this phone staying reachable, so the system's51    // "run in the background?" dialog has obvious context. Asking on arrival52    // would be a scary prompt for a feature they hadn't committed to yet.53    LaunchedEffect(state) {54        if (state is CodePairUiState.Paired) {55            vm.batteryExemptionIntent()?.let { intent ->56                runCatching { context.startActivity(intent) }57            }58        }59    }6061    val animState = when (state) {62        is CodePairUiState.Paired -> PairingAnimState.CONNECTED63        is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING64        else -> PairingAnimState.WAITING65    }6667    ShellBackground {68        Column(69            modifier = Modifier70                .fillMaxSize()71                .systemBarsPadding()72                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),73        ) {74            Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {75                BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))76            }7778            when (val s = state) {79                is CodePairUiState.Starting -> Headline("Starting…", "Opening a pairing window on your network.")8081                is CodePairUiState.ShowingCode -> {82                    Headline(83                        "Type this on your computer",84                        "Open Peach in your browser, choose “Pair a device”, and enter this code. " +85                            "Both devices need to be on the same Wi-Fi.",86                    )87                    Spacer(Modifier.height(PeachSpacing.lg))88                    Text(89                        text = s.code,90                        color = LocalPeachAccent.current,91                        fontFamily = PeachType.Mono,92                        fontSize = 26.sp,93                        letterSpacing = 2.sp,94                        textAlign = TextAlign.Center,95                        modifier = Modifier.fillMaxWidth(),96                    )97                    // The code carries this phone's address, so a VPN that98                    // routes the computer elsewhere makes it undialable. The99                    // failure is a silent timeout with nothing to look at, so100                    // say it here rather than let it be debugged from scratch.101                    Spacer(Modifier.height(PeachSpacing.md))102                    Text(103                        text = "The code contains this phone's address on your network (${s.ip}). " +104                            "If either device is on a VPN, turn it off for a moment — the computer " +105                            "won't be able to reach this address through it.",106                        color = peachColors.T2,107                        fontFamily = PeachType.Body,108                        fontSize = 11.sp,109                        textAlign = TextAlign.Center,110                        modifier = Modifier.fillMaxWidth(),111                    )112                }113114                is CodePairUiState.ConfirmSas -> {115                    Headline(116                        "Verify the code",117                        "Only continue if this code is identical on both screens. " +118                            "This is what proves you're talking to your own device.",119                    )120                    Spacer(Modifier.height(PeachSpacing.lg))121                    Text(122                        text = s.sas,123                        color = LocalPeachAccent.current,124                        fontFamily = PeachType.Mono,125                        fontSize = 38.sp,126                        letterSpacing = 6.sp,127                        textAlign = TextAlign.Center,128                        modifier = Modifier.fillMaxWidth(),129                    )130                    Spacer(Modifier.height(PeachSpacing.lg))131                    PillButton("They match — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }132                    Spacer(Modifier.height(PeachSpacing.sm))133                    PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() }134                }135136                is CodePairUiState.Paired -> {137                    Headline("Paired", "${s.deviceName} will now keep your vault in step over the local network.")138                    Spacer(Modifier.height(PeachSpacing.lg))139                    PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)140                }141142                is CodePairUiState.Expired -> {143                    Headline(144                        "That code expired",145                        "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",146                    )147                    Spacer(Modifier.height(PeachSpacing.lg))148                    PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }149                }150151                is CodePairUiState.Failed -> {152                    Headline("Couldn't pair", failureCopy(s.reason))153                    Spacer(Modifier.height(PeachSpacing.lg))154                    PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)155                }156            }157            Spacer(Modifier.height(PeachSpacing.md))158        }159    }160}161162private fun failureCopy(reason: String): String = when (reason) {163    "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."164    "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."165    "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."166    else -> "Something interrupted the connection. Try again."167}168169@Composable170private fun Headline(title: String, body: String) {171    Column(172        modifier = Modifier.fillMaxWidth(),173        horizontalAlignment = Alignment.CenterHorizontally,174        verticalArrangement = Arrangement.Center,175    ) {176        Text(177            title,178            color = peachColors.T0,179            fontFamily = PeachType.Display,180            fontStyle = FontStyle.Italic,181            fontSize = 22.sp,182            textAlign = TextAlign.Center,183        )184        Spacer(Modifier.height(PeachSpacing.xs))185        Text(186            body,187            color = peachColors.T2,188            fontFamily = PeachType.Body,189            fontSize = 13.sp,190            textAlign = TextAlign.Center,191        )192    }193}