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.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.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 /** * 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 pairing window on your network.") is CodePairUiState.ShowingCode -> { Headline( "Type this on your computer", "Open Peach in your browser, choose “Pair a device”, and enter this code. " + "Both devices need to be on the same Wi-Fi.", ) Spacer(Modifier.height(PeachSpacing.lg)) Text( text = s.code, color = LocalPeachAccent.current, fontFamily = PeachType.Mono, fontSize = 26.sp, letterSpacing = 2.sp, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) // The code carries this phone's address, so a VPN that // routes the computer elsewhere makes it undialable. The // failure is a silent timeout with nothing to look at, so // say it here rather than let it be debugged from scratch. Spacer(Modifier.height(PeachSpacing.md)) Text( text = "The code contains this phone's address on your network (${s.ip}). " + "If either device is on a VPN, turn it off for a moment — the computer " + "won't be able to reach this address through it.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 11.sp, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) } 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 keep your vault in step over the local network.") 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)) } } } /** 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." // 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, ) } }