diff --git a/app/src/main/java/app/closer/data/remote/BackupCodec.kt b/app/src/main/java/app/closer/data/remote/BackupCodec.kt new file mode 100644 index 00000000..a56b8ec9 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/BackupCodec.kt @@ -0,0 +1,95 @@ +package app.closer.data.remote + +import app.closer.domain.model.BackupMessageRecord +import org.json.JSONArray +import org.json.JSONObject +import java.security.MessageDigest + +/** + * Pure (no-Android) JSON codec for backup chunks/snapshots. The output string is what gets wrapped in + * a couple-key `enc:v1:` envelope by the datasource, so the codec itself handles no crypto and stays + * unit-testable. Round-trip must be stable (see `BackupCodecTest`). + */ +object BackupCodec { + + /** Encode a batch of message records into a canonical JSON payload string. */ + fun encode(records: List): String { + val arr = JSONArray() + for (r in records) { + val o = JSONObject() + o.put("messageId", r.messageId) + o.put("conversationId", r.conversationId) + o.put("conversationType", r.conversationType) + o.put("authorUserId", r.authorUserId) + o.put("type", r.type) + o.put("encText", r.encText ?: JSONObject.NULL) + o.put("mediaUrl", r.mediaUrl ?: JSONObject.NULL) + o.put("durationMs", r.durationMs ?: JSONObject.NULL) + o.put("createdAt", r.createdAt) + o.put("deleted", r.deleted) + val reactions = JSONObject() + for ((k, v) in r.reactions) reactions.put(k, v) + o.put("reactions", reactions) + arr.put(o) + } + return JSONObject().put("records", arr).toString() + } + + /** Decode a payload string back into records. Unknown/extra fields are ignored (forward-compat). */ + fun decode(payload: String): List { + val arr = JSONObject(payload).optJSONArray("records") ?: return emptyList() + val out = ArrayList(arr.length()) + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + val reactions = LinkedHashMap() + o.optJSONObject("reactions")?.let { rj -> + val keys = rj.keys() + while (keys.hasNext()) { + val k = keys.next() + reactions[k] = rj.getString(k) + } + } + out.add( + BackupMessageRecord( + messageId = o.optString("messageId"), + conversationId = o.optString("conversationId"), + conversationType = o.optString("conversationType", "main"), + authorUserId = o.optString("authorUserId"), + type = o.optString("type", "text"), + encText = if (o.isNull("encText")) null else o.optString("encText"), + mediaUrl = if (o.isNull("mediaUrl")) null else o.optString("mediaUrl"), + durationMs = if (o.isNull("durationMs")) null else o.optLong("durationMs"), + createdAt = o.optLong("createdAt"), + deleted = o.optBoolean("deleted", false), + reactions = reactions + ) + ) + } + return out + } + + /** Serialize a uid→emoji reactions map to a compact JSON object (for the Room cache column). */ + fun reactionsToJson(reactions: Map): String { + val o = JSONObject() + for ((k, v) in reactions) o.put(k, v) + return o.toString() + } + + fun reactionsFromJson(json: String?): Map { + if (json.isNullOrBlank()) return emptyMap() + val o = runCatching { JSONObject(json) }.getOrNull() ?: return emptyMap() + val out = LinkedHashMap() + val keys = o.keys() + while (keys.hasNext()) { + val k = keys.next() + out[k] = o.getString(k) + } + return out + } + + /** Integrity checksum of a plaintext payload (before encryption) — stored in the manifest. */ + fun checksum(payload: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8)) + return "sha256:" + digest.joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/test/java/app/closer/data/remote/BackupCodecTest.kt b/app/src/test/java/app/closer/data/remote/BackupCodecTest.kt new file mode 100644 index 00000000..b6dd35a8 --- /dev/null +++ b/app/src/test/java/app/closer/data/remote/BackupCodecTest.kt @@ -0,0 +1,65 @@ +package app.closer.data.remote + +import app.closer.domain.model.BackupCursor +import app.closer.domain.model.BackupMessageRecord +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BackupCodecTest { + + private val records = listOf( + BackupMessageRecord( + messageId = "m1", conversationId = "main", conversationType = "main", + authorUserId = "a", type = "text", encText = "enc:v1:abc", createdAt = 100L, + reactions = mapOf("b" to "❤️") + ), + BackupMessageRecord( + messageId = "m2", conversationId = "main", conversationType = "main", + authorUserId = "b", type = "image", mediaUrl = "https://x/y?token=1", createdAt = 200L, deleted = true + ) + ) + + @Test + fun `encode then decode round-trips records including reactions and tombstones`() { + val decoded = BackupCodec.decode(BackupCodec.encode(records)) + assertEquals(records.size, decoded.size) + assertEquals("enc:v1:abc", decoded[0].encText) + assertEquals(mapOf("b" to "❤️"), decoded[0].reactions) + assertEquals("image", decoded[1].type) + assertTrue(decoded[1].deleted) + assertEquals("https://x/y?token=1", decoded[1].mediaUrl) + assertEquals(records, decoded) + } + + @Test + fun `checksum is stable for identical payloads and differs otherwise`() { + val a = BackupCodec.encode(records) + assertEquals(BackupCodec.checksum(a), BackupCodec.checksum(a)) + assertNotEquals(BackupCodec.checksum(a), BackupCodec.checksum(BackupCodec.encode(records.take(1)))) + assertTrue(BackupCodec.checksum(a).startsWith("sha256:")) + } + + @Test + fun `reactions json round-trips`() { + val map = mapOf("u1" to "😍", "u2" to "👍") + assertEquals(map, BackupCodec.reactionsFromJson(BackupCodec.reactionsToJson(map))) + assertEquals(emptyMap(), BackupCodec.reactionsFromJson(null)) + } + + @Test + fun `decode ignores unknown fields (forward-compat)`() { + val payload = """{"records":[{"messageId":"x","conversationId":"main","createdAt":5,"futureField":"ignored"}],"extra":true}""" + val decoded = BackupCodec.decode(payload) + assertEquals(1, decoded.size) + assertEquals("x", decoded[0].messageId) + } + + @Test + fun `cursor ordering is by createdAt then messageId`() { + assertTrue(BackupCursor(200L, "a").isAfter(BackupCursor(100L, "z"))) + assertTrue(BackupCursor(100L, "b").isAfter(BackupCursor(100L, "a"))) + assertTrue(!BackupCursor(100L, "a").isAfter(BackupCursor(100L, "a"))) + } +}