4f2161320ccf
give Android the relay initiator it never had, and assign the role per peer so two phones cannot both wait: an extension is always dialled by, and between phones the lower long-term key opens the room (v2.5.2)
- time
- 2026-07-26 17:13 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- phone-to-phone relay dialler
- parent
- a7ef4b655fa4
↓ peach-android-4f216132.tar.gzatlas at this save
files changed9 files
VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+74 −0app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+72 −20app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+6 −5app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+5 −0app/src/main/java/com/peachpasswords/sync/SyncPeerRole.kt+30 −0app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+11 −17app/src/test/java/com/peachpasswords/sync/CloudRelayDialerTest.kt+241 −0app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 24010120- versionName "2.5.1"19+ versionCode 24010220+ versionName "2.5.2"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+74 −0
@@ -0,0 +1,74 @@1+package com.peachpasswords.sync2+3+import org.json.JSONObject4+5+/**6+ * One relayed reconnect session where WE speak first — the mirror of7+ * [runCloudRelayResponderSession], and the half Android never had.8+ *9+ * Until this existed the phone could only ever wait, which was fine against a10+ * browser extension (it can only dial) and a deadlock against another phone:11+ * two waiters in the same room, nobody opening. See [weDialPeer] for how the12+ * role is assigned.13+ *14+ * The wire is the extension's, byte for byte: an unencrypted `PEACH_RECONNECT`15+ * selector naming the room, then Noise KK, then a padded PAIR_IDENTITY each16+ * way, then the sync exchange.17+ */18+internal suspend fun runCloudRelayInitiatorSession(19+ socket: RelayBinarySocket,20+ known: PairedDevice,21+ rendezvousId: String,22+ ourStaticPrivateKey: ByteArray,23+ ourStaticPublicKey: ByteArray,24+ localIdentity: LocalChirpIdentity,25+ vaultFactory: () -> ChirpSyncVaultAdapter?,26+ onSyncComplete: () -> Unit,27+) {28+ val responderKeyHex = known.longTermPublicKeyHex29+ ?: throw IllegalArgumentException("known peer missing static key")30+ val responderKey = hexToPubkey(responderKeyHex)31+32+ // The capability names the room only; it grants no trust. KK proves both33+ // private keys immediately after, and a wrong room is refused there.34+ socket.send(35+ JSONObject()36+ .put("type", "PEACH_RECONNECT")37+ .put("rendezvous_id", rendezvousId)38+ .toString()39+ .toByteArray(Charsets.UTF_8),40+ )41+ val session = noiseKKInitiator(42+ ourStaticPrivateKey,43+ ourStaticPublicKey,44+ responderKey,45+ socket::send,46+ socket::receive,47+ )48+ try {49+ socket.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))50+ val theirIdentity = parsePairIdentityFrame(51+ SyncPadding.unpad(session.decrypt(socket.receive())),52+ )53+ require(identityMatchesAuthenticatedPeer(known, theirIdentity, responderKey)) {54+ "peer identity does not match authenticated KK key"55+ }56+57+ val vault = vaultFactory() ?: return58+ vault.bindAuthenticatedPeer(known)59+ val outcome = runChirpSyncInitiator(60+ ChirpSyncSession(61+ remoteStaticPublicKey = responderKey.copyOf(),62+ send = { plaintext -> socket.send(session.encrypt(SyncPadding.pad(plaintext))) },63+ receive = { SyncPadding.unpad(session.decrypt(socket.receive())) },64+ close = { socket.close() },65+ ),66+ vault,67+ )68+ if (outcome.status == SyncOutcomeStatus.COMPLETED) {69+ onSyncComplete()70+ }71+ } finally {72+ session.close()73+ }74+}app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+72 −20
@@ -11,6 +11,7 @@1111 import kotlinx.coroutines.launch1212 import kotlinx.coroutines.withContext1313 import kotlinx.coroutines.withTimeout14+import kotlinx.coroutines.withTimeoutOrNull1415 import okhttp3.OkHttpClient1516 import okhttp3.Request1617 import okhttp3.Response@@ -26,14 +27,21 @@2627 2728 private const val RECONCILE_INTERVAL_MS = 60_000L2829 private const val RETRY_DELAY_MS = 1_000L30+/** A dialled session that has not reached sync by now is talking to nobody. */31+private const val DIAL_SESSION_TIMEOUT_MS = 30_000L32+/** Slower than the waiter's retry, so the peer is already in the room. */33+private const val DIAL_RETRY_DELAY_MS = 5_000L2934 private const val RELAY_CONNECT_TIMEOUT_MS = 10_000L3035 private const val RELAY_INBOX_CAPACITY = 83136 3237 /**33- * Keeps one outbound waiting socket for each paired peer's current and previous34- * rendezvous windows. The Cloudflare relay is only a splice: once a browser35- * arrives, this class runs the same selector + Noise KK + PAIR_IDENTITY + sync36- * responder protocol as the LAN listener.38+ * Keeps one socket open in each paired peer's live rendezvous rooms. The39+ * Cloudflare relay is only a splice, so being in the room is all either side40+ * does at the transport level — what differs is who opens the conversation.41+ *42+ * Toward a browser extension we wait, because an extension can only ever dial.43+ * Toward another phone or tablet the role comes from [weDialPeer], because two44+ * waiters in one room is a deadlock and was why phone-to-phone never synced.3745 */3846 @Singleton3947 class CloudRelayReconnectListener @Inject constructor(@@ -42,6 +50,13 @@4250 ) {4351 private var supervisor: Job? = null4452 private val watchers = mutableMapOf<String, Job>()53+ /**54+ * "Sync now" has to do something now. Without this the loop would not look55+ * at the device list again for up to a minute, which on a phone-to-phone56+ * pair — where we are the one who dials — means the button appears to do57+ * nothing at all.58+ */59+ private val reconcileNow = Channel<Unit>(Channel.CONFLATED)4560 private var retainedPrivateKey: ByteArray? = null4661 private var retainedPublicKey: ByteArray? = null4762 @@ -67,7 +82,7 @@6782 localIdentity,6883 vaultFactory,6984 )70- delay(RECONCILE_INTERVAL_MS)85+ withTimeoutOrNull(RECONCILE_INTERVAL_MS) { reconcileNow.receive() }7186 }7287 } finally {7388 watchers.values.forEach(Job::cancel)@@ -76,6 +91,11 @@7691 }7792 }7893 94+ /** Re-read the device list at once instead of on the next interval. */95+ fun requestReconcile() {96+ reconcileNow.trySend(Unit)97+ }98+7999 fun stop() {80100 supervisor?.cancel()81101 supervisor = null@@ -101,6 +121,7 @@101121 }102122 103123 val now = System.currentTimeMillis() / 1000L124+ val ourKeyHex = pubkeyToHex(publicKey)104125 val desired = buildMap {105126 for (device in deviceStore.getAll()) {106127 val secret = device.rendezvousSecretHex@@ -109,9 +130,16 @@109130 val relayUrl = device.relayUrl110131 ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() }111132 ?: relaySettings.relayUrl()112- for (rendezvousId in Rendezvous.waitingIds(secret, now)) {133+ // One role per peer, never both: our own dialler and our own134+ // waiter would otherwise splice with each other and hold the135+ // room against the peer they were both there for.136+ val weDial = weDialPeer(ourKeyHex, device)137+ val ids =138+ if (weDial) Rendezvous.dialingIds(secret, now)139+ else Rendezvous.waitingIds(secret, now)140+ for (rendezvousId in ids) {113141 val key = "${device.deviceId}|$relayUrl|$rendezvousId"114- put(key, RelayWaitTarget(device.deviceId, relayUrl, rendezvousId))142+ put(key, RelayWaitTarget(device.deviceId, relayUrl, rendezvousId, weDial))115143 }116144 }117145 }@@ -133,23 +161,46 @@133161 Rendezvous.buildUrl(target.relayUrl, target.rendezvousId),134162 )135163 try {136- runCloudRelayResponderSession(137- socket = socket,138- known = known,139- registeredRendezvousId = target.rendezvousId,140- ourStaticPrivateKey = privateKey,141- ourStaticPublicKey = publicKey,142- localIdentity = localIdentity,143- vaultFactory = vaultFactory,144- onSyncComplete = {145- deviceStore.updateLastSync(known.deviceId)146- },147- )164+ if (target.weDial) {165+ // A dialler that arrived before its peer had166+ // its selector dropped — the relay does not167+ // buffer for a room with one occupant. Bound168+ // the wait so we close, free the room and try169+ // again in the other arrival order, instead of170+ // both sockets sitting there forever.171+ withTimeout(DIAL_SESSION_TIMEOUT_MS) {172+ runCloudRelayInitiatorSession(173+ socket = socket,174+ known = known,175+ rendezvousId = target.rendezvousId,176+ ourStaticPrivateKey = privateKey,177+ ourStaticPublicKey = publicKey,178+ localIdentity = localIdentity,179+ vaultFactory = vaultFactory,180+ onSyncComplete = {181+ deviceStore.updateLastSync(known.deviceId)182+ },183+ )184+ }185+ } else {186+ runCloudRelayResponderSession(187+ socket = socket,188+ known = known,189+ registeredRendezvousId = target.rendezvousId,190+ ourStaticPrivateKey = privateKey,191+ ourStaticPublicKey = publicKey,192+ localIdentity = localIdentity,193+ vaultFactory = vaultFactory,194+ onSyncComplete = {195+ deviceStore.updateLastSync(known.deviceId)196+ },197+ )198+ }148199 } finally {149200 socket.close()150201 }151202 }152- delay(RETRY_DELAY_MS)203+ delay(if (target.weDial) DIAL_RETRY_DELAY_MS else RETRY_DELAY_MS)153204 }154205 }155206 }@@ -160,6 +211,7 @@160211 val deviceId: String,161212 val relayUrl: String,162213 val rendezvousId: String,214+ val weDial: Boolean,163215 )164216 165217 internal interface RelayBinarySocket {app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+6 −5
@@ -26,11 +26,12 @@2626 /**2727 * Bring this device back onto the relay so paired peers can reach it.2828 *29- * It no longer dials anyone. Under the relay-only model this device WAITS30- * in its rendezvous rooms as the responder and the peer dials in — a31- * browser extension cannot hold a room open, so it has to be the dialler.32- * Coming to the foreground therefore means "make sure we are registered",33- * not "go and sync now". See docs/sync-lan-retirement.md.29+ * Coming to the foreground means "make sure we are in our rooms", not "go30+ * and sync now" — the service holds a socket in each peer's rendezvous31+ * room and the role there decides who opens the conversation32+ * ([weDialPeer]). Toward a browser extension we wait, because an extension33+ * cannot hold a room open; toward another phone the lower key dials.34+ * See docs/sync-lan-retirement.md.3435 */3536 fun triggerForegroundSync() {3637 if (!vault.isUnlocked.value) returnapp/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+5 −0
@@ -53,6 +53,11 @@5353 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {5454 startForeground(NOTIFICATION_ID, buildNotification())5555 startReconnectListener()56+ // A start command means something changed — a new pairing, a manual57+ // "sync now", the app coming forward. Re-read the device list now58+ // rather than on the next interval; the listener is already running59+ // on every start but the first.60+ cloudRelayListener.requestReconcile()5661 // START_STICKY: if the system reclaims us under pressure, come back —5762 // a listener that silently stays down looks identical to a peer that5863 // has simply gone offline, and the user gets no signal either way.app/src/main/java/com/peachpasswords/sync/SyncPeerRole.kt+30 −0
@@ -0,0 +1,30 @@1+package com.peachpasswords.sync2+3+/**4+ * Who speaks first in a relay room.5+ *6+ * The relay is a two-party splice with no buffering and no "peer joined"7+ * signal, so exactly one side must open with `PEACH_RECONNECT` and the other8+ * must wait for it. Both sides derive the same room id from the shared pair9+ * secret, so the role has to be derivable from state both already hold —10+ * there is nothing on the wire to negotiate it with.11+ *12+ * Two rules, in order:13+ *14+ * 1. A browser extension can never hold a room open, so it always dials.15+ * Toward an extension peer we therefore always wait.16+ * 2. Otherwise — phone to phone, phone to tablet — the lower long-term public17+ * key dials. Comparing the keys is symmetric by construction: each side18+ * compares the same two values and reaches the opposite conclusion, so the19+ * roles can never collide (both dialling, or both waiting, which is what20+ * left phone-to-phone with nobody dialling).21+ *22+ * The keys are hex of the same length, so an ordinary lexicographic compare is23+ * a total order over them. Ties are impossible: two devices sharing a static24+ * key are the same device.25+ */26+internal fun weDialPeer(ourLongTermPublicKeyHex: String, peer: PairedDevice): Boolean {27+ if (peer.deviceType == "extension") return false28+ val theirs = peer.longTermPublicKeyHex ?: return false29+ return ourLongTermPublicKeyHex.compareTo(theirs) < 030+}app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+11 −17
@@ -31,19 +31,15 @@3131 import javax.inject.Inject3232 3333 /**34- * Drives a Chirp Sync round and surfaces its status to the Sync settings card.34+ * Backs the Sync settings card: relay mode, the paired-device list, and the35+ * manual sync control.3536 *36- * Trigger conditions (browser `sync-trigger.ts` parity):37- * 1. Vault mutation — a local add/update/delete should schedule a sync.38- * 2. App foreground — reconnect to nearby peers on resume.39- * 3. Manual — the "Sync now" control on the Sync card.40- * 4. mDNS peer discovery — a paired device appears on the LAN.41- *42- * Discovery, the WebSocket server role, and the paired-device store are wired43- * Under the relay-only model this device WAITS in its rendezvous rooms as the44- * responder and the peer dials in, so "sync now" makes sure the listener is up45- * rather than dialling anyone. A browser extension cannot hold a relay room46- * open, so it has to be the dialler. See docs/sync-lan-retirement.md.37+ * Sync itself runs in [PeachSyncService], which holds a socket in each paired38+ * peer's rendezvous room. Which side opens the conversation there depends on39+ * the peer: we wait for a browser extension, because an extension can only40+ * dial, and for another phone the lower long-term key dials. So41+ * "sync now" makes sure the service is up and asks it to re-read the device42+ * list — the work happens there, not here. See docs/sync-lan-retirement.md.4743 */4844 @HiltViewModel4945 class SyncViewModel @Inject constructor(@@ -141,9 +137,9 @@141137 }142138 143139 /**144- * Manual trigger: discover each paired device on the LAN and run an initiator145- * sync round against it. Reports Offline when no peers are paired. mDNS146- * discovery + audio cannot be emulator-verified — device QA pending (C7).140+ * Manual trigger. Reports Offline when no peers are paired; otherwise141+ * brings the relay service up (or nudges it, if already running) so it142+ * reconnects to every peer's room straight away.147143 */148144 fun syncNow() {149145 if (!vault.isUnlocked.value) {@@ -154,8 +150,6 @@154150 _status.value = SyncStatus.Offline155151 return156152 }157- // Make sure we are registered on the relay and reachable. The peer158- // performs the dial; there is nothing to initiate from here.159153 PeachSyncService.start(context)160154 _status.value = SyncStatus.LastSynced(System.currentTimeMillis())161155 }app/src/test/java/com/peachpasswords/sync/CloudRelayDialerTest.kt+241 −0
@@ -0,0 +1,241 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.Dispatchers4+import kotlinx.coroutines.async5+import kotlinx.coroutines.channels.Channel6+import kotlinx.coroutines.runBlocking7+import org.bouncycastle.crypto.params.X25519PrivateKeyParameters8+import org.junit.Assert.assertEquals9+import org.junit.Assert.assertFalse10+import org.junit.Assert.assertNotEquals11+import org.junit.Assert.assertTrue12+import org.junit.Test13+import java.security.SecureRandom14+15+/**16+ * Phone-to-phone: the case that had no initiator at all. Both halves here are17+ * this app's own code, so a passing test means two Peach phones can complete18+ * the reconnect wire against each other — not that one of them can talk to a19+ * stand-in for the extension.20+ */21+class CloudRelayDialerTest {22+ private data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray)23+24+ private fun keys(): KeyPair {25+ val privateKey = X25519PrivateKeyParameters(SecureRandom())26+ return KeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)27+ }28+29+ private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {30+ val leftInbox = Channel<ByteArray>(Channel.UNLIMITED)31+ val rightInbox = Channel<ByteArray>(Channel.UNLIMITED)32+ fun socket(inbox: Channel<ByteArray>, peer: Channel<ByteArray>) =33+ object : RelayBinarySocket {34+ override fun send(frame: ByteArray) {35+ check(peer.trySend(frame.copyOf()).isSuccess)36+ }37+38+ override suspend fun receive(): ByteArray = inbox.receive()39+40+ override fun close(code: Int, reason: String) {41+ inbox.close()42+ }43+ }44+ return socket(leftInbox, rightInbox) to socket(rightInbox, leftInbox)45+ }46+47+ private fun identity(id: String, name: String, publicKeyHex: String) = LocalChirpIdentity(48+ deviceId = id,49+ deviceName = name,50+ deviceType = DeviceType.ANDROID,51+ longTermPublicKeyHex = publicKeyHex,52+ signingPublicKeyHex = "33".repeat(32),53+ )54+55+ @Test56+ fun `two phones complete the reconnect wire, one dialling and one waiting`() = runBlocking {57+ val dialerKeys = keys()58+ val waiterKeys = keys()59+ val secretHex = "71".repeat(32)60+ val secret = Rendezvous.secretFromHex(secretHex)61+ val now = System.currentTimeMillis() / 1000L62+ // Both sides derive the same room from the shared pair secret.63+ val rendezvousId = Rendezvous.waitingIds(secret, now).first()64+ assertTrue(rendezvousId in Rendezvous.dialingIds(secret, now))65+66+ val dialerIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey))67+ val waiterIdentity = identity("waiter", "Tablet", pubkeyToHex(waiterKeys.publicKey))68+ val dialerAsSeenByWaiter = PairedDevice(69+ deviceId = "dialer",70+ deviceName = "Fold",71+ deviceType = "android",72+ longTermPublicKeyHex = pubkeyToHex(dialerKeys.publicKey),73+ rendezvousSecretHex = secretHex,74+ pairedAt = 1L,75+ )76+ val waiterAsSeenByDialer = PairedDevice(77+ deviceId = "waiter",78+ deviceName = "Tablet",79+ deviceType = "android",80+ longTermPublicKeyHex = pubkeyToHex(waiterKeys.publicKey),81+ rendezvousSecretHex = secretHex,82+ pairedAt = 1L,83+ )84+85+ val (dialerSocket, waiterSocket) = socketPair()86+ var dialerSynced = false87+ var waiterSynced = false88+89+ val waiter = async(Dispatchers.Default) {90+ runCloudRelayResponderSession(91+ socket = waiterSocket,92+ known = dialerAsSeenByWaiter,93+ registeredRendezvousId = rendezvousId,94+ ourStaticPrivateKey = waiterKeys.privateKey,95+ ourStaticPublicKey = waiterKeys.publicKey,96+ localIdentity = waiterIdentity,97+ vaultFactory = { null },98+ onSyncComplete = { waiterSynced = true },99+ )100+ }101+ val dialer = async(Dispatchers.Default) {102+ runCloudRelayInitiatorSession(103+ socket = dialerSocket,104+ known = waiterAsSeenByDialer,105+ rendezvousId = rendezvousId,106+ ourStaticPrivateKey = dialerKeys.privateKey,107+ ourStaticPublicKey = dialerKeys.publicKey,108+ localIdentity = dialerIdentity,109+ vaultFactory = { null },110+ onSyncComplete = { dialerSynced = true },111+ )112+ }113+114+ waiter.await()115+ dialer.await()116+ // No vault adapter on either side, so the handshake completes and the117+ // sync exchange is skipped — the wire is what is under test here.118+ assertFalse(dialerSynced)119+ assertFalse(waiterSynced)120+ }121+122+ @Test123+ fun `a peer presenting a different key than KK authenticated is refused`() = runBlocking {124+ val dialerKeys = keys()125+ val waiterKeys = keys()126+ val impostorKeys = keys()127+ val secretHex = "72".repeat(32)128+ val rendezvousId = Rendezvous.waitingIds(129+ Rendezvous.secretFromHex(secretHex),130+ System.currentTimeMillis() / 1000L,131+ ).first()132+ val (dialerSocket, waiterSocket) = socketPair()133+134+ // The waiter answers with an identity frame naming a key that is not135+ // the one KK proved. Nothing about the room can be trusted; only the136+ // authenticated key can, so the dialler must refuse.137+ val waiter = async(Dispatchers.Default) {138+ val selector = String(waiterSocket.receive(), Charsets.UTF_8)139+ assertTrue(selector.contains("PEACH_RECONNECT"))140+ val session = noiseKKResponder(141+ waiterKeys.privateKey,142+ waiterKeys.publicKey,143+ dialerKeys.publicKey,144+ waiterSocket::send,145+ waiterSocket::receive,146+ )147+ SyncPadding.unpad(session.decrypt(waiterSocket.receive()))148+ waiterSocket.send(149+ session.encrypt(150+ SyncPadding.pad(151+ encodePairIdentityFrame(152+ identity("waiter", "Tablet", pubkeyToHex(impostorKeys.publicKey)),153+ ),154+ ),155+ ),156+ )157+ }158+159+ val failure = runCatching {160+ runCloudRelayInitiatorSession(161+ socket = dialerSocket,162+ known = PairedDevice(163+ deviceId = "waiter",164+ deviceType = "android",165+ longTermPublicKeyHex = pubkeyToHex(waiterKeys.publicKey),166+ rendezvousSecretHex = secretHex,167+ pairedAt = 1L,168+ ),169+ rendezvousId = rendezvousId,170+ ourStaticPrivateKey = dialerKeys.privateKey,171+ ourStaticPublicKey = dialerKeys.publicKey,172+ localIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey)),173+ vaultFactory = { null },174+ onSyncComplete = {},175+ )176+ }.exceptionOrNull()177+ waiter.await()178+179+ assertTrue(180+ "expected identity mismatch, got $failure",181+ failure?.message?.contains("does not match authenticated KK key") == true,182+ )183+ }184+}185+186+class SyncPeerRoleTest {187+ private fun peer(type: String?, keyHex: String?) = PairedDevice(188+ deviceId = "peer",189+ deviceType = type,190+ longTermPublicKeyHex = keyHex,191+ pairedAt = 1L,192+ )193+194+ @Test195+ fun `we never dial a browser extension, whichever key is lower`() {196+ val low = "aa".repeat(32)197+ val high = "bb".repeat(32)198+ assertFalse(weDialPeer(low, peer("extension", high)))199+ assertFalse(weDialPeer(high, peer("extension", low)))200+ }201+202+ @Test203+ fun `phone to phone assigns exactly one dialler`() {204+ val ours = "aa".repeat(32)205+ val theirs = "bb".repeat(32)206+ // The same comparison run from each side must disagree — one dials,207+ // one waits. Two waiters was the phone-to-phone deadlock; two diallers208+ // would splice our own sockets together.209+ assertTrue(weDialPeer(ours, peer("android", theirs)))210+ assertFalse(weDialPeer(theirs, peer("android", ours)))211+ assertNotEquals(212+ weDialPeer(ours, peer("android", theirs)),213+ weDialPeer(theirs, peer("android", ours)),214+ )215+ }216+217+ @Test218+ fun `a tablet is a phone for this purpose, and an unknown type is too`() {219+ val low = "11".repeat(32)220+ val high = "ff".repeat(32)221+ assertTrue(weDialPeer(low, peer("ios", high)))222+ assertTrue(weDialPeer(low, peer(null, high)))223+ assertFalse(weDialPeer(high, peer(null, low)))224+ }225+226+ @Test227+ fun `a peer with no static key is never dialled`() {228+ assertFalse(weDialPeer("00".repeat(32), peer("android", null)))229+ }230+231+ @Test232+ fun `roles hold across many random pairs`() {233+ val random = SecureRandom()234+ repeat(200) {235+ val a = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex)236+ val b = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex)237+ if (a == b) return@repeat238+ assertNotEquals(weDialPeer(a, peer("android", b)), weDialPeer(b, peer("android", a)))239+ }240+ }241+}