CodePairScreen.kt

10.5 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.mutableStateOf23import androidx.compose.runtime.remember24import androidx.compose.runtime.setValue25import androidx.compose.runtime.getValue26import androidx.compose.ui.platform.LocalContext27import androidx.compose.ui.Alignment28import androidx.compose.ui.Modifier29import androidx.compose.ui.text.font.FontStyle30import androidx.compose.ui.text.style.TextAlign31import androidx.compose.ui.unit.dp32import androidx.compose.ui.unit.sp33import androidx.compose.ui.geometry.Offset34import androidx.compose.ui.geometry.Size35import androidx.compose.ui.platform.LocalClipboardManager36import androidx.compose.ui.text.AnnotatedString37import androidx.hilt.navigation.compose.hiltViewModel38import androidx.lifecycle.compose.collectAsStateWithLifecycle39import com.peachpasswords.ui.components.BirdAnimation40import com.peachpasswords.ui.components.PairingAnimState41import com.peachpasswords.ui.theme.LocalPeachAccent42import com.peachpasswords.ui.theme.PeachSpacing43import com.peachpasswords.ui.theme.PeachType44import com.peachpasswords.ui.theme.ShellBackground45import com.peachpasswords.ui.theme.peachColors46import com.google.zxing.BarcodeFormat47import com.google.zxing.qrcode.QRCodeWriter4849/**50 * Pair a device by code. The phone shows the code; the browser types it in and51 * dials. Mirrors the extension's own phase structure (enter → connecting → sas52 * → paired), minus the entry field — the phone never types a code, it publishes53 * one.54 */55@Composable56fun CodePairScreen(57    onBack: () -> Unit,58    vm: CodePairViewModel = hiltViewModel(),59) {60    val state by vm.state.collectAsStateWithLifecycle()61    val context = LocalContext.current6263    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }6465    LaunchedEffect(Unit) { vm.start() }6667    LaunchedEffect(state) {68        if (shouldPromptForBackgroundSync(state is CodePairUiState.Paired)) showBackgroundSyncPrompt = true69    }7071    if (showBackgroundSyncPrompt) {72        BackgroundSyncDialog { enabled ->73            showBackgroundSyncPrompt = false74            vm.setBackgroundSync(enabled)75            if (enabled) {76                vm.batteryExemptionIntent()?.let { intent ->77                    runCatching { context.startActivity(intent) }78                }79            }80        }81    }8283    val animState = when (state) {84        is CodePairUiState.Paired -> PairingAnimState.CONNECTED85        is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING86        else -> PairingAnimState.WAITING87    }8889    ShellBackground {90        Column(91            modifier = Modifier92                .fillMaxSize()93                .systemBarsPadding()94                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),95        ) {96            Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {97                BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))98            }99100            when (val s = state) {101                is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")102103                is CodePairUiState.ShowingInvitation -> {104                    val clipboard = LocalClipboardManager.current105                    Headline(106                        "Scan this in Peach",107                        "Connecting via secure relay — works anywhere. No vault or identity information is in this QR.",108                    )109                    Spacer(Modifier.height(PeachSpacing.md))110                    PairingQrCode(111                        value = s.invitation,112                        modifier = Modifier113                            .fillMaxWidth()114                            .height(260.dp),115                    )116                    Spacer(Modifier.height(PeachSpacing.md))117                    PillButton("Copy invitation", LocalPeachAccent.current, filled = false) {118                        clipboard.setText(AnnotatedString(s.invitation))119                    }120                }121122                is CodePairUiState.ConfirmSas -> {123                    Headline(124                        "Verify the code",125                        "Only continue if this code is identical on both screens. " +126                            "This is what proves you're talking to your own device.",127                    )128                    Spacer(Modifier.height(PeachSpacing.lg))129                    Text(130                        text = s.sas,131                        color = LocalPeachAccent.current,132                        fontFamily = PeachType.Mono,133                        fontSize = 38.sp,134                        letterSpacing = 6.sp,135                        textAlign = TextAlign.Center,136                        modifier = Modifier.fillMaxWidth(),137                    )138                    Spacer(Modifier.height(PeachSpacing.lg))139                    PillButton("They match — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }140                    Spacer(Modifier.height(PeachSpacing.sm))141                    PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() }142                }143144                is CodePairUiState.Paired -> {145                    Headline("Paired", "${s.deviceName} will now sync through the relay.")146                    Spacer(Modifier.height(PeachSpacing.lg))147                    PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)148                }149150                is CodePairUiState.Expired -> {151                    Headline(152                        "That code expired",153                        "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",154                    )155                    Spacer(Modifier.height(PeachSpacing.lg))156                    PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }157                }158159                is CodePairUiState.Failed -> {160                    Headline("Couldn't pair", failureCopy(s.reason))161                    Spacer(Modifier.height(PeachSpacing.lg))162                    PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)163                }164            }165            Spacer(Modifier.height(PeachSpacing.md))166        }167    }168}169170@Composable171private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {172    val matrix = remember(value) {173        QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)174    }175    Canvas(modifier = modifier) {176        val side = minOf(size.width, size.height)177        val module = side / matrix.width178        val qrSize = module * matrix.width179        val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)180        drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))181        for (y in 0 until matrix.height) {182            for (x in 0 until matrix.width) {183                if (matrix[x, y]) {184                    drawRect(185                        Color.Black,186                        topLeft = Offset(origin.x + x * module, origin.y + y * module),187                        size = Size(module + 0.5f, module + 0.5f),188                    )189                }190            }191        }192    }193}194195/** Full-width pill used for every action on the pairing screens. */196@Composable197internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {198    val accent = LocalPeachAccent.current199    Box(200        modifier = Modifier201            .fillMaxWidth()202            .clip(RoundedCornerShape(14.dp))203            .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)204            .clickable(onClick = onClick)205            .padding(vertical = PeachSpacing.md),206        contentAlignment = Alignment.Center,207    ) {208        Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)209    }210}211212private fun failureCopy(reason: String): String = when (reason) {213    "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."214    "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."215    "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."216    "sync_disabled" -> "Sync is switched off for this vault. Turn it on in Settings → Sync first."217    // Pairing hands the relay address to the other device, so there is nothing218    // honest to put in the code until one exists.219    "private_relay_unset" -> "Private relay chosen but no address set. Enter your relay URL in Settings → Sync first."220    // Every other reason collapsed into this one sentence, which is fine for a221    // user and useless for anyone debugging: "bad_token", "no_hello" and a raw222    // exception message all looked identical on screen. Debug builds show the223    // real one.224    else -> "Something interrupted the connection. Try again."225        .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }226}227228@Composable229private fun Headline(title: String, body: String) {230    Column(231        modifier = Modifier.fillMaxWidth(),232        horizontalAlignment = Alignment.CenterHorizontally,233        verticalArrangement = Arrangement.Center,234    ) {235        Text(236            title,237            color = peachColors.T0,238            fontFamily = PeachType.Display,239            fontStyle = FontStyle.Italic,240            fontSize = 22.sp,241            textAlign = TextAlign.Center,242        )243        Spacer(Modifier.height(PeachSpacing.xs))244        Text(245            body,246            color = peachColors.T2,247            fontFamily = PeachType.Body,248            fontSize = 13.sp,249            textAlign = TextAlign.Center,250        )251    }252}