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.remember 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.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.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString 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, vm: CodePairViewModel = hiltViewModel(), ) { val state by vm.state.collectAsStateWithLifecycle() val context = LocalContext.current LaunchedEffect(Unit) { vm.start() } // Asked after a successful pairing, not before: at that point the user has // just chosen to rely on this phone staying reachable, so the system's // "run in the background?" dialog has obvious context. Asking on arrival // would be a scary prompt for a feature they hadn't committed to yet. LaunchedEffect(state) { if (state is CodePairUiState.Paired) { vm.batteryExemptionIntent()?.let { intent -> runCatching { context.startActivity(intent) } } } } val animState = when (state) { is CodePairUiState.Paired -> PairingAnimState.CONNECTED is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING else -> PairingAnimState.WAITING } ShellBackground { Column( modifier = Modifier .fillMaxSize() .systemBarsPadding() .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md), ) { 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 -> { val clipboard = LocalClipboardManager.current Headline( "Scan this in Peach", "Connecting via secure relay — works anywhere. No vault or identity information is in this QR.", ) Spacer(Modifier.height(PeachSpacing.md)) PairingQrCode( value = s.invitation, modifier = Modifier .fillMaxWidth() .height(260.dp), ) Spacer(Modifier.height(PeachSpacing.md)) PillButton("Copy invitation", LocalPeachAccent.current, filled = false) { clipboard.setText(AnnotatedString(s.invitation)) } } is CodePairUiState.ConfirmSas -> { Headline( "Verify the code", "Only continue if this code is identical on both screens. " + "This is what proves you're talking to your own device.", ) 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 — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) } Spacer(Modifier.height(PeachSpacing.sm)) PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() } } 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 -> { Headline("Couldn't pair", failureCopy(s.reason)) Spacer(Modifier.height(PeachSpacing.lg)) 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, onClick: () -> Unit) { val accent = LocalPeachAccent.current Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(14.dp)) .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent) .clickable(onClick = onClick) .padding(vertical = PeachSpacing.md), contentAlignment = Alignment.Center, ) { Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp) } } private fun failureCopy(reason: String): String = when (reason) { "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, ) } }