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 vm: CodePairViewModel = hiltViewModel(),58) {59 val state by vm.state.collectAsStateWithLifecycle()60 val syncMode by vm.syncMode.collectAsStateWithLifecycle()61 val privateRelayUrl by vm.privateRelayUrl.collectAsStateWithLifecycle()62 val context = LocalContext.current6364 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }65 var relayExpanded by remember { mutableStateOf(false) }6667 LaunchedEffect(Unit) { vm.start() }6869 LaunchedEffect(state) {70 if (shouldPromptForBackgroundSync(state is CodePairUiState.Paired)) showBackgroundSyncPrompt = true71 }7273 if (showBackgroundSyncPrompt) {74 BackgroundSyncDialog { enabled ->75 showBackgroundSyncPrompt = false76 vm.setBackgroundSync(enabled)77 if (enabled) {78 vm.batteryExemptionIntent()?.let { intent ->79 runCatching { context.startActivity(intent) }80 }81 }82 }83 }8485 val animState = when (state) {86 is CodePairUiState.Paired -> PairingAnimState.CONNECTED87 is CodePairUiState.AwaitingPeerConfirmation -> PairingAnimState.LISTENING88 else -> PairingAnimState.WAITING89 }9091 ShellBackground {92 Column(93 modifier = Modifier94 .fillMaxSize()95 .systemBarsPadding()96 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),97 ) {98 Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {99 BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))100 }101102 when (val s = state) {103 is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")104105 is CodePairUiState.ShowingInvitation -> {106 Headline(107 "Pair a device",108 "Scan this QR on the empty device. Peach will send this vault only after the 8-digit code matches.",109 )110 Spacer(Modifier.height(PeachSpacing.md))111 PairingRelayChoice(112 mode = syncMode,113 configuredPrivateUrl = privateRelayUrl,114 expanded = relayExpanded,115 onExpandedChange = { relayExpanded = it },116 onSelectMode = vm::selectRelayMode,117 onSavePrivateUrl = vm::savePrivateRelayUrl,118 )119 if (!relayExpanded) {120 PairingQrCode(121 value = s.invitation,122 modifier = Modifier123 .fillMaxWidth()124 .height(260.dp),125 )126 }127 }128129 is CodePairUiState.AwaitingPeerConfirmation -> {130 Headline(131 "Confirm on the scanning device",132 "If this code matches there, tap “They match” on that device. " +133 "Peach finishes only after both sides save the pairing.",134 )135 Spacer(Modifier.height(PeachSpacing.lg))136 Text(137 text = s.sas,138 color = LocalPeachAccent.current,139 fontFamily = PeachType.Mono,140 fontSize = 38.sp,141 letterSpacing = 6.sp,142 textAlign = TextAlign.Center,143 modifier = Modifier.fillMaxWidth(),144 )145 }146147 is CodePairUiState.Paired -> {148 Headline("Paired", "${s.deviceName} will now sync through the relay.")149 Spacer(Modifier.height(PeachSpacing.lg))150 PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)151 }152153 is CodePairUiState.Expired -> {154 Headline(155 "That code expired",156 "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",157 )158 Spacer(Modifier.height(PeachSpacing.lg))159 PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }160 }161162 is CodePairUiState.Failed -> {163 Headline("Couldn't pair", failureCopy(s.reason))164 Spacer(Modifier.height(PeachSpacing.lg))165 if (s.reason == "sync_disabled" || s.reason == "private_relay_unset") {166 PairingRelayChoice(167 mode = syncMode,168 configuredPrivateUrl = privateRelayUrl,169 expanded = true,170 collapsible = false,171 onExpandedChange = { relayExpanded = it },172 onSelectMode = vm::selectRelayMode,173 onSavePrivateUrl = vm::savePrivateRelayUrl,174 )175 Spacer(Modifier.height(PeachSpacing.md))176 }177 PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)178 }179 }180 Spacer(Modifier.height(PeachSpacing.md))181 }182 }183}184185@Composable186private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {187 val matrix = remember(value) {188 QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)189 }190 Canvas(modifier = modifier) {191 val side = minOf(size.width, size.height)192 val module = side / matrix.width193 val qrSize = module * matrix.width194 val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)195 drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))196 for (y in 0 until matrix.height) {197 for (x in 0 until matrix.width) {198 if (matrix[x, y]) {199 drawRect(200 Color.Black,201 topLeft = Offset(origin.x + x * module, origin.y + y * module),202 size = Size(module + 0.5f, module + 0.5f),203 )204 }205 }206 }207 }208}209210211@Composable212internal fun PillButton(213 label: String,214 color: Color,215 filled: Boolean,216 enabled: Boolean = true,217 onClick: () -> Unit,218) {219 val accent = LocalPeachAccent.current220 val buttonColor = if (filled) accent else color221 Box(222 modifier = Modifier223 .fillMaxWidth()224 .clip(RoundedCornerShape(14.dp))225 .background(226 if (filled) {227 accent.copy(alpha = if (enabled) 0.14f else 0.05f)228 } else {229 Color.Transparent230 },231 )232 .clickable(enabled = enabled, role = Role.Button, onClick = onClick)233 .padding(vertical = PeachSpacing.md),234 contentAlignment = Alignment.Center,235 ) {236 Text(237 label,238 color = buttonColor.copy(alpha = if (enabled) 1f else 0.38f),239 fontFamily = PeachType.Mono,240 fontSize = 13.sp,241 )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 "sync_disabled" -> "Sync is switched off for this vault. Turn it on in Settings → Sync first."250 251 252 "private_relay_unset" -> "Private relay chosen but no address set. Enter your relay URL in Settings → Sync first."253 254 255 256 257 else -> "Something interrupted the connection. Try again."258 .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }259}260261@Composable262private fun Headline(title: String, body: String) {263 Column(264 modifier = Modifier.fillMaxWidth(),265 horizontalAlignment = Alignment.CenterHorizontally,266 verticalArrangement = Arrangement.Center,267 ) {268 Text(269 title,270 color = peachColors.T0,271 fontFamily = PeachType.Display,272 fontStyle = FontStyle.Italic,273 fontSize = 22.sp,274 textAlign = TextAlign.Center,275 )276 Spacer(Modifier.height(PeachSpacing.xs))277 Text(278 body,279 color = peachColors.T2,280 fontFamily = PeachType.Body,281 fontSize = 13.sp,282 textAlign = TextAlign.Center,283 )284 }285}