CodePairScreen.kt

11.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.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    vm: CodePairViewModel = hiltViewModel(),58) {59    val state by vm.state.collectAsStateWithLifecycle()60    val syncMode by vm.syncMode.collectAsStateWithLifecycle()61    val privateRelayUrl by vm.privateRelayUrl.collectAsStateWithLifecycle()62    val context = LocalContext.current6364    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }65    var relayExpanded by remember { mutableStateOf(false) }6667    LaunchedEffect(Unit) { vm.start() }6869    LaunchedEffect(state) {70        if (shouldPromptForBackgroundSync(state is CodePairUiState.Paired)) showBackgroundSyncPrompt = true71    }7273    if (showBackgroundSyncPrompt) {74        BackgroundSyncDialog { enabled ->75            showBackgroundSyncPrompt = false76            vm.setBackgroundSync(enabled)77            if (enabled) {78                vm.batteryExemptionIntent()?.let { intent ->79                    runCatching { context.startActivity(intent) }80                }81            }82        }83    }8485    val animState = when (state) {86        is CodePairUiState.Paired -> PairingAnimState.CONNECTED87        is CodePairUiState.AwaitingPeerConfirmation -> PairingAnimState.LISTENING88        else -> PairingAnimState.WAITING89    }9091    ShellBackground {92        Column(93            modifier = Modifier94                .fillMaxSize()95                .systemBarsPadding()96                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),97        ) {98            Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {99                BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))100            }101102            when (val s = state) {103                is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")104105                is CodePairUiState.ShowingInvitation -> {106                    Headline(107                        "Pair a device",108                        "Scan this QR on the empty device. Peach will send this vault only after the 8-digit code matches.",109                    )110                    Spacer(Modifier.height(PeachSpacing.md))111                    PairingRelayChoice(112                        mode = syncMode,113                        configuredPrivateUrl = privateRelayUrl,114                        expanded = relayExpanded,115                        onExpandedChange = { relayExpanded = it },116                        onSelectMode = vm::selectRelayMode,117                        onSavePrivateUrl = vm::savePrivateRelayUrl,118                    )119                    if (!relayExpanded) {120                        PairingQrCode(121                            value = s.invitation,122                            modifier = Modifier123                                .fillMaxWidth()124                                .height(260.dp),125                        )126                    }127                }128129                is CodePairUiState.AwaitingPeerConfirmation -> {130                    Headline(131                        "Confirm on the scanning device",132                        "If this code matches there, tap “They match” on that device. " +133                            "Peach finishes only after both sides save the pairing.",134                    )135                    Spacer(Modifier.height(PeachSpacing.lg))136                    Text(137                        text = s.sas,138                        color = LocalPeachAccent.current,139                        fontFamily = PeachType.Mono,140                        fontSize = 38.sp,141                        letterSpacing = 6.sp,142                        textAlign = TextAlign.Center,143                        modifier = Modifier.fillMaxWidth(),144                    )145                }146147                is CodePairUiState.Paired -> {148                    Headline("Paired", "${s.deviceName} will now sync through the relay.")149                    Spacer(Modifier.height(PeachSpacing.lg))150                    PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)151                }152153                is CodePairUiState.Expired -> {154                    Headline(155                        "That code expired",156                        "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",157                    )158                    Spacer(Modifier.height(PeachSpacing.lg))159                    PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }160                }161162                is CodePairUiState.Failed -> {163                    Headline("Couldn't pair", failureCopy(s.reason))164                    Spacer(Modifier.height(PeachSpacing.lg))165                    if (s.reason == "sync_disabled" || s.reason == "private_relay_unset") {166                        PairingRelayChoice(167                            mode = syncMode,168                            configuredPrivateUrl = privateRelayUrl,169                            expanded = true,170                            collapsible = false,171                            onExpandedChange = { relayExpanded = it },172                            onSelectMode = vm::selectRelayMode,173                            onSavePrivateUrl = vm::savePrivateRelayUrl,174                        )175                        Spacer(Modifier.height(PeachSpacing.md))176                    }177                    PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)178                }179            }180            Spacer(Modifier.height(PeachSpacing.md))181        }182    }183}184185@Composable186private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {187    val matrix = remember(value) {188        QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)189    }190    Canvas(modifier = modifier) {191        val side = minOf(size.width, size.height)192        val module = side / matrix.width193        val qrSize = module * matrix.width194        val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)195        drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))196        for (y in 0 until matrix.height) {197            for (x in 0 until matrix.width) {198                if (matrix[x, y]) {199                    drawRect(200                        Color.Black,201                        topLeft = Offset(origin.x + x * module, origin.y + y * module),202                        size = Size(module + 0.5f, module + 0.5f),203                    )204                }205            }206        }207    }208}209210/** Full-width pill used for every action on the pairing screens. */211@Composable212internal fun PillButton(213    label: String,214    color: Color,215    filled: Boolean,216    enabled: Boolean = true,217    onClick: () -> Unit,218) {219    val accent = LocalPeachAccent.current220    val buttonColor = if (filled) accent else color221    Box(222        modifier = Modifier223            .fillMaxWidth()224            .clip(RoundedCornerShape(14.dp))225            .background(226                if (filled) {227                    accent.copy(alpha = if (enabled) 0.14f else 0.05f)228                } else {229                    Color.Transparent230                },231            )232            .clickable(enabled = enabled, role = Role.Button, onClick = onClick)233            .padding(vertical = PeachSpacing.md),234        contentAlignment = Alignment.Center,235    ) {236        Text(237            label,238            color = buttonColor.copy(alpha = if (enabled) 1f else 0.38f),239            fontFamily = PeachType.Mono,240            fontSize = 13.sp,241        )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    "sync_disabled" -> "Sync is switched off for this vault. Turn it on in Settings → Sync first."250    // Pairing hands the relay address to the other device, so there is nothing251    // honest to put in the code until one exists.252    "private_relay_unset" -> "Private relay chosen but no address set. Enter your relay URL in Settings → Sync first."253    // Every other reason collapsed into this one sentence, which is fine for a254    // user and useless for anyone debugging: "bad_token", "no_hello" and a raw255    // exception message all looked identical on screen. Debug builds show the256    // real one.257    else -> "Something interrupted the connection. Try again."258        .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }259}260261@Composable262private fun Headline(title: String, body: String) {263    Column(264        modifier = Modifier.fillMaxWidth(),265        horizontalAlignment = Alignment.CenterHorizontally,266        verticalArrangement = Arrangement.Center,267    ) {268        Text(269            title,270            color = peachColors.T0,271            fontFamily = PeachType.Display,272            fontStyle = FontStyle.Italic,273            fontSize = 22.sp,274            textAlign = TextAlign.Center,275        )276        Spacer(Modifier.height(PeachSpacing.xs))277        Text(278            body,279            color = peachColors.T2,280            fontFamily = PeachType.Body,281            fontSize = 13.sp,282            textAlign = TextAlign.Center,283        )284    }285}