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 vm.batteryExemptionIntent()?.let { intent ->80 runCatching { context.startActivity(intent) }81 }82 }83 }84 }8586 val animState = when (state) {87 is CodePairUiState.Paired -> PairingAnimState.CONNECTED88 is CodePairUiState.AwaitingPeerConfirmation -> PairingAnimState.LISTENING89 else -> PairingAnimState.WAITING90 }9192 ShellBackground {93 Column(94 modifier = Modifier95 .fillMaxSize()96 .systemBarsPadding()97 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),98 ) {99 if (state !is CodePairUiState.Failed ||100 (state as? CodePairUiState.Failed)?.reason != "ADOPT_PRO_REQUIRED"101 ) {102 Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {103 BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))104 }105 }106107 when (val s = state) {108 is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")109110 is CodePairUiState.ShowingInvitation -> {111 Headline(112 "Pair a device",113 "Scan this QR on the empty device. Peach will send this vault only after the 8-digit code matches.",114 )115 Spacer(Modifier.height(PeachSpacing.md))116 PairingRelayChoice(117 mode = syncMode,118 configuredPrivateUrl = privateRelayUrl,119 expanded = relayExpanded,120 onExpandedChange = { relayExpanded = it },121 onSelectMode = vm::selectRelayMode,122 onSavePrivateUrl = vm::savePrivateRelayUrl,123 )124 if (!relayExpanded) {125 PairingQrCode(126 value = s.invitation,127 modifier = Modifier128 .fillMaxWidth()129 .height(260.dp),130 )131 }132 }133134 is CodePairUiState.AwaitingPeerConfirmation -> {135 Headline(136 "Confirm on the scanning device",137 "If this code matches there, tap “They match” on that device. " +138 "Peach finishes only after both sides save the pairing.",139 )140 Spacer(Modifier.height(PeachSpacing.lg))141 Text(142 text = s.sas,143 color = LocalPeachAccent.current,144 fontFamily = PeachType.Mono,145 fontSize = 38.sp,146 letterSpacing = 6.sp,147 textAlign = TextAlign.Center,148 modifier = Modifier.fillMaxWidth(),149 )150 }151152 is CodePairUiState.Paired -> {153 Headline("Paired", "${s.deviceName} will now sync through the relay.")154 Spacer(Modifier.height(PeachSpacing.lg))155 PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)156 }157158 is CodePairUiState.Expired -> {159 Headline(160 "That code expired",161 "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",162 )163 Spacer(Modifier.height(PeachSpacing.lg))164 PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }165 }166167 is CodePairUiState.Failed -> {168 if (s.reason == "ADOPT_PRO_REQUIRED") {169 PairingProRequired(170 onRetry = vm::restart,171 onOpenPro = onOpenPro,172 )173 } else {174 Headline("Couldn't pair", failureCopy(s.reason))175 Spacer(Modifier.height(PeachSpacing.md))176 if (s.reason == "sync_disabled" || s.reason == "private_relay_unset") {177 PairingRelayChoice(178 mode = syncMode,179 configuredPrivateUrl = privateRelayUrl,180 expanded = true,181 collapsible = false,182 onExpandedChange = { relayExpanded = it },183 onSelectMode = vm::selectRelayMode,184 onSavePrivateUrl = vm::savePrivateRelayUrl,185 )186 Spacer(Modifier.height(PeachSpacing.md))187 }188 PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)189 }190 }191 }192 Spacer(Modifier.height(PeachSpacing.md))193 }194 }195}196197@Composable198private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {199 val matrix = remember(value) {200 QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)201 }202 Canvas(modifier = modifier) {203 val side = minOf(size.width, size.height)204 val module = side / matrix.width205 val qrSize = module * matrix.width206 val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)207 drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))208 for (y in 0 until matrix.height) {209 for (x in 0 until matrix.width) {210 if (matrix[x, y]) {211 drawRect(212 Color.Black,213 topLeft = Offset(origin.x + x * module, origin.y + y * module),214 size = Size(module + 0.5f, module + 0.5f),215 )216 }217 }218 }219 }220}221222223@Composable224internal fun PillButton(225 label: String,226 color: Color,227 filled: Boolean,228 enabled: Boolean = true,229 onClick: () -> Unit,230) {231 val accent = LocalPeachAccent.current232 val buttonColor = if (filled) accent else color233 Box(234 modifier = Modifier235 .fillMaxWidth()236 .clip(RoundedCornerShape(14.dp))237 .background(238 if (filled) {239 accent.copy(alpha = if (enabled) 0.14f else 0.05f)240 } else {241 Color.Transparent242 },243 )244 .clickable(enabled = enabled, role = Role.Button, onClick = onClick)245 .padding(vertical = PeachSpacing.md),246 contentAlignment = Alignment.Center,247 ) {248 Text(249 label,250 color = buttonColor.copy(alpha = if (enabled) 1f else 0.38f),251 fontFamily = PeachType.Mono,252 fontSize = 13.sp,253 )254 }255}256257private fun failureCopy(reason: String): String = when (reason) {258 "ADOPT_PRO_REQUIRED" -> "Peach Pro is required on at least one paired device."259 "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."260 "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."261 "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."262 "sync_disabled" -> "Sync is switched off for this vault. Turn it on in Settings → Sync first."263 264 265 "private_relay_unset" -> "Private relay chosen but no address set. Enter your relay URL in Settings → Sync first."266 267 268 269 270 else -> "Something interrupted the connection. Try again."271 .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }272}273274@Composable275private fun Headline(title: String, body: String) {276 Column(277 modifier = Modifier.fillMaxWidth(),278 horizontalAlignment = Alignment.CenterHorizontally,279 verticalArrangement = Arrangement.Center,280 ) {281 Text(282 title,283 color = peachColors.T0,284 fontFamily = PeachType.Display,285 fontStyle = FontStyle.Italic,286 fontSize = 22.sp,287 textAlign = TextAlign.Center,288 )289 Spacer(Modifier.height(PeachSpacing.xs))290 Text(291 body,292 color = peachColors.T2,293 fontFamily = PeachType.Body,294 fontSize = 13.sp,295 textAlign = TextAlign.Center,296 )297 }298}