Post a story that is only words, or a link

Android half of ios#32, spec §6.21. `att` becomes optional and the
caption becomes the story.

🔴 The decode sits before the attachment branch, and that placement is
the feature. The branch requires `att` for a story; a text story failing
it falls through to the ordinary-message path and shows as a chat bubble
in the conversation that never expires — both of the things §6.6 says a
story must not be. There is a test named for it.

`Vault.Story.ref` is nullable rather than carrying a sentinel, so nothing
can read a blob id off a story that has none. That forced a `key` for
seen-marks and dedup, and an `expiresAt` that folds the two cases so no
caller has to know which kind it is holding.

⚠️ The stored record omits `ref` entirely rather than writing an empty
object — a reload would otherwise resurrect a blob that fails to fetch,
and `loadStories` had to stop treating an absent ref as malformed.

A "Write" entry on the Stories tab posts one, and says who enforces the
expiry: there is no photo to delete, so it relies on the receiving app
honouring `exp`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 17:43:43 -04:00
co-authored by Claude Opus 4.8
parent 021dc2c24d
commit 7c51bc2521
8 changed files with 392 additions and 13 deletions
@@ -0,0 +1,115 @@
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
/** Stories that are only words, and the links in them (spec §6.21, ios#32). */
class TextStoryTest {
@Test
fun `a text story carries its caption and expiry`() {
val content = Content.parse(Content.textStory("back Thursday", expires = 1788000000.0))
assertTrue(content.isTextStory)
assertEquals("back Thursday", content.text)
assertEquals(1788000000.0, content.storyExpiry)
}
/** ⚠️ No `att`: an empty attachment would be fetched, fail, and show broken. */
@Test
fun `a text story carries no attachment`() {
val json = JSONObject(Content.textStory("hello", expires = 1.0))
assertFalse(json.has("att"))
assertEquals(Attachment.STORY, json.getString("kind"))
}
/**
* 🔴 The decode must run BEFORE the attachment branch. If it does not, a
* story with no `att` falls through to the ordinary-message path and shows
* as a chat bubble that never expires — both things §6.6 forbids.
*/
@Test
fun `a text story is never an ordinary message`() {
val content = Content.parse("""{"fnc":1,"kind":"story","caption":"hi","exp":99}""")
assertTrue(content.isTextStory)
assertNull(content.attachment)
}
/** A story with neither blob nor words is nothing, and is not a story. */
@Test
fun `a story with no attachment and no caption is not a text story`() {
assertFalse(Content.parse("""{"fnc":1,"kind":"story","caption":"","exp":9}""").isTextStory)
}
/** An unknown background falls back rather than refusing the story. */
@Test
fun `an unknown background still yields a story`() {
val content = Content.parse(
"""{"fnc":1,"kind":"story","caption":"hi","exp":99,"bg":9999}""")
assertTrue(content.isTextStory)
assertEquals(9999, content.storyBackground)
}
@Test
fun `the marker stays first`() {
assertTrue(Content.textStory("hi", expires = 1.0).startsWith("{\"fnc\":1"))
}
// -- The model and its 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()
}
private fun key() = javax.crypto.KeyGenerator.getInstance("AES")
.apply { init(256) }.generateKey()
private fun textStory(expires: Double) = Vault.Story(
id = "s1", authorId = 7, mine = false, ref = null, caption = "hi",
postedAt = 100, textExpiry = expires,
)
@Test
fun `a text story expires on its top-level stamp`() {
val now = System.currentTimeMillis() / 1000.0
assertFalse(textStory(now - 60).isActive(now))
assertTrue(textStory(now + 60).isActive(now))
}
@Test
fun `a text story is marked as having no blob`() {
assertTrue(textStory(0.0).isTextOnly)
}
/**
* 🔴 The stored record must omit `ref` entirely rather than write an empty
* object — a reload would otherwise resurrect a blob that fails to fetch.
*/
@Test
fun `a text story round-trips through the vault without a ref`() {
val vault = Vault(MapStorage(), key())
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(listOf(textStory(now + 600)), now)
val back = vault.loadStories()
assertEquals(1, back.size)
assertNull(back[0].ref)
assertEquals("hi", back[0].caption)
assertTrue(back[0].isTextOnly)
}
/** ⚠️ An expired text story must leave on write, like any other. */
@Test
fun `an expired text story is not written back`() {
val vault = Vault(MapStorage(), key())
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(listOf(textStory(now - 10)), now)
assertTrue(vault.loadStories().isEmpty())
}
}