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-02 17:44:21 -04:00
co-authored by Claude Opus 4.8
parent bf728727b0
commit 83d7803f9e
6 changed files with 166 additions and 2 deletions
@@ -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))
}
}