CodePairScreen.kt

9.6 KB · raw

1package com.peachpasswords.ui.pairing23import com.peachpasswords.BuildConfig4import androidx.compose.foundation.background5import androidx.compose.foundation.clickable6import androidx.compose.foundation.shape.RoundedCornerShape7import androidx.compose.ui.draw.clip8import androidx.compose.ui.graphics.Color9import androidx.compose.foundation.layout.Arrangement10import androidx.compose.foundation.layout.Box11import androidx.compose.foundation.layout.Column12import androidx.compose.foundation.layout.Spacer13import androidx.compose.foundation.layout.fillMaxSize14import androidx.compose.foundation.layout.fillMaxWidth15import androidx.compose.foundation.layout.height16import androidx.compose.foundation.layout.padding17import androidx.compose.foundation.layout.systemBarsPadding18import androidx.compose.material3.Text19import androidx.compose.runtime.Composable20import androidx.compose.runtime.LaunchedEffect21import androidx.compose.runtime.getValue22import androidx.compose.ui.platform.LocalContext23import androidx.compose.ui.Alignment24import androidx.compose.ui.Modifier25import androidx.compose.ui.text.font.FontStyle26import androidx.compose.ui.text.style.TextAlign27import androidx.compose.ui.unit.dp28import androidx.compose.ui.unit.sp29import androidx.hilt.navigation.compose.hiltViewModel30import androidx.lifecycle.compose.collectAsStateWithLifecycle31import com.peachpasswords.ui.components.BirdAnimation32import com.peachpasswords.ui.components.PairingAnimState33import com.peachpasswords.ui.theme.LocalPeachAccent34import com.peachpasswords.ui.theme.PeachSpacing35import com.peachpasswords.ui.theme.PeachType36import com.peachpasswords.ui.theme.ShellBackground37import com.peachpasswords.ui.theme.peachColors3839/**40 * Pair a device by code. The phone shows the code; the browser types it in and41 * dials. Mirrors the extension's own phase structure (enter → connecting → sas42 * → paired), minus the entry field — the phone never types a code, it publishes43 * one.44 */45@Composable46fun CodePairScreen(47    onBack: () -> Unit,48    vm: CodePairViewModel = hiltViewModel(),49) {50    val state by vm.state.collectAsStateWithLifecycle()51    val context = LocalContext.current5253    LaunchedEffect(Unit) { vm.start() }5455    // Asked after a successful pairing, not before: at that point the user has56    // just chosen to rely on this phone staying reachable, so the system's57    // "run in the background?" dialog has obvious context. Asking on arrival58    // would be a scary prompt for a feature they hadn't committed to yet.59    LaunchedEffect(state) {60        if (state is CodePairUiState.Paired) {61            vm.batteryExemptionIntent()?.let { intent ->62                runCatching { context.startActivity(intent) }63            }64        }65    }6667    val animState = when (state) {68        is CodePairUiState.Paired -> PairingAnimState.CONNECTED69        is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING70        else -> PairingAnimState.WAITING71    }7273    ShellBackground {74        Column(75            modifier = Modifier76                .fillMaxSize()77                .systemBarsPadding()78                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),79        ) {80            Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {81                BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))82            }8384            when (val s = state) {85                is CodePairUiState.Starting -> Headline("Starting…", "Opening a pairing window on your network.")8687                is CodePairUiState.ShowingCode -> {88                    Headline(89                        "Type this on your computer",90                        "Open Peach in your browser, choose “Pair a device”, and enter this code. " +91                            "Both devices need to be on the same Wi-Fi.",92                    )93                    Spacer(Modifier.height(PeachSpacing.lg))94                    Text(95                        text = s.code,96                        color = LocalPeachAccent.current,97                        fontFamily = PeachType.Mono,98                        fontSize = 26.sp,99                        letterSpacing = 2.sp,100                        textAlign = TextAlign.Center,101                        modifier = Modifier.fillMaxWidth(),102                    )103                    // The code carries this phone's address, so a VPN that104                    // routes the computer elsewhere makes it undialable. The105                    // failure is a silent timeout with nothing to look at, so106                    // say it here rather than let it be debugged from scratch.107                    Spacer(Modifier.height(PeachSpacing.md))108                    Text(109                        text = "The code contains this phone's address on your network (${s.ip}). " +110                            "If either device is on a VPN, turn it off for a moment — the computer " +111                            "won't be able to reach this address through it.",112                        color = peachColors.T2,113                        fontFamily = PeachType.Body,114                        fontSize = 11.sp,115                        textAlign = TextAlign.Center,116                        modifier = Modifier.fillMaxWidth(),117                    )118                }119120                is CodePairUiState.ConfirmSas -> {121                    Headline(122                        "Verify the code",123                        "Only continue if this code is identical on both screens. " +124                            "This is what proves you're talking to your own device.",125                    )126                    Spacer(Modifier.height(PeachSpacing.lg))127                    Text(128                        text = s.sas,129                        color = LocalPeachAccent.current,130                        fontFamily = PeachType.Mono,131                        fontSize = 38.sp,132                        letterSpacing = 6.sp,133                        textAlign = TextAlign.Center,134                        modifier = Modifier.fillMaxWidth(),135                    )136                    Spacer(Modifier.height(PeachSpacing.lg))137                    PillButton("They match — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }138                    Spacer(Modifier.height(PeachSpacing.sm))139                    PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() }140                }141142                is CodePairUiState.Paired -> {143                    Headline("Paired", "${s.deviceName} will now keep your vault in step over the local network.")144                    Spacer(Modifier.height(PeachSpacing.lg))145                    PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)146                }147148                is CodePairUiState.Expired -> {149                    Headline(150                        "That code expired",151                        "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",152                    )153                    Spacer(Modifier.height(PeachSpacing.lg))154                    PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }155                }156157                is CodePairUiState.Failed -> {158                    Headline("Couldn't pair", failureCopy(s.reason))159                    Spacer(Modifier.height(PeachSpacing.lg))160                    PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)161                }162            }163            Spacer(Modifier.height(PeachSpacing.md))164        }165    }166}167168/** Full-width pill used for every action on the pairing screens. */169@Composable170internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {171    val accent = LocalPeachAccent.current172    Box(173        modifier = Modifier174            .fillMaxWidth()175            .clip(RoundedCornerShape(14.dp))176            .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)177            .clickable(onClick = onClick)178            .padding(vertical = PeachSpacing.md),179        contentAlignment = Alignment.Center,180    ) {181        Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)182    }183}184185private fun failureCopy(reason: String): String = when (reason) {186    "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."187    "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."188    "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."189    // Every other reason collapsed into this one sentence, which is fine for a190    // user and useless for anyone debugging: "bad_token", "no_hello" and a raw191    // exception message all looked identical on screen. Debug builds show the192    // real one.193    else -> "Something interrupted the connection. Try again."194        .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }195}196197@Composable198private fun Headline(title: String, body: String) {199    Column(200        modifier = Modifier.fillMaxWidth(),201        horizontalAlignment = Alignment.CenterHorizontally,202        verticalArrangement = Arrangement.Center,203    ) {204        Text(205            title,206            color = peachColors.T0,207            fontFamily = PeachType.Display,208            fontStyle = FontStyle.Italic,209            fontSize = 22.sp,210            textAlign = TextAlign.Center,211        )212        Spacer(Modifier.height(PeachSpacing.xs))213        Text(214            body,215            color = peachColors.T2,216            fontFamily = PeachType.Body,217            fontSize = 13.sp,218            textAlign = TextAlign.Center,219        )220    }221}