diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 132124a..67a528e 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -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? = diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ControlFlagInteropTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ControlFlagInteropTest.kt new file mode 100644 index 0000000..ed27c51 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/ControlFlagInteropTest.kt @@ -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) + } +}