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:
co-authored by
Claude Opus 4.8
parent
a727475447
commit
9ca2c352e7
@@ -2616,10 +2616,19 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(remaining, style = MaterialTheme.typography.bodySmall)
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user