feat(backup): add BackupCodec (JSON codec for backup chunks, checksum, reactions)
This commit is contained in:
parent
6b469357c1
commit
7f8dac0b14
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue