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:
co-authored by
Claude Opus 4.8
parent
861b14fcde
commit
d525ef3d69
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user