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,8 +2616,17 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(remaining, style = MaterialTheme.typography.bodySmall)
|
Text(remaining, style = MaterialTheme.typography.bodySmall)
|
||||||
KitChip(text = "Close", selected = false,
|
Row {
|
||||||
onClick = model::closeStory, tag = "close-story")
|
// 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
|
* 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.
|
* 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. */
|
/** Change who may see this account's stories. */
|
||||||
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
|
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
|
||||||
val current = session ?: return@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 threadForUser(userId: Long) = "user:$userId"
|
||||||
fun threadForGroup(groupId: String) = "group:$groupId"
|
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