Merge branch 'test/kotlin-content-interop' into 'main'

Kotlin content interop commands (e2e#4)

See merge request paulhitt/flamenet-messenger-android!66
This commit was merged in pull request #93.
This commit is contained in:
Paul Hitt
2026-09-05 10:08:12 +00:00
@@ -1,6 +1,7 @@
package io.flamenet.interop
import io.flamenet.e2e.AccountKdf
import io.flamenet.e2e.Content
import io.flamenet.e2e.Aead
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.DoubleRatchet
@@ -123,6 +124,8 @@ private fun handle(req: JSONObject): JSONObject = when (req.getString("cmd")) {
req.bytes("key"), req.bytes("box"), req.bytes("aad")), Charsets.UTF_8))
"seal" -> sealSender(req)
"open_sealed" -> openSealed(req)
"content_encode" -> contentEncode(req)
"content_parse" -> contentParse(req)
"verify" -> JSONObject().put("ok", Ed25519.verify(
req.bytes("public_key"), req.bytes("signature"), req.bytes("message")))
else -> JSONObject().put("error", "unknown cmd ${req.getString("cmd")}")
@@ -506,3 +509,89 @@ private fun logLeaf(req: JSONObject): JSONObject {
.put("leaf", String(leaf, Charsets.UTF_8))
.put("hash", b64(KeyLog.leafHash(leaf)))
}
// ---------------------------------------------------------------------------
// §6.6–§6.27 — the content envelope
//
// 🔴 **This layer had no cross-language check at all until now, and it is
// where every silent divergence has actually happened.** The ratchet is
// covered from three directions; the plaintext layer that rides inside it was
// covered by each engine parsing what it had written itself. SPEC.md §6.10
// says so in as many words, after the `ctl:true` bug, and calls the interop
// harness "the standing argument". The Swift driver grew `content_encode` /
// `content_parse` for that reason. This is the other half.
//
// ⚠️ Android is the half that most needed it. This engine reads field by
// field through `org.json` — whose bundled Android build coerces a JSON null
// to the *string* `"null"` where Maven's returns `""` — while iOS decodes
// all-or-nothing through `Codable`, so the same additive change degrades
// differently on each platform. That asymmetry is invisible to any test that
// only round-trips one codec.
// ---------------------------------------------------------------------------
private fun reference(req: JSONObject): Content.Reference? {
val re = req.optJSONObject("re") ?: return null
return Content.Reference(
messageId = re.optString("mid", ""),
author = re.optLong("author", 0L),
preview = re.optString("preview", ""),
)
}
/** Assemble a content envelope the way the app would. Mirrors `cmdContentEncode`. */
private fun contentEncode(req: JSONObject): JSONObject {
val about = reference(req)
val gid = req.optString("gid", "").ifEmpty { null }
val mid = req.optString("mid", "").ifEmpty { null }
val reaction = req.optString("reaction", "").ifEmpty { null }
// ⚠️ A reaction is checked before text, and needs a target. The Swift
// driver orders it the same way; a divergence here would show up as one
// engine emitting a text envelope where the other emitted a react, which
// reads downstream as a duplicate bubble rather than as an error.
if (reaction != null && about != null) {
return JSONObject().put("wire", Content.reaction(reaction, about, gid))
}
val mentions = req.optJSONArray("mentions")?.let { array ->
(0 until array.length()).map { array.getInt(it) }
} ?: emptyList()
val wire = if (gid != null) {
Content.groupText(req.optString("text", ""), gid, messageId = mid,
about = about, mentions = mentions)
} else {
Content.text(req.optString("text", ""), messageId = mid, about = about)
}
return JSONObject().put("wire", wire)
}
/**
* Parse one, and report what the app would render. Mirrors `cmdContentParse`.
*
* ⚠️ Reports only what the Swift side also reports, plus mentions. A field
* present here and absent there cannot be compared, and a comparison that
* silently skips a field is the shape of guard this whole exercise exists to
* replace.
*/
private fun contentParse(req: JSONObject): JSONObject {
val parsed = Content.parse(req.getString("wire"))
val out = JSONObject().put("text", parsed.text)
parsed.messageId?.let { out.put("mid", it) }
parsed.reaction?.let { out.put("reaction", it) }
parsed.groupId?.let { out.put("gid", it) }
parsed.about?.let {
out.put("re", JSONObject()
.put("mid", it.messageId).put("author", it.author).put("preview", it.preview))
}
if (parsed.mentions.isNotEmpty()) {
out.put("mentions", org.json.JSONArray(parsed.mentions))
}
// 🔴 **The field the `ctl:true` bug turned on.** §6.10 requires a receiver
// to consume ANY envelope carrying `ctl` silently, known kind or not.
// Reported so the harness can check the rule ACROSS engines — this engine
// wrote `"ctl":true` until 2026-08-30 and iOS decoded it as a number, and
// each side testing itself is precisely what missed that.
out.put("control", parsed.isControl)
return out
}