CodePairScreen.kt

12.7 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.semantics.Role30import androidx.compose.ui.text.font.FontStyle31import androidx.compose.ui.text.style.TextAlign32import androidx.compose.ui.unit.dp33import androidx.compose.ui.unit.sp34import androidx.compose.ui.geometry.Offset35import androidx.compose.ui.geometry.Size36import androidx.hilt.navigation.compose.hiltViewModel37import androidx.lifecycle.compose.collectAsStateWithLifecycle38import com.peachpasswords.ui.components.BirdAnimation39import com.peachpasswords.ui.components.PairingAnimState40import com.peachpasswords.ui.theme.LocalPeachAccent41import com.peachpasswords.ui.theme.PeachSpacing42import com.peachpasswords.ui.theme.PeachType43import com.peachpasswords.ui.theme.ShellBackground44import com.peachpasswords.ui.theme.peachColors45import com.google.zxing.BarcodeFormat46import com.google.zxing.qrcode.QRCodeWriter4748/**49 * Pair a device by code. The phone shows the code; the browser types it in and50 * dials. Mirrors the extension's own phase structure (enter → connecting → sas51 * → paired), minus the entry field — the phone never types a code, it publishes52 * one.53 */54@Composable55fun CodePairScreen(56    onBack: () -> Unit,57    onOpenPro: () -> Unit = onBack,58    vm: CodePairViewModel = hiltViewModel(),59) {60    val state by vm.state.collectAsStateWithLifecycle()61    val syncMode by vm.syncMode.collectAsStateWithLifecycle()62    val privateRelayUrl by vm.privateRelayUrl.collectAsStateWithLifecycle()63    val context = LocalContext.current6465    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }66    var relayExpanded by remember { mutableStateOf(false) }6768    LaunchedEffect(Unit) { vm.start() }6970    LaunchedEffect(state) {71        if (shouldPromptForBackgroundSync(state is CodePairUiState.Paired)) showBackgroundSyncPrompt = true72    }7374    if (showBackgroundSyncPrompt) {75        BackgroundSyncDialog { enabled ->76            showBackgroundSyncPrompt = false77            vm.setBackgroundSync(enabled)78            if (enabled) {79                // The dialog tap is the confirmation; when there's no exemption to80                // ask for (already granted, or already declined once) fall through81                // to the foreground-only sync path rather than doing nothing.82                val intent = vm.batteryExemptionIntent(userConfirmed = true)83                if (intent != null) runCatching { context.startActivity(intent) }84                else vm.syncOnce()85            }86        }87    }8889    val animState = when (state) {90        is CodePairUiState.Paired -> PairingAnimState.CONNECTED91        is CodePairUiState.AwaitingPeerConfirmation -> PairingAnimState.LISTENING92        else -> PairingAnimState.WAITING93    }9495    ShellBackground {96        Column(97            modifier = Modifier98                .fillMaxSize()99                .systemBarsPadding()100                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),101        ) {102            if (state !is CodePairUiState.Failed ||103                (state as? CodePairUiState.Failed)?.reason != "ADOPT_PRO_REQUIRED"104            ) {105                Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {106                    BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))107                }108            }109110            when (val s = state) {111                is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")112113                is CodePairUiState.ShowingInvitation -> {114                    Headline(115                        "Pair a device",116                        "Scan this QR on the empty device. Peach will send this vault only after the 8-digit code matches.",117                    )118                    Spacer(Modifier.height(PeachSpacing.md))119                    PairingRelayChoice(120                        mode = syncMode,121                        configuredPrivateUrl = privateRelayUrl,122                        expanded = relayExpanded,123                        onExpandedChange = { relayExpanded = it },124                        onSelectMode = vm::selectRelayMode,125                        onSavePrivateUrl = vm::savePrivateRelayUrl,126                    )127                    if (!relayExpanded) {128                        PairingQrCode(129                            value = s.invitation,130                            modifier = Modifier131                                .fillMaxWidth()132                                .height(260.dp),133                        )134                    }135                }136137                is CodePairUiState.AwaitingPeerConfirmation -> {138                    Headline(139                        "Confirm on both devices",140                        "Compare this code with ${s.deviceName}. Approve on this device only if " +141                            "it matches there too — until you do, nothing leaves this vault.",142                    )143                    Spacer(Modifier.height(PeachSpacing.lg))144                    Text(145                        text = s.sas,146                        color = LocalPeachAccent.current,147                        fontFamily = PeachType.Mono,148                        fontSize = 38.sp,149                        letterSpacing = 6.sp,150                        textAlign = TextAlign.Center,151                        modifier = Modifier.fillMaxWidth(),152                    )153                    Spacer(Modifier.height(PeachSpacing.lg))154                    PillButton("They match", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }155                    Spacer(Modifier.height(PeachSpacing.sm))156                    PillButton("Cancel", LocalPeachAccent.current, filled = false) { vm.confirmSas(false) }157                }158159                is CodePairUiState.Paired -> {160                    Headline("Paired", "${s.deviceName} will now sync through the relay.")161                    Spacer(Modifier.height(PeachSpacing.lg))162                    PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)163                }164165                is CodePairUiState.Expired -> {166                    Headline(167                        "That code expired",168                        "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",169                    )170                    Spacer(Modifier.height(PeachSpacing.lg))171                    PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }172                }173174                is CodePairUiState.Failed -> {175                    if (s.reason == "ADOPT_PRO_REQUIRED") {176                        PairingProRequired(177                            onRetry = vm::restart,178                            onOpenPro = onOpenPro,179                        )180                    } else {181                        Headline("Couldn't pair", failureCopy(s.reason))182                        Spacer(Modifier.height(PeachSpacing.md))183                        if (s.reason == "sync_disabled" || s.reason == "private_relay_unset") {184                            PairingRelayChoice(185                                mode = syncMode,186                                configuredPrivateUrl = privateRelayUrl,187                                expanded = true,188                                collapsible = false,189                                onExpandedChange = { relayExpanded = it },190                                onSelectMode = vm::selectRelayMode,191                                onSavePrivateUrl = vm::savePrivateRelayUrl,192                            )193                            Spacer(Modifier.height(PeachSpacing.md))194                        }195                        PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)196                    }197                }198            }199            Spacer(Modifier.height(PeachSpacing.md))200        }201    }202}203204@Composable205private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {206    val matrix = remember(value) {207        QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)208    }209    Canvas(modifier = modifier) {210        val side = minOf(size.width, size.height)211        val module = side / matrix.width212        val qrSize = module * matrix.width213        val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)214        drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))215        for (y in 0 until matrix.height) {216            for (x in 0 until matrix.width) {217                if (matrix[x, y]) {218                    drawRect(219                        Color.Black,220                        topLeft = Offset(origin.x + x * module, origin.y + y * module),221                        size = Size(module + 0.5f, module + 0.5f),222                    )223                }224            }225        }226    }227}228229/** Full-width pill used for every action on the pairing screens. */230@Composable231internal fun PillButton(232    label: String,233    color: Color,234    filled: Boolean,235    enabled: Boolean = true,236    onClick: () -> Unit,237) {238    val accent = LocalPeachAccent.current239    val buttonColor = if (filled) accent else color240    Box(241        modifier = Modifier242            .fillMaxWidth()243            .clip(RoundedCornerShape(14.dp))244            .background(245                if (filled) {246                    accent.copy(alpha = if (enabled) 0.14f else 0.05f)247                } else {248                    Color.Transparent249                },250            )251            .clickable(enabled = enabled, role = Role.Button, onClick = onClick)252            .padding(vertical = PeachSpacing.md),253        contentAlignment = Alignment.Center,254    ) {255        Text(256            label,257            color = buttonColor.copy(alpha = if (enabled) 1f else 0.38f),258            fontFamily = PeachType.Mono,259            fontSize = 13.sp,260        )261    }262}263264private fun failureCopy(reason: String): String = when (reason) {265    "ADOPT_PRO_REQUIRED" -> "Peach Pro is required on at least one paired device."266    "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."267    "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."268    "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."269    "sync_disabled" -> "Sync is switched off for this vault. Turn it on in Settings → Sync first."270    // Pairing hands the relay address to the other device, so there is nothing271    // honest to put in the code until one exists.272    "private_relay_unset" -> "Private relay chosen but no address set. Enter your relay URL in Settings → Sync first."273    // Every other reason collapsed into this one sentence, which is fine for a274    // user and useless for anyone debugging: "bad_token", "no_hello" and a raw275    // exception message all looked identical on screen. Debug builds show the276    // real one.277    else -> "Something interrupted the connection. Try again."278        .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }279}280281@Composable282private fun Headline(title: String, body: String) {283    Column(284        modifier = Modifier.fillMaxWidth(),285        horizontalAlignment = Alignment.CenterHorizontally,286        verticalArrangement = Arrangement.Center,287    ) {288        Text(289            title,290            color = peachColors.T0,291            fontFamily = PeachType.Display,292            fontStyle = FontStyle.Italic,293            fontSize = 22.sp,294            textAlign = TextAlign.Center,295        )296        Spacer(Modifier.height(PeachSpacing.xs))297        Text(298            body,299            color = peachColors.T2,300            fontFamily = PeachType.Body,301            fontSize = 13.sp,302            textAlign = TextAlign.Center,303        )304    }305}