85d691ffdf4a
feat: inline vault snapshot over pairing channel (v2.9.0)
- time
- 2026-07-30 00:31 UTC · 2 months ago
- machine
- koh2
- parent
- 933dfea7fdd0
↓ peach-android-85d691ff.tar.gzatlas at this save
files changed8 files
VERSION+1 −1app/build.gradle+1 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+26 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+55 −2app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+147 −0app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+42 −0app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+28 −0app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+129 −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 24014835+ versionCode 2401493636 versionName semanticVersion3737 3838 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+26 −1
@@ -11,6 +11,7 @@1111 import kotlinx.coroutines.flow.callbackFlow1212 import kotlinx.coroutines.launch1313 import kotlinx.coroutines.withTimeout14+import kotlinx.coroutines.withTimeoutOrNull1415 import org.json.JSONObject1516 1617 sealed interface RelayAdoptionEvent {@@ -201,8 +202,32 @@201202 override suspend fun upsertPairedDevice(device: PairedDevice) {202203 sourceDeps.upsertPairedDevice(device)203204 }205+ override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()206+ override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()207+ override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)204208 }205- return grantAdoption(channel, request, boundDeps)209+ val adopted = grantAdoption(channel, request, boundDeps) ?: return null210+ // If the receiver advertised snapshot support, send the vault inline.211+ if (request.snapshotCapable == true) {212+ try {213+ val snapshot = boundDeps.vaultSnapshot()214+ if (snapshot != null) {215+ sendVaultSnapshot(channel, snapshot.first, snapshot.second)216+ val applied = receiveFrame(channel)217+ if (applied is AdoptionFrame.SnapshotApplied) {218+ val manifest = boundDeps.outgoingDeviceManifest()219+ if (manifest != null) {220+ sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))221+ withTimeoutOrNull(5_000) { receiveFrame(channel) }222+ }223+ boundDeps.markSynced(adopted.deviceId)224+ }225+ }226+ } catch (_: Exception) {227+ // Fallback: receiver uses the sync pipeline.228+ }229+ }230+ return adopted206231 } finally {207232 noise.close()208233 }app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+55 −2
@@ -3,6 +3,7 @@33 import javax.inject.Inject44 import javax.inject.Singleton55 import kotlinx.coroutines.withTimeout6+import kotlinx.coroutines.withTimeoutOrNull67 import org.json.JSONObject78 89 /**@@ -218,8 +219,36 @@218219 override fun onError(error: String) {219220 receiverDeps.onError(error)220221 }222+ override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>) {223+ receiverDeps.applySnapshot(entries, tombstones)224+ }225+ override suspend fun markSynced(deviceId: String) {226+ receiverDeps.markSynced(deviceId)227+ }221228 }222- return requestAdoption(channel, boundDeps)229+ val adopted = requestAdoption(channel, boundDeps) ?: return null230+ // Try to receive the inline vault snapshot.231+ try {232+ val (entries, tombstones) = withTimeout(15_000) {233+ receiveVaultSnapshot(channel)234+ }235+ boundDeps.applySnapshot(entries, tombstones)236+ sendFrame(channel, AdoptionFrame.SnapshotApplied(237+ entriesReceived = entries.size,238+ tombstonesReceived = tombstones.size,239+ ))240+ // Optional device manifest from source241+ try {242+ val manifestFrame = withTimeout(5_000) { receiveFrame(channel) }243+ if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {244+ sendFrame(channel, AdoptionFrame.DeviceManifestApplied)245+ }246+ } catch (_: Exception) {}247+ boundDeps.markSynced(adopted.deviceId)248+ } catch (_: Exception) {249+ // Fallback: sync pipeline fills the vault.250+ }251+ return adopted223252 } finally {224253 noise.close()225254 }@@ -291,8 +320,32 @@291320 override suspend fun upsertPairedDevice(device: PairedDevice) {292321 sourceDeps.upsertPairedDevice(device)293322 }323+ override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()324+ override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()325+ override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)294326 }295- return grantAdoption(channel, request, boundDeps)327+ val adopted = grantAdoption(channel, request, boundDeps) ?: return null328+ // If the receiver advertised snapshot support, send the vault inline.329+ if (request.snapshotCapable == true) {330+ try {331+ val snapshot = boundDeps.vaultSnapshot()332+ if (snapshot != null) {333+ sendVaultSnapshot(channel, snapshot.first, snapshot.second)334+ val applied = receiveFrame(channel)335+ if (applied is AdoptionFrame.SnapshotApplied) {336+ val manifest = boundDeps.outgoingDeviceManifest()337+ if (manifest != null) {338+ sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))339+ withTimeoutOrNull(5_000) { receiveFrame(channel) }340+ }341+ boundDeps.markSynced(adopted.deviceId)342+ }343+ }344+ } catch (_: Exception) {345+ // Fallback: receiver uses the sync pipeline.346+ }347+ }348+ return adopted296349 } finally {297350 noise.close()298351 }app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+147 −0
@@ -2,6 +2,7 @@22 33 import kotlinx.serialization.SerialName44 import kotlinx.serialization.Serializable5+import kotlinx.serialization.encodeToString56 import kotlinx.serialization.json.Json67 78 /**@@ -37,6 +38,7 @@3738 @SerialName("device_name") val deviceName: String,3839 @SerialName("device_type") val deviceType: String,3940 val license: String? = null,41+ @SerialName("snapshot_capable") val snapshotCapable: Boolean? = null,4042 ) : AdoptionFrame()4143 4244 @Serializable@@ -85,7 +87,41 @@8587 val reason: String,8688 ) : AdoptionFrame()8789 90+ /**91+ * One chunk of the inline vault snapshot sent by the source after92+ * ADOPT_APPLIED over the same authenticated Noise NK channel. Entries are93+ * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.94+ */8895 @Serializable96+ @SerialName("ADOPT_SNAPSHOT")97+ data class Snapshot(98+ @SerialName("chunk_index") val chunkIndex: Int,99+ @SerialName("chunk_count") val chunkCount: Int,100+ val entries: Map<String, String>,101+ val tombstones: Map<String, Long>,102+ ) : AdoptionFrame()103+104+ /** Receiver acknowledgement sent after all snapshot chunks are applied. */105+ @Serializable106+ @SerialName("ADOPT_SNAPSHOT_APPLIED")107+ data class SnapshotApplied(108+ @SerialName("entries_received") val entriesReceived: Int,109+ @SerialName("tombstones_received") val tombstonesReceived: Int,110+ ) : AdoptionFrame()111+112+ /** Optional signed device graph appended after the snapshot (manifest v3+). */113+ @Serializable114+ @SerialName("ADOPT_DEVICE_MANIFEST")115+ data class DeviceManifestFrame(116+ val manifest: SignedDeviceManifest,117+ ) : AdoptionFrame()118+119+ /** Receiver acknowledgement for an applied device manifest. */120+ @Serializable121+ @SerialName("ADOPT_DEVICE_MANIFEST_APPLIED")122+ data object DeviceManifestApplied : AdoptionFrame()123+124+ @Serializable89125 data class Source(90126 @SerialName("device_id") val deviceId: String,91127 @SerialName("device_name") val deviceName: String,@@ -102,6 +138,19 @@102138 val relayUrl: String? = null,103139 )104140 141+/**142+ * Wire guardrails for the inline snapshot. A chunk's serialized entries map143+ * stays under [MAX_SNAPSHOT_CHUNK_BYTES]; the whole transfer is capped at144+ * [MAX_SNAPSHOT_CHUNKS] frames so a compromised source cannot exhaust receiver145+ * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).146+ */147+const val MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024 // 512 KiB148+const val MAX_SNAPSHOT_CHUNKS = 200149+150+// Headroom added to the per-chunk entries budget when validating an encoded151+// frame on the wire: JSON envelope plus the final chunk's tombstone map.152+private const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024153+105154 /** What the RECEIVER (joining device) needs to adopt a vault from a source. */106155 interface AdoptionReceiverDeps {107156 val deviceName: String@@ -126,6 +175,10 @@126175 suspend fun license(): String? = null127176 val pairState: AdoptionPairState? get() = null128177 fun onError(error: String)178+ /** Apply encrypted entries + tombstones from the inline snapshot. */179+ suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>) {}180+ /** Mark the paired device as synced after a successful snapshot. */181+ suspend fun markSynced(deviceId: String) {}129182 }130183 131184 /** What the SOURCE (existing device) needs to grant its vault to a receiver. */@@ -146,6 +199,12 @@146199 suspend fun onConsent(request: AdoptionFrame.Request): Boolean147200 suspend fun upsertPairedDevice(device: PairedDevice)148201 val pairState: AdoptionPairState? get() = null202+ /** Return all encrypted entries + tombstones for the inline snapshot. */203+ suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null204+ /** Return the signed device manifest for the graph, if available. */205+ suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null206+ /** Mark the paired device as synced after a successful snapshot. */207+ suspend fun markSynced(deviceId: String) {}149208 }150209 151210 private val Hex64 = Regex("^[0-9a-f]{64}\$")@@ -184,6 +243,11 @@184243 fun isAdoptReject(frame: AdoptionFrame?): Boolean =185244 frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"186245 246+fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =247+ frame is AdoptionFrame.Snapshot &&248+ frame.chunkIndex >= 0 &&249+ frame.chunkCount >= 1250+187251 /**188252 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the189253 * granted root key actually derives the advertised vault id (catching a lying or@@ -204,6 +268,7 @@204268 deviceName = deps.deviceName,205269 deviceType = deps.deviceType.wireValue,206270 license = receiverLicense,271+ snapshotCapable = true,207272 ))208273 209274 val confirmed = deps.confirmSas()@@ -373,6 +438,88 @@373438 runCatching {374439 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))375440 }.getOrNull()441+442+/**443+ * Source side: chunk the vault snapshot and send it over the pairing channel.444+ * Entries are split so each chunk's serialized entries map stays under445+ * [MAX_SNAPSHOT_CHUNK_BYTES]; tombstones ride the final chunk. If the vault446+ * needs more than [MAX_SNAPSHOT_CHUNKS] chunks, only the first that many are447+ * sent and the fallback sync pipeline fills the remainder.448+ */449+suspend fun sendVaultSnapshot(450+ channel: PairingTransport,451+ entries: Map<String, String>,452+ tombstones: Map<String, Long>,453+) {454+ val chunks = mutableListOf<Map<String, String>>()455+ var current = LinkedHashMap<String, String>()456+ var currentBytes = 2 // "{}"457+ for ((id, envelope) in entries) {458+ // +1 for the separating comma; an overestimate only shrinks chunks.459+ val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1460+ if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {461+ chunks.add(current)462+ current = LinkedHashMap()463+ currentBytes = 2464+ }465+ current[id] = envelope466+ currentBytes += entryBytes467+ }468+ chunks.add(current)469+470+ val chunkCount = minOf(chunks.size, MAX_SNAPSHOT_CHUNKS)471+ for (index in 0 until chunkCount) {472+ val isFinal = index == chunkCount - 1473+ sendFrame(channel, AdoptionFrame.Snapshot(474+ chunkIndex = index,475+ chunkCount = chunkCount,476+ entries = chunks[index],477+ tombstones = if (isFinal) tombstones else emptyMap(),478+ ))479+ }480+}481+482+/**483+ * Receiver side: receive and reassemble every snapshot chunk. Validates the484+ * encoded frame size before parsing, then enforces sequential 0-based485+ * chunk_index ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and486+ * structural validity of each frame. Throws on any validation failure so the487+ * caller can fall back to the sync pipeline.488+ */489+suspend fun receiveVaultSnapshot(490+ channel: PairingTransport,491+): Pair<Map<String, String>, Map<String, Long>> {492+ val entries = LinkedHashMap<String, String>()493+ val tombstones = LinkedHashMap<String, Long>()494+ var chunkCount = -1495+ var received = 0496+ while (chunkCount == -1 || received < chunkCount) {497+ val raw = channel.receive()498+ if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {499+ throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")500+ }501+ val frame = runCatching {502+ AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))503+ }.getOrNull()504+ if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")505+ frame as AdoptionFrame.Snapshot506+ if (chunkCount == -1) {507+ chunkCount = frame.chunkCount508+ if (chunkCount > MAX_SNAPSHOT_CHUNKS) {509+ throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")510+ }511+ } else if (frame.chunkCount != chunkCount) {512+ throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")513+ }514+ if (frame.chunkIndex != received) {515+ throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")516+ }517+ entries.putAll(frame.entries)518+ tombstones.putAll(frame.tombstones)519+ received++520+ }521+ return entries to tombstones522+}376523 377524 private fun isDeviceType(value: String): Boolean =378525 DeviceType.entries.any { it.wireValue == value }app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+42 −0
@@ -12,6 +12,8 @@1212 import com.peachpasswords.sync.LocalDeviceIdentity1313 import com.peachpasswords.sync.PairedDevice1414 import com.peachpasswords.sync.PairedDeviceStore15+import com.peachpasswords.sync.SignedDeviceManifest16+import com.peachpasswords.sync.VaultSyncAdapter1517 import com.peachpasswords.sync.PeachSyncService1618 import com.peachpasswords.sync.RelayAdoptionReceiver1719 import com.peachpasswords.sync.RelaySettingsStore@@ -311,6 +313,20 @@311313 override fun onError(error: String) {312314 protocolError = error313315 }316+317+ override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>) {318+ val adapter = VaultSyncAdapter(319+ repo = vault,320+ ourDeviceId = getOrCreateDeviceId(appContext),321+ pairedDevices = devices,322+ )323+ adapter.applyEntries(entries)324+ adapter.applyTombstones(tombstones)325+ }326+327+ override suspend fun markSynced(deviceId: String) {328+ devices.updateLastSync(deviceId)329+ }314330 }315331 316332 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =@@ -348,6 +364,32 @@348364 override suspend fun upsertPairedDevice(device: PairedDevice) {349365 devices.clearRevocation(device.deviceId)350366 devices.upsert(device)367+ }368+369+ override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {370+ val adapter = VaultSyncAdapter(371+ repo = vault,372+ ourDeviceId = getOrCreateDeviceId(appContext),373+ pairedDevices = devices,374+ )375+ val entryManifest = adapter.buildEntryManifest()376+ val entryIds = entryManifest.keys.toList()377+ val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()378+ val tombstones = adapter.buildTombstoneManifest()379+ return entries to tombstones380+ }381+382+ override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {383+ val adapter = VaultSyncAdapter(384+ repo = vault,385+ ourDeviceId = getOrCreateDeviceId(appContext),386+ pairedDevices = devices,387+ )388+ return adapter.buildOutgoingDeviceManifest()389+ }390+391+ override suspend fun markSynced(deviceId: String) {392+ devices.updateLastSync(deviceId)351393 }352394 }353395 app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+28 −0
@@ -19,6 +19,8 @@1919 import com.peachpasswords.sync.LocalDeviceIdentity2020 import com.peachpasswords.sync.PairedDevice2121 import com.peachpasswords.sync.PairedDeviceStore22+import com.peachpasswords.sync.SignedDeviceManifest23+import com.peachpasswords.sync.VaultSyncAdapter2224 import com.peachpasswords.sync.buildLocalIdentity2325 import com.peachpasswords.sync.deriveLocalSyncKeypair2426 import com.peachpasswords.sync.getOrCreateDeviceId@@ -194,6 +196,32 @@194196 // enough to lift a terminal revocation.195197 deviceStore.clearRevocation(device.deviceId)196198 deviceStore.upsert(device)199+ }200+201+ override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {202+ val adapter = VaultSyncAdapter(203+ repo = vault,204+ ourDeviceId = getOrCreateDeviceId(appContext),205+ pairedDevices = deviceStore,206+ )207+ val entryManifest = adapter.buildEntryManifest()208+ val entryIds = entryManifest.keys.toList()209+ val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()210+ val tombstones = adapter.buildTombstoneManifest()211+ return entries to tombstones212+ }213+214+ override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {215+ val adapter = VaultSyncAdapter(216+ repo = vault,217+ ourDeviceId = getOrCreateDeviceId(appContext),218+ pairedDevices = deviceStore,219+ )220+ return adapter.buildOutgoingDeviceManifest()221+ }222+223+ override suspend fun markSynced(deviceId: String) {224+ deviceStore.updateLastSync(deviceId)197225 }198226 }199227 app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+129 −0
@@ -0,0 +1,129 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.channels.Channel4+import kotlinx.coroutines.runBlocking5+import org.junit.Assert.assertEquals6+import org.junit.Assert.assertTrue7+import org.junit.Test8+9+/**10+ * Inline vault snapshot protocol parity with the browser11+ * `vault-adopt-snapshot.test.ts`. Round-trip, chunking, and validation12+ * tests for the ADOPT_SNAPSHOT / ADOPT_SNAPSHOT_APPLIED frames.13+ */14+class VaultAdoptSnapshotTest {15+16+ private fun memoryPair(): Pair<PairingTransport, PairingTransport> {17+ val a2b = Channel<ByteArray>(Channel.UNLIMITED)18+ val b2a = Channel<ByteArray>(Channel.UNLIMITED)19+ val a = object : PairingTransport {20+ override suspend fun send(msg: ByteArray) { a2b.send(msg) }21+ override suspend fun receive(): ByteArray = b2a.receive()22+ override suspend fun close() {}23+ }24+ val b = object : PairingTransport {25+ override suspend fun send(msg: ByteArray) { b2a.send(msg) }26+ override suspend fun receive(): ByteArray = a2b.receive()27+ override suspend fun close() {}28+ }29+ return a to b30+ }31+32+ @Test33+ fun `round-trips an empty vault`() = runBlocking {34+ val (source, receiver) = memoryPair()35+ sendVaultSnapshot(source, emptyMap(), emptyMap())36+ val (entries, tombstones) = receiveVaultSnapshot(receiver)37+ assertTrue(entries.isEmpty())38+ assertTrue(tombstones.isEmpty())39+ }40+41+ @Test42+ fun `round-trips 50 entries and 5 tombstones in a single chunk`() = runBlocking {43+ val (source, receiver) = memoryPair()44+ val entries = (0 until 50).associate { "id-$it" to "cipher-$it" }45+ val tombstones = (0 until 5).associate { "dead-$it" to (1000L + it) }46+ sendVaultSnapshot(source, entries, tombstones)47+ val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)48+ assertEquals(entries, receivedEntries)49+ assertEquals(tombstones, receivedTombstones)50+ }51+52+ @Test53+ fun `splits a large vault into 3+ chunks and reassembles`() = runBlocking {54+ val (source, receiver) = memoryPair()55+ var frames = 056+ val countingSource = object : PairingTransport {57+ override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }58+ override suspend fun receive(): ByteArray = source.receive()59+ override suspend fun close() {}60+ }61+ val big = "x".repeat(200 * 1024)62+ val entries = (0 until 5).associate { "id-$it" to big }63+ val tombstones = mapOf("dead-0" to 42L)64+ sendVaultSnapshot(countingSource, entries, tombstones)65+ val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)66+ assertTrue("expected 3+ chunks, got $frames", frames >= 3)67+ assertEquals(entries, receivedEntries)68+ assertEquals(tombstones, receivedTombstones)69+ }70+71+ @Test72+ fun `rejects out-of-order chunks`() = runBlocking {73+ val (source, receiver) = memoryPair()74+ sendFrame(source, AdoptionFrame.Snapshot(75+ chunkIndex = 1,76+ chunkCount = 2,77+ entries = mapOf("a" to "x"),78+ tombstones = emptyMap(),79+ ))80+ val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()81+ assertTrue(error is IllegalStateException)82+ assertTrue(error!!.message!!.contains("OUT_OF_ORDER"))83+ }84+85+ @Test86+ fun `rejects chunk_count exceeding MAX_SNAPSHOT_CHUNKS`() = runBlocking {87+ val (source, receiver) = memoryPair()88+ sendFrame(source, AdoptionFrame.Snapshot(89+ chunkIndex = 0,90+ chunkCount = MAX_SNAPSHOT_CHUNKS + 1,91+ entries = emptyMap(),92+ tombstones = emptyMap(),93+ ))94+ val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()95+ assertTrue(error is IllegalStateException)96+ assertTrue(error!!.message!!.contains("TOO_MANY_CHUNKS"))97+ }98+99+ @Test100+ fun `validates ADOPT_SNAPSHOT frames`() {101+ val valid = AdoptionFrame.Snapshot(102+ chunkIndex = 0, chunkCount = 1,103+ entries = mapOf("a" to "x"), tombstones = emptyMap(),104+ )105+ assertTrue(isAdoptSnapshot(valid))106+ assertTrue(!isAdoptSnapshot(null))107+ assertTrue(!isAdoptSnapshot(AdoptionFrame.Complete))108+ }109+110+ @Test111+ fun `snapshot_capable serializes in ADOPT_REQUEST`() {112+ val request = AdoptionFrame.Request(113+ sessionRequestId = "req-1",114+ deviceName = "test",115+ deviceType = "extension",116+ snapshotCapable = true,117+ )118+ val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), request)119+ assertTrue(json.contains("\"snapshot_capable\":true"))120+ // Absent when null (encodeDefaults = false)121+ val noFlag = AdoptionFrame.Request(122+ sessionRequestId = "req-2",123+ deviceName = "test",124+ deviceType = "extension",125+ )126+ val jsonNoFlag = AdoptJson.encodeToString(AdoptionFrame.serializer(), noFlag)127+ assertTrue(!jsonNoFlag.contains("snapshot_capable"))128+ }129+}