CodePairScreen.kt

9.2 KB · raw

1package com.peachpasswords.ui.pairing23import androidx.compose.foundation.background4import androidx.compose.foundation.clickable5import androidx.compose.foundation.shape.RoundedCornerShape6import androidx.compose.ui.draw.clip7import androidx.compose.ui.graphics.Color8import androidx.compose.foundation.layout.Arrangement9import androidx.compose.foundation.layout.Box10import androidx.compose.foundation.layout.Column11import androidx.compose.foundation.layout.Spacer12import androidx.compose.foundation.layout.fillMaxSize13import androidx.compose.foundation.layout.fillMaxWidth14import androidx.compose.foundation.layout.height15import androidx.compose.foundation.layout.padding16import androidx.compose.foundation.layout.systemBarsPadding17import androidx.compose.material3.Text18import androidx.compose.runtime.Composable19import androidx.compose.runtime.LaunchedEffect20import androidx.compose.runtime.getValue21import androidx.compose.ui.platform.LocalContext22import androidx.compose.ui.Alignment23import androidx.compose.ui.Modifier24import androidx.compose.ui.text.font.FontStyle25import 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.components.BirdAnimation31import com.peachpasswords.ui.components.PairingAnimState32import com.peachpasswords.ui.theme.LocalPeachAccent33import com.peachpasswords.ui.theme.PeachSpacing34import com.peachpasswords.ui.theme.PeachType35import com.peachpasswords.ui.theme.ShellBackground36import com.peachpasswords.ui.theme.peachColors3738/**39 * Pair a device by code. The phone shows the code; the browser types it in and40 * dials. Mirrors the extension's own phase structure (enter → connecting → sas41 * → paired), minus the entry field — the phone never types a code, it publishes42 * one.43 */44@Composable45fun CodePairScreen(46    onBack: () -> Unit,47    vm: CodePairViewModel = hiltViewModel(),48) {49    val state by vm.state.collectAsStateWithLifecycle()50    val context = LocalContext.current5152    LaunchedEffect(Unit) { vm.start() }5354    // Asked after a successful pairing, not before: at that point the user has55    // just chosen to rely on this phone staying reachable, so the system's56    // "run in the background?" dialog has obvious context. Asking on arrival57    // would be a scary prompt for a feature they hadn't committed to yet.58    LaunchedEffect(state) {59        if (state is CodePairUiState.Paired) {60            vm.batteryExemptionIntent()?.let { intent ->61                runCatching { context.startActivity(intent) }62            }63        }64    }6566    val animState = when (state) {67        is CodePairUiState.Paired -> PairingAnimState.CONNECTED68        is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING69        else -> PairingAnimState.WAITING70    }7172    ShellBackground {73        Column(74            modifier = Modifier75                .fillMaxSize()76                .systemBarsPadding()77                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),78        ) {79            Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {80                BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))81            }8283            when (val s = state) {84                is CodePairUiState.Starting -> Headline("Starting…", "Opening a pairing window on your network.")8586                is CodePairUiState.ShowingCode -> {87                    Headline(88                        "Type this on your computer",89                        "Open Peach in your browser, choose “Pair a device”, and enter this code. " +90                            "Both devices need to be on the same Wi-Fi.",91                    )92                    Spacer(Modifier.height(PeachSpacing.lg))93                    Text(94                        text = s.code,95                        color = LocalPeachAccent.current,96                        fontFamily = PeachType.Mono,97                        fontSize = 26.sp,98                        letterSpacing = 2.sp,99                        textAlign = TextAlign.Center,100                        modifier = Modifier.fillMaxWidth(),101                    )102                    // The code carries this phone's address, so a VPN that103                    // routes the computer elsewhere makes it undialable. The104                    // failure is a silent timeout with nothing to look at, so105                    // say it here rather than let it be debugged from scratch.106                    Spacer(Modifier.height(PeachSpacing.md))107                    Text(108                        text = "The code contains this phone's address on your network (${s.ip}). " +109                            "If either device is on a VPN, turn it off for a moment — the computer " +110                            "won't be able to reach this address through it.",111                        color = peachColors.T2,112                        fontFamily = PeachType.Body,113                        fontSize = 11.sp,114                        textAlign = TextAlign.Center,115                        modifier = Modifier.fillMaxWidth(),116                    )117                }118119                is CodePairUiState.ConfirmSas -> {120                    Headline(121                        "Verify the code",122                        "Only continue if this code is identical on both screens. " +123                            "This is what proves you're talking to your own device.",124                    )125                    Spacer(Modifier.height(PeachSpacing.lg))126                    Text(127                        text = s.sas,128                        color = LocalPeachAccent.current,129                        fontFamily = PeachType.Mono,130                        fontSize = 38.sp,131                        letterSpacing = 6.sp,132                        textAlign = TextAlign.Center,133                        modifier = Modifier.fillMaxWidth(),134                    )135                    Spacer(Modifier.height(PeachSpacing.lg))136                    PillButton("They match — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }137                    Spacer(Modifier.height(PeachSpacing.sm))138                    PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() }139                }140141                is CodePairUiState.Paired -> {142                    Headline("Paired", "${s.deviceName} will now keep your vault in step over the local network.")143                    Spacer(Modifier.height(PeachSpacing.lg))144                    PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)145                }146147                is CodePairUiState.Expired -> {148                    Headline(149                        "That code expired",150                        "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",151                    )152                    Spacer(Modifier.height(PeachSpacing.lg))153                    PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }154                }155156                is CodePairUiState.Failed -> {157                    Headline("Couldn't pair", failureCopy(s.reason))158                    Spacer(Modifier.height(PeachSpacing.lg))159                    PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)160                }161            }162            Spacer(Modifier.height(PeachSpacing.md))163        }164    }165}166167/** Full-width pill used for every action on the pairing screens. */168@Composable169internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {170    val accent = LocalPeachAccent.current171    Box(172        modifier = Modifier173            .fillMaxWidth()174            .clip(RoundedCornerShape(14.dp))175            .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)176            .clickable(onClick = onClick)177            .padding(vertical = PeachSpacing.md),178        contentAlignment = Alignment.Center,179    ) {180        Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)181    }182}183184private fun failureCopy(reason: String): String = when (reason) {185    "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."186    "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."187    "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."188    else -> "Something interrupted the connection. Try again."189}190191@Composable192private fun Headline(title: String, body: String) {193    Column(194        modifier = Modifier.fillMaxWidth(),195        horizontalAlignment = Alignment.CenterHorizontally,196        verticalArrangement = Arrangement.Center,197    ) {198        Text(199            title,200            color = peachColors.T0,201            fontFamily = PeachType.Display,202            fontStyle = FontStyle.Italic,203            fontSize = 22.sp,204            textAlign = TextAlign.Center,205        )206        Spacer(Modifier.height(PeachSpacing.xs))207        Text(208            body,209            color = peachColors.T2,210            fontFamily = PeachType.Body,211            fontSize = 13.sp,212            textAlign = TextAlign.Center,213        )214    }215}