Save a story you posted yourself, before it expires

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-03 19:11:21 -04:00
co-authored by Claude Opus 4.8
parent a727475447
commit 9ca2c352e7
5 changed files with 149 additions and 2 deletions
@@ -2616,8 +2616,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")
}
}
}
}
@@ -1913,6 +1913,38 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* would need the relay to know who may read it, which is a list of your
* contacts handed to the server for a feature that works without it.
*/
/**
* 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<android.app.Application>().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
@@ -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()
}
}
@@ -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"
@@ -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))
}
}