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.remember23import androidx.compose.runtime.getValue24import androidx.compose.ui.platform.LocalContext25import androidx.compose.ui.Alignment26import androidx.compose.ui.Modifier27import androidx.compose.ui.text.font.FontStyle28import androidx.compose.ui.text.style.TextAlign29import androidx.compose.ui.unit.dp30import androidx.compose.ui.unit.sp31import androidx.compose.ui.geometry.Offset32import androidx.compose.ui.geometry.Size33import androidx.compose.ui.platform.LocalClipboardManager34import androidx.compose.ui.text.AnnotatedString35import 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.QRCodeWriter4647484950515253@Composable54fun CodePairScreen(55 onBack: () -> Unit,56 vm: CodePairViewModel = hiltViewModel(),57) {58 val state by vm.state.collectAsStateWithLifecycle()59 val context = LocalContext.current6061 LaunchedEffect(Unit) { vm.start() }6263 64 65 66 67 LaunchedEffect(state) {68 if (state is CodePairUiState.Paired) {69 vm.batteryExemptionIntent()?.let { intent ->70 runCatching { context.startActivity(intent) }71 }72 }73 }7475 val animState = when (state) {76 is CodePairUiState.Paired -> PairingAnimState.CONNECTED77 is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING78 else -> PairingAnimState.WAITING79 }8081 ShellBackground {82 Column(83 modifier = Modifier84 .fillMaxSize()85 .systemBarsPadding()86 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),87 ) {88 Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {89 BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))90 }9192 when (val s = state) {93 is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")9495 is CodePairUiState.ShowingInvitation -> {96 val clipboard = LocalClipboardManager.current97 Headline(98 "Scan this in Peach",99 "Connecting via secure relay — works anywhere. No vault or identity information is in this QR.",100 )101 Spacer(Modifier.height(PeachSpacing.md))102 PairingQrCode(103 value = s.invitation,104 modifier = Modifier105 .fillMaxWidth()106 .height(260.dp),107 )108 Spacer(Modifier.height(PeachSpacing.md))109 PillButton("Copy invitation", LocalPeachAccent.current, filled = false) {110 clipboard.setText(AnnotatedString(s.invitation))111 }112 Spacer(Modifier.height(PeachSpacing.sm))113 PillButton("Use local network only", peachColors.T2, filled = false) {114 vm.useLanOnly(true)115 }116 }117118 is CodePairUiState.ShowingCode -> {119 Headline(120 "Type this on your computer",121 "Open Peach in your browser, choose “Pair a device”, and enter this code. " +122 "Both devices need to be on the same Wi-Fi.",123 )124 Spacer(Modifier.height(PeachSpacing.lg))125 Text(126 text = s.code,127 color = LocalPeachAccent.current,128 fontFamily = PeachType.Mono,129 fontSize = 26.sp,130 letterSpacing = 2.sp,131 textAlign = TextAlign.Center,132 modifier = Modifier.fillMaxWidth(),133 )134 135 136 137 138 Spacer(Modifier.height(PeachSpacing.md))139 Text(140 text = "The code contains this phone's address on your network (${s.ip}). " +141 "If either device is on a VPN, turn it off for a moment — the computer " +142 "won't be able to reach this address through it.",143 color = peachColors.T2,144 fontFamily = PeachType.Body,145 fontSize = 11.sp,146 textAlign = TextAlign.Center,147 modifier = Modifier.fillMaxWidth(),148 )149 Spacer(Modifier.height(PeachSpacing.sm))150 PillButton("Use secure relay instead", peachColors.T2, filled = false) {151 vm.useLanOnly(false)152 }153 }154155 is CodePairUiState.ConfirmSas -> {156 Headline(157 "Verify the code",158 "Only continue if this code is identical on both screens. " +159 "This is what proves you're talking to your own device.",160 )161 Spacer(Modifier.height(PeachSpacing.lg))162 Text(163 text = s.sas,164 color = LocalPeachAccent.current,165 fontFamily = PeachType.Mono,166 fontSize = 38.sp,167 letterSpacing = 6.sp,168 textAlign = TextAlign.Center,169 modifier = Modifier.fillMaxWidth(),170 )171 Spacer(Modifier.height(PeachSpacing.lg))172 PillButton("They match — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }173 Spacer(Modifier.height(PeachSpacing.sm))174 PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() }175 }176177 is CodePairUiState.Paired -> {178 Headline("Paired", "${s.deviceName} will now keep your vault in step through the secure relay.")179 Spacer(Modifier.height(PeachSpacing.lg))180 PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)181 }182183 is CodePairUiState.Expired -> {184 Headline(185 "That code expired",186 "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",187 )188 Spacer(Modifier.height(PeachSpacing.lg))189 PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }190 }191192 is CodePairUiState.Failed -> {193 Headline("Couldn't pair", failureCopy(s.reason))194 Spacer(Modifier.height(PeachSpacing.lg))195 PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)196 }197 }198 Spacer(Modifier.height(PeachSpacing.md))199 }200 }201}202203@Composable204private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {205 val matrix = remember(value) {206 QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)207 }208 Canvas(modifier = modifier) {209 val side = minOf(size.width, size.height)210 val module = side / matrix.width211 val qrSize = module * matrix.width212 val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)213 drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))214 for (y in 0 until matrix.height) {215 for (x in 0 until matrix.width) {216 if (matrix[x, y]) {217 drawRect(218 Color.Black,219 topLeft = Offset(origin.x + x * module, origin.y + y * module),220 size = Size(module + 0.5f, module + 0.5f),221 )222 }223 }224 }225 }226}227228229@Composable230internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {231 val accent = LocalPeachAccent.current232 Box(233 modifier = Modifier234 .fillMaxWidth()235 .clip(RoundedCornerShape(14.dp))236 .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)237 .clickable(onClick = onClick)238 .padding(vertical = PeachSpacing.md),239 contentAlignment = Alignment.Center,240 ) {241 Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)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 250 251 252 253 else -> "Something interrupted the connection. Try again."254 .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }255}256257@Composable258private fun Headline(title: String, body: String) {259 Column(260 modifier = Modifier.fillMaxWidth(),261 horizontalAlignment = Alignment.CenterHorizontally,262 verticalArrangement = Arrangement.Center,263 ) {264 Text(265 title,266 color = peachColors.T0,267 fontFamily = PeachType.Display,268 fontStyle = FontStyle.Italic,269 fontSize = 22.sp,270 textAlign = TextAlign.Center,271 )272 Spacer(Modifier.height(PeachSpacing.xs))273 Text(274 body,275 color = peachColors.T2,276 fontFamily = PeachType.Body,277 fontSize = 13.sp,278 textAlign = TextAlign.Center,279 )280 }281}