CodePairScreen.kt

12.2 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.foundation.Canvas19import androidx.compose.material3.Text20import androidx.compose.runtime.Composable21import androidx.compose.runtime.LaunchedEffect22import androidx.compose.runtime.remember23import androidx.compose.runtime.getValue24import androidx.compose.ui.platform.LocalContext25import androidx.compose.ui.Alignment26import androidx.compose.ui.Modifier27import androidx.compose.ui.text.font.FontStyle28import androidx.compose.ui.text.style.TextAlign29import androidx.compose.ui.unit.dp30import androidx.compose.ui.unit.sp31import androidx.compose.ui.geometry.Offset32import androidx.compose.ui.geometry.Size33import androidx.compose.ui.platform.LocalClipboardManager34import androidx.compose.ui.text.AnnotatedString35import androidx.hilt.navigation.compose.hiltViewModel36import androidx.lifecycle.compose.collectAsStateWithLifecycle37import com.peachpasswords.ui.components.BirdAnimation38import com.peachpasswords.ui.components.PairingAnimState39import com.peachpasswords.ui.theme.LocalPeachAccent40import com.peachpasswords.ui.theme.PeachSpacing41import com.peachpasswords.ui.theme.PeachType42import com.peachpasswords.ui.theme.ShellBackground43import com.peachpasswords.ui.theme.peachColors44import com.google.zxing.BarcodeFormat45import com.google.zxing.qrcode.QRCodeWriter4647/**48 * Pair a device by code. The phone shows the code; the browser types it in and49 * dials. Mirrors the extension's own phase structure (enter → connecting → sas50 * → paired), minus the entry field — the phone never types a code, it publishes51 * one.52 */53@Composable54fun CodePairScreen(55    onBack: () -> Unit,56    vm: CodePairViewModel = hiltViewModel(),57) {58    val state by vm.state.collectAsStateWithLifecycle()59    val context = LocalContext.current6061    LaunchedEffect(Unit) { vm.start() }6263    // Asked after a successful pairing, not before: at that point the user has64    // just chosen to rely on this phone staying reachable, so the system's65    // "run in the background?" dialog has obvious context. Asking on arrival66    // would be a scary prompt for a feature they hadn't committed to yet.67    LaunchedEffect(state) {68        if (state is CodePairUiState.Paired) {69            vm.batteryExemptionIntent()?.let { intent ->70                runCatching { context.startActivity(intent) }71            }72        }73    }7475    val animState = when (state) {76        is CodePairUiState.Paired -> PairingAnimState.CONNECTED77        is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING78        else -> PairingAnimState.WAITING79    }8081    ShellBackground {82        Column(83            modifier = Modifier84                .fillMaxSize()85                .systemBarsPadding()86                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),87        ) {88            Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {89                BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))90            }9192            when (val s = state) {93                is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")9495                is CodePairUiState.ShowingInvitation -> {96                    val clipboard = LocalClipboardManager.current97                    Headline(98                        "Scan this in Peach",99                        "Connecting via secure relay — works anywhere. No vault or identity information is in this QR.",100                    )101                    Spacer(Modifier.height(PeachSpacing.md))102                    PairingQrCode(103                        value = s.invitation,104                        modifier = Modifier105                            .fillMaxWidth()106                            .height(260.dp),107                    )108                    Spacer(Modifier.height(PeachSpacing.md))109                    PillButton("Copy invitation", LocalPeachAccent.current, filled = false) {110                        clipboard.setText(AnnotatedString(s.invitation))111                    }112                    Spacer(Modifier.height(PeachSpacing.sm))113                    PillButton("Use local network only", peachColors.T2, filled = false) {114                        vm.useLanOnly(true)115                    }116                }117118                is CodePairUiState.ShowingCode -> {119                    Headline(120                        "Type this on your computer",121                        "Open Peach in your browser, choose “Pair a device”, and enter this code. " +122                            "Both devices need to be on the same Wi-Fi.",123                    )124                    Spacer(Modifier.height(PeachSpacing.lg))125                    Text(126                        text = s.code,127                        color = LocalPeachAccent.current,128                        fontFamily = PeachType.Mono,129                        fontSize = 26.sp,130                        letterSpacing = 2.sp,131                        textAlign = TextAlign.Center,132                        modifier = Modifier.fillMaxWidth(),133                    )134                    // The code carries this phone's address, so a VPN that135                    // routes the computer elsewhere makes it undialable. The136                    // failure is a silent timeout with nothing to look at, so137                    // say it here rather than let it be debugged from scratch.138                    Spacer(Modifier.height(PeachSpacing.md))139                    Text(140                        text = "The code contains this phone's address on your network (${s.ip}). " +141                            "If either device is on a VPN, turn it off for a moment — the computer " +142                            "won't be able to reach this address through it.",143                        color = peachColors.T2,144                        fontFamily = PeachType.Body,145                        fontSize = 11.sp,146                        textAlign = TextAlign.Center,147                        modifier = Modifier.fillMaxWidth(),148                    )149                    Spacer(Modifier.height(PeachSpacing.sm))150                    PillButton("Use secure relay instead", peachColors.T2, filled = false) {151                        vm.useLanOnly(false)152                    }153                }154155                is CodePairUiState.ConfirmSas -> {156                    Headline(157                        "Verify the code",158                        "Only continue if this code is identical on both screens. " +159                            "This is what proves you're talking to your own device.",160                    )161                    Spacer(Modifier.height(PeachSpacing.lg))162                    Text(163                        text = s.sas,164                        color = LocalPeachAccent.current,165                        fontFamily = PeachType.Mono,166                        fontSize = 38.sp,167                        letterSpacing = 6.sp,168                        textAlign = TextAlign.Center,169                        modifier = Modifier.fillMaxWidth(),170                    )171                    Spacer(Modifier.height(PeachSpacing.lg))172                    PillButton("They match — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }173                    Spacer(Modifier.height(PeachSpacing.sm))174                    PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() }175                }176177                is CodePairUiState.Paired -> {178                    Headline("Paired", "${s.deviceName} will now keep your vault in step through the secure relay.")179                    Spacer(Modifier.height(PeachSpacing.lg))180                    PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)181                }182183                is CodePairUiState.Expired -> {184                    Headline(185                        "That code expired",186                        "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",187                    )188                    Spacer(Modifier.height(PeachSpacing.lg))189                    PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }190                }191192                is CodePairUiState.Failed -> {193                    Headline("Couldn't pair", failureCopy(s.reason))194                    Spacer(Modifier.height(PeachSpacing.lg))195                    PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)196                }197            }198            Spacer(Modifier.height(PeachSpacing.md))199        }200    }201}202203@Composable204private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {205    val matrix = remember(value) {206        QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)207    }208    Canvas(modifier = modifier) {209        val side = minOf(size.width, size.height)210        val module = side / matrix.width211        val qrSize = module * matrix.width212        val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)213        drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))214        for (y in 0 until matrix.height) {215            for (x in 0 until matrix.width) {216                if (matrix[x, y]) {217                    drawRect(218                        Color.Black,219                        topLeft = Offset(origin.x + x * module, origin.y + y * module),220                        size = Size(module + 0.5f, module + 0.5f),221                    )222                }223            }224        }225    }226}227228/** Full-width pill used for every action on the pairing screens. */229@Composable230internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {231    val accent = LocalPeachAccent.current232    Box(233        modifier = Modifier234            .fillMaxWidth()235            .clip(RoundedCornerShape(14.dp))236            .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)237            .clickable(onClick = onClick)238            .padding(vertical = PeachSpacing.md),239        contentAlignment = Alignment.Center,240    ) {241        Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)242    }243}244245private fun failureCopy(reason: String): String = when (reason) {246    "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."247    "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."248    "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."249    // Every other reason collapsed into this one sentence, which is fine for a250    // user and useless for anyone debugging: "bad_token", "no_hello" and a raw251    // exception message all looked identical on screen. Debug builds show the252    // real one.253    else -> "Something interrupted the connection. Try again."254        .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }255}256257@Composable258private fun Headline(title: String, body: String) {259    Column(260        modifier = Modifier.fillMaxWidth(),261        horizontalAlignment = Alignment.CenterHorizontally,262        verticalArrangement = Arrangement.Center,263    ) {264        Text(265            title,266            color = peachColors.T0,267            fontFamily = PeachType.Display,268            fontStyle = FontStyle.Italic,269            fontSize = 22.sp,270            textAlign = TextAlign.Center,271        )272        Spacer(Modifier.height(PeachSpacing.xs))273        Text(274            body,275            color = peachColors.T2,276            fontFamily = PeachType.Body,277            fontSize = 13.sp,278            textAlign = TextAlign.Center,279        )280    }281}