From acee4597d57015206d2b13c81b97799422e741a5 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 14:58:39 -0400 Subject: [PATCH] Android: stories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An image sealed and uploaded ONCE, then one ordinary story envelope down each contact's existing ratchet. Uploading per recipient would multiply the bytes by the size of the contact list for nothing, and would hand the relay a set of distinct blobs it could correlate by size and timing. ⚠️ **There is no story feed on the relay, and there should not be.** A server-side story needs the relay to know who may read it — which means giving it your contact list for a feature that works without one. The cost is that posting is a fan-out and partial delivery is normal: a contact with no registered device cannot receive anything, story or not, so the count is reported rather than the post failing. ⚠️ **A story bypasses the thread entirely.** It belongs to the strip, and filing it as a message would drop every story somebody posted into a conversation they did not have. One that arrives already expired — posted while this device was offline — is dropped rather than shown late. ⚠️ **Expired stories are deleted on WRITE, not filtered on read.** Filtering on read leaves a store that grows for ever and still holds every image anybody posted, which is the opposite of what an expiring story promises. The bytes actually leave the device. 🔴 **Expiry is a promise, not a guarantee, and the empty-state copy says so.** This client deletes the bytes when the clock runs out because it chooses to. Nothing stops a recipient's client keeping them. A UI implying otherwise would tell people a story is safe to post when it is not, which is worse than not having the feature. Unseen is marked rather than styled away — a strip where read and unread look alike is a strip nobody returns to. Stories are read back on every poll rather than only when a thread was touched, because a story files itself outside the thread map and would otherwise not appear until something else arrived. Co-Authored-By: Claude Opus 5 --- PARITY.md | 2 +- .../flamenet/messenger/app/CrudSurfaceTest.kt | 5 + .../io/flamenet/messenger/app/ChatScreens.kt | 120 ++++++++++++++++++ .../io/flamenet/messenger/app/MainActivity.kt | 1 + .../messenger/app/MessengerViewModel.kt | 65 ++++++++++ .../io/flamenet/messenger/app/Session.kt | 80 ++++++++++++ .../io/flamenet/client/Conversations.kt | 44 +++++++ .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 66 ++++++++++ 8 files changed, 382 insertions(+), 1 deletion(-) diff --git a/PARITY.md b/PARITY.md index 40e52d5..3d4b1f8 100644 --- a/PARITY.md +++ b/PARITY.md @@ -96,7 +96,7 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | Reactions | 🟡 codec done, no UI | | Photos | ✅ send via the system picker, render inline | | Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send | -| Stories, and seen-state | ⚪ | +| Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted | | Groups: create, list, members, leave | ⚪ | | Group text and images | ⚪ | | Delivery keys + rotation | ✅ published as a digest, rotatable from Account | diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt index a5f7c5b..7f0939d 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt @@ -100,6 +100,11 @@ class CrudSurfaceTest { if (!relayReachable()) return signUp() + // Stories are reachable from the contact list, and say what they are + // when there are none — an empty strip with no explanation is a + // feature nobody discovers. + compose.onNodeWithTag("post-story").assertExists() + compose.onNodeWithTag("nav-alerts").performClick() awaitTag("mark-all-read", 20_000) compose.onNodeWithTag("mark-all-read").assertExists() 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 832f6e5..ced4f46 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -46,6 +46,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.launch +import androidx.compose.foundation.lazy.LazyRow +import io.flamenet.e2e.Vault import androidx.compose.ui.platform.testTag import io.flamenet.client.IdentityTrust import io.flamenet.e2e.SafetyNumber @@ -925,3 +927,121 @@ private fun VoiceBubble(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerVie ) } } + +/** + * The story strip (§6.6). + * + * ⚠️ **Stories are sent to contacts, not published.** There is no feed on the + * relay and there should not be: a server-side story would need the relay to + * know who is allowed to read it, which means handing it your contact list for + * a feature that works without one. Each story goes down an existing ratchet, + * one per person. + * + * ⚠️ **Expiry is a promise, not a guarantee, and the copy says so.** This + * client deletes the bytes when the clock runs out because it chooses to. + * Nothing stops a recipient's client keeping them, and a UI implying otherwise + * would be telling people a story is safe to post when it is not. + */ +@Composable +fun ColumnScope.StoryStrip( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedIn, +) { + val context = LocalContext.current + // The system picker again: the app is handed the one file the person chose + // and never asks for storage permission. + val pickStory = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + uri ?: return@rememberLauncherForActivityResult + val bytes = runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() ?: return@rememberLauncherForActivityResult + val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } + android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + model.postStory( + bytes, + context.contentResolver.getType(uri) ?: "image/jpeg", + bounds.outWidth, + bounds.outHeight, + caption = null, + ) + } + + LaunchedEffect(Unit) { model.refreshStories() } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Stories", style = MaterialTheme.typography.titleMedium) + TextButton( + onClick = { pickStory.launch("image/*") }, + modifier = Modifier.testTag("post-story"), + ) { Text("Post") } + } + + if (phase.stories.isEmpty()) { + Text( + "Nothing here. A story goes to your contacts and disappears after a day — on your " + + "device and theirs, because their app deletes it too. It is not a secret you " + + "can take back.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag("stories-empty"), + ) + return + } + + LazyRow( + modifier = Modifier.fillMaxWidth().testTag("stories"), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(phase.stories, key = { it.id }) { story -> + val label = phase.buddies.firstOrNull { it.userId == story.authorId } + ?.let { it.displayName.ifBlank { it.handle } } + ?: if (story.mine) "You" else "#${story.authorId}" + TextButton( + onClick = { model.openStory(story) }, + modifier = Modifier.testTag("story-${story.id}"), + ) { + // Unseen is marked rather than styled away: a strip where read + // and unread look alike is a strip nobody comes back to. + Text(if (story.seen) label else "• $label") + } + } + } + + phase.openStory?.let { story -> StoryViewer(model, story) } +} + +/** One story, full width, with what it says about its own lifetime. */ +@Composable +private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) { + val remaining = remember(story.id) { + val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0 + when { + seconds <= 0 -> "expired" + seconds < 3600 -> "${(seconds / 60).toInt()}m left" + else -> "${(seconds / 3600).toInt()}h left" + } + } + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("story-viewer"), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + AttachedImage(story.ref, model) + if (story.caption.isNotBlank()) Text(story.caption) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(remaining, style = MaterialTheme.typography.bodySmall) + TextButton( + onClick = model::closeStory, + modifier = Modifier.testTag("close-story"), + ) { Text("Close") } + } + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index e2e206a..b32f0d7 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -310,6 +310,7 @@ private fun ColumnScope.SignedIn( modifier = Modifier.testTag("device-id")) PresencePicker(model, phase.presence) + StoryStrip(model, phase) BuddyList(model, phase) GroupsSection(model, phase) } 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 a87765c..ffe8baa 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -43,6 +43,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val groups: List = emptyList(), val blocked: Set = emptySet(), val notifications: List = emptyList(), + /** Live stories, newest first (§6.6). */ + val stories: List = emptyList(), + /** The story being viewed full-screen, if any. */ + val openStory: Vault.Story? = null, val presence: String = "online", /** Which screen is showing. One field, so two cannot be open at once. */ val screen: Screen = Screen.CONTACTS, @@ -677,6 +681,55 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } } + // -- Stories (§6.6) ------------------------------------------------------ + + fun refreshStories() = viewModelScope.launch { + val current = session ?: return@launch + val live = withContext(Dispatchers.IO) { runCatching { current.stories() }.getOrDefault(emptyList()) } + update { it.copy(stories = live) } + } + + /** + * Post a story to every contact. + * + * ⚠️ **To contacts, one by one, down their existing ratchets** — there is + * no broadcast channel and there should not be. A relay-side story feed + * 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. + */ + fun postStory(bytes: ByteArray, mime: String, width: Int, height: Int, caption: String?) = + viewModelScope.launch { + val current = session ?: return@launch + val recipients = signedIn()?.buddies?.map { it.userId }.orEmpty() + if (recipients.isEmpty()) { + update { it.copy(error = "Add a contact before posting a story.") } + return@launch + } + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { + runCatching { current.postStory(bytes, mime, width, height, caption, recipients) } + } + done.onSuccess { posted -> + update { + it.copy( + busy = false, + stories = current.stories(), + // Partial delivery stated rather than hidden: a contact + // with no registered device cannot receive anything. + error = if (posted.missed == 0) null + else "Posted to ${posted.reached} of ${posted.reached + posted.missed} contacts.", + ) + } + }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } + } + + fun openStory(story: Vault.Story) { + session?.markStorySeen(story.id) + update { it.copy(openStory = story, stories = session?.stories() ?: it.stories) } + } + + fun closeStory() = update { it.copy(openStory = null) } + // -- Voice notes (§6.6) -------------------------------------------------- private var recording: VoiceNote.Recording? = null @@ -848,9 +901,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { while (true) { val current = session if (current != null) { + // A story arriving must appear without the user pulling + // to refresh; it is filed by `receive()` and read back here. val threads = withContext(Dispatchers.IO) { runCatching { current.receive() }.getOrDefault(emptySet()) } + // Read back every tick rather than only when a thread was + // touched: a story files itself outside the thread map, so + // `threads` is empty for a poll that brought one in. + val live = withContext(Dispatchers.IO) { + runCatching { current.stories() }.getOrDefault(emptyList()) + } + update { state -> + if (state.stories.map { it.id } == live.map { it.id }) state + else state.copy(stories = live) + } if (threads.isNotEmpty()) { update { state -> val open = state.openChat diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index c2087a6..a1bbda4 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -151,7 +151,28 @@ class Session( fun receive(): Set { val arrived = conversations.poll().filter { it.content.isRenderable } val touched = mutableSetOf() + val now = System.currentTimeMillis() / 1000.0 + val incomingStories = mutableListOf() + for (message in arrived) { + // ⚠️ **A story bypasses the thread entirely (§6.6).** It belongs to + // the strip, and filing it as a message would put every story + // somebody posted into a one-to-one conversation they did not have. + // One that arrives already expired — posted while this device was + // offline — is dropped rather than shown late. + val ref = message.content.attachment + if (ref != null && ref.kind == Attachment.STORY) { + val story = Vault.Story( + id = ref.id, + authorId = message.fromUser, + mine = false, + ref = ref, + caption = message.content.text, + postedAt = System.currentTimeMillis(), + ) + if (story.isActive(now)) incomingStories += story + continue + } // ⚠️ A group message is filed under the GROUP, not the sender. // Filing by sender scatters one conversation across as many threads // as it has participants. @@ -162,6 +183,65 @@ class Session( attachment = message.content.attachment, author = message.fromUser) touched += thread } + + if (incomingStories.isNotEmpty()) { + val existing = vault.loadStories() + val known = existing.map { it.id }.toSet() + vault.saveStories(existing + incomingStories.filterNot { it.id in known }, now) + } return touched } + + // -- Stories (§6.6) ------------------------------------------------------ + + /** Everything still live, newest first. */ + fun stories(): List { + val now = System.currentTimeMillis() / 1000.0 + val live = vault.loadStories().filter { it.isActive(now) } + // Written back so expired bytes leave the device rather than lingering + // until the next post. + vault.saveStories(live, now) + return live.sortedByDescending { it.postedAt } + } + + /** + * Post to several contacts at once. + * + * Recorded locally from the reference we just built, so the author's own + * strip shows it without fetching their own image back from the relay. + */ + fun postStory( + bytes: ByteArray, + mime: String, + width: Int, + height: Int, + caption: String?, + toUserIds: List, + ): io.flamenet.client.Conversations.StoryPosted { + val posted = conversations.postStory(bytes, mime, width, height, caption, toUserIds) + if (posted.reached > 0) { + val now = System.currentTimeMillis() / 1000.0 + vault.saveStories( + vault.loadStories() + Vault.Story( + id = posted.ref.id, + authorId = userId, + mine = true, + ref = posted.ref, + caption = caption.orEmpty(), + postedAt = System.currentTimeMillis(), + seen = true, + ), + now, + ) + } + return posted + } + + fun markStorySeen(storyId: String) { + val now = System.currentTimeMillis() / 1000.0 + vault.saveStories( + vault.loadStories().map { if (it.id == storyId) it.copy(seen = true) else it }, + now, + ) + } } diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index b243d18..764ff1a 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -23,6 +23,8 @@ import io.flamenet.e2e.Vault * That costs a write per message and buys the difference between an app that * survives being killed and one that has to be reinstalled. */ +private const val STORY_LIFETIME_SECONDS = 24.0 * 60 * 60 + class Conversations( private val identity: DeviceIdentity, private val vault: Vault, @@ -156,6 +158,48 @@ class Conversations( .copy(attachment = ref) } + /** + * Post a story to several people at once (§6.6). + * + * ⚠️ **Sealed and uploaded ONCE, then fanned out.** One blob, one key, one + * upload — and an ordinary story envelope down each recipient's existing + * ratchet. Uploading per recipient would multiply the bytes by the size of + * the contact list for no gain, and would hand the relay a set of distinct + * blobs it could correlate by size and timing. + * + * ⚠️ Partial delivery is normal and reported rather than thrown. A contact + * with no registered device cannot receive anything, story or not, and + * failing the whole post because one person is unreachable would make + * posting impossible for anybody with a stale contact. + */ + fun postStory( + data: ByteArray, + mime: String, + width: Int, + height: Int, + caption: String?, + toUserIds: List, + lifetimeSeconds: Double = STORY_LIFETIME_SECONDS, + ): StoryPosted { + val sealed = Attachment.seal(data) + val id = relay.uploadAttachment(sealed.blob) + val ref = Attachment.Ref( + kind = Attachment.STORY, id = id, key = sealed.key, digest = sealed.digest, + bytes = sealed.blob.size, mime = mime, width = width, height = height, + expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds, + ) + val body = Content.attachment(ref, caption, null) + + var reached = 0 + var missed = 0 + for (userId in toUserIds) { + if (sendPlaintext(userId, body).reachedSomeone) reached++ else missed++ + } + return StoryPosted(ref, reached, missed) + } + + data class StoryPosted(val ref: Attachment.Ref, val reached: Int, val missed: Int) + /** Download and open a blob a message referred to. */ fun fetchAttachment(ref: Attachment.Ref): ByteArray = Attachment.open(relay.downloadAttachment(ref.id), ref) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 8ff0853..dd3b985 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -50,6 +50,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val TRUST_PREFIX = "trust/" private const val KEYLOG_PIN = "keylog-pin" private const val DELIVERY_KEY = "delivery-key" + private const val STORIES = "stories" /** The two kinds of thread, named once so they cannot drift apart. */ fun threadForUser(userId: Long) = "user:$userId" @@ -266,6 +267,71 @@ class Vault(private val storage: Storage, private val key: SecretKey) { .put("told", org.json.JSONArray(delivery.toldPeers.toList()))) } + // -- Stories (§6.6) ------------------------------------------------------ + + /** + * One story, as this device holds it. + * + * ⚠️ `expires` is the author's claim, carried in the envelope. It is not + * enforceable — a recipient's client could keep the bytes for ever, and + * this one deletes them because it chooses to. A story is not a secret you + * can take back, and the UI should not suggest otherwise. + */ + data class Story( + val id: String, + val authorId: Long, + val mine: Boolean, + val ref: Attachment.Ref, + val caption: String, + val postedAt: Long, + val seen: Boolean = false, + ) { + fun isActive(nowSeconds: Double): Boolean = ref.expires <= 0 || ref.expires > nowSeconds + } + + fun loadStories(): List { + val array = readJson(STORIES)?.optJSONArray("items") ?: return emptyList() + return (0 until array.length()).mapNotNull { i -> + val o = array.getJSONObject(i) + val ref = Attachment.Ref.fromJson( + Attachment.STORY, o.getJSONObject("ref"), o.getJSONObject("ref").optDouble("exp", 0.0) + ) ?: return@mapNotNull null + Story( + id = o.getString("id"), + authorId = o.getLong("author"), + mine = o.optBoolean("mine", false), + ref = ref, + caption = o.optString("caption"), + postedAt = o.optLong("at"), + seen = o.optBoolean("seen", false), + ) + } + } + + /** + * Replace the stored set. + * + * ⚠️ Expired stories are dropped on WRITE rather than filtered on read, so + * the bytes actually leave the device. Filtering on read leaves a store + * that grows for ever and still holds every image somebody posted, which + * is the opposite of what an expiring story promises. + */ + fun saveStories(stories: List, nowSeconds: Double) { + val live = stories.filter { it.isActive(nowSeconds) } + val array = org.json.JSONArray() + live.forEach { story -> + array.put(JSONObject() + .put("id", story.id) + .put("author", story.authorId) + .put("mine", story.mine) + .put("ref", story.ref.toJson()) + .put("caption", story.caption) + .put("at", story.postedAt) + .put("seen", story.seen)) + } + writeJson(STORIES, JSONObject().put("items", array)) + } + // -- Key-log pin (§19.3) ------------------------------------------------- /**