package com.peachpasswords.ui.pairing import com.peachpasswords.BuildConfig import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.Canvas import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.peachpasswords.ui.components.BirdAnimation import com.peachpasswords.ui.components.PairingAnimState import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import com.peachpasswords.ui.theme.peachColors import com.google.zxing.BarcodeFormat import com.google.zxing.qrcode.QRCodeWriter /** * Pair a device by code. The phone shows the code; the browser types it in and * dials. Mirrors the extension's own phase structure (enter → connecting → sas * → paired), minus the entry field — the phone never types a code, it publishes * one. */ @Composable fun CodePairScreen( onBack: () -> Unit, onOpenPro: () -> Unit = onBack, vm: CodePairViewModel = hiltViewModel(), ) { val state by vm.state.collectAsStateWithLifecycle() val syncMode by vm.syncMode.collectAsStateWithLifecycle() val privateRelayUrl by vm.privateRelayUrl.collectAsStateWithLifecycle() val context = LocalContext.current var showBackgroundSyncPrompt by remember { mutableStateOf(false) } var relayExpanded by remember { mutableStateOf(false) } LaunchedEffect(Unit) { vm.start() } LaunchedEffect(state) { if (shouldPromptForBackgroundSync(state is CodePairUiState.Paired)) showBackgroundSyncPrompt = true } if (showBackgroundSyncPrompt) { BackgroundSyncDialog { enabled -> showBackgroundSyncPrompt = false vm.setBackgroundSync(enabled) if (enabled) { // The dialog tap is the confirmation; when there's no exemption to // ask for (already granted, or already declined once) fall through // to the foreground-only sync path rather than doing nothing. val intent = vm.batteryExemptionIntent(userConfirmed = true) if (intent != null) runCatching { context.startActivity(intent) } else vm.syncOnce() } } } val animState = when (state) { is CodePairUiState.Paired -> PairingAnimState.CONNECTED is CodePairUiState.AwaitingPeerConfirmation -> PairingAnimState.LISTENING else -> PairingAnimState.WAITING } ShellBackground { Column( modifier = Modifier .fillMaxSize() .systemBarsPadding() .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md), ) { if (state !is CodePairUiState.Failed || (state as? CodePairUiState.Failed)?.reason != "ADOPT_PRO_REQUIRED" ) { Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp)) } } when (val s = state) { is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.") is CodePairUiState.ShowingInvitation -> { Headline( "Pair a device", "Scan this QR on the empty device. Peach will send this vault only after the 8-digit code matches.", ) Spacer(Modifier.height(PeachSpacing.md)) PairingRelayChoice( mode = syncMode, configuredPrivateUrl = privateRelayUrl, expanded = relayExpanded, onExpandedChange = { relayExpanded = it }, onSelectMode = vm::selectRelayMode, onSavePrivateUrl = vm::savePrivateRelayUrl, ) if (!relayExpanded) { PairingQrCode( value = s.invitation, modifier = Modifier .fillMaxWidth() .height(260.dp), ) } } is CodePairUiState.AwaitingPeerConfirmation -> { Headline( "Confirm on both devices", "Compare this code with ${s.deviceName}. Approve on this device only if " + "it matches there too — until you do, nothing leaves this vault.", ) Spacer(Modifier.height(PeachSpacing.lg)) Text( text = s.sas, color = LocalPeachAccent.current, fontFamily = PeachType.Mono, fontSize = 38.sp, letterSpacing = 6.sp, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(PeachSpacing.lg)) PillButton("They match", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) } Spacer(Modifier.height(PeachSpacing.sm)) PillButton("Cancel", LocalPeachAccent.current, filled = false) { vm.confirmSas(false) } } is CodePairUiState.Paired -> { Headline("Paired", "${s.deviceName} will now sync through the relay.") Spacer(Modifier.height(PeachSpacing.lg)) PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack) } is CodePairUiState.Expired -> { Headline( "That code expired", "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.", ) Spacer(Modifier.height(PeachSpacing.lg)) PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() } } is CodePairUiState.Failed -> { if (s.reason == "ADOPT_PRO_REQUIRED") { PairingProRequired( onRetry = vm::restart, onOpenPro = onOpenPro, ) } else { Headline("Couldn't pair", failureCopy(s.reason)) Spacer(Modifier.height(PeachSpacing.md)) if (s.reason == "sync_disabled" || s.reason == "private_relay_unset") { PairingRelayChoice( mode = syncMode, configuredPrivateUrl = privateRelayUrl, expanded = true, collapsible = false, onExpandedChange = { relayExpanded = it }, onSelectMode = vm::selectRelayMode, onSavePrivateUrl = vm::savePrivateRelayUrl, ) Spacer(Modifier.height(PeachSpacing.md)) } PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack) } } } Spacer(Modifier.height(PeachSpacing.md)) } } } @Composable private fun PairingQrCode(value: String, modifier: Modifier = Modifier) { val matrix = remember(value) { QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0) } Canvas(modifier = modifier) { val side = minOf(size.width, size.height) val module = side / matrix.width val qrSize = module * matrix.width val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f) drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize)) for (y in 0 until matrix.height) { for (x in 0 until matrix.width) { if (matrix[x, y]) { drawRect( Color.Black, topLeft = Offset(origin.x + x * module, origin.y + y * module), size = Size(module + 0.5f, module + 0.5f), ) } } } } } /** Full-width pill used for every action on the pairing screens. */ @Composable internal fun PillButton( label: String, color: Color, filled: Boolean, enabled: Boolean = true, onClick: () -> Unit, ) { val accent = LocalPeachAccent.current val buttonColor = if (filled) accent else color Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(14.dp)) .background( if (filled) { accent.copy(alpha = if (enabled) 0.14f else 0.05f) } else { Color.Transparent }, ) .clickable(enabled = enabled, role = Role.Button, onClick = onClick) .padding(vertical = PeachSpacing.md), contentAlignment = Alignment.Center, ) { Text( label, color = buttonColor.copy(alpha = if (enabled) 1f else 0.38f), fontFamily = PeachType.Mono, fontSize = 13.sp, ) } } private fun failureCopy(reason: String): String = when (reason) { "ADOPT_PRO_REQUIRED" -> "Peach Pro is required on at least one paired device." "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it." "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out." "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection." "sync_disabled" -> "Sync is switched off for this vault. Turn it on in Settings → Sync first." // Pairing hands the relay address to the other device, so there is nothing // honest to put in the code until one exists. "private_relay_unset" -> "Private relay chosen but no address set. Enter your relay URL in Settings → Sync first." // Every other reason collapsed into this one sentence, which is fine for a // user and useless for anyone debugging: "bad_token", "no_hello" and a raw // exception message all looked identical on screen. Debug builds show the // real one. else -> "Something interrupted the connection. Try again." .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it } } @Composable private fun Headline(title: String, body: String) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( title, color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp, textAlign = TextAlign.Center, ) Spacer(Modifier.height(PeachSpacing.xs)) Text( body, color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, textAlign = TextAlign.Center, ) } }