CodePairScreen.kt

11.1 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.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 syncMode by vm.syncMode.collectAsStateWithLifecycle()60    val privateRelayUrl by vm.privateRelayUrl.collectAsStateWithLifecycle()61    val context = LocalContext.current6263    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }64    var relayExpanded by remember { mutableStateOf(false) }6566    LaunchedEffect(Unit) { vm.start() }6768    LaunchedEffect(state) {69        if (shouldPromptForBackgroundSync(state is CodePairUiState.Paired)) showBackgroundSyncPrompt = true70    }7172    if (showBackgroundSyncPrompt) {73        BackgroundSyncDialog { enabled ->74            showBackgroundSyncPrompt = false75            vm.setBackgroundSync(enabled)76            if (enabled) {77                vm.batteryExemptionIntent()?.let { intent ->78                    runCatching { context.startActivity(intent) }79                }80            }81        }82    }8384    val animState = when (state) {85        is CodePairUiState.Paired -> PairingAnimState.CONNECTED86        is CodePairUiState.AwaitingPeerConfirmation -> PairingAnimState.LISTENING87        else -> PairingAnimState.WAITING88    }8990    ShellBackground {91        Column(92            modifier = Modifier93                .fillMaxSize()94                .systemBarsPadding()95                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),96        ) {97            Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {98                BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))99            }100101            when (val s = state) {102                is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")103104                is CodePairUiState.ShowingInvitation -> {105                    Headline(106                        "Pair a device",107                        "Scan this QR on the empty device. Peach will send this vault only after the 8-digit code matches.",108                    )109                    Spacer(Modifier.height(PeachSpacing.md))110                    PairingRelayChoice(111                        mode = syncMode,112                        configuredPrivateUrl = privateRelayUrl,113                        expanded = relayExpanded,114                        onExpandedChange = { relayExpanded = it },115                        onSelectMode = vm::selectRelayMode,116                        onSavePrivateUrl = vm::savePrivateRelayUrl,117                    )118                    if (!relayExpanded) {119                        PairingQrCode(120                            value = s.invitation,121                            modifier = Modifier122                                .fillMaxWidth()123                                .height(260.dp),124                        )125                    }126                }127128                is CodePairUiState.AwaitingPeerConfirmation -> {129                    Headline(130                        "Confirm on the scanning device",131                        "If this code matches there, tap “They match” on that device. " +132                            "Peach finishes only after both sides save the pairing.",133                    )134                    Spacer(Modifier.height(PeachSpacing.lg))135                    Text(136                        text = s.sas,137                        color = LocalPeachAccent.current,138                        fontFamily = PeachType.Mono,139                        fontSize = 38.sp,140                        letterSpacing = 6.sp,141                        textAlign = TextAlign.Center,142                        modifier = Modifier.fillMaxWidth(),143                    )144                }145146                is CodePairUiState.Paired -> {147                    Headline("Paired", "${s.deviceName} will now sync through the relay.")148                    Spacer(Modifier.height(PeachSpacing.lg))149                    PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)150                }151152                is CodePairUiState.Expired -> {153                    Headline(154                        "That code expired",155                        "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",156                    )157                    Spacer(Modifier.height(PeachSpacing.lg))158                    PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }159                }160161                is CodePairUiState.Failed -> {162                    Headline("Couldn't pair", failureCopy(s.reason))163                    Spacer(Modifier.height(PeachSpacing.lg))164                    if (s.reason == "sync_disabled" || s.reason == "private_relay_unset") {165                        PairingRelayChoice(166                            mode = syncMode,167                            configuredPrivateUrl = privateRelayUrl,168                            expanded = true,169                            collapsible = false,170                            onExpandedChange = { relayExpanded = it },171                            onSelectMode = vm::selectRelayMode,172                            onSavePrivateUrl = vm::savePrivateRelayUrl,173                        )174                        Spacer(Modifier.height(PeachSpacing.md))175                    }176                    PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)177                }178            }179            Spacer(Modifier.height(PeachSpacing.md))180        }181    }182}183184@Composable185private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {186    val matrix = remember(value) {187        QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)188    }189    Canvas(modifier = modifier) {190        val side = minOf(size.width, size.height)191        val module = side / matrix.width192        val qrSize = module * matrix.width193        val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)194        drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))195        for (y in 0 until matrix.height) {196            for (x in 0 until matrix.width) {197                if (matrix[x, y]) {198                    drawRect(199                        Color.Black,200                        topLeft = Offset(origin.x + x * module, origin.y + y * module),201                        size = Size(module + 0.5f, module + 0.5f),202                    )203                }204            }205        }206    }207}208209/** Full-width pill used for every action on the pairing screens. */210@Composable211internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {212    val accent = LocalPeachAccent.current213    Box(214        modifier = Modifier215            .fillMaxWidth()216            .clip(RoundedCornerShape(14.dp))217            .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)218            .clickable(onClick = onClick)219            .padding(vertical = PeachSpacing.md),220        contentAlignment = Alignment.Center,221    ) {222        Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)223    }224}225226private fun failureCopy(reason: String): String = when (reason) {227    "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."228    "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."229    "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."230    "sync_disabled" -> "Sync is switched off for this vault. Turn it on in Settings → Sync first."231    // Pairing hands the relay address to the other device, so there is nothing232    // honest to put in the code until one exists.233    "private_relay_unset" -> "Private relay chosen but no address set. Enter your relay URL in Settings → Sync first."234    // Every other reason collapsed into this one sentence, which is fine for a235    // user and useless for anyone debugging: "bad_token", "no_hello" and a raw236    // exception message all looked identical on screen. Debug builds show the237    // real one.238    else -> "Something interrupted the connection. Try again."239        .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }240}241242@Composable243private fun Headline(title: String, body: String) {244    Column(245        modifier = Modifier.fillMaxWidth(),246        horizontalAlignment = Alignment.CenterHorizontally,247        verticalArrangement = Arrangement.Center,248    ) {249        Text(250            title,251            color = peachColors.T0,252            fontFamily = PeachType.Display,253            fontStyle = FontStyle.Italic,254            fontSize = 22.sp,255            textAlign = TextAlign.Center,256        )257        Spacer(Modifier.height(PeachSpacing.xs))258        Text(259            body,260            color = peachColors.T2,261            fontFamily = PeachType.Body,262            fontSize = 13.sp,263            textAlign = TextAlign.Center,264        )265    }266}