From 83d7803f9eea1c71be2167e8b6af54e1b8410449 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 2 Sep 2026 09:59:51 -0400 Subject: [PATCH] Save a story you posted yourself, before it expires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android half of ios#34. A story disappears from its author's own device too, so there was no way to keep something you made. ๐Ÿ”ด Only ever your own, and the rule is `Vault.isSaveable` rather than a condition on the menu item. Saving somebody else's story keeps a copy of something they were told would disappear โ€” the opposite of what the feature promises them โ€” and a guard living in a composable is one refactor away from being lost. Tested from both directions. โš ๏ธ Written through MediaStore, so no storage permission is asked for. An app inserting its own entry needs no runtime grant on any version this app supports; asking for WRITE_EXTERNAL_STORAGE in order to save one picture would request read access to everything the person has, to perform a write. โš ๏ธ The message says the copy has left the disappearing model and names where it went. "Saved" alone would be true and useless โ€” somebody not told that may reasonably assume the expiry still covers it. Co-Authored-By: Claude Opus 4.8 --- PARITY.md | 17 +++++++ .../io/flamenet/messenger/app/ChatScreens.kt | 13 ++++- .../messenger/app/MessengerViewModel.kt | 32 ++++++++++++ .../io/flamenet/messenger/app/StorySave.kt | 49 +++++++++++++++++++ .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 15 ++++++ .../kotlin/io/flamenet/e2e/StorySaveTest.kt | 42 ++++++++++++++++ 6 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt diff --git a/PARITY.md b/PARITY.md index 8479045..3835f58 100644 --- a/PARITY.md +++ b/PARITY.md @@ -638,3 +638,20 @@ empty object; a reload would otherwise resurrect a blob that fails to fetch. โš ๏ธ A text story has no blob, so nothing dies at the server's retention purge. Client-enforced expiry is the whole mechanism rather than a backstop to one, and both composers say so instead of implying more. +||||||| parent of 91c5181 (Save a story you posted yourself, before it expires) +## Saving your own story (ios#34) +Both platforms can write a story you posted yourself out to the device gallery +before it expires. Local only; no protocol change. +๐Ÿ”ด **Only ever your own, and the rule lives in the engine** โ€” `Vault.isSaveable` +on Android, `E2EInbox.isSaveable` on iOS โ€” rather than in the button. Saving +somebody else's story keeps a copy of something they were told would disappear, +and a guard living in a view is one refactor away from being lost. Both are +tested from both directions. +โš ๏ธ **Neither asks for broad storage access.** Android writes through +`MediaStore`, which needs no runtime grant; iOS asks for `.addOnly` +authorisation. Requesting read access in order to perform a write would be a +much larger ask than the feature. +โš ๏ธ **iOS needs `NSPhotoLibraryAddUsageDescription`** โ€” without it the app is +terminated on first save rather than warned. Android has no equivalent trap. +โš ๏ธ Both messages say the saved copy has left the disappearing model, and where +it went. "Saved" alone would let somebody assume the expiry still covered it. 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 96d4535..2f03c0b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -2502,8 +2502,17 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor verticalAlignment = Alignment.CenterVertically, ) { Text(remaining, style = MaterialTheme.typography.bodySmall) - KitChip(text = "Close", selected = false, - onClick = model::closeStory, tag = "close-story") + Row { + // ios#34 โ€” only on your own, and the rule is + // `Vault.isSaveable`, not this condition. + if (Vault.isSaveable(story.mine, story.ref != null)) { + KitChip(text = "Save", selected = false, + onClick = { model.saveStory(story) }, tag = "save-story") + Spacer(Modifier.size(Space.sm)) + } + KitChip(text = "Close", selected = false, + onClick = model::closeStory, tag = "close-story") + } } } } 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 2b7324a..0ec7fb3 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -1963,6 +1963,38 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { }.onFailure { f -> update { it.copy(busy = false, error = describe(f)) } } } + /** + * Save a story you posted yourself, before it expires (ios#34). + * + * ๐Ÿ”ด The rule is `Vault.isSaveable`, checked here rather than only where + * the menu item is drawn. Saving somebody else's story keeps a copy of + * something they were told would disappear. + * + * โš ๏ธ The message says the copy has LEFT the disappearing model and where + * it went. "Saved" alone would be true and useless โ€” somebody not told + * that may reasonably assume the expiry still covers it. + */ + fun saveStory(story: Vault.Story) = viewModelScope.launch { + val current = session ?: return@launch + val ref = story.ref + if (!Vault.isSaveable(story.mine, ref != null) || ref == null) return@launch + val saved = withContext(Dispatchers.IO) { + runCatching { + val bytes = current.attachmentBytes(ref, getApplication().cacheDir) + StorySave.save(getApplication(), bytes, ref.mime, ref.id) + }.getOrNull() + } + update { + val message = if (saved != null) { + "Saved to your gallery as $saved. That copy is yours to keep โ€” " + + "it will not disappear when the story does." + } else { + "Could not save that story." + } + it.copy(error = message) + } + } + /** 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/StorySave.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt new file mode 100644 index 0000000..200df9b --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt @@ -0,0 +1,49 @@ +package io.flamenet.messenger.app + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore + +/** + * Writing one of your own stories out to the device gallery (ios#34). + * + * โš ๏ธ **MediaStore, so no storage permission is asked for.** An app writing its + * own entry through `MediaStore` needs no runtime grant on any version this app + * supports. Asking for `WRITE_EXTERNAL_STORAGE` in order to save one picture + * would request read access to everything the person has, to perform a write โ€” + * a much larger ask than the feature. + */ +object StorySave { + + /** + * Save bytes to the gallery under Flamenet's own album. + * + * Returns the display name written, or null if the insert failed. + */ + fun save(context: Context, bytes: ByteArray, mime: String, id: String): String? { + val isVideo = mime.startsWith("video/") + val extension = if (isVideo) "mp4" else "jpg" + val name = "flamenet-story-$id.$extension" + val collection = if (isVideo) { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, name) + put(MediaStore.MediaColumns.MIME_TYPE, mime) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val root = if (isVideo) Environment.DIRECTORY_MOVIES + else Environment.DIRECTORY_PICTURES + put(MediaStore.MediaColumns.RELATIVE_PATH, "$root/Flamenet") + } + } + val uri = context.contentResolver.insert(collection, values) ?: return null + return runCatching { + context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } + name + }.getOrNull() + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 23c6e3c..c39133e 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -137,6 +137,21 @@ class Vault(private val storage: Storage, private val key: SecretKey) { } } + /** + * Whether this story may be written out of the app (ios#34). + * + * ๐Ÿ”ด **Only ever your own, and the rule lives here rather than in the + * button.** Saving somebody else's story keeps a copy of something + * they were told would disappear โ€” the opposite of what the feature + * promises them โ€” and no wording on a menu item makes that acceptable. + * A guard in a composable is one refactor away from being lost. + * + * โš ๏ธ A text story (ยง6.21) has no blob and nothing to write out. False + * rather than rendering the words into a picture: a story is what the + * author posted, and an image of it is a different object. + */ + fun isSaveable(mine: Boolean, hasBlob: Boolean): Boolean = mine && hasBlob + fun threadForUser(userId: Long) = "user:$userId" fun threadForGroup(groupId: String) = "group:$groupId" diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt new file mode 100644 index 0000000..993fd38 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt @@ -0,0 +1,42 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Saving your own story before it expires (ios#34). + * + * ๐Ÿ”ด **Only ever your own.** Saving somebody else's keeps a copy of something + * they were told would disappear, which is the opposite of what the feature + * promises them. The rule lives in the engine, not in the button, because a + * guard in a composable is one refactor away from being lost. + */ +class StorySaveTest { + + @Test + fun `your own story with a picture can be saved`() { + assertTrue(Vault.isSaveable(mine = true, hasBlob = true)) + } + + /** ๐Ÿ”ด The whole point: never somebody else's, whatever the UI offers. */ + @Test + fun `somebody elses story can never be saved`() { + assertFalse(Vault.isSaveable(mine = false, hasBlob = true)) + } + + /** + * โš ๏ธ A text story (ยง6.21) has no blob and nothing to write out. False + * rather than rendering the words into an image: a story is what the + * author posted, and a picture of it is a different object. + */ + @Test + fun `a text story of your own has nothing to save`() { + assertFalse(Vault.isSaveable(mine = true, hasBlob = false)) + } + + @Test + fun `somebody elses text story is also not saveable`() { + assertFalse(Vault.isSaveable(mine = false, hasBlob = false)) + } +}