Write the control flag as the number the spec says
`Content.disappearing` emitted `"ctl":true` where §6.10 specifies `"ctl":1`. That was not a cosmetic disagreement between engines. iOS decodes the field as a number through a synthesised `Codable`, so a boolean did not merely fail to set the flag — it threw out of the initialiser and failed the WHOLE envelope's decode, dropping the message onto the plain-text path. An Android user turning on disappearing messages sent an iPhone a bubble containing raw JSON, and the timer was never applied at that end. Nothing caught it because each engine parsed what it had written itself. The piece-level tests passed on both sides, the vectors do not cover this envelope, and the two halves only meet on two real phones. Both spellings are accepted on the way in. `org.json`'s `optBoolean` does not coerce a number, so reading only that missed every `"ctl":1` the other two engines send — and reading only the number would miss the `true` this engine wrote until today, which is on phones now and cannot be recalled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
72314491fa
commit
db5aa33280
@@ -153,7 +153,13 @@ data class Content(
|
||||
* not carry it and never will.
|
||||
*/
|
||||
val isControl: Boolean
|
||||
get() = kind in Kind.CONTROL || raw?.optBoolean("ctl", false) == true
|
||||
// ⚠️ Both shapes accepted. `org.json`'s `optBoolean` does not coerce a
|
||||
// number, so reading only that missed every `"ctl":1` the other two
|
||||
// engines send — and reading only the number would miss the `true` this
|
||||
// engine itself wrote before 2026-08-30, which is on phones now.
|
||||
get() = kind in Kind.CONTROL ||
|
||||
raw?.optBoolean("ctl", false) == true ||
|
||||
(raw?.optInt("ctl", 0) ?: 0) != 0
|
||||
|
||||
/**
|
||||
* A kind this build does not know, which is not control traffic.
|
||||
@@ -378,7 +384,13 @@ data class Content(
|
||||
* as raw JSON in somebody's chat.
|
||||
*/
|
||||
fun disappearing(seconds: Int): String =
|
||||
"{\"fnc\":1,\"kind\":${quote(Kind.DISAPPEAR)},\"ctl\":true,\"secs\":$seconds}"
|
||||
// 🔴 `1`, not `true`. The specification says `"ctl":1`, and iOS
|
||||
// decodes the field as a number — so a boolean here did not merely
|
||||
// fail to set the flag, it failed the WHOLE envelope's decode and
|
||||
// fell through to the plain-text path. An Android user turning on
|
||||
// disappearing messages sent an iPhone a bubble containing raw
|
||||
// JSON, and the timer was never applied at that end.
|
||||
"{\"fnc\":1,\"kind\":${quote(Kind.DISAPPEAR)},\"ctl\":1,\"secs\":$seconds}"
|
||||
|
||||
/** The timer a [Kind.DISAPPEAR] envelope carries, or null if it is not one. */
|
||||
fun disappearSeconds(content: Content): Int? =
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The `ctl` flag, as all three engines actually write it.
|
||||
*
|
||||
* 🔴 **This engine wrote `"ctl":true` and the specification says `"ctl":1`.**
|
||||
* That was not a cosmetic disagreement. iOS decodes the field as a number
|
||||
* through a synthesised `Codable`, so a boolean did not fail to set the flag —
|
||||
* it failed the whole envelope's decode and dropped the message onto the
|
||||
* plain-text path. An Android user turning on disappearing messages therefore
|
||||
* sent an iPhone a bubble containing raw JSON, and the timer was never applied
|
||||
* at that end. Nothing caught it because each engine parsed what it had written
|
||||
* itself, and the piece-level tests on both sides passed.
|
||||
*/
|
||||
class ControlFlagInteropTest {
|
||||
|
||||
@Test
|
||||
fun `a disappearing envelope carries the number the spec asks for`() {
|
||||
val envelope = JSONObject(Content.disappearing(3600))
|
||||
assertEquals(1, envelope.get("ctl"),
|
||||
"the spec says {\"fnc\":1,\"kind\":\"…\",\"ctl\":1}")
|
||||
assertEquals(3600, envelope.getInt("secs"))
|
||||
assertEquals(1, envelope.getInt("fnc"))
|
||||
}
|
||||
|
||||
/** And it still parses here, which is the half that already worked. */
|
||||
@Test
|
||||
fun `our own disappearing envelope round trips`() {
|
||||
val content = Content.parse(Content.disappearing(900))
|
||||
assertEquals(900, Content.disappearSeconds(content))
|
||||
assertTrue(content.isControl)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Both shapes are accepted on the way IN. `org.json`'s `optBoolean` does
|
||||
* not coerce a number, so reading only that missed every `"ctl":1` the
|
||||
* other two engines send — and reading only the number would miss the
|
||||
* `true` this engine itself wrote, which is on phones now and cannot be
|
||||
* recalled.
|
||||
*/
|
||||
@Test
|
||||
fun `both spellings of the flag are understood`() {
|
||||
for (spelling in listOf("1", "true")) {
|
||||
val raw = """{"fnc":1,"kind":"some_future_thing","ctl":$spelling}"""
|
||||
assertTrue(Content.parse(raw).isControl,
|
||||
"a control envelope written as ctl:$spelling was shown to the user")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The point of the flag: a control kind from a NEWER peer must be
|
||||
* swallowed, not rendered. Without it, every control kind added after a
|
||||
* given release shows in that release as a bubble the person cannot act on.
|
||||
*/
|
||||
@Test
|
||||
fun `an unknown control kind is swallowed rather than shown`() {
|
||||
val content = Content.parse("""{"fnc":1,"kind":"invented_next_year","ctl":1}""")
|
||||
assertTrue(content.isControl)
|
||||
assertTrue(!content.isRenderable, "control traffic must not reach the thread")
|
||||
}
|
||||
|
||||
/** An ordinary message is untouched by any of it. */
|
||||
@Test
|
||||
fun `a plain message is not control traffic`() {
|
||||
assertTrue(!Content.parse("just a message").isControl)
|
||||
assertTrue(!Content.parse(Content.text("hello")).isControl)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user