From bee63e0ef7b7b8652a8a2132c847ab316f23cd86 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 3 Sep 2026 19:13:56 -0400 Subject: [PATCH] Keep your own stories in a private archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reinstates ios#54, taken by hand rather than cherry-picked. The original commit was cut from a tree where story mute, text stories and story replies all looked different from the versions rebuilt on this branch today, so applying it whole would have left two implementations of each under different names โ€” no compile error, and exactly the kind of divergence that rots quietly. What came across is the archive and nothing else: the `archive()` rule, the store, the sweep, and the setting. ๐Ÿ”ด The rule refuses anything not the author's own, and it does so in the engine as well as at the caller. An archive holding somebody else's expired story breaks the promise the app made to them, and "it is private to you" does not repair that. โš ๏ธ The sweep runs at the one moment it can. Expired stories leave on write, so if they are not caught before `saveStories` they are gone โ€” which is correct for everybody else's and wrong for your own. `archiveEnabled` is injected rather than read from a context, because this layer has no Android dependency and an unset value has to read as OFF. This is a feature that keeps something the interface said would disappear, so it stays off until somebody asks for it. Vault story serialisation is now shared between the live set and the archive rather than written twice. --- .../flamenet/messenger/app/MediaSettings.kt | 12 +++ .../io/flamenet/messenger/app/Session.kt | 28 +++++- .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 52 ++++++++++- .../io/flamenet/e2e/StoryArchiveTest.kt | 93 +++++++++++++++++++ 4 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt index e1f066f..9a338a7 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt @@ -49,6 +49,18 @@ class MediaSettings(context: Context) { /** Seconds, as [io.flamenet.client.Conversations.postStory] wants it. */ val storyLifetimeSeconds: Double get() = storyLifetimeHours * 60.0 * 60.0 + /** + * Keep your own stories after they expire, in a private archive (ios#54). + * + * ๐Ÿ”ด **Off unless switched on, and an absent value reads as false.** This + * keeps something the interface told everybody would disappear, so it + * cannot be the quiet default โ€” and it only ever holds stories this + * account posted. + */ + var storyArchiveEnabled: Boolean + get() = prefs.getBoolean("stories.archiveEnabled", false) + set(value) { prefs.edit().putBoolean("stories.archiveEnabled", value).apply() } + /** * Refuse to send unless the relay can prove the recipient's keys appear in * the log it publishes. diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 93c3c51..c8105bc 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -1064,6 +1064,21 @@ class Session( if (forStory.isEmpty()) storyReactions.remove(storyKey) } + /** + * Whether the private story archive is switched on (ios#54). + * + * โš ๏ธ Injected rather than read from a context here: this layer has no + * Android dependency, and an unset value must read as OFF โ€” the archive + * keeps something the interface said would disappear. + */ + var archiveEnabled: () -> Boolean = { false } + + /** Stories this account posted, kept past their expiry (ios#54). */ + fun storyArchive(): List = vault.storyArchive() + + /** Empty the archive (ios#54). */ + fun clearStoryArchive() = vault.saveStoryArchive(emptyList()) + fun reactionsFor(storyKey: String): Map = storyReactions[storyKey].orEmpty() fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId) @@ -1119,7 +1134,18 @@ class Session( /** Everything still live, newest first. */ fun stories(): List { val now = System.currentTimeMillis() / 1000.0 - val live = vault.loadStories() + val all = vault.loadStories() + // ios#54 โ€” anything of YOUR OWN that has expired is archived here, + // before `saveStories` drops it. This is the only moment it can be + // caught: expired stories leave on write, so nothing downstream sees + // them again. + if (archiveEnabled()) { + val expiredMine = all.filter { it.mine && !it.isActive(now) } + if (expiredMine.isNotEmpty()) { + vault.saveStoryArchive(Vault.archive(vault.storyArchive(), expiredMine)) + } + } + val live = all .filter { it.isActive(now) } // One place, so every screen agrees about whose stories are hidden. .filter { it.mine || !storiesMuted(it.authorId) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 791e698..6265591 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -83,6 +83,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val CALL_LOG = "call-log" private const val STORY_AUDIENCE = "story-audience" private const val STORY_MUTES = "story-mutes" + private const val STORY_ARCHIVE = "story-archive" private const val DISAPPEAR = "disappear" private const val SNOOZE = "snooze" private const val REPLY_REMINDER = "reply-reminder" @@ -102,6 +103,28 @@ 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. */ + /** + * Fold newly-expired stories into the archive (ios#54). + * + * ๐Ÿ”ด **Refuses anything not the author's own.** The caller filters as + * well, and this is the guard that survives the caller being + * rewritten โ€” an archive holding somebody else's expired story breaks + * the promise the app made to them, and "it is private to you" does + * not repair that. + * + * โš ๏ธ De-duplicated by id, because the sweep runs on every load and a + * story that expired weeks ago would otherwise be appended each time. + * Newest first, so the screen needs no sort. + */ + fun archive(existing: List, adding: List): List { + val seen = existing.map { it.id }.toMutableSet() + val result = existing.toMutableList() + adding.forEach { story -> + if (story.mine && seen.add(story.id)) result += story + } + return result.sortedByDescending { it.postedAt } + } + /** * Drop muted authors' stories from the strip (ios#33). * @@ -680,8 +703,11 @@ class Vault(private val storage: Storage, private val key: SecretKey) { if (ref == null) storage.delete(MY_AVATAR) else writeJson(MY_AVATAR, ref.toJson()) } - fun loadStories(): List { - val array = readJson(STORIES)?.optJSONArray("items") ?: return emptyList() + fun loadStories(): List = storiesFrom(readJson(STORIES)) + + /** Shared by the live set and the archive (ios#54). */ + private fun storiesFrom(record: JSONObject?): List { + val array = record?.optJSONArray("items") ?: return emptyList() return (0 until array.length()).mapNotNull { i -> val o = array.getJSONObject(i) // โš ๏ธ `optJSONObject`, not `getJSONObject`. A text story has no ref, @@ -718,9 +744,14 @@ class Vault(private val storage: Storage, private val key: SecretKey) { * is the opposite of what an expiring story promises. */ fun saveStories(stories: List, nowSeconds: Double) { - val live = stories.filter { it.isActive(nowSeconds) } + writeJson(STORIES, JSONObject() + .put("items", storiesToJson(stories.filter { it.isActive(nowSeconds) }))) + } + + /** Shared by the live set and the archive (ios#54). */ + private fun storiesToJson(stories: List): org.json.JSONArray { val array = org.json.JSONArray() - live.forEach { story -> + stories.forEach { story -> val row = JSONObject() .put("id", story.id) .put("author", story.authorId) @@ -735,9 +766,20 @@ class Vault(private val storage: Storage, private val key: SecretKey) { story.ref?.let { row.put("ref", it.toJson()) } array.put(row) } - writeJson(STORIES, JSONObject().put("items", array)) + return array } + /** + * Stories this account posted, kept past their expiry (ios#54). + * + * ๐Ÿ”ด Only ever your own, and only when the setting is on. This keeps + * something the interface told everybody would disappear. + */ + fun storyArchive(): List = storiesFrom(readJson(STORY_ARCHIVE)) + + fun saveStoryArchive(stories: List) = + writeJson(STORY_ARCHIVE, JSONObject().put("items", storiesToJson(stories))) + // -- Key-log pin (ยง19.3) ------------------------------------------------- /** diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt new file mode 100644 index 0000000..9084c8f --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt @@ -0,0 +1,93 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A private archive of your own stories (ios#54). + * + * ๐Ÿ”ด **This keeps something the interface said would disappear.** So it is + * opt-in, and it only ever holds stories this account posted โ€” an archive of + * other people's expiring stories is the exact opposite of what they were + * promised, and "it is private to you" does not repair that. + */ +class StoryArchiveTest { + + private fun story(id: String, mine: Boolean, at: Long) = Vault.Story( + id = id, authorId = if (mine) 1L else 7L, mine = mine, + ref = 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 = 1.0, + ), + caption = "c$id", postedAt = at, + ) + + @Test + fun `your own expired story is kept`() { + assertEquals(listOf("1"), + Vault.archive(emptyList(), listOf(story("1", true, 100))).map { it.id }) + } + + /** ๐Ÿ”ด The rule the whole feature turns on. */ + @Test + fun `somebody elses expired story is never kept`() { + assertTrue(Vault.archive(emptyList(), listOf(story("2", false, 100))).isEmpty()) + } + + @Test + fun `a mixed batch keeps only your own`() { + val result = Vault.archive(emptyList(), listOf( + story("1", true, 100), story("2", false, 200), story("3", true, 300))) + assertEquals(setOf("1", "3"), result.map { it.id }.toSet()) + } + + /** โš ๏ธ The sweep runs on every load; without this, one story piles up. */ + @Test + fun `archiving is idempotent`() { + val one = story("1", true, 100) + val first = Vault.archive(emptyList(), listOf(one)) + assertEquals(1, Vault.archive(first, listOf(one)).size) + } + + /** โš ๏ธ Newest first, so the screen needs no sort. */ + @Test + fun `the archive is newest first`() { + val result = Vault.archive(emptyList(), listOf( + story("1", true, 100), story("3", true, 300), story("2", true, 200))) + assertEquals(listOf("3", "2", "1"), result.map { it.id }) + } + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + 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() + + /** โš ๏ธ Kept in the vault, like everything else about somebody's stories. */ + @Test + fun `the archive round-trips through the vault`() { + val vault = Vault(MapStorage(), key()) + assertTrue(vault.storyArchive().isEmpty()) + vault.saveStoryArchive(listOf(story("1", true, 100))) + assertEquals(listOf("1"), vault.storyArchive().map { it.id }) + } + + /** + * ๐Ÿ”ด The archive must NOT be swept by the expiry rule the live set uses โ€” + * everything in it is expired by definition, so reusing `saveStories` + * would empty it on the next write. + */ + @Test + fun `an expired story survives in the archive`() { + val vault = Vault(MapStorage(), key()) + vault.saveStoryArchive(listOf(story("1", true, 100))) + assertEquals(1, vault.storyArchive().size) + } +}