From d525ef3d69b4753aa17d5b614f134a465fffffb9 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 2 Sep 2026 10:23:30 -0400 Subject: [PATCH] Post several pictures as one story sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android half of ios#53, spec §6.23. Each item is an ordinary story envelope carrying a `set` naming the sequence, its position and its size. 🔴 One envelope per item rather than an array of attachments. The array fails the single-`att` guard on any build predating §6.23 and renders the whole set as a message in the conversation; separate envelopes degrade to several stories in order, which every existing build already draws. 🔴 `setExpiry` takes the earliest stamp. Items are required to carry the same one, but a sender can write different ones, and if the latest won the difference would keep part of a sequence alive after the rest had gone. A zero is skipped rather than read as "expires at the epoch", which would kill the whole set. ⚠️ `groupedStories` renders what arrived: `n` is a claim, any item can fail to arrive, and a missing one is indistinguishable from one never sent. Stories with no set are keyed so two of them cannot collide. ⚠️ The set is persisted, or a relaunch scatters every sequence back into loose stories. Co-Authored-By: Claude Opus 4.8 --- PARITY.md | 19 +++ .../main/kotlin/io/flamenet/e2e/Content.kt | 44 +++++- .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 40 ++++++ .../kotlin/io/flamenet/e2e/StorySetTest.kt | 129 ++++++++++++++++++ 4 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt diff --git a/PARITY.md b/PARITY.md index 5fc0b80..090f0f8 100644 --- a/PARITY.md +++ b/PARITY.md @@ -691,3 +691,22 @@ Android has a separate record and a test named for exactly that. ⚠️ Android injects the setting into `Session` as a lambda rather than reading preferences in the engine layer, which has no Android dependency. An absent value reads as OFF on both platforms. +||||||| parent of f67edeb (Post several pictures as one story sequence) +## Multi-item stories (§6.23, ios#53) +Several pictures as one sequence. Each item is an ordinary story envelope +carrying a `set` that names the sequence, its position and its size. +🔴 **One envelope per item, not one carrying an array.** The array is the +obvious encoding and degrades badly: a client predating §6.23 requires a single +`att`, fails that guard, and renders the whole set as a message in the +conversation. Separate envelopes degrade to what they actually are — several +stories from one person, in order — which every existing build already draws. +🔴 **Expiry is per set, and the earliest stamp wins.** Items are required to +carry the same `exp`, but a sender can write different ones; if the latest won, +the difference would keep part of a sequence alive after the rest had gone. That +decay is confusing to look at *and* a disclosure — the gaps reveal the order the +author added things. Both engines have `setExpiry` and a test for it. A zero +means "never" and is skipped rather than read as "expires at the epoch", which +would kill the whole set. +⚠️ **Grouping renders what arrived.** `n` is the sender's claim and any item can +fail to arrive, so a set is never held back or shown as incomplete. Stories with +no set are keyed so two of them cannot collide into one group. diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index f627842..4ddd609 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -114,6 +114,9 @@ data class Content( * the trap §6.19 documents for pins, through a different door. */ val storyReplyId: String? = null, + + /** Which sequence this story belongs to (§6.23), if any. */ + val storySet: StorySet? = null, ) { /** @@ -146,6 +149,27 @@ data class Content( * was said. The preview is the sender's copy of their own words, the only * copy anyone can be sure of. */ + /** + * Which sequence a story belongs to, and where in it (§6.23). + * + * ⚠️ [n] is the SENDER'S CLAIM and may be wrong: items are separate + * envelopes and any of them can fail to arrive. Never wait for [n] items + * and never show a set as incomplete — a missing item is indistinguishable + * from one that was never sent. + */ + data class StorySet(val id: String, val i: Int, val n: Int) { + fun toJson(): JSONObject = + JSONObject().put("id", id).put("i", i).put("n", n) + + companion object { + fun fromJson(o: JSONObject): StorySet? { + val id = o.optString("id") + if (id.isEmpty()) return null + return StorySet(id, o.optInt("i", 0), o.optInt("n", 1)) + } + } + } + data class Reference(val messageId: String, val author: Long, val preview: String) { fun toJson(): JSONObject = JSONObject() .put("mid", messageId).put("author", author).put("preview", preview) @@ -459,6 +483,16 @@ data class Content( groupName: String? = null, forwarded: Boolean = false, lifetimeSeconds: Int? = null, + + /** + * The sequence this item belongs to (§6.23). + * + * ⚠️ Every item of a set must carry the same `exp`. Expiry is per + * set: per-item expiry lets a sequence decay into items 0 and 2, + * which is confusing and is also a disclosure — the gaps reveal + * the order the author added things. + */ + storySet: StorySet? = null, ): String = assemble( kind = ref.kind, caption = caption?.takeIf { it.isNotEmpty() }, @@ -471,6 +505,8 @@ data class Content( groupName = groupName, forwarded = forwarded, lifetimeSeconds = lifetimeSeconds, + + storySet = storySet, ) /** @@ -553,6 +589,8 @@ data class Content( background: Int? = null, storyReplyId: String? = null, + + storySet: StorySet? = null, ): String { val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}") // ⚠️ `1`, not `true` — §6.10's shape for every flag on this @@ -588,6 +626,9 @@ data class Content( streamUrl?.let { parts += "\"stream\":${quote(it)}" } // §6.21 — a text story's background style. A hint, never required. background?.let { parts += "\"bg\":$it" } + + // §6.23 — the sequence this item belongs to. + storySet?.let { parts += "\"set\":${it.toJson()}" } return parts.joinToString(",", prefix = "{", postfix = "}") } @@ -811,7 +852,8 @@ data class Content( ) return Content(caption, kind = kind, raw = json, attachment = ref, groupId = groupId, messageId = messageId, about = about, - streamUrl = json.optString("stream").takeIf { it.isNotEmpty() }) + streamUrl = json.optString("stream").takeIf { it.isNotEmpty() }, + storySet = json.optJSONObject("set")?.let { StorySet.fromJson(it) }) } } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index daf846a..551d58d 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -176,6 +176,40 @@ class Vault(private val storage: Storage, private val key: SecretKey) { return result.sortedByDescending { it.postedAt } } + /** + * Collapse a set (§6.23) to the expiry every item shares. + * + * 🔴 **The EARLIEST wins.** Every item is required to carry the same + * `exp`, but a sender can write different ones — and if the latest + * won, the difference would keep part of a sequence alive after the + * rest had gone, which is the decay the per-set rule exists to stop. + * + * ⚠️ A zero means "no expiry on that item" and is skipped, not read + * as "expires at the epoch" — which would kill the whole set. + */ + fun setExpiry(items: List): Double = + items.mapNotNull { it.ref.expires.takeIf { e -> e > 0 } }.minOrNull() ?: 0.0 + + /** + * Group one author's stories into sets, in position order (§6.23). + * + * ⚠️ Renders what arrived. `n` is the sender's claim and any item can + * fail to arrive, so a set is never held back or shown as incomplete — + * a missing item is indistinguishable from one never sent. + */ + fun groupedStories(stories: List): List> { + val order = mutableListOf() + val sets = LinkedHashMap>() + stories.forEach { story -> + // A story with no set is its own group of one, keyed so it can + // never collide with a real set id. + val key = story.set?.let { "set:${it.id}" } ?: "one:${story.id}" + if (sets[key] == null) { order += key; sets[key] = mutableListOf() } + sets.getValue(key) += story + } + return order.map { key -> sets.getValue(key).sortedBy { it.set?.i ?: 0 } } + } + fun threadForUser(userId: Long) = "user:$userId" fun threadForGroup(groupId: String) = "group:$groupId" @@ -507,6 +541,9 @@ class Vault(private val storage: Storage, private val key: SecretKey) { * a story that is the whole audience, not one correspondent. */ val link: Content.LinkPreview? = null, + + /** Which sequence this story belongs to (§6.23), if any. */ + val set: Content.StorySet? = null, ) { /** * When this story stops rendering, unix seconds. 0 = never. @@ -754,6 +791,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) { seen = o.optBoolean("seen", false), textExpiry = o.optDouble("exp", 0.0).takeIf { it > 0.0 }, background = if (o.has("bg")) o.optInt("bg") else null, + + set = o.optJSONObject("set")?.let { Content.StorySet.fromJson(it) }, ) } } @@ -786,6 +825,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) { .also { if (story.background != null) it.put("bg", story.background) } .put("caption", story.caption) .put("at", story.postedAt) + .also { j -> story.set?.let { j.put("set", it.toJson()) } } .put("seen", story.seen)) } return array diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt new file mode 100644 index 0000000..a173879 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt @@ -0,0 +1,129 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Multi-item stories (spec §6.23, ios#53). */ +class StorySetTest { + + private fun ref(id: Int, expires: Double = 100.0) = Attachment.Ref( + kind = Attachment.STORY, id = "b$id", key = ByteArray(32), + digest = ByteArray(32), bytes = 1, mime = "image/jpeg", + width = 1, height = 1, duration = 0.0, expires = expires, + ) + + private fun item(id: Int, set: String?, i: Int = 0, n: Int = 1, expires: Double = 100.0) = + Vault.Story( + id = "s$id", authorId = 7, mine = false, ref = ref(id, expires), + caption = "", postedAt = id.toLong(), + set = set?.let { Content.StorySet(it, i, n) }, + ) + + // -- The wire ------------------------------------------------------------- + + @Test + fun `an item carries its set position and size`() { + val wire = Content.attachment(ref(1), storySet = Content.StorySet("set-1", 2, 5)) + val content = Content.parse(wire) + assertEquals("set-1", content.storySet?.id) + assertEquals(2, content.storySet?.i) + assertEquals(5, content.storySet?.n) + } + + /** + * 🔴 One envelope per item. An array of attachments would fail the + * single-`att` guard on any build predating §6.23 and render the whole set + * as a message in the conversation. + */ + @Test + fun `an item is an ordinary story envelope`() { + val json = JSONObject(Content.attachment(ref(1), storySet = Content.StorySet("s", 0, 2))) + assertEquals(Attachment.STORY, json.getString("kind")) + assertTrue(json.has("att")) + assertFalse(json.has("atts")) + } + + @Test + fun `an ordinary story has no set`() { + assertNull(Content.parse(Content.attachment(ref(1))).storySet) + } + + // -- Grouping ------------------------------------------------------------- + + @Test + fun `items of one set are grouped in position order`() { + val groups = Vault.groupedStories(listOf( + item(3, "a", i = 2, n = 3), item(1, "a", i = 0, n = 3), item(2, "a", i = 1, n = 3))) + assertEquals(1, groups.size) + assertEquals(listOf("s1", "s2", "s3"), groups[0].map { it.id }) + } + + /** ⚠️ `n` says three; two came. Render what arrived. */ + @Test + fun `a partly arrived set is still rendered`() { + val groups = Vault.groupedStories(listOf( + item(1, "a", i = 0, n = 3), item(3, "a", i = 2, n = 3))) + assertEquals(1, groups.size) + assertEquals(2, groups[0].size) + } + + /** ⚠️ Two singles must not collide just because neither has a set id. */ + @Test + fun `two sets and a single stay apart`() { + val groups = Vault.groupedStories(listOf( + item(1, "a", i = 0, n = 2), item(2, "b"), item(3, null), item(4, "a", i = 1, n = 2))) + assertEquals(3, groups.size) + assertEquals(listOf("s1", "s4"), groups[0].map { it.id }) + } + + // -- Expiry --------------------------------------------------------------- + + /** 🔴 The earliest wins, so a sender cannot outlive part of its own set. */ + @Test + fun `a set expires at its earliest stamp`() { + assertEquals(100.0, Vault.setExpiry(listOf( + item(1, "a", expires = 300.0), + item(2, "a", expires = 100.0), + item(3, "a", expires = 200.0)))) + } + + /** ⚠️ A zero means "never" and must not be read as "expires at the epoch". */ + @Test + fun `a zero stamp does not expire the whole set`() { + assertEquals(500.0, Vault.setExpiry(listOf( + item(1, "a", expires = 0.0), item(2, "a", expires = 500.0)))) + } + + @Test + fun `a set of never-expiring items never expires`() { + assertEquals(0.0, Vault.setExpiry(listOf( + item(1, "a", expires = 0.0), item(2, "a", expires = 0.0)))) + } + + // -- Storage -------------------------------------------------------------- + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + /** ⚠️ The set survives a reload, or a relaunch scatters every sequence. */ + @Test + fun `the set survives the vault round trip`() { + val vault = Vault(MapStorage(), + javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + val now = System.currentTimeMillis() / 1000.0 + vault.saveStories(listOf(item(1, "a", i = 1, n = 4, expires = now + 600)), now) + val back = vault.loadStories().single() + assertEquals("a", back.set?.id) + assertEquals(1, back.set?.i) + assertEquals(4, back.set?.n) + } +} -- 2.54.0