Post several pictures as one story sequence

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 17:48:16 -04:00
co-authored by Claude Opus 4.8
parent 861b14fcde
commit d525ef3d69
4 changed files with 231 additions and 1 deletions
+19
View File
@@ -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 ⚠️ Android injects the setting into `Session` as a lambda rather than reading
preferences in the engine layer, which has no Android dependency. An absent preferences in the engine layer, which has no Android dependency. An absent
value reads as OFF on both platforms. 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.
@@ -114,6 +114,9 @@ data class Content(
* the trap §6.19 documents for pins, through a different door. * the trap §6.19 documents for pins, through a different door.
*/ */
val storyReplyId: String? = null, 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 * was said. The preview is the sender's copy of their own words, the only
* copy anyone can be sure of. * 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) { data class Reference(val messageId: String, val author: Long, val preview: String) {
fun toJson(): JSONObject = JSONObject() fun toJson(): JSONObject = JSONObject()
.put("mid", messageId).put("author", author).put("preview", preview) .put("mid", messageId).put("author", author).put("preview", preview)
@@ -459,6 +483,16 @@ data class Content(
groupName: String? = null, groupName: String? = null,
forwarded: Boolean = false, forwarded: Boolean = false,
lifetimeSeconds: Int? = null, 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( ): String = assemble(
kind = ref.kind, kind = ref.kind,
caption = caption?.takeIf { it.isNotEmpty() }, caption = caption?.takeIf { it.isNotEmpty() },
@@ -471,6 +505,8 @@ data class Content(
groupName = groupName, groupName = groupName,
forwarded = forwarded, forwarded = forwarded,
lifetimeSeconds = lifetimeSeconds, lifetimeSeconds = lifetimeSeconds,
storySet = storySet,
) )
/** /**
@@ -553,6 +589,8 @@ data class Content(
background: Int? = null, background: Int? = null,
storyReplyId: String? = null, storyReplyId: String? = null,
storySet: StorySet? = null,
): String { ): String {
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}") val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
// ⚠️ `1`, not `true` — §6.10's shape for every flag on this // ⚠️ `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)}" } streamUrl?.let { parts += "\"stream\":${quote(it)}" }
// §6.21 — a text story's background style. A hint, never required. // §6.21 — a text story's background style. A hint, never required.
background?.let { parts += "\"bg\":$it" } background?.let { parts += "\"bg\":$it" }
// §6.23 — the sequence this item belongs to.
storySet?.let { parts += "\"set\":${it.toJson()}" }
return parts.joinToString(",", prefix = "{", postfix = "}") return parts.joinToString(",", prefix = "{", postfix = "}")
} }
@@ -811,7 +852,8 @@ data class Content(
) )
return Content(caption, kind = kind, raw = json, attachment = ref, return Content(caption, kind = kind, raw = json, attachment = ref,
groupId = groupId, messageId = messageId, about = about, 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) })
} }
} }
} }
@@ -176,6 +176,40 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
return result.sortedByDescending { it.postedAt } 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<Story>): 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<Story>): List<List<Story>> {
val order = mutableListOf<String>()
val sets = LinkedHashMap<String, MutableList<Story>>()
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 threadForUser(userId: Long) = "user:$userId"
fun threadForGroup(groupId: String) = "group:$groupId" 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. * a story that is the whole audience, not one correspondent.
*/ */
val link: Content.LinkPreview? = null, 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. * 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), seen = o.optBoolean("seen", false),
textExpiry = o.optDouble("exp", 0.0).takeIf { it > 0.0 }, textExpiry = o.optDouble("exp", 0.0).takeIf { it > 0.0 },
background = if (o.has("bg")) o.optInt("bg") else null, 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) } .also { if (story.background != null) it.put("bg", story.background) }
.put("caption", story.caption) .put("caption", story.caption)
.put("at", story.postedAt) .put("at", story.postedAt)
.also { j -> story.set?.let { j.put("set", it.toJson()) } }
.put("seen", story.seen)) .put("seen", story.seen))
} }
return array return array
@@ -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<String, ByteArray>()
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)
}
}