Post several pictures as one story sequence (§6.23)
Taken by hand rather than cherry-picked, for the same reason as the archive: the original was cut from a tree where text stories looked different from the version rebuilt on this branch, and applying it whole would have added a second `isTextStory`/`storyExpiry` beside the existing `textStoryExpires`. What came across is the sequence and nothing else — the `StorySet` wire shape, the field on a story, and the two rules that decide how a set behaves when it does not all arrive. 🔴 One envelope per item, sharing one set id. An array of attachments in a single envelope is the obvious encoding and degrades badly: a client predating §6.23 requires a single `att`, fails the guard, 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 correctly. 🔴 `setExpiry` takes the EARLIEST. Every item is required to carry the same `exp`, but a sender can write different ones — and if the latest won, the difference would be a way to keep part of a sequence alive after the rest had gone. A text story has no attachment and contributes nothing, so one in a set cannot drag the shared expiry to zero. ⚠️ `groupedStories` 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. 777 tests green across app, engine and client, up from 648.
This commit is contained in:
@@ -107,6 +107,9 @@ data class Content(
|
||||
* 🔴 An id and never a copy of the message.
|
||||
*/
|
||||
val pins: String? = null,
|
||||
|
||||
/** The sequence this story item belongs to (§6.23), if any. */
|
||||
val storySet: StorySet? = null,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -139,6 +142,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 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.
|
||||
*/
|
||||
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)
|
||||
@@ -433,6 +457,15 @@ 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,
|
||||
// 🔴 A file envelope ALWAYS carries a caption, even with none
|
||||
@@ -455,6 +488,7 @@ data class Content(
|
||||
groupName = groupName,
|
||||
forwarded = forwarded,
|
||||
lifetimeSeconds = lifetimeSeconds,
|
||||
storySet = storySet,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -572,6 +606,7 @@ data class Content(
|
||||
aboutAStory: Boolean = false,
|
||||
forwarded: Boolean = false,
|
||||
lifetimeSeconds: Int? = 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
|
||||
@@ -599,6 +634,8 @@ data class Content(
|
||||
attachment?.let { parts += "\"att\":${it.toJson()}" }
|
||||
link?.let { parts += "\"lnk\":${it.toJson()}" }
|
||||
streamUrl?.let { parts += "\"stream\":${quote(it)}" }
|
||||
// §6.23 — the sequence this item belongs to.
|
||||
storySet?.let { parts += "\"set\":${it.toJson()}" }
|
||||
// File envelopes only. Additive top-level keys: a build that
|
||||
// predates them ignores them and still reads the caption, which is
|
||||
// what keeps a file message legible to an older peer.
|
||||
@@ -719,6 +756,7 @@ data class Content(
|
||||
// Additive keys. An older peer omits them and every default here
|
||||
// is what that peer meant.
|
||||
val aboutAStory = json.optInt("st", 0) != 0 || json.optBoolean("st", false)
|
||||
val storySet = json.optJSONObject("set")?.let(StorySet::fromJson)
|
||||
val mentions = json.optJSONArray("men")?.let { array ->
|
||||
(0 until array.length()).map { array.optInt(it) }
|
||||
} ?: emptyList()
|
||||
@@ -824,6 +862,7 @@ data class Content(
|
||||
)
|
||||
return Content(caption, kind = kind, raw = json, attachment = ref,
|
||||
groupId = groupId, messageId = messageId, about = about,
|
||||
storySet = storySet,
|
||||
streamUrl = json.optString("stream").takeIf { it.isNotEmpty() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,41 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
private const val MAX_GROUP_NAME = 80
|
||||
|
||||
/** The two kinds of thread, named once so they cannot drift apart. */
|
||||
/**
|
||||
* Collapse a set to the expiry every item shares (§6.23).
|
||||
*
|
||||
* 🔴 **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 be a way to keep part of a sequence alive
|
||||
* after the rest had gone.
|
||||
*
|
||||
* ⚠️ A zero means "no expiry on that item" and is skipped, not read as
|
||||
* "expires at the epoch", which would kill the whole set. A text story
|
||||
* has no attachment at all and contributes nothing here.
|
||||
*/
|
||||
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 } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold newly-expired stories into the archive (ios#54).
|
||||
*
|
||||
@@ -485,6 +520,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
val seen: Boolean = false,
|
||||
/** When it stops showing, in unix seconds. 0 means it does not. */
|
||||
val expiresAt: Double? = null,
|
||||
/** Which sequence this story belongs to (§6.23), if any. */
|
||||
val set: Content.StorySet? = null,
|
||||
/**
|
||||
* A live stream this story embeds, if it is one (§6.13).
|
||||
*
|
||||
@@ -731,6 +768,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
postedAt = o.optLong("at"),
|
||||
seen = o.optBoolean("seen", false),
|
||||
expiresAt = if (o.has("expires")) o.optDouble("expires") else null,
|
||||
set = o.optJSONObject("set")?.let { Content.StorySet.fromJson(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -764,6 +802,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
// which is what we want, but being explicit is clearer than
|
||||
// relying on it.
|
||||
story.ref?.let { row.put("ref", it.toJson()) }
|
||||
story.set?.let { row.put("set", it.toJson()) }
|
||||
array.put(row)
|
||||
}
|
||||
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