f90df691e6d2

add PeachSync relay reconcile, room, socket, and dial diagnostics with log-capture coverage (v2.5.15)

time
2026-07-27 03:48 UTC · 2 months ago
machine
koh2
agent
claude-opus-5
session
android sync listener visibility
parent
d115d87bd22a
6 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+84 −57app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+139 −7app/src/test/java/com/peachpasswords/sync/CloudRelayDialerTest.kt+81 −26app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+48 −0
VERSION+1 −1
@@ -1 +1 @@1-2.5.14\ No newline at end of file1+2.5.15\ No newline at end of file

view file

app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24011420-        versionName "2.5.14"19+        versionCode 24011520+        versionName "2.5.15"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+84 −57
@@ -27,68 +27,95 @@2727     onSyncComplete: () -> Unit,2828     report: (SyncOutcomeSummary) -> Unit = {},2929 ) {30-    val responderKeyHex = known.longTermPublicKeyHex31-        ?: throw IllegalArgumentException("known peer missing static key")32-    val responderKey = hexToPubkey(responderKeyHex)33-34-    // The capability names the room only; it grants no trust. KK proves both35-    // private keys immediately after, and a wrong room is refused there.36-    socket.send(37-        JSONObject()38-            .put("type", "PEACH_RECONNECT")39-            .put("rendezvous_id", rendezvousId)40-            .toString()41-            .toByteArray(Charsets.UTF_8),30+    PeachSyncLog.i(31+        "cloud-relay: dial — rendezvous=$rendezvousId, " +32+            "peer=${relayLogDetail(known.deviceName ?: known.deviceId)}",4233     )43-    val session = noiseKKInitiator(44-        ourStaticPrivateKey,45-        ourStaticPublicKey,46-        responderKey,47-        socket::send,48-        socket::receive,49-    )50-    // KK completed, so a peer is really there and answered. Everything past this51-    // point is a reportable outcome; everything before it is a dial into an52-    // empty room, which is the resting state of a phone, not a failure.5334     try {54-        sendSyncFrame(55-            encodePairIdentityFrame(localIdentity),56-            session::encrypt,35+        val responderKeyHex = known.longTermPublicKeyHex36+            ?: throw IllegalArgumentException("known peer missing static key")37+        val responderKey = hexToPubkey(responderKeyHex)38+39+        // The capability names the room only; it grants no trust. KK proves both40+        // private keys immediately after, and a wrong room is refused there.41+        socket.send(42+            JSONObject()43+                .put("type", "PEACH_RECONNECT")44+                .put("rendezvous_id", rendezvousId)45+                .toString()46+                .toByteArray(Charsets.UTF_8),47+        )48+        val session = noiseKKInitiator(49+            ourStaticPrivateKey,50+            ourStaticPublicKey,51+            responderKey,5752             socket::send,53+            socket::receive,5854         )59-        val theirIdentity = parsePairIdentityFrame(60-            receiveSyncFrame(socket::receive, session::decrypt),61-        )62-        require(identityMatchesAuthenticatedPeer(known, theirIdentity, responderKey)) {63-            "peer identity does not match authenticated KK key"64-        }55+        // KK completed, so a peer is really there and answered. Everything past this56+        // point is a reportable outcome; everything before it is a dial into an57+        // empty room, which is the resting state of a phone, not a failure.58+        try {59+            sendSyncFrame(60+                encodePairIdentityFrame(localIdentity),61+                session::encrypt,62+                socket::send,63+            )64+            val theirIdentity = parsePairIdentityFrame(65+                receiveSyncFrame(socket::receive, session::decrypt),66+            )67+            require(identityMatchesAuthenticatedPeer(known, theirIdentity, responderKey)) {68+                "peer identity does not match authenticated KK key"69+            }6570 66-        val vault = vaultFactory() ?: run {67-            report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))68-            return71+            val vault = vaultFactory()72+            if (vault == null) {73+                report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))74+            } else {75+                vault.bindAuthenticatedPeer(known)76+                val outcome = runSyncInitiator(77+                    SyncSession(78+                        remoteStaticPublicKey = responderKey.copyOf(),79+                        send = { plaintext -> sendSyncFrame(plaintext, session::encrypt, socket::send) },80+                        receive = { receiveSyncFrame(socket::receive, session::decrypt) },81+                        close = { socket.close() },82+                    ),83+                    vault,84+                )85+                if (outcome.status == SyncOutcomeStatus.COMPLETED) {86+                    onSyncComplete()87+                }88+                report(outcome.toSummary())89+            }90+        } catch (e: CancellationException) {91+            throw e92+        } catch (e: Exception) {93+            // Rethrown unchanged: the caller's retry loop owns what happens next.94+            // This only makes the failure sayable.95+            report(SyncOutcomeSummary(reason = classifySyncError(e.message ?: e.toString()), detail = e.message))96+            throw e97+        } finally {98+            session.close()6999         }70-        vault.bindAuthenticatedPeer(known)71-        val outcome = runSyncInitiator(72-            SyncSession(73-                remoteStaticPublicKey = responderKey.copyOf(),74-                send = { plaintext -> sendSyncFrame(plaintext, session::encrypt, socket::send) },75-                receive = { receiveSyncFrame(socket::receive, session::decrypt) },76-                close = { socket.close() },77-            ),78-            vault,79-        )80-        if (outcome.status == SyncOutcomeStatus.COMPLETED) {81-            onSyncComplete()82-        }83-        report(outcome.toSummary())84-    } catch (e: CancellationException) {85-        throw e86-    } catch (e: Exception) {87-        // Rethrown unchanged: the caller's retry loop owns what happens next.88-        // This only makes the failure sayable.89-        report(SyncOutcomeSummary(reason = classifySyncError(e.message ?: e.toString()), detail = e.message))90-        throw e91-    } finally {92-        session.close()100+        PeachSyncLog.i("cloud-relay: dial ok — rendezvous=$rendezvousId")101+    } catch (error: CancellationException) {102+        logCloudRelayDialFailure(rendezvousId, error)103+        throw error104+    } catch (error: Exception) {105+        logCloudRelayDialFailure(rendezvousId, error)106+        throw error93107     }94108 }109+110+private fun logCloudRelayDialFailure(rendezvousId: String, error: Throwable) {111+    val reason = relayLogReason(error)112+    val outcome = classifySyncError(reason)113+    val message = "cloud-relay: dial failed — rendezvous=$rendezvousId, reason=$reason"114+    when {115+        error is CancellationException -> PeachSyncLog.w(message)116+        outcome == SyncOutcomeReason.RELAY_UNREACHABLE || outcome == SyncOutcomeReason.SYNC_TIMEOUT ->117+            PeachSyncLog.w(message)118+        else -> PeachSyncLog.e(message)119+    }120+}121+

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+139 −7
@@ -1,5 +1,6 @@11 package com.peachpasswords.sync22 3+import android.util.Log34 import com.peachpasswords.vault.VaultCrypto45 import kotlinx.coroutines.CancellationException56 import kotlinx.coroutines.CompletableDeferred@@ -24,6 +25,7 @@2425 import org.json.JSONObject2526 import java.security.MessageDigest2627 import java.util.concurrent.TimeUnit28+import java.util.concurrent.atomic.AtomicBoolean2729 import javax.inject.Inject2830 import javax.inject.Singleton2931 @@ -51,7 +53,57 @@5153 private const val DIAL_RETRY_DELAY_MS = 5_000L5254 private const val RELAY_CONNECT_TIMEOUT_MS = 10_000L5355 private const val RELAY_INBOX_CAPACITY = 856+private const val PEACH_SYNC_LOG_TAG = "PeachSync"5457 58+internal object PeachSyncLog {59+    @Volatile60+    internal var sink: (Int, String) -> Unit = ::writeToLogcat61+62+    fun i(message: String) = sink(Log.INFO, message)63+    fun w(message: String) = sink(Log.WARN, message)64+    fun e(message: String) = sink(Log.ERROR, message)65+66+    private fun writeToLogcat(priority: Int, message: String) {67+        try {68+            when (priority) {69+                Log.INFO -> Log.i(PEACH_SYNC_LOG_TAG, message)70+                Log.WARN -> Log.w(PEACH_SYNC_LOG_TAG, message)71+                else -> Log.e(PEACH_SYNC_LOG_TAG, message)72+            }73+        } catch (error: RuntimeException) {74+            // Local JVM tests link android.jar's throwing Log stub; a device75+            // never takes this branch.76+            if (!error.message.orEmpty().contains("not mocked")) throw error77+        }78+    }79+}80+81+internal fun relayLogDetail(detail: String): String =82+    detail.replace('\n', ' ').replace('\r', ' ')83+84+internal fun relayLogReason(error: Throwable): String = relayLogDetail(85+    error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName,86+)87+88+internal data class RelayReconcileInventory(89+    val paired: List<PairedDevice>,90+    val eligible: List<PairedDevice>,91+)92+93+internal fun logRelayReconcileInventory(94+    deviceStore: PairedDeviceRegistry,95+): RelayReconcileInventory {96+    val paired = deviceStore.getAll()97+    val eligible = foregroundSyncPeers(paired)98+    PeachSyncLog.i(99+        "cloud-relay: reconcile start — ${paired.size} paired device(s), ${eligible.size} eligible",100+    )101+    if (eligible.isEmpty()) {102+        PeachSyncLog.i("cloud-relay: reconcile — no eligible peers")103+    }104+    return RelayReconcileInventory(paired, eligible)105+}106+55107 /**56108  * Keeps one socket open in each paired peer's live rendezvous rooms. The57109  * Cloudflare relay is only a splice, so being in the room is all either side@@ -76,6 +128,7 @@76128      * nothing at all.77129      */78130     private val reconcileNow = Channel<Unit>(Channel.CONFLATED)131+    private val reconcileRequested = AtomicBoolean(false)79132     private var retainedPrivateKey: ByteArray? = null80133     private var retainedPublicKey: ByteArray? = null81134 @@ -94,6 +147,9 @@94147         supervisor = scope.launch(Dispatchers.IO) {95148             try {96149                 while (isActive) {150+                    if (reconcileRequested.getAndSet(false)) {151+                        PeachSyncLog.i("cloud-relay: reconcile requested")152+                    }97153                     reconcile(98154                         this,99155                         privateKey,@@ -112,6 +168,7 @@112168 113169     /** Re-read the device list at once instead of on the next interval. */114170     fun requestReconcile() {171+        reconcileRequested.set(true)115172         reconcileNow.trySend(Unit)116173     }117174 @@ -153,6 +210,8 @@153210         localIdentity: LocalDeviceIdentity,154211         vaultFactory: () -> SyncVaultAdapter?,155212     ) {213+        val inventory = logRelayReconcileInventory(deviceStore)214+        val noEligiblePeers = inventory.eligible.isEmpty()156215         val meshRelayUrl = when (val decision = relaySettings.relayInForce()) {157216             is RelayDecision.Refuse -> {158217                 watchers.values.forEach(Job::cancel)@@ -171,10 +230,16 @@171230             is RelayDecision.Dial -> decision.relayUrl172231         }173232 233+        if (noEligiblePeers) {234+            watchers.values.forEach(Job::cancel)235+            watchers.clear()236+            return237+        }238+174239         val now = System.currentTimeMillis() / 1000L175240         val ourKeyHex = pubkeyToHex(publicKey)176241         val desired = buildMap {177-            for (device in deviceStore.getAll()) {242+            for (device in inventory.paired) {178243                 val secret = device.rendezvousSecretHex179244                     ?.let { runCatching { Rendezvous.secretFromHex(it) }.getOrNull() }180245                     ?: continue@@ -190,7 +255,18 @@190255                     else Rendezvous.waitingIds(secret, now)191256                 for (rendezvousId in ids) {192257                     val key = "${device.deviceId}|$relayUrl|$rendezvousId"193-                    put(key, RelayWaitTarget(device.deviceId, relayUrl, rendezvousId, weDial))258+                    put(259+                        key,260+                        RelayWaitTarget(261+                            deviceId = device.deviceId,262+                            deviceName = device.deviceName,263+                            deviceType = device.deviceType,264+                            trustSource = device.trustSource,265+                            relayUrl = relayUrl,266+                            rendezvousId = rendezvousId,267+                            weDial = weDial,268+                        ),269+                    )194270                 }195271             }196272         }@@ -203,14 +279,32 @@203279         }204280         for ((key, target) in desired) {205281             if (watchers[key]?.isActive == true) continue282+            val deviceDescription = relayDeviceDescription(target)206283             watchers[key] = scope.launch {207284                 while (isActive) {208285                     runCatching {209286                         val known = deviceStore.get(target.deviceId)210287                             ?: throw IllegalStateException("relay peer was unpaired")211-                        val socket = OkHttpRelaySocket.connect(212-                            Rendezvous.buildUrl(target.relayUrl, target.rendezvousId),288+                        PeachSyncLog.i(289+                            "cloud-relay: open — device=$deviceDescription, " +290+                                "relay=${target.relayUrl}, rendezvous=${target.rendezvousId}, " +291+                                "role=${if (target.weDial) "dialer" else "waiter"}",213292                         )293+                        val socket = try {294+                            OkHttpRelaySocket.connect(295+                                Rendezvous.buildUrl(target.relayUrl, target.rendezvousId),296+                            )297+                        } catch (error: Throwable) {298+                            PeachSyncLog.w(299+                                "cloud-relay: open failed — rendezvous=${target.rendezvousId}, " +300+                                    "reason=${relayLogReason(error)}",301+                            )302+                            throw error303+                        }304+                        PeachSyncLog.i(305+                            "cloud-relay: open ok — rendezvous=${target.rendezvousId}",306+                        )307+                        var closeError: Throwable? = null214308                         try {215309                             if (target.weDial) {216310                                 // A dialler that arrived before its peer had@@ -235,10 +329,14 @@235329                                             report = syncOutcomes::report,236330                                         )237331                                     }238-                                } catch (_: TimeoutCancellationException) {332+                                } catch (timeout: TimeoutCancellationException) {333+                                    closeError = timeout239334                                     reportDialUnanswered()240335                                 }241336                             } else {337+                                PeachSyncLog.i(338+                                    "cloud-relay: wait — rendezvous=${target.rendezvousId}",339+                                )242340                                 runCloudRelayResponderSession(243341                                     socket = socket,244342                                     known = known,@@ -253,8 +351,18 @@253351                                     report = syncOutcomes::report,254352                                 )255353                             }354+                        } catch (error: Throwable) {355+                            closeError = error356+                            throw error256357                         } finally {257-                            socket.close()358+                            try {359+                                socket.close()360+                            } catch (error: Throwable) {361+                                if (closeError == null) closeError = error362+                                throw error363+                            } finally {364+                                logRelaySocketClosed(target.rendezvousId, closeError)365+                            }258366                         }259367                     }260368                     delay(if (target.weDial) DIAL_RETRY_DELAY_MS else RETRY_DELAY_MS)@@ -266,11 +374,32 @@266374 267375 private data class RelayWaitTarget(268376     val deviceId: String,377+    val deviceName: String?,378+    val deviceType: String?,379+    val trustSource: String?,269380     val relayUrl: String,270381     val rendezvousId: String,271382     val weDial: Boolean,272383 )273384 385+private fun relayDeviceDescription(target: RelayWaitTarget): String =386+    "${relayLogDetail(target.deviceName ?: target.deviceId)}" +387+        "[type=${relayLogDetail(target.deviceType ?: "unset")}," +388+        "trust=${relayLogDetail(target.trustSource ?: "unset")}]"389+390+private fun logRelaySocketClosed(rendezvousId: String, error: Throwable?) {391+    val reason = error?.let(::relayLogReason) ?: "relay session complete"392+    val outcome = error?.let { classifySyncError(reason) }393+    val message = "cloud-relay: socket closed — rendezvous=$rendezvousId, reason=$reason"394+    when {395+        error == null -> PeachSyncLog.i(message)396+        error is CancellationException && error !is TimeoutCancellationException -> PeachSyncLog.i(message)397+        outcome == SyncOutcomeReason.RELAY_UNREACHABLE || outcome == SyncOutcomeReason.SYNC_TIMEOUT ->398+            PeachSyncLog.w(message)399+        else -> PeachSyncLog.e(message)400+    }401+}402+274403 internal interface RelayBinarySocket {275404     fun send(frame: ByteArray)276405     suspend fun receive(): ByteArray@@ -408,7 +537,10 @@408537             }409538 410539             override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {411-                incoming.close(IllegalStateException("relay_closed_$code"))540+                val detail = reason.takeIf { it.isNotBlank() }541+                    ?.let(::relayLogDetail)542+                    ?: "no reason"543+                incoming.close(IllegalStateException("relay_closed_$code: $detail"))412544             }413545 414546             override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {

view file

app/src/test/java/com/peachpasswords/sync/CloudRelayDialerTest.kt+81 −26
@@ -1,5 +1,6 @@11 package com.peachpasswords.sync22 3+import android.util.Log34 import kotlinx.coroutines.Dispatchers45 import kotlinx.coroutines.async56 import kotlinx.coroutines.channels.Channel@@ -86,37 +87,91 @@8687         var dialerSynced = false8788         var waiterSynced = false8889 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-        }90+        val logs = capturePeachSyncLogs {91+            val waiter = async(Dispatchers.Default) {92+                runCloudRelayResponderSession(93+                    socket = waiterSocket,94+                    known = dialerAsSeenByWaiter,95+                    registeredRendezvousId = rendezvousId,96+                    ourStaticPrivateKey = waiterKeys.privateKey,97+                    ourStaticPublicKey = waiterKeys.publicKey,98+                    localIdentity = waiterIdentity,99+                    vaultFactory = { null },100+                    onSyncComplete = { waiterSynced = true },101+                )102+            }103+            val dialer = async(Dispatchers.Default) {104+                runCloudRelayInitiatorSession(105+                    socket = dialerSocket,106+                    known = waiterAsSeenByDialer,107+                    rendezvousId = rendezvousId,108+                    ourStaticPrivateKey = dialerKeys.privateKey,109+                    ourStaticPublicKey = dialerKeys.publicKey,110+                    localIdentity = dialerIdentity,111+                    vaultFactory = { null },112+                    onSyncComplete = { dialerSynced = true },113+                )114+            }113115 114-        waiter.await()115-        dialer.await()116+            waiter.await()117+            dialer.await()118+        }119+        assertTrue(120+            logs.contains(121+                Log.INFO to "cloud-relay: dial — rendezvous=$rendezvousId, peer=Tablet",122+            ),123+        )124+        assertTrue(125+            logs.contains(126+                Log.INFO to "cloud-relay: dial ok — rendezvous=$rendezvousId",127+            ),128+        )116129         // No vault adapter on either side, so the handshake completes and the117130         // sync exchange is skipped — the wire is what is under test here.118131         assertFalse(dialerSynced)119132         assertFalse(waiterSynced)133+    }134+135+    @Test136+    fun `dial failure logs the rendezvous and underlying reason`() = runBlocking {137+        val dialerKeys = keys()138+        val rendezvousId = "ROOM-FAILED"139+        val socket = object : RelayBinarySocket {140+            override fun send(frame: ByteArray) = error("not reached")141+            override suspend fun receive(): ByteArray = error("not reached")142+            override fun close(code: Int, reason: String) = Unit143+        }144+        var failure: Throwable? = null145+146+        val logs = capturePeachSyncLogs {147+            failure = runCatching {148+                runCloudRelayInitiatorSession(149+                    socket = socket,150+                    known = PairedDevice(151+                        deviceId = "waiter",152+                        deviceName = "Tablet",153+                        deviceType = "android",154+                        pairedAt = 1L,155+                    ),156+                    rendezvousId = rendezvousId,157+                    ourStaticPrivateKey = dialerKeys.privateKey,158+                    ourStaticPublicKey = dialerKeys.publicKey,159+                    localIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey)),160+                    vaultFactory = { null },161+                    onSyncComplete = {},162+                )163+            }.exceptionOrNull()164+        }165+166+        assertEquals("known peer missing static key", failure?.message)167+        assertEquals(168+            listOf(169+                Log.INFO to "cloud-relay: dial — rendezvous=$rendezvousId, peer=Tablet",170+                Log.ERROR to "cloud-relay: dial failed — rendezvous=$rendezvousId, " +171+                    "reason=known peer missing static key",172+            ),173+            logs,174+        )120175     }121176 122177     @Test

view file

app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+48 −0
@@ -1,9 +1,12 @@11 package com.peachpasswords.sync22 3+import android.util.Log4+import kotlinx.coroutines.CoroutineScope35 import kotlinx.coroutines.Dispatchers46 import kotlinx.coroutines.async57 import kotlinx.coroutines.awaitCancellation68 import kotlinx.coroutines.channels.Channel9+import kotlinx.coroutines.coroutineScope710 import kotlinx.coroutines.launch811 import kotlinx.coroutines.runBlocking912 import kotlinx.coroutines.test.runTest@@ -14,6 +17,7 @@1417 import org.junit.Assert.assertTrue1518 import org.junit.Test1619 import java.security.SecureRandom20+import java.util.concurrent.CopyOnWriteArrayList1721 1822 /** Enough of a vault to send SYNC_HELLO; the peer stalls before anything else. */1923 private object SilentPeerVault : SyncVaultAdapter {@@ -27,7 +31,51 @@2731     override suspend fun applyTombstones(tombstones: Map<String, Long>) {}2832 }2933 34+internal suspend fun capturePeachSyncLogs(35+    block: suspend CoroutineScope.() -> Unit,36+): List<Pair<Int, String>> = coroutineScope {37+    val events = CopyOnWriteArrayList<Pair<Int, String>>()38+    val previousSink = PeachSyncLog.sink39+    PeachSyncLog.sink = { priority, message -> events += priority to message }40+    try {41+        block()42+        events.toList()43+    } finally {44+        PeachSyncLog.sink = previousSink45+    }46+}47+3048 class CloudRelayReconnectListenerTest {49+    @Test50+    fun `reconcile logs paired and eligible counts when no peer passes the filter`() = runBlocking {51+        val devices = listOf(52+            PairedDevice(53+                deviceId = "old-desktop",54+                deviceName = "Old desktop",55+                deviceType = "desktop",56+                trustSource = "direct",57+                pairedAt = 1L,58+            ),59+        )60+        val fakeDeviceStore = object : PairedDeviceRegistry {61+            override fun getAll() = devices62+            override fun get(id: String) = devices.firstOrNull { it.deviceId == id }63+            override fun upsert(device: PairedDevice) = Unit64+        }65+66+        val logs = capturePeachSyncLogs {67+            logRelayReconcileInventory(fakeDeviceStore)68+        }69+70+        assertEquals(71+            listOf(72+                Log.INFO to "cloud-relay: reconcile start — 1 paired device(s), 0 eligible",73+                Log.INFO to "cloud-relay: reconcile — no eligible peers",74+            ),75+            logs,76+        )77+    }78+3179     private data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray)3280 3381     private fun keys(): KeyPair {

view file