e5993569bbf6

feat: Echo `snapshot_capable` on ADOPT_GRANT so receivers skip the inline wait when no snapshot is coming (v2.9.6)

time
2026-07-30 14:50 UTC · 2 months ago
machine
koh2
parent
309a3ecfa246
5 files VERSION+1 −1app/build.gradle+1 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+29 −19app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+14 −0app/src/test/java/com/peachpasswords/sync/VaultAdoptTest.kt+94 −0
VERSION+1 −1
@@ -1 +1 @@1-2.9.51+2.9.6

view file

app/build.gradle+1 −1
@@ -32,7 +32,7 @@3232         minSdk 263333         targetSdk 353434         // Keep the Android code in Gradle; the semantic name comes from root VERSION.35-        versionCode 24015135+        versionCode 2401523636         versionName semanticVersion3737 3838         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+29 −19
@@ -226,27 +226,37 @@226226                 receiverDeps.markSynced(deviceId)227227             }228228         }229-        val adopted = requestAdoption(channel, boundDeps) ?: return null230-        // Try to receive the inline vault snapshot.231-        try {232-            val snapshot = withTimeout(15_000) {233-                receiveVaultSnapshot(channel)234-            }235-            boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)236-            sendFrame(channel, AdoptionFrame.SnapshotApplied(237-                entriesReceived = snapshot.entries.size,238-                tombstonesReceived = snapshot.tombstones.size,239-            ))240-            // Optional device manifest from source229+        // Capture the source's ADOPT_GRANT.snapshot_capable echo so we can skip230+        // the 15s inline-snapshot wait when the source has nothing to send.231+        // Default false = "pre-C source, no echo" = skip the wait.232+        var sourceCanSendSnapshot = false233+        val adopted = requestAdoption(234+            channel,235+            boundDeps,236+            onGrant = { grant -> sourceCanSendSnapshot = grant.snapshotCapable },237+        ) ?: return null238+        if (sourceCanSendSnapshot) {239+            // Try to receive the inline vault snapshot.241240             try {242-                val manifestFrame = withTimeout(5_000) { receiveFrame(channel) }243-                if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {244-                    sendFrame(channel, AdoptionFrame.DeviceManifestApplied)241+                val snapshot = withTimeout(15_000) {242+                    receiveVaultSnapshot(channel)245243                 }246-            } catch (_: Exception) {}247-            boundDeps.markSynced(adopted.deviceId)248-        } catch (_: Exception) {249-            // Fallback: sync pipeline fills the vault.244+                boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)245+                sendFrame(channel, AdoptionFrame.SnapshotApplied(246+                    entriesReceived = snapshot.entries.size,247+                    tombstonesReceived = snapshot.tombstones.size,248+                ))249+                // Optional device manifest from source250+                try {251+                    val manifestFrame = withTimeout(5_000) { receiveFrame(channel) }252+                    if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {253+                        sendFrame(channel, AdoptionFrame.DeviceManifestApplied)254+                    }255+                } catch (_: Exception) {}256+                boundDeps.markSynced(adopted.deviceId)257+            } catch (_: Exception) {258+                // Fallback: sync pipeline fills the vault.259+            }250260         }251261         return adopted252262     } finally {

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+14 −0
@@ -54,6 +54,12 @@5454         @SerialName("vault_id") val vaultId: String,5555         val license: String? = null,5656         val source: Source,57+        // SOURCE echoes receiver's ADOPT_REQUEST.snapshot_capable so the receiver58+        // can skip its 15s inline-snapshot wait when the source has nothing to send.59+        // Always sent as explicit true or false (never omitted) so receivers can rely60+        // on the field's presence as a deliberate answer. Pre-C sources default to61+        // false here and receivers fall back to the sync pipeline immediately.62+        @SerialName("snapshot_capable") val snapshotCapable: Boolean = false,5763     ) : AdoptionFrame()5864 5965     @Serializable@@ -272,6 +278,12 @@272278 suspend fun requestAdoption(273279     channel: PairingTransport,274280     deps: AdoptionReceiverDeps,281+    // Called once after a valid ADOPT_GRANT is decoded and validated, before282+    // any side effects (vault creation, paired-device upsert). Used by the283+    // receiver to inspect the grant's snapshot_capable flag and skip the 15s284+    // inline-snapshot wait when the source has nothing to send. Defaults to no-op285+    // for callers (tests, other helpers) that don't need to inspect the grant.286+    onGrant: ((AdoptionFrame.Grant) -> Unit)? = null,275287     nowMs: () -> Long = { System.currentTimeMillis() },276288 ): PairedDevice? {277289     val receiverLicense = deps.license()@@ -302,6 +314,7 @@302314         return null303315     }304316     val grant = frame as AdoptionFrame.Grant317+    onGrant?.invoke(grant)305318 306319     val rootKeyBytes = hexToPubkey(grant.rootKeyHex)307320     try {@@ -409,6 +422,7 @@409422             wsPort = deps.sourceIdentity.wsPort,410423             relayUrl = deps.pairState?.relayUrl,411424         ),425+        snapshotCapable = request.snapshotCapable == true,412426     ))413427 414428     val identity = receiveFrame(channel)

view file

app/src/test/java/com/peachpasswords/sync/VaultAdoptTest.kt+94 −0
@@ -461,4 +461,98 @@461461         assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))462462         assertTrue(json.contains("\"root_key_hex\""))463463     }464+465+    @Test fun grant_json_carries_snapshot_capable_true_when_request_advertised_it() {466+        val grant = AdoptionFrame.Grant(467+            rootKeyHex = "11".repeat(32),468+            vaultId = "22".repeat(32),469+            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),470+            snapshotCapable = true,471+        )472+        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)473+        assertTrue(474+            "explicit snapshot_capable: true must serialize",475+            json.contains("\"snapshot_capable\":true"),476+        )477+    }478+479+    @Test fun grant_decodes_snapshot_capable_false_when_field_absent() {480+        // Android wire shape diverges from browser here: AdoptJson uses481+        // encodeDefaults = false, so a Grant constructed with snapshotCapable = false482+        // (the Kotlin default) serializes WITHOUT the field. The decoder then483+        // defaults it back to false. This is wire-compatible with both ends:484+        // pre-C sources that never set the field decode to false (receiver skips485+        // 15s wait), and v4.6.1 browser sources that DO set it to false... actually486+        // browser always sends explicit true OR false; Android only sends true.487+        // Both shapes decode correctly on the receiver side because the receiver's488+        // gate is `if (sourceCanSendSnapshot)` — missing field, false field, and489+        // undefined all flow to "skip wait". Documented as a deliberate divergence.490+        val withoutField = AdoptJson.decodeFromString(491+            AdoptionFrame.serializer(),492+            "{\"type\":\"ADOPT_GRANT\",\"root_key_hex\":\"${"11".repeat(32)}\",\"vault_id\":\"${"22".repeat(32)}\",\"source\":{\"device_id\":\"source\",\"device_name\":\"Source\",\"device_type\":\"extension\",\"long_term_public_key_hex\":\"${"ab".repeat(32)}\"}}",493+        ) as AdoptionFrame.Grant494+        assertFalse(495+            "missing field must decode to default false (receiver skips 15s wait)",496+            withoutField.snapshotCapable,497+        )498+        // And explicit-false round-trips (when a future change makes Android send499+        // explicit false, the decoder still produces false).500+        val withFieldFalse = AdoptJson.decodeFromString(501+            AdoptionFrame.serializer(),502+            "{\"type\":\"ADOPT_GRANT\",\"root_key_hex\":\"${"11".repeat(32)}\",\"vault_id\":\"${"22".repeat(32)}\",\"snapshot_capable\":false,\"source\":{\"device_id\":\"source\",\"device_name\":\"Source\",\"device_type\":\"extension\",\"long_term_public_key_hex\":\"${"ab".repeat(32)}\"}}",503+        ) as AdoptionFrame.Grant504+        assertFalse(505+            "explicit false must decode to false",506+            withFieldFalse.snapshotCapable,507+        )508+    }509+510+    @Test fun requestAdoption_invokes_onGrant_with_decoded_grant() = runBlocking {511+        // Receiver captures the grant seen on the wire via the onGrant callback.512+        // Source (RecorderSource) sends an ADOPT_GRANT that echoes the receiver's513+        // ADOPT_REQUEST.snapshot_capable, which the receiver-side callback then514+        // surfaces for the runRelayAdoptionReceiverSession gate.515+        val (receiverCh, sourceCh) = memoryPair()516+        val rootKeyHex = "55".repeat(32)517+        val vaultId = vaultIdFor(rootKeyHex)518+        val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)519+        val receiver = RecorderReceiver(520+            "Receiver",521+            DeviceType.ANDROID,522+            vaultId,523+            derived = receiverIdentity,524+            localLicense = null,525+        )526+        val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")527+        val seenGrants = mutableListOf<AdoptionFrame.Grant>()528+529+        val recvJob = async {530+            requestAdoption(531+                receiverCh,532+                receiver,533+                onGrant = { grant -> seenGrants += grant },534+            )535+        }536+        val grantJob = async {537+            val request = receiveFrame(sourceCh) as AdoptionFrame.Request538+            assertTrue(539+                "receiver must advertise snapshot_capable in ADOPT_REQUEST",540+                request.snapshotCapable == true,541+            )542+            grantAdoption(sourceCh, request, source) { 0L }543+        }544+        recvJob.await()545+        grantJob.await()546+547+        assertEquals(1, seenGrants.size)548+        assertEquals(rootKeyHex, seenGrants.single().rootKeyHex)549+        // grantAdoption echoes request.snapshot_capable === true on the wire, so550+        // the receiver's onGrant callback sees snapshot_capable: true and the551+        // receiver's runRelayAdoptionReceiverSession enters the 15s inline wait.552+        // The serializer tests above cover the explicit-false case.553+        assertTrue(554+            "grant must echo request.snapshot_capable === true",555+            seenGrants.single().snapshotCapable,556+        )557+    }464558 }

view file