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
↓ peach-android-e5993569.tar.gzatlas at this save
files changed5 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 −0app/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"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 {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)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 }