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) ------------------------------------------------- /**