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
@@ -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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 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
|
||||
|
||||
@@ -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