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.text.font.FontStyle30import androidx.compose.ui.text.style.TextAlign31import androidx.compose.ui.unit.dp32import androidx.compose.ui.unit.sp33import androidx.compose.ui.geometry.Offset34import androidx.compose.ui.geometry.Size35import 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 syncMode by vm.syncMode.collectAsStateWithLifecycle()60 val privateRelayUrl by vm.privateRelayUrl.collectAsStateWithLifecycle()61 val context = LocalContext.current6263 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }64 var relayExpanded by remember { mutableStateOf(false) }6566 LaunchedEffect(Unit) { vm.start() }6768 LaunchedEffect(state) {69 if (shouldPromptForBackgroundSync(state is CodePairUiState.Paired)) showBackgroundSyncPrompt = true70 }7172 if (showBackgroundSyncPrompt) {73 BackgroundSyncDialog { enabled ->74 showBackgroundSyncPrompt = false75 vm.setBackgroundSync(enabled)76 if (enabled) {77 vm.batteryExemptionIntent()?.let { intent ->78 runCatching { context.startActivity(intent) }79 }80 }81 }82 }8384 val animState = when (state) {85 is CodePairUiState.Paired -> PairingAnimState.CONNECTED86 is CodePairUiState.AwaitingPeerConfirmation -> PairingAnimState.LISTENING87 else -> PairingAnimState.WAITING88 }8990 ShellBackground {91 Column(92 modifier = Modifier93 .fillMaxSize()94 .systemBarsPadding()95 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),96 ) {97 Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {98 BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))99 }100101 when (val s = state) {102 is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")103104 is CodePairUiState.ShowingInvitation -> {105 Headline(106 "Pair a device",107 "Scan this QR on the empty device. Peach will send this vault only after the 8-digit code matches.",108 )109 Spacer(Modifier.height(PeachSpacing.md))110 PairingRelayChoice(111 mode = syncMode,112 configuredPrivateUrl = privateRelayUrl,113 expanded = relayExpanded,114 onExpandedChange = { relayExpanded = it },115 onSelectMode = vm::selectRelayMode,116 onSavePrivateUrl = vm::savePrivateRelayUrl,117 )118 if (!relayExpanded) {119 PairingQrCode(120 value = s.invitation,121 modifier = Modifier122 .fillMaxWidth()123 .height(260.dp),124 )125 }126 }127128 is CodePairUiState.AwaitingPeerConfirmation -> {129 Headline(130 "Confirm on the scanning device",131 "If this code matches there, tap “They match” on that device. " +132 "Peach finishes only after both sides save the pairing.",133 )134 Spacer(Modifier.height(PeachSpacing.lg))135 Text(136 text = s.sas,137 color = LocalPeachAccent.current,138 fontFamily = PeachType.Mono,139 fontSize = 38.sp,140 letterSpacing = 6.sp,141 textAlign = TextAlign.Center,142 modifier = Modifier.fillMaxWidth(),143 )144 }145146 is CodePairUiState.Paired -> {147 Headline("Paired", "${s.deviceName} will now sync through the relay.")148 Spacer(Modifier.height(PeachSpacing.lg))149 PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)150 }151152 is CodePairUiState.Expired -> {153 Headline(154 "That code expired",155 "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",156 )157 Spacer(Modifier.height(PeachSpacing.lg))158 PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }159 }160161 is CodePairUiState.Failed -> {162 Headline("Couldn't pair", failureCopy(s.reason))163 Spacer(Modifier.height(PeachSpacing.lg))164 if (s.reason == "sync_disabled" || s.reason == "private_relay_unset") {165 PairingRelayChoice(166 mode = syncMode,167 configuredPrivateUrl = privateRelayUrl,168 expanded = true,169 collapsible = false,170 onExpandedChange = { relayExpanded = it },171 onSelectMode = vm::selectRelayMode,172 onSavePrivateUrl = vm::savePrivateRelayUrl,173 )174 Spacer(Modifier.height(PeachSpacing.md))175 }176 PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)177 }178 }179 Spacer(Modifier.height(PeachSpacing.md))180 }181 }182}183184@Composable185private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {186 val matrix = remember(value) {187 QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)188 }189 Canvas(modifier = modifier) {190 val side = minOf(size.width, size.height)191 val module = side / matrix.width192 val qrSize = module * matrix.width193 val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)194 drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))195 for (y in 0 until matrix.height) {196 for (x in 0 until matrix.width) {197 if (matrix[x, y]) {198 drawRect(199 Color.Black,200 topLeft = Offset(origin.x + x * module, origin.y + y * module),201 size = Size(module + 0.5f, module + 0.5f),202 )203 }204 }205 }206 }207}208209210@Composable211internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {212 val accent = LocalPeachAccent.current213 Box(214 modifier = Modifier215 .fillMaxWidth()216 .clip(RoundedCornerShape(14.dp))217 .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)218 .clickable(onClick = onClick)219 .padding(vertical = PeachSpacing.md),220 contentAlignment = Alignment.Center,221 ) {222 Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)223 }224}225226private fun failureCopy(reason: String): String = when (reason) {227 "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."228 "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."229 "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."230 "sync_disabled" -> "Sync is switched off for this vault. Turn it on in Settings → Sync first."231 232 233 "private_relay_unset" -> "Private relay chosen but no address set. Enter your relay URL in Settings → Sync first."234 235 236 237 238 else -> "Something interrupted the connection. Try again."239 .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }240}241242@Composable243private fun Headline(title: String, body: String) {244 Column(245 modifier = Modifier.fillMaxWidth(),246 horizontalAlignment = Alignment.CenterHorizontally,247 verticalArrangement = Arrangement.Center,248 ) {249 Text(250 title,251 color = peachColors.T0,252 fontFamily = PeachType.Display,253 fontStyle = FontStyle.Italic,254 fontSize = 22.sp,255 textAlign = TextAlign.Center,256 )257 Spacer(Modifier.height(PeachSpacing.xs))258 Text(259 body,260 color = peachColors.T2,261 fontFamily = PeachType.Body,262 fontSize = 13.sp,263 textAlign = TextAlign.Center,264 )265 }266}