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