package com.sptmobile.endpoint
/**
* `body` frame decoding — the phone-side twin of
* `rust/link-proto/src/event.rs` (clean-room from the public adapter
* contract; parity pinned by mirrored round-trip tests). The digest's
* `Context{kind: owl_message}` row body is the composed `` verbatim,
* and the `--json-payload` metadata blob rides its `json` attr — this is the
* own-send echo dedup seam (KNOWN-HAZARDS 3.1).
*
* Escaping per the adapter contract: `\n` → `
`, HTML entities for
* `<`/`>`/`"`, `&` LAST on encode — so decode order is load-bearing:
* `
` first, then `<`/`>`/`"`, `&` LAST (amp-last, else
* `<` double-decodes to `<`).
*/
data class EventFrame(
val attrs: List>,
val body: String,
) {
fun attr(name: String): String? = attrs.firstOrNull { it.first == name }?.second
companion object {
/** Decode an escaped body. ORDER MATTERS: `
` first, `&` LAST. */
fun decodeBody(s: String): String = s
.replace("
", "\n")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("&", "&")
/**
* Parse one `…` frame; null on anything malformed
* (a bad digest row must never crash the timeline). Attribute values
* decode with the same entity rules as the body.
*/
fun parse(frame: String): EventFrame? {
val s = frame.trim()
if (!s.startsWith("')
if (headEnd < 0) return null
val head = rest.substring(0, headEnd)
val tail = rest.substring(headEnd + 1)
if (!tail.endsWith("")) return null
val bodyEsc = tail.removeSuffix("")
val attrs = mutableListOf>()
var cur = head.trim()
while (cur.isNotEmpty()) {
val eq = cur.indexOf('=')
if (eq < 0) return null
val key = cur.substring(0, eq).trim()
val after = cur.substring(eq + 1)
if (!after.startsWith('"')) return null
val value = after.substring(1)
val endq = value.indexOf('"')
if (endq < 0) return null
attrs += key to decodeBody(value.substring(0, endq))
cur = value.substring(endq + 1).trim()
}
return EventFrame(attrs, decodeBody(bodyEsc))
}
}
}