feat(backup): add BackupCodec (JSON codec for backup chunks, checksum, reactions)

This commit is contained in:
null 2026-06-30 20:42:12 -05:00
parent 6b469357c1
commit 7f8dac0b14
2 changed files with 160 additions and 0 deletions

View File

@ -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<BackupMessageRecord>): 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<BackupMessageRecord> {
val arr = JSONObject(payload).optJSONArray("records") ?: return emptyList()
val out = ArrayList<BackupMessageRecord>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val reactions = LinkedHashMap<String, String>()
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, String>): String {
val o = JSONObject()
for ((k, v) in reactions) o.put(k, v)
return o.toString()
}
fun reactionsFromJson(json: String?): Map<String, String> {
if (json.isNullOrBlank()) return emptyMap()
val o = runCatching { JSONObject(json) }.getOrNull() ?: return emptyMap()
val out = LinkedHashMap<String, String>()
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) }
}
}

View File

@ -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<String, String>(), 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")))
}
}