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