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
7 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 −0
app/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 

view file

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 }

view file

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+}

view file

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 }

view file

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 -> {

view file

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() {

view file

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+}

view file