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.QRCodeWriter4748495051525354@Composable55fun CodePairScreen(56 onBack: () -> Unit,57 onOpenPro: () -> Unit = onBack,58 vm: CodePairViewModel = hiltViewModel(),59) {60 val state by vm.state.collectAsStateWithLifecycle()61 val syncMode by vm.syncMode.collectAsStateWithLifecycle()62 val privateRelayUrl by vm.privateRelayUrl.collectAsStateWithLifecycle()63 val context = LocalContext.current6465 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }66 var relayExpanded by remember { mutableStateOf(false) }6768 LaunchedEffect(Unit) { vm.start() }6970 LaunchedEffect(state) {71 if (shouldPromptForBackgroundSync(state is CodePairUiState.Paired)) showBackgroundSyncPrompt = true72 }7374 if (showBackgroundSyncPrompt) {75 BackgroundSyncDialog { enabled ->76 showBackgroundSyncPrompt = false77 vm.setBackgroundSync(enabled)78 if (enabled) {79 80 81 82 val intent = vm.batteryExemptionIntent(userConfirmed = true)83 if (intent != null) runCatching { context.startActivity(intent) }84 else vm.syncOnce()85 }86 }87 }8889 val animState = when (state) {90 is CodePairUiState.Paired -> PairingAnimState.CONNECTED91 is CodePairUiState.AwaitingPeerConfirmation -> PairingAnimState.LISTENING92 else -> PairingAnimState.WAITING93 }9495 ShellBackground {96 Column(97 modifier = Modifier98 .fillMaxSize()99 .systemBarsPadding()100 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),101 ) {102 if (state !is CodePairUiState.Failed ||103 (state as? CodePairUiState.Failed)?.reason != "ADOPT_PRO_REQUIRED"104 ) {105 Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {106 BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))107 }108 }109110 when (val s = state) {111 is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")112113 is CodePairUiState.ShowingInvitation -> {114 Headline(115 "Pair a device",116 "Scan this QR on the empty device. Peach will send this vault only after the 8-digit code matches.",117 )118 Spacer(Modifier.height(PeachSpacing.md))119 PairingRelayChoice(120 mode = syncMode,121 configuredPrivateUrl = privateRelayUrl,122 expanded = relayExpanded,123 onExpandedChange = { relayExpanded = it },124 onSelectMode = vm::selectRelayMode,125 onSavePrivateUrl = vm::savePrivateRelayUrl,126 )127 if (!relayExpanded) {128 PairingQrCode(129 value = s.invitation,130 modifier = Modifier131 .fillMaxWidth()132 .height(260.dp),133 )134 }135 }136137 is CodePairUiState.AwaitingPeerConfirmation -> {138 Headline(139 "Confirm on both devices",140 "Compare this code with ${s.deviceName}. Approve on this device only if " +141 "it matches there too — until you do, nothing leaves this vault.",142 )143 Spacer(Modifier.height(PeachSpacing.lg))144 Text(145 text = s.sas,146 color = LocalPeachAccent.current,147 fontFamily = PeachType.Mono,148 fontSize = 38.sp,149 letterSpacing = 6.sp,150 textAlign = TextAlign.Center,151 modifier = Modifier.fillMaxWidth(),152 )153 Spacer(Modifier.height(PeachSpacing.lg))154 PillButton("They match", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }155 Spacer(Modifier.height(PeachSpacing.sm))156 PillButton("Cancel", LocalPeachAccent.current, filled = false) { vm.confirmSas(false) }157 }158159 is CodePairUiState.Paired -> {160 Headline("Paired", "${s.deviceName} will now sync through the relay.")161 Spacer(Modifier.height(PeachSpacing.lg))162 PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)163 }164165 is CodePairUiState.Expired -> {166 Headline(167 "That code expired",168 "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",169 )170 Spacer(Modifier.height(PeachSpacing.lg))171 PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }172 }173174 is CodePairUiState.Failed -> {175 if (s.reason == "ADOPT_PRO_REQUIRED") {176 PairingProRequired(177 onRetry = vm::restart,178 onOpenPro = onOpenPro,179 )180 } else {181 Headline("Couldn't pair", failureCopy(s.reason))182 Spacer(Modifier.height(PeachSpacing.md))183 if (s.reason == "sync_disabled" || s.reason == "private_relay_unset") {184 PairingRelayChoice(185 mode = syncMode,186 configuredPrivateUrl = privateRelayUrl,187 expanded = true,188 collapsible = false,189 onExpandedChange = { relayExpanded = it },190 onSelectMode = vm::selectRelayMode,191 onSavePrivateUrl = vm::savePrivateRelayUrl,192 )193 Spacer(Modifier.height(PeachSpacing.md))194 }195 PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)196 }197 }198 }199 Spacer(Modifier.height(PeachSpacing.md))200 }201 }202}203204@Composable205private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {206 val matrix = remember(value) {207 QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)208 }209 Canvas(modifier = modifier) {210 val side = minOf(size.width, size.height)211 val module = side / matrix.width212 val qrSize = module * matrix.width213 val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)214 drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))215 for (y in 0 until matrix.height) {216 for (x in 0 until matrix.width) {217 if (matrix[x, y]) {218 drawRect(219 Color.Black,220 topLeft = Offset(origin.x + x * module, origin.y + y * module),221 size = Size(module + 0.5f, module + 0.5f),222 )223 }224 }225 }226 }227}228229230@Composable231internal fun PillButton(232 label: String,233 color: Color,234 filled: Boolean,235 enabled: Boolean = true,236 onClick: () -> Unit,237) {238 val accent = LocalPeachAccent.current239 val buttonColor = if (filled) accent else color240 Box(241 modifier = Modifier242 .fillMaxWidth()243 .clip(RoundedCornerShape(14.dp))244 .background(245 if (filled) {246 accent.copy(alpha = if (enabled) 0.14f else 0.05f)247 } else {248 Color.Transparent249 },250 )251 .clickable(enabled = enabled, role = Role.Button, onClick = onClick)252 .padding(vertical = PeachSpacing.md),253 contentAlignment = Alignment.Center,254 ) {255 Text(256 label,257 color = buttonColor.copy(alpha = if (enabled) 1f else 0.38f),258 fontFamily = PeachType.Mono,259 fontSize = 13.sp,260 )261 }262}263264private fun failureCopy(reason: String): String = when (reason) {265 "ADOPT_PRO_REQUIRED" -> "Peach Pro is required on at least one paired device."266 "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."267 "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."268 "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."269 "sync_disabled" -> "Sync is switched off for this vault. Turn it on in Settings → Sync first."270 271 272 "private_relay_unset" -> "Private relay chosen but no address set. Enter your relay URL in Settings → Sync first."273 274 275 276 277 else -> "Something interrupted the connection. Try again."278 .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }279}280281@Composable282private fun Headline(title: String, body: String) {283 Column(284 modifier = Modifier.fillMaxWidth(),285 horizontalAlignment = Alignment.CenterHorizontally,286 verticalArrangement = Arrangement.Center,287 ) {288 Text(289 title,290 color = peachColors.T0,291 fontFamily = PeachType.Display,292 fontStyle = FontStyle.Italic,293 fontSize = 22.sp,294 textAlign = TextAlign.Center,295 )296 Spacer(Modifier.height(PeachSpacing.xs))297 Text(298 body,299 color = peachColors.T2,300 fontFamily = PeachType.Body,301 fontSize = 13.sp,302 textAlign = TextAlign.Center,303 )304 }305}