From 242391119b65ac4fa962bbe29649c83e90e09724 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 2 Sep 2026 10:15:52 -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 Android half of ios#54. Expired stories were gone from the author's device too, so there was no way to look back at what you posted. ๐Ÿ”ด This keeps something the interface told everybody would disappear, so the shape of the feature is the argument for it being acceptable: off unless switched on, labelled as what it is, and only ever holding stories this account posted. `Vault.archive` refuses anything else โ€” the caller filters too, and this is the guard that survives the caller being rewritten. ๐Ÿ”ด The archive is deliberately NOT written through `saveStories`. Everything in it is expired by definition, and that path drops expired stories on write โ€” so reusing it would empty the archive on the next pass. Separate record, and a test named for exactly that. Archiving happens in `stories()`, before `saveStories` drops the expired ones: the only moment they can be caught, since expired bytes leave on write. That runs on every load, so the fold is idempotent. โš ๏ธ The setting is injected into `Session` as a lambda. The engine layer has no Android dependency and must not read preferences, and an absent value has to read as OFF. Co-Authored-By: Claude Opus 4.8 --- PARITY.md | 20 ++++ .../io/flamenet/messenger/app/ChatScreens.kt | 64 +++++++++++++ .../flamenet/messenger/app/MediaSettings.kt | 12 +++ .../messenger/app/MessengerViewModel.kt | 46 ++++++++- .../io/flamenet/messenger/app/Session.kt | 28 +++++- .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 51 +++++++++- .../io/flamenet/e2e/StoryArchiveTest.kt | 93 +++++++++++++++++++ 7 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt diff --git a/PARITY.md b/PARITY.md index fe4ac5d..5fc0b80 100644 --- a/PARITY.md +++ b/PARITY.md @@ -671,3 +671,23 @@ dropped โ€” on exactly the path a story reply normally takes. โš ๏ธ **The disclosure comes before the first reply**, on both platforms. Replying tells the author you watched, which stories otherwise never reveal. Neither platform sends anything automatic when a story is merely opened. +||||||| parent of 4c004eb (Keep your own stories in a private archive) +## Private story archive (ios#54) +Your own stories, kept past their expiry. Local only; no protocol change. +๐Ÿ”ด **This keeps something the interface told everybody would disappear**, so +the shape is the argument for it being acceptable: off unless switched on, +labelled as what it is rather than as a feature, and **only ever your own**. +`Vault.archive` / `E2EInbox.archive` refuse anything not the author's own โ€” the +callers filter too, but these are the guards that survive a caller being +rewritten. Both are tested from both directions. +โš ๏ธ **Archiving happens at the moment expiry is applied**, which is the only +moment it can. iOS catches it on restore (a story past its expiry is never +loaded into `stories`); Android catches it in `Session.stories()` before +`saveStories` drops it. Both run repeatedly, so the fold is idempotent โ€” +otherwise one long-expired story would be appended on every pass. +๐Ÿ”ด **The archive is not swept by the expiry rule.** Everything in it is expired +by definition, so reusing `saveStories` would empty it on the next write. +Android has a separate record and a test named for exactly that. +โš ๏ธ Android injects the setting into `Session` as a lambda rather than reading +preferences in the engine layer, which has no Android dependency. An absent +value reads as OFF on both platforms. diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index a059ccb..65a2d4e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -738,6 +738,9 @@ fun ColumnScope.AccountScreen( SectionBand() SettingsSection { StoryAudienceSection(model, phase) } + // ios#54 โ€” beside the audience setting, because both answer "what happens + // to my stories". + SettingsSection { StoryArchiveSection(model, phase) } SectionBand() SettingsSection { AppLockSection() } @@ -1110,6 +1113,67 @@ private fun storyLifetimeLabel(hours: Int): String = when { else -> (hours / 24).let { if (it == 1) "1 day" else "$it days" } } +/** + * Stories this account posted, kept past their expiry (ios#54). + * + * ๐Ÿ”ด **Labelled as what it is, not as a feature.** This holds copies of things + * the app told everybody would disappear. Somebody looking at it should be in + * no doubt that these are kept, that they are kept only here, and that nobody + * else's stories are or can be in it. + */ +@Composable +fun ColumnScope.StoryArchiveSection( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedIn, +) { + LaunchedEffect(Unit) { model.refreshStoryArchive() } + Text("Your story archive", style = MaterialTheme.typography.titleMedium) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Keep my stories", modifier = Modifier.weight(1f)) + androidx.compose.material3.Switch( + checked = phase.storyArchiveOn, + onCheckedChange = { model.setStoryArchiveEnabled(it) }, + modifier = Modifier.testTag("story-archive-toggle"), + ) + } + Text( + "Your stories disappear for everyone, including you. With this on, a copy of " + + "the ones YOU posted stays on this device after they expire. It never " + + "keeps anybody else's โ€” theirs disappear as they were told they would.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + if (phase.storyArchive.isEmpty()) { + Text( + if (phase.storyArchiveOn) { + "Nothing kept yet. Stories you post from now on appear here once they expire." + } else { + "Nothing kept. Turn the switch on to start keeping your own." + }, + fontSize = 12.sp, + color = Palette.SecondaryText, + modifier = Modifier.padding(top = Space.sm), + ) + } else { + phase.storyArchive.forEach { kept -> + Text( + kept.caption.ifBlank { "No caption" }, + modifier = Modifier.padding(top = Space.sm).testTag("kept-${kept.id}"), + ) + } + Spacer(Modifier.size(Space.sm)) + KitChip( + text = "Delete everything kept", + selected = false, + onClick = { model.clearStoryArchive() }, + tag = "clear-story-archive", + ) + } +} + @Composable private fun ColumnScope.StoryAudienceSection( model: MessengerViewModel, 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/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index f363cf1..50433f2 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -149,6 +149,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** Who may see this account's stories. */ val storyAudience: Session.StoryAudience = Session.StoryAudience(), + /** Stories this account posted, kept past expiry (ios#54). */ + val storyArchive: List = emptyList(), + /** Whether the archive is switched on (ios#54). */ + val storyArchiveOn: Boolean = false, val myAvatar: io.flamenet.e2e.Attachment.Ref? = null, /** Live stories, newest first (ยง6.6). */ val stories: List = emptyList(), @@ -360,7 +364,13 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { relay = relay, api = MessengerApi( credential.relayBaseUrl, credential.username, credential.appPassword), - ) + ).also { + // ios#54 โ€” injected, because the engine layer has no + // Android dependency and must not read preferences. + it.archiveEnabled = { + MediaSettings(getApplication()).storyArchiveEnabled + } + } val fetchedBuddies = runCatching { session!!.roster() } val fetchedGroups = runCatching { session!!.namedGroups() } stored to Phase.SignedIn( @@ -570,7 +580,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { vault = vault, relay = relay, api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential), - ) + ).also { + it.archiveEnabled = { MediaSettings(getApplication()).storyArchiveEnabled } + } // ๐Ÿ”ด **Our return address, before the first federated send.** A relay // that accepts a federated envelope records no sender, and our user id @@ -2017,6 +2029,36 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } } + /** + * Keep your own stories after they expire (ios#54). + * + * ๐Ÿ”ด Off unless switched on. 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. + */ + fun setStoryArchiveEnabled(on: Boolean) = viewModelScope.launch { + MediaSettings(getApplication()).storyArchiveEnabled = on + update { it.copy(storyArchiveOn = on) } + refreshStoryArchive() + } + + fun refreshStoryArchive() = viewModelScope.launch { + val current = session ?: return@launch + val on = MediaSettings(getApplication()).storyArchiveEnabled + update { it.copy(storyArchiveOn = on) } + val kept = withContext(Dispatchers.IO) { + runCatching { current.storyArchive() }.getOrNull() + } ?: return@launch + update { it.copy(storyArchive = kept) } + } + + /** Empty the archive (ios#54). */ + fun clearStoryArchive() = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { runCatching { current.clearStoryArchive() } } + update { it.copy(storyArchive = emptyList()) } + } + /** Change who may see this account's stories. */ fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch { val current = session ?: return@launch 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 c3950f6..add41b5 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -887,7 +887,18 @@ class Session( /** Everything still live, newest first. */ fun stories(): List { val now = System.currentTimeMillis() / 1000.0 - val live = vault.loadStories().filter { it.isActive(now) } + val all = vault.loadStories() + val live = all.filter { it.isActive(now) } + // 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)) + } + } // Written back so expired bytes leave the device rather than lingering // until the next post. // @@ -1105,6 +1116,21 @@ class Session( return reached } + /** + * Whether the private story archive is switched on (ios#54). + * + * โš ๏ธ Injected rather than read from a context here: the engine 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()) + /** * Members of a group we are in, or null if the relay does not list it for * us (audit H1). Cached for a short window: a poll pass can carry many diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index c39133e..daf846a 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -83,6 +83,8 @@ 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 MUTED_STORIES = "muted-stories" + + private const val STORY_ARCHIVE = "story-archive" private const val DISAPPEAR = "disappear" private const val SNOOZE = "snooze" private const val REPLY_REMINDER = "reply-reminder" @@ -152,6 +154,28 @@ class Vault(private val storage: Storage, private val key: SecretKey) { */ fun isSaveable(mine: Boolean, hasBlob: Boolean): Boolean = mine && hasBlob + /** + * 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 } + } + fun threadForUser(userId: Long) = "user:$userId" fun threadForGroup(groupId: String) = "group:$groupId" @@ -705,8 +729,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) // ยง6.21 โ€” a text story has no `ref`. Absent is legitimate now, so @@ -741,8 +768,13 @@ class Vault(private val storage: Storage, private val key: SecretKey) { */ fun saveStories(stories: List, nowSeconds: Double) { val live = stories.filter { it.isActive(nowSeconds) } + writeJson(STORIES, JSONObject().put("items", storiesToJson(live))) + } + + /** 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 -> array.put(JSONObject() .put("id", story.id) .put("author", story.authorId) @@ -756,7 +788,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) { .put("at", story.postedAt) .put("seen", story.seen)) } - writeJson(STORIES, JSONObject().put("items", array)) + return array } // -- Key-log pin (ยง19.3) ------------------------------------------------- @@ -1198,6 +1230,17 @@ class Vault(private val storage: Storage, private val key: SecretKey) { * โš ๏ธ In the vault. An audience list is a statement about who somebody * trusts, which is exactly the metadata this app exists to keep private. */ + /** + * 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))) + fun storyAudience(): JSONObject? = readJson(STORY_AUDIENCE) fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience) 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) + } +}