792a4039d36c
expire the pairing code after five minutes, prefer wlan for the advertised address, offer battery exemption after pairing
- time
- 2026-07-24 23:37 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- pairing window and battery
- parent
- a4e36e3eaef3
↓ peach-android-792a4039.tar.gzatlas at this save
files changed7 files
app/src/main/AndroidManifest.xml+3 −0app/src/main/java/com/peachpasswords/di/VaultModule.kt+6 −0app/src/main/java/com/peachpasswords/sync/BatteryExemption.kt+51 −0app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+57 −16app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+23 −0app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+16 −0app/src/test/java/com/peachpasswords/sync/CodePairingServerTest.kt+135 −0app/src/main/AndroidManifest.xml+3 −0
@@ -14,6 +14,9 @@1414 <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />1515 <!-- Android 14+ requires the specific type alongside the generic permission. -->1616 <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />17+ <!-- Doze defers network work even for a foreground service, which would make18+ an already-paired browser's reconnect time out with no visible cause. -->19+ <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />1720 <uses-permission android:name="android.permission.CAMERA" />1821 <uses-feature android:name="android.hardware.camera.any" android:required="false" />1922 app/src/main/java/com/peachpasswords/di/VaultModule.kt+6 −0
@@ -1,5 +1,7 @@11 package com.peachpasswords.di22 3+import com.peachpasswords.sync.PairedDeviceRegistry4+import com.peachpasswords.sync.PairedDeviceStore35 import com.peachpasswords.vault.VaultStorage46 import com.peachpasswords.vault.VaultStore57 import dagger.Binds@@ -14,4 +16,8 @@1416 @Binds1517 @Singleton1618 abstract fun bindVaultStorage(impl: VaultStore): VaultStorage19+20+ @Binds21+ @Singleton22+ abstract fun bindPairedDeviceRegistry(impl: PairedDeviceStore): PairedDeviceRegistry1723 }app/src/main/java/com/peachpasswords/sync/BatteryExemption.kt+51 −0
@@ -0,0 +1,51 @@1+package com.peachpasswords.sync2+3+import android.content.Context4+import android.content.Intent5+import android.net.Uri6+import android.os.PowerManager7+import android.provider.Settings8+9+/**10+ * Asks once to be left out of Doze / App Standby.11+ *12+ * Without it, a phone that has been idle for a while stops answering, and the13+ * failure is invisible: a browser dialing a cached address just times out, which14+ * is indistinguishable from the phone being off the network. The foreground15+ * service keeps the process alive but does not by itself stop Doze from16+ * deferring network work.17+ *18+ * Deliberately only offered once and never nagged — the system dialog is19+ * alarming ("let this app run in the background all the time?"), and a user who20+ * says no should get a phone that syncs when they open the app rather than one21+ * that keeps asking. [isExempt] lets settings show the real state instead of22+ * assuming the request worked.23+ */24+object BatteryExemption {25+26+ private const val PREFS = "peach_sync_battery"27+ private const val ASKED_KEY = "asked_exemption"28+29+ fun isExempt(context: Context): Boolean {30+ val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return false31+ return pm.isIgnoringBatteryOptimizations(context.packageName)32+ }33+34+ fun shouldAsk(context: Context): Boolean =35+ !isExempt(context) && !context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)36+ .getBoolean(ASKED_KEY, false)37+38+ /**39+ * Returns the intent to launch, or null when there is nothing to ask for.40+ * The caller launches it so the result lands on a real Activity.41+ */42+ fun requestIntent(context: Context): Intent? {43+ if (!shouldAsk(context)) return null44+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)45+ .edit().putBoolean(ASKED_KEY, true).apply()46+ return Intent(47+ Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,48+ Uri.parse("package:${context.packageName}"),49+ )50+ }51+}app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+57 −16
@@ -4,6 +4,7 @@44 import kotlinx.coroutines.CoroutineScope55 import kotlinx.coroutines.Dispatchers66 import kotlinx.coroutines.channels.awaitClose7+import kotlinx.coroutines.delay78 import kotlinx.coroutines.flow.Flow89 import kotlinx.coroutines.flow.callbackFlow910 import kotlinx.coroutines.isActive@@ -50,6 +51,9 @@5051 5152 data class Paired(val deviceName: String) : PairingEvent5253 54+ /** The code's window elapsed without a pairing; the user must start again. */55+ data object Expired : PairingEvent56+5357 /** Includes the silent-close case: a peer presented a bad token. */5458 data class Rejected(val reason: String) : PairingEvent5559 }@@ -62,10 +66,21 @@6266 6367 @Singleton6468 class CodePairingServer @Inject constructor(65- private val deviceStore: PairedDeviceStore,69+ // The interface, not the concrete store: the store needs Android's70+ // EncryptedSharedPreferences, which would make this whole class untestable71+ // off device — and the token/window logic is exactly what wants testing.72+ private val deviceStore: PairedDeviceRegistry,6673 ) {6774 private var serverSocket: ServerSocket? = null6875 76+ companion object {77+ /** Doc says 5–10 minutes; the low end is plenty to walk to a laptop. */78+ const val PAIRING_WINDOW_MS = 5L * 60 * 100079+80+ /** Interfaces whose address a LAN peer cannot route back to. */81+ private val TUNNEL_PREFIXES = listOf("tun", "ppp", "ipsec", "rmnet", "rndis", "ap")82+ }83+6984 /**7085 * Bind, publish a code, and accept pairing attempts until the flow is7186 * cancelled. One token per call: after a successful pairing the caller@@ -77,6 +92,7 @@7792 ourStaticPrivateKey: ByteArray,7893 ourStaticPublicKey: ByteArray,7994 localIdentity: LocalChirpIdentity,95+ windowMs: Long = PAIRING_WINDOW_MS,8096 ): Flow<PairingEvent> = callbackFlow {8197 val ip = localIpv4()8298 if (ip == null) {@@ -92,18 +108,34 @@92108 93109 trySend(PairingEvent.Listening(PairingCode.encode(ip, socket.localPort, token), ip, socket.localPort))94110 111+ // The window is not a race against an attacker — there isn't a112+ // realistic one on your own LAN for a single-use code. It just stops an113+ // abandoned code from staying live indefinitely on a phone in a pocket.114+ val expiryJob = scope.launch {115+ delay(windowMs)116+ trySend(PairingEvent.Expired)117+ stop()118+ }119+95120 val acceptJob = scope.launch(Dispatchers.IO) {96121 while (isActive && !socket.isClosed) {97122 val client = runCatching { socket.accept() }.getOrNull() ?: break98123 launch {99- trySend(100- handleClient(client, tokenHex, ourStaticPrivateKey, ourStaticPublicKey, localIdentity),101- )124+ val event = handleClient(client, tokenHex, ourStaticPrivateKey, ourStaticPublicKey, localIdentity)125+ trySend(event)126+ // One code, one device. A rejected or failed attempt leaves127+ // the window open so the user can retry without generating128+ // a new code; only a real pairing closes it.129+ if (event is PairingEvent.Paired) {130+ expiryJob.cancel()131+ stop()132+ }102133 }103134 }104135 }105136 106137 awaitClose {138+ expiryJob.cancel()107139 acceptJob.cancel()108140 stop()109141 }@@ -203,17 +235,26 @@203235 }204236 205237 /**206- * The address to put in the code. Picks a site-local IPv4 on an up,207- * non-loopback interface — a VPN or tethering interface would produce a208- * code the browser cannot dial.238+ * The address to put in the code — it has to be the one the browser can239+ * actually dial, which is the Wi-Fi address.240+ *241+ * Interface order is not meaningful, so taking the first site-local IPv4242+ * can hand back a VPN (tun/ppp) or tethering (ap/rndis) address. Both are243+ * site-local and both produce a code that looks fine and then times out244+ * with nothing to debug, so Wi-Fi-ish names are preferred explicitly and245+ * tunnels are excluded rather than merely deprioritised.209246 */210- private fun localIpv4(): String? =211- runCatching {212- NetworkInterface.getNetworkInterfaces().toList()213- .filter { it.isUp && !it.isLoopback && !it.isVirtual }214- .flatMap { it.inetAddresses.toList() }215- .filterIsInstance<Inet4Address>()216- .firstOrNull { it.isSiteLocalAddress }217- ?.hostAddress218- }.getOrNull()247+ internal fun localIpv4(): String? = runCatching {248+ val candidates = NetworkInterface.getNetworkInterfaces().toList()249+ .filter { it.isUp && !it.isLoopback && !it.isVirtual }250+ .filterNot { iface -> TUNNEL_PREFIXES.any { iface.name.startsWith(it) } }251+252+ fun addressOf(iface: NetworkInterface): String? = iface.inetAddresses.toList()253+ .filterIsInstance<Inet4Address>()254+ .firstOrNull { it.isSiteLocalAddress }255+ ?.hostAddress256+257+ candidates.firstOrNull { it.name.startsWith("wlan") }?.let(::addressOf)258+ ?: candidates.firstNotNullOfOrNull(::addressOf)259+ }.getOrNull()219260 }app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+23 −0
@@ -13,6 +13,7 @@1313 import androidx.compose.runtime.Composable1414 import androidx.compose.runtime.LaunchedEffect1515 import androidx.compose.runtime.getValue16+import androidx.compose.ui.platform.LocalContext1617 import androidx.compose.ui.Alignment1718 import androidx.compose.ui.Modifier1819 import androidx.compose.ui.text.font.FontStyle@@ -41,9 +42,22 @@4142 vm: CodePairViewModel = hiltViewModel(),4243 ) {4344 val state by vm.state.collectAsStateWithLifecycle()45+ val context = LocalContext.current4446 4547 LaunchedEffect(Unit) { vm.start() }4648 49+ // Asked after a successful pairing, not before: at that point the user has50+ // just chosen to rely on this phone staying reachable, so the system's51+ // "run in the background?" dialog has obvious context. Asking on arrival52+ // would be a scary prompt for a feature they hadn't committed to yet.53+ LaunchedEffect(state) {54+ if (state is CodePairUiState.Paired) {55+ vm.batteryExemptionIntent()?.let { intent ->56+ runCatching { context.startActivity(intent) }57+ }58+ }59+ }60+4761 val animState = when (state) {4862 is CodePairUiState.Paired -> PairingAnimState.CONNECTED4963 is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING@@ -108,6 +122,15 @@108122 Headline("Paired", "${s.deviceName} will now keep your vault in step over the local network.")109123 Spacer(Modifier.height(PeachSpacing.lg))110124 PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)125+ }126+127+ is CodePairUiState.Expired -> {128+ Headline(129+ "That code expired",130+ "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",131+ )132+ Spacer(Modifier.height(PeachSpacing.lg))133+ PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }111134 }112135 113136 is CodePairUiState.Failed -> {app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+16 −0
@@ -3,6 +3,7 @@33 import android.content.Context44 import androidx.lifecycle.ViewModel55 import androidx.lifecycle.viewModelScope6+import com.peachpasswords.sync.BatteryExemption67 import com.peachpasswords.sync.CodePairingServer78 import com.peachpasswords.sync.PairingEvent89 import com.peachpasswords.sync.PairingSession@@ -32,6 +33,7 @@3233 data class ShowingCode(val code: String, val ip: String, val port: Int) : CodePairUiState3334 data class ConfirmSas(val sas: String, val deviceName: String, val session: PairingSession) : CodePairUiState3435 data class Paired(val deviceName: String) : CodePairUiState36+ data object Expired : CodePairUiState3537 data class Failed(val reason: String) : CodePairUiState3638 }3739 @@ -45,6 +47,13 @@4547 private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)4648 val state: StateFlow<CodePairUiState> = _state.asStateFlow()4749 50+ /**51+ * The battery-exemption dialog to show, or null if it's already granted or52+ * already been declined once. Consumed by the screen, which owns an53+ * Activity to launch it from.54+ */55+ fun batteryExemptionIntent(): android.content.Intent? = BatteryExemption.requestIntent(appContext)56+4857 fun start() {4958 val master = vault.syncMasterKey() ?: run {5059 _state.value = CodePairUiState.Failed("vault_locked")@@ -68,6 +77,7 @@6877 _state.value = when (event) {6978 is PairingEvent.Listening -> CodePairUiState.ShowingCode(event.code, event.ip, event.port)7079 is PairingEvent.Paired -> CodePairUiState.Paired(event.deviceName)80+ PairingEvent.Expired -> CodePairUiState.Expired7181 is PairingEvent.Rejected -> CodePairUiState.Failed(event.reason)7282 // Delivered via confirmationSink; the flow only sees the outcome.7383 is PairingEvent.AwaitingConfirmation -> _state.value@@ -78,6 +88,12 @@7888 7989 fun confirmSas(matches: Boolean) {8090 (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)91+ }92+93+ fun restart() {94+ server.stop()95+ _state.value = CodePairUiState.Starting96+ start()8197 }8298 8399 fun stop() {app/src/test/java/com/peachpasswords/sync/CodePairingServerTest.kt+135 −0
@@ -0,0 +1,135 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.CoroutineScope4+import kotlinx.coroutines.Dispatchers5+import kotlinx.coroutines.SupervisorJob6+import kotlinx.coroutines.launch7+import kotlinx.coroutines.runBlocking8+import okhttp3.OkHttpClient9+import okhttp3.Request10+import okhttp3.Response11+import okhttp3.WebSocket12+import okhttp3.WebSocketListener13+import org.junit.Assert.assertEquals14+import org.junit.Assert.assertTrue15+import org.junit.Test16+import java.util.concurrent.CompletableFuture17+import java.util.concurrent.TimeUnit18+19+/**20+ * Covers the two behaviours that are easy to get quietly wrong: a bad token21+ * must be refused with no reply at all, and an abandoned code must stop working.22+ * Both are invisible when broken — a leaky token check still pairs happily with23+ * the right code, and a window that never closes looks identical to one that24+ * does until months later.25+ */26+class CodePairingServerTest {27+28+ private val identity = LocalChirpIdentity(29+ deviceId = "test-device",30+ deviceName = "Test Phone",31+ deviceType = DeviceType.ANDROID,32+ longTermPublicKeyHex = "00".repeat(32),33+ signingPublicKeyHex = "00".repeat(32),34+ )35+36+ /** Enough of the registry for pairing; the real store needs Android crypto. */37+ private class FakeRegistry : PairedDeviceRegistry {38+ val devices = mutableListOf<PairedDevice>()39+ override fun getAll(): List<PairedDevice> = devices40+ override fun get(id: String): PairedDevice? = devices.firstOrNull { it.deviceId == id }41+ override fun upsert(device: PairedDevice) { devices.add(device) }42+ }43+44+ private fun server() = CodePairingServer(FakeRegistry())45+46+ private class Client {47+ val opened = CompletableFuture<Boolean>()48+ val closed = CompletableFuture<Boolean>()49+ val frames = java.util.concurrent.LinkedBlockingQueue<String>()50+ lateinit var ws: WebSocket51+52+ fun connect(port: Int) {53+ ws = OkHttpClient.Builder().readTimeout(10, TimeUnit.SECONDS).build().newWebSocket(54+ Request.Builder().url("ws://127.0.0.1:$port").build(),55+ object : WebSocketListener() {56+ override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(true) }57+ override fun onMessage(webSocket: WebSocket, text: String) { frames.add(text) }58+ override fun onFailure(webSocket: WebSocket, t: Throwable, r: Response?) { closed.complete(true) }59+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { closed.complete(true) }60+ },61+ )62+ opened.get(5, TimeUnit.SECONDS)63+ }64+ }65+66+ /**67+ * Collects into a queue on a job that stays alive. Terminating the flow with68+ * `first {}` would trip awaitClose and shut the listener down before the69+ * test could connect to it.70+ */71+ private class Harness(val scope: CoroutineScope) {72+ val events = java.util.concurrent.LinkedBlockingQueue<PairingEvent>()73+ inline fun <reified T : PairingEvent> await(timeoutMs: Long = 10_000): T {74+ val deadline = System.currentTimeMillis() + timeoutMs75+ val seen = mutableListOf<PairingEvent>()76+ while (System.currentTimeMillis() < deadline) {77+ val e = events.poll(200, TimeUnit.MILLISECONDS) ?: continue78+ seen.add(e)79+ if (e is T) return e80+ }81+ throw AssertionError("never saw ${T::class.simpleName}; saw $seen")82+ }83+ }84+85+ private fun CoroutineScope.run(srv: CodePairingServer, windowMs: Long = CodePairingServer.PAIRING_WINDOW_MS): Harness {86+ val h = Harness(this)87+ launch { srv.start(this@run, ByteArray(32), ByteArray(32), identity, windowMs).collect { h.events.add(it) } }88+ return h89+ }90+91+ @Test fun `a wrong token gets no reply and the socket closes`() = runBlocking {92+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)93+ val srv = server()94+ try {95+ val listening = scope.run(srv).await<PairingEvent.Listening>()96+97+ val c = Client()98+ c.connect(listening.port)99+ c.ws.send("""{"type":"PEACH_HELLO","token":"00000000"}""")100+101+ assertTrue("socket should be closed on a bad token", c.closed.get(5, TimeUnit.SECONDS))102+ assertEquals("nothing should be sent back — silence is the signal", 0, c.frames.size)103+ } finally {104+ srv.stop()105+ }106+ }107+108+ @Test fun `the pairing window expires and stops accepting`() = runBlocking {109+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)110+ val srv = server()111+ try {112+ // 300ms rather than the real five minutes; the window is a parameter113+ // precisely so this is testable at all.114+ val h = scope.run(srv, windowMs = 300)115+ val listening = h.await<PairingEvent.Listening>()116+ h.await<PairingEvent.Expired>(timeoutMs = 5_000)117+118+ var refused = false119+ runCatching { java.net.Socket("127.0.0.1", listening.port).close() }120+ .onFailure { refused = true }121+ assertTrue("the port should be closed after the window", refused)122+ } finally {123+ srv.stop()124+ }125+ }126+127+ @Test fun `the advertised address is never a tunnel interface`() {128+ // Not asserting a specific address — CI and dev machines differ — only129+ // that whatever is chosen is one a LAN peer could route back to.130+ val ip = server().localIpv4()131+ if (ip != null) {132+ assertTrue("expected a site-local address, got $ip", java.net.InetAddress.getByName(ip).isSiteLocalAddress)133+ }134+ }135+}