diff --git a/PARITY.md b/PARITY.md index ccd8d81..18167a7 100644 --- a/PARITY.md +++ b/PARITY.md @@ -15,8 +15,8 @@ screen renders. | | iOS | Android | |---|---|---| | Protocol engine | `Sources/E2E`, 15 files | `engine`, 10 files | -| App layer | ~9,700 lines, 45 files | accounts, contacts, chat | -| Tests | XCUITest + JS interop | 96 unit + 37 cross-engine + 11 on-device + live round trips | +| App layer | ~9,700 lines, 45 files | accounts, contacts, chat, photos | +| Tests | XCUITest + JS interop | 114 unit + 37 cross-engine + 15 on-device + live round trips | | Ships to | nobody (no signing team) | a debug APK that installs and runs | Android's engine is **verified against the web engine in both directions** @@ -38,11 +38,13 @@ everything above the protocol. | Transparency log (`KeyLog`) | ✅ | ✅ | canonical leaf + inclusion proofs, cross-engine verified | | Account KDF / fnid | ✅ | ✅ | both pinned to the published vector file | | Sealed sender | ✅ | ⚪ | `E2E/SealedSender.swift` | +| Content envelopes (§6.6–§6.9) | ✅ | ✅ | attachments, replies, reactions, groups, calls — one codec | +| Attachment encryption | ✅ | ✅ | verified end to end; relay holds only ciphertext | | Envelope + wire codec | ✅ | ✅ | cross-engine verified at the envelope level | | Session archive / restore | ✅ | ✅ | cross-engine verified, in-flight messages included | | Device identity + prekey rotation | ✅ | ✅ | retired prekeys retained past the relay window | | Vault (encryption at rest) | ✅ | ✅ | Android Keystore, AES-GCM, record name bound into the AAD | -| Attachment encryption | ✅ | ⚪ | `E2E/E2EAttachment.swift` | + | ML-KEM-768 (PQ agreement) | ⚠️ | ⚪ | see below | | ML-DSA-65 (PQ identity) | ⚠️ | ⚪ | see below | @@ -67,9 +69,9 @@ From `Sources/Auth/*` and `Sources/Views/Sign*`. | Sign up — managed / shared self-hosted | 🟡 built, not yet exercised end to end | | Sign in, session restore | ✅ | | Sign out (erases the vault key) | ✅ | -| Password reset request + confirm | ⚪ | -| Change password | ⚪ | -| Delete account | ⚪ | +| Password reset request + confirm | ✅ on the sign-in screen | +| Change password | ✅ signs the device out, because that is what it does | +| Delete account | ✅ confirms in place, erases the phone too | | Relay picker / `SiteConfig` equivalent | ⚪ | | Deterministic local-relay KDF salt | ⚪ | | Multi-homing (publish + read home records) | ⚪ | @@ -91,8 +93,8 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | Multi-device fan-out | ✅ one envelope per device, partial delivery reported | | History at rest | ✅ encrypted, keyed by person not device | | Session restore across restarts | ✅ credential and device both survive | -| Reactions | ⚪ | -| Photos | ⚪ | +| Reactions | 🟡 codec done, no UI | +| Photos | ✅ send via the system picker, render inline | | Voice notes (record + play) | ⚪ | | Stories, and seen-state | ⚪ | | Groups: create, list, members, leave | ⚪ | @@ -100,15 +102,15 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | Delivery keys + rotation | ⚪ | | Trust state, verification, identity-change acceptance | ⚪ | | Key-log enforcement and head gossip | ⚪ | -| Clear thread / all threads / caches | ⚪ | +| Clear thread | ✅ keeps the session, so the chat still works | | History at rest (encrypted, device-only key) | ⚪ | -| Presence (read) | ✅ shown from the relay, never inferred | +| Presence (read and set) | ✅ shown from the relay, never inferred | | Buddy list, add by username | ✅ | -| Remove, block | ⚪ built in `:client`, no UI | +| Remove, block, unblock | ✅ | | Member lookup | ⚪ | | Buddy commands | ⚪ | | Voice calls (WebRTC, DTLS-SRTP, TURN) | ⚪ | -| Notifications list, read state | ⚪ | +| Notifications list, read state | ✅ | | Services (AIM/ICQ via `flamenet-oscar`) | ⚪ | ⚠️ Calls need a WebRTC dependency on Android as they do on iOS. That is the one @@ -142,7 +144,7 @@ order and not a nicer-looking one. 3. ~~**Vault** — Android Keystore, encryption at rest.~~ ✅ 4. ~~**`:app` scaffolding** — Compose, sign-in/up/out, on-device tests.~~ ✅ 5. ~~**Buddy list and chat.**~~ ✅ -6. **Attachments, voice notes, groups, stories, calls** — in that order. +6. **Voice notes, groups, stories, calls** — in that order. 7. **More instrumented tests** — the JVM tests prove nothing about Conscrypt. This engine has already been bitten once by exactly that. @@ -170,6 +172,19 @@ list uses `id`/`presence`/`screen_name`, not `user_id`/`status`. None of these were findable without a real relay, which is why the live tests exist and why they are opt-in rather than absent. +## The CRUD rule + +**Every capability must be reachable from the UI, with its full create / read / +update / delete surface.** Owner standing instruction, and it exists because +features kept landing in `:client` with a live test proving them and no door for +a person to walk through — remove a contact, block someone, clear a thread, +change a password. `CrudSurfaceTest` walks the app and asserts the controls +exist; it checks reachability rather than behaviour, because behaviour is what +the live tests already cover and the door is what was missing. + +It found two real gaps on its first run: sign-out was reachable only from the +contact list rather than from Account, and several controls sat below the fold. + ## Known flakiness ⚠️ **The emulator's network drops occasionally** — seen twice as "Failed to @@ -177,6 +192,13 @@ connect" and "Software caused connection abort" on sign-up, with the relay reachable from the host at the same moment. A rerun succeeds. Worth knowing before treating a red instrumented run as a regression. +⚠️ **Instrumented tests share a process and a vault.** The Compose rule gives +each test a fresh Activity, but the app spends its first moments reading the +vault behind a spinner. A reset that checks for a signed-in control immediately +finds nothing, concludes "already signed out", and then the session restores +underneath it — so the suite passes one test at a time and fails as a set. Wait +for the app to settle before deciding anything. + ⚠️ **Two peer processes on one account race for the inbox.** The one that polls first takes the message and the other reports zero, which reads as "nothing was sent". Run a single `autoreply` at a time. diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt index 45cc662..431d8a4 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt @@ -43,10 +43,7 @@ class ChatFlowTest { @Before fun startSignedOut() { handle = "andc" + (System.currentTimeMillis() % 100_000_000) - if (nodesWithTag("sign-out").isNotEmpty()) { - compose.onNodeWithTag("sign-out").performClick() - awaitTag("submit", 30_000) - } + signOutIfSignedIn() } @Test @@ -138,6 +135,54 @@ class ChatFlowTest { awaitTag("device-id", 120_000) } + /** + * Get to a signed-out app. + * + * ⚠️ Sign-out lives on the Account screen, so a test that only looks for it + * on whatever screen the previous test left behind silently does nothing — + * and the next assertion fails looking for a sign-in field that was never + * going to be there. + */ + private fun signOutIfSignedIn() { + // ⚠️ **Wait for the app to settle before deciding anything.** The + // Compose rule launches a fresh Activity per test but the process — and + // the vault — survive, so the app spends its first moments reading the + // vault and showing a spinner. Checking for a signed-in control right + // away finds nothing, concludes "already signed out", and then the + // session restores underneath: every later test fails looking for a + // sign-in field. This race is why the suite passed one test at a time + // and failed as a set. + awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000) + + // Close any open thread. The chat screen replaces the bottom nav, so a + // test inheriting an open conversation cannot reach Account at all. + if (nodesWithTag("back").isNotEmpty()) { + compose.onNodeWithTag("back").performClick() + awaitTag("nav-contacts", 20_000) + } + if (nodesWithTag("nav-account").isEmpty()) return + compose.onNodeWithTag("nav-account").performClick() + awaitTag("sign-out", 20_000) + compose.onNodeWithTag("sign-out").performClick() + awaitTag("relay", 30_000) + } + + + /** Wait until any one of these tags exists. */ + private fun awaitAny(tags: List, timeoutMillis: Long) { + compose.mainClock.autoAdvance = false + try { + val deadline = System.currentTimeMillis() + timeoutMillis + while (System.currentTimeMillis() < deadline) { + compose.mainClock.advanceTimeBy(100) + if (tags.any { nodesWithTag(it).isNotEmpty() }) return + Thread.sleep(100) + } + } finally { + compose.mainClock.autoAdvance = true + } + } + private fun nodesWithTag(tag: String): List = runCatching { compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() }.getOrDefault(emptyList()) diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt new file mode 100644 index 0000000..e9b4f82 --- /dev/null +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt @@ -0,0 +1,247 @@ +package io.flamenet.messenger.app + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.printToString +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Every capability is reachable from the UI. + * + * ⚠️ **This exists because of a real habit.** Features kept landing in + * `:client` with a live test proving them and no way for a person to use them — + * remove a contact, block someone, clear a thread, change a password, read a + * notification. A capability only reachable from a terminal is not shipped, + * and a screen that can add a contact but not remove one reads as broken + * rather than unfinished. + * + * So this walks the app and asserts the controls exist. It deliberately checks + * *presence and reachability* rather than behaviour — the behaviour is covered + * by the live tests; what was missing was the door. + */ +@RunWith(AndroidJUnit4::class) +class CrudSurfaceTest { + + @get:Rule val compose = createAndroidComposeRule() + + private val relay = "relay1.flamenet.io" + private val peerHandle = "peerjvmone" + private lateinit var handle: String + + @Before + fun startSignedOut() { + handle = "andu" + (System.currentTimeMillis() % 100_000_000) + signOutIfSignedIn() + } + + /** Signed out: sign in, create an account, and recover a forgotten password. */ + @Test + fun theSignedOutScreenOffersEveryWayIn() { + compose.onNodeWithTag("relay").assertExists() + compose.onNodeWithTag("handle").assertExists() + compose.onNodeWithTag("passphrase").assertExists() + compose.onNodeWithTag("submit").assertExists() + compose.onNodeWithTag("toggle-register").assertExists() + + // ⚠️ `assertExists`, not `assertIsDisplayed`. The sign-in column + // scrolls, so a control below the fold is present and reachable but not + // on screen — and this test is about whether the door exists, not about + // where it currently sits. + compose.onNodeWithTag("toggle-reset").performClick() + compose.onNodeWithTag("send-reset-code").assertExists() + compose.onNodeWithTag("reset-code").assertExists() + compose.onNodeWithTag("reset-password").assertExists() + compose.onNodeWithTag("confirm-reset").assertExists() + compose.onNodeWithTag("toggle-reset").performClick() + + // Registering a local account asks for an email; signing in does not. + compose.onNodeWithTag("relay").performTextClearance() + compose.onNodeWithTag("relay").performTextInput(relay) + compose.onNodeWithTag("toggle-register").performClick() + compose.onNodeWithTag("email").assertExists() + } + + @Test + fun everyContactActionIsReachable() { + if (!relayReachable()) return + signUp() + + // Presence is a claim about you, so you can set it. + listOf("online", "away", "busy", "offline").forEach { + compose.onNodeWithTag("presence-$it").assertExists() + } + + compose.onNodeWithTag("add-contact").performClick() + compose.onNodeWithTag("new-contact").performTextInput(peerHandle) + compose.onNodeWithTag("confirm-contact").performClick() + awaitNode(hasText(peerHandle, substring = true), 60_000) + + // Read the id the row was given, then open its actions. + val peerId = peerIdFromTags() + compose.onNodeWithTag("contact-menu-$peerId").performClick() + compose.onNodeWithTag("block-$peerId").assertExists() + compose.onNodeWithTag("clear-$peerId").assertExists() + compose.onNodeWithTag("remove-$peerId").assertExists() + } + + @Test + fun theOtherScreensAreReachableAndComplete() { + if (!relayReachable()) return + signUp() + + compose.onNodeWithTag("nav-alerts").performClick() + awaitTag("mark-all-read", 20_000) + compose.onNodeWithTag("mark-all-read").assertExists() + + compose.onNodeWithTag("nav-account").performClick() + awaitTag("display-name", 20_000) + compose.onNodeWithTag("display-name").assertExists() + compose.onNodeWithTag("save-display-name").assertExists() + compose.onNodeWithTag("new-password").assertExists() + compose.onNodeWithTag("save-password").assertExists() + compose.onNodeWithTag("sign-out").assertExists() + compose.onNodeWithTag("delete-account").assertExists() + + compose.onNodeWithTag("nav-contacts").performClick() + awaitTag("add-contact", 20_000) + } + + @Test + fun aThreadCanSendTextAndPhotos() { + if (!relayReachable()) return + signUp() + compose.onNodeWithTag("add-contact").performClick() + compose.onNodeWithTag("new-contact").performTextInput(peerHandle) + compose.onNodeWithTag("confirm-contact").performClick() + awaitNode(hasText(peerHandle, substring = true), 60_000) + compose.onNode(hasText(peerHandle, substring = true)).performClick() + + awaitTag("draft", 20_000) + compose.onNodeWithTag("draft").assertExists() + compose.onNodeWithTag("send").assertExists() + compose.onNodeWithTag("attach").assertExists() + compose.onNodeWithTag("back").assertExists() + } + + // -- Helpers ------------------------------------------------------------- + + /** The contact row carries its user id in its tag; read it back from the tree. */ + private fun peerIdFromTags(): Long { + val dump = visibleText() + val match = Regex("contact-menu-(\\d+)").find(dump) + ?: throw AssertionError("no contact row found. On screen: $dump") + return match.groupValues[1].toLong() + } + + private fun signUp() { + compose.onNodeWithTag("relay").performTextClearance() + compose.onNodeWithTag("relay").performTextInput(relay) + compose.onNodeWithTag("toggle-register").performClick() + compose.onNodeWithTag("email").performTextInput("$handle@example.invalid") + compose.onNodeWithTag("handle").performTextInput(handle) + compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase") + compose.onNodeWithTag("submit").performClick() + awaitTag("nav-contacts", 120_000) + } + + /** + * Get to a signed-out app. + * + * ⚠️ Sign-out lives on the Account screen, so a test that only looks for it + * on whatever screen the previous test left behind silently does nothing — + * and the next assertion fails looking for a sign-in field that was never + * going to be there. + */ + private fun signOutIfSignedIn() { + // ⚠️ **Wait for the app to settle before deciding anything.** The + // Compose rule launches a fresh Activity per test but the process — and + // the vault — survive, so the app spends its first moments reading the + // vault and showing a spinner. Checking for a signed-in control right + // away finds nothing, concludes "already signed out", and then the + // session restores underneath: every later test fails looking for a + // sign-in field. This race is why the suite passed one test at a time + // and failed as a set. + awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000) + + // Close any open thread. The chat screen replaces the bottom nav, so a + // test inheriting an open conversation cannot reach Account at all. + if (nodesWithTag("back").isNotEmpty()) { + compose.onNodeWithTag("back").performClick() + awaitTag("nav-contacts", 20_000) + } + if (nodesWithTag("nav-account").isEmpty()) return + compose.onNodeWithTag("nav-account").performClick() + awaitTag("sign-out", 20_000) + compose.onNodeWithTag("sign-out").performClick() + runCatching { awaitTag("relay", 30_000) }.onFailure { + throw AssertionError("could not get back to signed out. On screen: " + visibleText()) + } + } + + + /** Wait until any one of these tags exists. */ + private fun awaitAny(tags: List, timeoutMillis: Long) { + compose.mainClock.autoAdvance = false + try { + val deadline = System.currentTimeMillis() + timeoutMillis + while (System.currentTimeMillis() < deadline) { + compose.mainClock.advanceTimeBy(100) + if (tags.any { nodesWithTag(it).isNotEmpty() }) return + Thread.sleep(100) + } + } finally { + compose.mainClock.autoAdvance = true + } + } + + private fun nodesWithTag(tag: String): List = runCatching { + compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() + }.getOrDefault(emptyList()) + + private fun awaitTag(tag: String, timeoutMillis: Long) = + awaitNode(hasTestTag(tag), timeoutMillis) + + /** See ChatFlowTest: the busy spinner never lets the test clock go idle. */ + private fun awaitNode( + matcher: androidx.compose.ui.test.SemanticsMatcher, + timeoutMillis: Long, + ) { + compose.mainClock.autoAdvance = false + try { + val deadline = System.currentTimeMillis() + timeoutMillis + while (System.currentTimeMillis() < deadline) { + compose.mainClock.advanceTimeBy(100) + val found = runCatching { + compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() + }.getOrDefault(false) + if (found) return + Thread.sleep(100) + } + throw AssertionError( + "nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}") + } finally { + compose.mainClock.autoAdvance = true + } + } + + private fun visibleText(): String = runCatching { + compose.onRoot().printToString(maxDepth = 40) + }.getOrElse { "(could not read the screen: $it)" } + + private fun relayReachable(): Boolean = runCatching { + (java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) + .apply { connectTimeout = 8000; readTimeout = 8000 } + .responseCode in 200..299 + }.getOrDefault(false) +} diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/SignUpFlowTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/SignUpFlowTest.kt index 63297da..84f217c 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/SignUpFlowTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/SignUpFlowTest.kt @@ -53,9 +53,54 @@ class SignUpFlowTest { // // Signing out through the UI is both safe and a truer test: it is what // a person does, and the view model reopens the vault afterwards. - if (nodesWithTag("sign-out").isNotEmpty()) { - compose.onNodeWithTag("sign-out").performClick() - awaitTag("submit", 30_000) + signOutIfSignedIn() + } + + /** + * Get to a signed-out app. + * + * ⚠️ Sign-out lives on the Account screen, so a test that only looks for it + * on whatever screen the previous test left behind silently does nothing — + * and the next assertion fails looking for a sign-in field that was never + * going to be there. + */ + private fun signOutIfSignedIn() { + // ⚠️ **Wait for the app to settle before deciding anything.** The + // Compose rule launches a fresh Activity per test but the process — and + // the vault — survive, so the app spends its first moments reading the + // vault and showing a spinner. Checking for a signed-in control right + // away finds nothing, concludes "already signed out", and then the + // session restores underneath: every later test fails looking for a + // sign-in field. This race is why the suite passed one test at a time + // and failed as a set. + awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000) + + // Close any open thread. The chat screen replaces the bottom nav, so a + // test inheriting an open conversation cannot reach Account at all. + if (nodesWithTag("back").isNotEmpty()) { + compose.onNodeWithTag("back").performClick() + awaitTag("nav-contacts", 20_000) + } + if (nodesWithTag("nav-account").isEmpty()) return + compose.onNodeWithTag("nav-account").performClick() + awaitTag("sign-out", 20_000) + compose.onNodeWithTag("sign-out").performClick() + awaitTag("relay", 30_000) + } + + + /** Wait until any one of these tags exists. */ + private fun awaitAny(tags: List, timeoutMillis: Long) { + compose.mainClock.autoAdvance = false + try { + val deadline = System.currentTimeMillis() + timeoutMillis + while (System.currentTimeMillis() < deadline) { + compose.mainClock.advanceTimeBy(100) + if (tags.any { nodesWithTag(it).isNotEmpty() }) return + Thread.sleep(100) + } + } finally { + compose.mainClock.autoAdvance = true } } @@ -149,8 +194,7 @@ class SignUpFlowTest { if (!relayReachable()) return aPersonCanCreateAnAccountOnASelfHostedRelay() - compose.onNodeWithTag("sign-out").performClick() - awaitTag("submit", 30_000) + signOutIfSignedIn() compose.onNodeWithTag("handle").assertIsDisplayed() } } 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 16fde71..a7cd376 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -21,6 +22,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -33,6 +35,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -96,6 +99,8 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P return } + var expanded by remember { mutableStateOf(null) } + LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f).testTag("buddies")) { items(phase.buddies, key = { it.userId }) { buddy -> Row( @@ -133,12 +138,238 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P .testTag("unread-${buddy.userId}") ) } + // Everything you can do to a contact, reachable without a + // long-press nobody discovers. + TextButton( + onClick = { expanded = if (expanded == buddy.userId) null else buddy.userId }, + modifier = Modifier.testTag("contact-menu-${buddy.userId}"), + ) { Text(if (expanded == buddy.userId) "Close" else "⋯") } + } + + if (expanded == buddy.userId) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + val isBlocked = buddy.userId in phase.blocked + TextButton( + onClick = { model.setBlocked(buddy.userId, !isBlocked) }, + modifier = Modifier.testTag("block-${buddy.userId}"), + ) { Text(if (isBlocked) "Unblock" else "Block") } + TextButton( + onClick = { model.clearHistory(buddy.userId) }, + modifier = Modifier.testTag("clear-${buddy.userId}"), + ) { Text("Clear chat") } + TextButton( + onClick = { model.removeBuddy(buddy.userId); expanded = null }, + modifier = Modifier.testTag("remove-${buddy.userId}"), + ) { Text("Remove", color = MaterialTheme.colorScheme.error) } + } } Divider() } } } +/** + * An attachment, fetched and decrypted on first view. + * + * ⚠️ Space is reserved from the reference's dimensions before the bytes + * arrive, so the thread does not jump as images land. The reference carries + * width and height for exactly this reason. + */ +@Composable +private fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) { + var bitmap by remember(ref.id) { mutableStateOf(null) } + var failed by remember(ref.id) { mutableStateOf(false) } + + LaunchedEffect(ref.id) { + val bytes = model.imageBytes(ref) + if (bytes == null) { failed = true; return@LaunchedEffect } + bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + if (bitmap == null) failed = true + } + + val ratio = if (ref.width > 0 && ref.height > 0) { + ref.width.toFloat() / ref.height.toFloat() + } else 1f + + Box( + modifier = Modifier + .widthIn(max = 260.dp) + .aspectRatio(ratio) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + val loaded = bitmap + when { + loaded != null -> androidx.compose.foundation.Image( + bitmap = loaded.asImageBitmap(), + contentDescription = "Attached image", + modifier = Modifier.fillMaxWidth(), + contentScale = androidx.compose.ui.layout.ContentScale.Crop, + ) + // Says which half failed. "Could not load" covers a network error, + // a wrong key and a relay serving different bytes, and those are + // very different things to be told. + failed -> Text("Image unavailable", style = MaterialTheme.typography.bodySmall) + else -> Text("Loading…", style = MaterialTheme.typography.bodySmall) + } + } +} + +/** Presence, which is a claim about you and therefore yours to set. */ +@Composable +fun PresencePicker(model: MessengerViewModel, current: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + listOf("online", "away", "busy", "offline").forEach { status -> + TextButton( + onClick = { model.setPresence(status) }, + modifier = Modifier.testTag("presence-$status"), + ) { + Text( + status, + fontWeight = if (status == current) FontWeight.Bold else null, + color = if (status == current) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + +@Composable +fun ColumnScope.NotificationsScreen( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedIn, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Notifications", style = MaterialTheme.typography.headlineSmall) + TextButton( + onClick = model::markAllNotificationsRead, + modifier = Modifier.testTag("mark-all-read"), + ) { Text("Mark all read") } + } + + if (phase.notifications.isEmpty()) { + Text("Nothing new.", style = MaterialTheme.typography.bodyMedium) + return + } + + LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f).testTag("notifications")) { + items(phase.notifications, key = { it.id }) { note -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { model.markNotificationRead(note.id) } + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!note.read) { + Box(modifier = Modifier.size(8.dp).clip(CircleShape) + .background(MaterialTheme.colorScheme.primary)) + } + Column(modifier = Modifier.weight(1f)) { + Text(note.body.ifBlank { note.kind }, + fontWeight = if (note.read) null else FontWeight.Bold) + Text(note.kind, style = MaterialTheme.typography.bodySmall) + } + } + Divider() + } + } +} + +/** + * The account. + * + * ⚠️ The two destructive actions confirm in place rather than in a dialog. A + * dialog is dismissed reflexively; a button that changes to say what it is + * about to do is read. + */ +@Composable +fun ColumnScope.AccountScreen( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedIn, +) { + var displayName by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirmingDelete by remember { mutableStateOf(false) } + + Text("Account", style = MaterialTheme.typography.headlineSmall) + Text("${phase.handle} · ${phase.relay}", style = MaterialTheme.typography.bodySmall) + Text("Device ${phase.deviceId}", style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag("device-id")) + + Divider() + + OutlinedTextField( + value = displayName, + onValueChange = { displayName = it }, + label = { Text("Display name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("display-name"), + ) + Button( + onClick = { model.updateDisplayName(displayName.trim()); displayName = "" }, + enabled = displayName.isNotBlank() && !phase.busy, + modifier = Modifier.fillMaxWidth().testTag("save-display-name"), + ) { Text("Save display name") } + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("New password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth().testTag("new-password"), + ) + Button( + onClick = { model.updatePassword(password); password = "" }, + enabled = password.length >= 8 && !phase.busy, + modifier = Modifier.fillMaxWidth().testTag("save-password"), + ) { Text("Change password") } + Text( + "Changing it signs this device out — that is what changing it means.", + style = MaterialTheme.typography.bodySmall, + ) + + Divider() + + // Sign-out lives here rather than under the contact list, because this is + // where people look for it — and because it is destructive enough to sit + // beside the other destructive things rather than under a list of friends. + Button( + onClick = model::signOut, + modifier = Modifier.fillMaxWidth().testTag("sign-out"), + ) { Text("Sign out") } + Text( + "Signing out erases this phone's keys and stored messages. They cannot be recovered.", + style = MaterialTheme.typography.bodySmall, + ) + + Button( + onClick = { if (confirmingDelete) model.deleteAccount() else confirmingDelete = true }, + modifier = Modifier.fillMaxWidth().testTag("delete-account"), + ) { + Text(if (confirmingDelete) "Really delete — this cannot be undone" else "Delete account") + } + Text( + "Deletes the account on ${phase.relay} and erases this phone's keys and messages.", + style = MaterialTheme.typography.bodySmall, + ) +} + @Composable private fun PresenceDot(status: String) { val colour = when (status.lowercase()) { @@ -177,18 +408,42 @@ fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.P modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - items(chat.lines) { line -> Bubble(line) } + items(chat.lines) { line -> Bubble(line, model) } } error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error")) } + val context = androidx.compose.ui.platform.LocalContext.current + // The system picker, so the app never asks for storage permission — it is + // handed exactly the one file the person chose and nothing else. + val pickImage = androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.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.sendImage( + bytes, + context.contentResolver.getType(uri) ?: "image/jpeg", + bounds.outWidth, + bounds.outHeight, + ) + } + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + TextButton( + onClick = { pickImage.launch("image/*") }, + modifier = Modifier.testTag("attach"), + ) { Text("Photo") } OutlinedTextField( value = draft, onValueChange = { draft = it }, @@ -207,7 +462,7 @@ fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.P } @Composable -private fun Bubble(line: Session.Line) { +private fun Bubble(line: Session.Line, model: MessengerViewModel) { val time = remember(line.at) { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(line.at)) } @@ -225,11 +480,14 @@ private fun Bubble(line: Session.Line) { ) .padding(horizontal = 12.dp, vertical = 8.dp), ) { - Text( - line.text, - color = if (line.mine) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurface, - ) + line.attachment?.let { AttachedImage(it, model) } + if (line.text.isNotEmpty()) { + Text( + line.text, + color = if (line.mine) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface, + ) + } Text( time, style = MaterialTheme.typography.labelSmall, 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 3e59aca..63ada0b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -74,15 +75,24 @@ private fun Root(model: MessengerViewModel = viewModel()) { is MessengerViewModel.Phase.SignedIn -> Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) { val chat = current.openChat - if (chat == null) SignedIn(model, current) - else ChatScreen(model, chat, current.error) + if (chat != null) { + ChatScreen(model, chat, current.error) + } else { + when (current.screen) { + MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current) + MessengerViewModel.Phase.Screen.NOTIFICATIONS -> + NotificationsScreen(model, current) + MessengerViewModel.Phase.Screen.ACCOUNT -> AccountScreen(model, current) + } + BottomNav(model, current) + } } } } } @Composable -private fun SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedOut) { +private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedOut) { val relay by model.relayInput.collectAsStateWithLifecycle() var handle by remember { mutableStateOf("") } var passphrase by remember { mutableStateOf("") } @@ -174,6 +184,112 @@ private fun SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.Si ) { Text(if (registering) "I already have an account" else "Create an account") } + + if (!registering) ResetPassword(model, handle) +} + +/** + * Password reset, in place rather than on another screen. + * + * ⚠️ The relay answers a reset request identically whatever the truth is — + * unknown handle, no address on file, an account that authenticates elsewhere. + * So the copy here says what was *done*, never what was *found*. "No such + * account" would undo that at the last step. + */ +@Composable +private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) { + var open by remember { mutableStateOf(false) } + var code by remember { mutableStateOf("") } + var fresh by remember { mutableStateOf("") } + + TextButton( + onClick = { open = !open }, + modifier = Modifier.fillMaxWidth().testTag("toggle-reset"), + ) { Text(if (open) "Back to signing in" else "Forgotten your password?") } + + if (!open) return + + Text( + "Enter your handle above, then send yourself a code. " + + "This server can only do that if it has an address on file for you.", + style = MaterialTheme.typography.bodySmall, + ) + Button( + onClick = { model.requestPasswordReset(handle.trim()) }, + enabled = handle.isNotBlank(), + modifier = Modifier.fillMaxWidth().testTag("send-reset-code"), + ) { Text("Send a code") } + + OutlinedTextField( + value = code, + onValueChange = { code = it }, + label = { Text("Code from the email") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("reset-code"), + ) + OutlinedTextField( + value = fresh, + onValueChange = { fresh = it }, + label = { Text("New password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth().testTag("reset-password"), + ) + Button( + onClick = { model.confirmPasswordReset(handle.trim(), code.trim(), fresh) }, + enabled = code.isNotBlank() && fresh.length >= 8, + modifier = Modifier.fillMaxWidth().testTag("confirm-reset"), + ) { Text("Set the new password") } +} + +/** + * The three places to be, always visible. + * + * A tab bar rather than a menu: notifications and the account are things people + * look for, and a capability hidden behind an overflow is one most users never + * find. + */ +@Composable +private fun BottomNav( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedIn, +) { + val unreadNotes = phase.notifications.count { !it.read } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS, + "Contacts" + (phase.unread.size.takeIf { it > 0 }?.let { " ($it)" } ?: ""), "nav-contacts") + NavButton(model, phase, MessengerViewModel.Phase.Screen.NOTIFICATIONS, + "Alerts" + (unreadNotes.takeIf { it > 0 }?.let { " ($it)" } ?: ""), "nav-alerts") + NavButton(model, phase, MessengerViewModel.Phase.Screen.ACCOUNT, "Account", "nav-account") + } +} + +@Composable +private fun NavButton( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedIn, + screen: MessengerViewModel.Phase.Screen, + label: String, + tag: String, +) { + TextButton( + onClick = { + model.showScreen(screen) + if (screen == MessengerViewModel.Phase.Screen.NOTIFICATIONS) { + model.refreshNotifications() + } + }, + modifier = Modifier.testTag(tag), + ) { + Text( + label, + color = if (phase.screen == screen) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + ) + } } @Composable @@ -186,21 +302,12 @@ private fun ColumnScope.SignedIn( style = MaterialTheme.typography.bodySmall, modifier = Modifier.testTag("device-id")) + PresencePicker(model, phase.presence) BuddyList(model, phase) - - TextButton(onClick = model::signOut, modifier = Modifier.testTag("sign-out")) { - Text("Sign out") - } - // Signing out destroys the Keystore key, and every stored conversation with - // it. Said where the button is rather than in a dialog nobody reads. - Text( - "Signing out erases this phone's keys and stored messages. They cannot be recovered.", - style = MaterialTheme.typography.bodySmall, - ) } @Composable -private fun VaultUnreadable( +private fun ColumnScope.VaultUnreadable( model: MessengerViewModel, phase: MessengerViewModel.Phase.VaultUnreadable, ) { 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 8e189d7..9ad7aa7 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -39,6 +39,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val relay: String, val status: String, val buddies: List = emptyList(), + val blocked: Set = emptySet(), + val notifications: List = emptyList(), + val presence: String = "online", + /** Which screen is showing. One field, so two cannot be open at once. */ + val screen: Screen = Screen.CONTACTS, /** People with unread mail, so the list can say so without opening each thread. */ val unread: Set = emptySet(), val openChat: Chat? = null, @@ -46,6 +51,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val error: String? = null, ) : Phase + enum class Screen { CONTACTS, NOTIFICATIONS, ACCOUNT } + /** An open conversation: who with, and everything said so far. */ data class Chat( val peerId: Long, @@ -274,6 +281,182 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { fun closeChat() = update { it.copy(openChat = null) } + fun showScreen(screen: Phase.Screen) = update { it.copy(screen = screen, error = null) } + + // -- Contacts: the rest of the CRUD -------------------------------------- + + fun removeBuddy(userId: Long) = act { current -> + current.api.removeBuddy(userId) + current.api.buddies() + }.let { } + + fun setBlocked(userId: Long, blocked: Boolean) = viewModelScope.launch { + val current = session ?: return@launch + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { + runCatching { + current.api.setBlocked(userId, blocked) + current.api.blocked().toSet() to current.api.buddies() + } + } + done.onSuccess { (blockedIds, list) -> + update { it.copy(busy = false, blocked = blockedIds, buddies = list) } + }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } + } + + /** + * ⚠️ Clears the thread and keeps the session. Dropping the ratchet as well + * would make the next message from that person undecryptable — a "clear + * chat" that quietly breaks the chat. + */ + fun clearHistory(peerUserId: Long) = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { current.clearHistory(peerUserId) } + update { + it.copy(openChat = it.openChat?.takeIf { chat -> chat.peerId == peerUserId } + ?.copy(lines = emptyList()) ?: it.openChat) + } + } + + // -- Presence ------------------------------------------------------------ + + fun setPresence(status: String) = viewModelScope.launch { + val current = session ?: return@launch + val done = withContext(Dispatchers.IO) { runCatching { current.api.setStatus(status) } } + done.onSuccess { update { it.copy(presence = status, error = null) } } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + // -- Notifications ------------------------------------------------------- + + fun refreshNotifications() = viewModelScope.launch { + val current = session ?: return@launch + val fetched = withContext(Dispatchers.IO) { runCatching { current.api.notifications() } } + fetched.onSuccess { list -> update { it.copy(notifications = list, error = null) } } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + fun markNotificationRead(id: Long) = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { runCatching { current.api.markNotificationRead(id) } } + refreshNotifications() + } + + fun markAllNotificationsRead() = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { runCatching { current.api.markAllNotificationsRead() } } + refreshNotifications() + } + + // -- The account --------------------------------------------------------- + + fun updateDisplayName(name: String) = viewModelScope.launch { + val current = session ?: return@launch + val done = withContext(Dispatchers.IO) { + runCatching { current.api.updateDisplayName(name) } + } + done.onSuccess { update { it.copy(error = null, status = "Display name updated.") } } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + /** + * ⚠️ Changing the password invalidates this device's credential too — that + * is what changing it means. The app therefore signs out rather than + * carrying on and 401-ing on the next request for no visible reason. + */ + fun updatePassword(password: String) = viewModelScope.launch { + val current = session ?: return@launch + val done = withContext(Dispatchers.IO) { runCatching { current.api.updatePassword(password) } } + done.onSuccess { signOut() } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + /** Deletes the account on the relay AND erases this phone. Irreversible. */ + fun deleteAccount() = viewModelScope.launch { + val current = session ?: return@launch + val done = withContext(Dispatchers.IO) { runCatching { current.api.deleteAccount() } } + done.onSuccess { signOut() } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + // -- Password reset (signed out) ----------------------------------------- + + /** + * ⚠️ The relay answers identically whatever the truth is — unknown handle, + * no address on file, an account that authenticates elsewhere. So this + * cannot report whether it worked, and the UI must not pretend it can. + */ + fun requestPasswordReset(handle: String) = viewModelScope.launch { + val config = site ?: return@launch + val done = withContext(Dispatchers.IO) { + runCatching { io.flamenet.client.RelayAccounts(config.baseUrl).requestPasswordReset(handle) } + } + _phase.value = Phase.SignedOut( + error = done.fold( + onSuccess = { "If that account exists and has an address on file, a code is on its way." }, + onFailure = { describe(it) }, + ), + ) + } + + fun confirmPasswordReset(handle: String, code: String, newPassword: String) = + viewModelScope.launch { + val config = site ?: return@launch + val done = withContext(Dispatchers.IO) { + runCatching { + io.flamenet.client.RelayAccounts(config.baseUrl) + .confirmPasswordReset(handle, code, newPassword) + } + } + _phase.value = Phase.SignedOut( + error = done.fold( + onSuccess = { "Password changed. Sign in with the new one." }, + onFailure = { describe(it) }, + ), + ) + } + + // -- Attachments --------------------------------------------------------- + + fun sendImage(bytes: ByteArray, mime: String, width: Int, height: Int) = + viewModelScope.launch { + val current = session ?: return@launch + val chat = signedIn()?.openChat ?: return@launch + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { + runCatching { current.sendImage(chat.peerId, bytes, mime, width, height) } + } + done.onSuccess { sent -> + update { + it.copy( + busy = false, + openChat = it.openChat?.copy(lines = current.history(chat.peerId)), + error = if (sent.failed.isEmpty()) null + else "Delivered to ${sent.delivered.size} of " + + "${sent.delivered.size + sent.failed.size} devices.", + ) + } + }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } + } + + /** Bytes for a received image, fetched and cached on first view. */ + suspend fun imageBytes(ref: io.flamenet.e2e.Attachment.Ref): ByteArray? { + val current = session ?: return null + return withContext(Dispatchers.IO) { + runCatching { current.attachmentBytes(ref, getApplication().cacheDir) } + .getOrNull() + } + } + + /** Run something against the API and refresh the contact list from the result. */ + private fun act(block: (Session) -> List) = viewModelScope.launch { + val current = session ?: return@launch + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { runCatching { block(current) } } + done.onSuccess { list -> update { it.copy(busy = false, buddies = list) } } + .onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } + } + fun sendMessage(text: String) = viewModelScope.launch { val current = session ?: return@launch val chat = signedIn()?.openChat ?: return@launch 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 ff6406b..c5789e1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -3,6 +3,8 @@ package io.flamenet.messenger.app import io.flamenet.client.Conversations import io.flamenet.client.MessengerApi import io.flamenet.client.RelayClient +import io.flamenet.e2e.Attachment +import io.flamenet.e2e.Content import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Vault import org.json.JSONObject @@ -25,19 +27,70 @@ class Session( ) { val conversations = Conversations(identity, vault, relay).also { it.myUserId = userId } - /** One line in a thread. `mine` is the only thing the UI needs to place it. */ - data class Line(val text: String, val mine: Boolean, val at: Long) + /** + * One line in a thread. + * + * An attachment is stored as its *reference*, not its bytes: the blob is + * cached separately and a thread that inlined megabytes of image would + * rewrite all of them on every append. + */ + data class Line( + val text: String, + val mine: Boolean, + val at: Long, + val attachment: Attachment.Ref? = null, + ) fun history(peerUserId: Long): List { val stored = vault.history(peerUserId) return (0 until stored.length()).map { val m = stored.getJSONObject(it) - Line(m.optString("text"), m.optBoolean("mine"), m.optLong("at")) + Line( + text = m.optString("text"), + mine = m.optBoolean("mine"), + at = m.optLong("at"), + attachment = m.optJSONObject("att")?.let { att -> + Attachment.Ref.fromJson(m.optString("kind", Attachment.IMAGE), att, 0.0) + }, + ) } } - fun record(peerUserId: Long, text: String, mine: Boolean, at: Long = System.currentTimeMillis()) { - vault.append(peerUserId, JSONObject().put("text", text).put("mine", mine).put("at", at)) + fun record( + peerUserId: Long, + text: String, + mine: Boolean, + attachment: Attachment.Ref? = null, + at: Long = System.currentTimeMillis(), + ) { + vault.append(peerUserId, JSONObject() + .put("text", text).put("mine", mine).put("at", at) + .also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) }) + } + + /** Clear a thread. Keeps the session, so the conversation still works. */ + fun clearHistory(peerUserId: Long) = vault.forgetHistory(peerUserId) + + /** Send an image. Returns what happened, same as a text send. */ + fun sendImage(peerUserId: Long, bytes: ByteArray, mime: String, width: Int, height: Int): + io.flamenet.client.Conversations.Sent { + val sent = conversations.sendAttachment( + peerUserId, bytes, mime, Attachment.IMAGE, width = width, height = height) + if (sent.reachedSomeone) { + // From the reference we just built, so the sender's own thread + // shows the image without fetching it back from the relay. + record(peerUserId, "", mine = true, attachment = sent.attachment) + } + return sent + } + + /** Bytes for an attachment, cached on disk after the first fetch. */ + fun attachmentBytes(ref: Attachment.Ref, cacheDir: java.io.File): ByteArray { + val cached = java.io.File(cacheDir, "att-" + ref.id.replace('/', '_')) + if (cached.exists()) return cached.readBytes() + val bytes = conversations.fetchAttachment(ref) + cached.writeBytes(bytes) + return bytes } /** @@ -54,10 +107,19 @@ class Session( return sent } - /** Poll once, filing anything that arrives. Returns who now has new mail. */ + /** + * Poll once, filing anything that arrives. Returns who now has new mail. + * + * ⚠️ Only renderable content is filed. Control messages — a contact's + * delivery key, a gossiped log head, a copy of what another of our own + * devices sent — travel down the same channel, and filing them would put + * raw JSON in the thread. + */ fun receive(): Set { - val arrived = conversations.poll() - arrived.forEach { record(it.fromUser, it.text, mine = false) } + val arrived = conversations.poll().filter { it.content.isRenderable } + arrived.forEach { + record(it.fromUser, it.content.text, mine = false, attachment = it.content.attachment) + } return arrived.map { it.fromUser }.toSet() } } diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 755d85f..1ecb704 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -1,5 +1,7 @@ package io.flamenet.client +import io.flamenet.e2e.Attachment +import io.flamenet.e2e.Content import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.E2ESession import io.flamenet.e2e.Envelope @@ -26,16 +28,28 @@ class Conversations( private val relay: RelayClient, ) { - /** One decrypted message, and where it came from. */ + /** One decrypted message, interpreted, and where it came from. */ data class Received( val id: Long, val fromUser: Long, val fromDevice: String, - val text: String, - ) + val content: Content, + ) { + val text: String get() = content.text + } /** What happened to one send. Partial success is the normal case, not an error. */ - data class Sent(val delivered: List, val failed: Map) { + data class Sent( + val delivered: List, + val failed: Map, + /** + * Set when this send carried a blob. Returned so the sender's own + * thread can show the image immediately — it already has the key, and + * fetching its own attachment back from the relay to display it would + * be absurd. + */ + val attachment: Attachment.Ref? = null, + ) { val reachedSomeone: Boolean get() = delivered.isNotEmpty() } @@ -48,7 +62,47 @@ class Conversations( * failure would turn "your tablet did not get it" into "the message was * not sent", which is both wrong and unrecoverable. */ - fun send(toUserId: Long, text: String): Sent { + fun send(toUserId: Long, text: String): Sent = sendPlaintext(toUserId, text) + + /** + * Upload an encrypted blob and send the reference through the ratchet. + * + * ⚠️ **The key never touches the relay.** It is minted here, the blob is + * uploaded already encrypted, and the key travels inside the message — so + * the relay stores bytes it cannot read and hands out an id that is a + * download capability and nothing more. + * + * ⚠️ Uploaded ONCE and referenced by every recipient device. Uploading per + * device would multiply the bytes by the number of phones someone owns and + * gain nothing: they all decrypt the same blob with the same key. + */ + fun sendAttachment( + toUserId: Long, + data: ByteArray, + mime: String, + kind: String = Attachment.IMAGE, + caption: String? = null, + width: Int = 0, + height: Int = 0, + duration: Double = 0.0, + groupId: String? = null, + ): Sent { + val sealed = Attachment.seal(data) + val id = relay.uploadAttachment(sealed.blob) + val ref = Attachment.Ref( + kind = kind, id = id, key = sealed.key, digest = sealed.digest, + bytes = sealed.blob.size, mime = mime, + width = width, height = height, duration = duration, + ) + return sendPlaintext(toUserId, Content.attachment(ref, caption, groupId)) + .copy(attachment = ref) + } + + /** Download and open a blob a message referred to. */ + fun fetchAttachment(ref: Attachment.Ref): ByteArray = + Attachment.open(relay.downloadAttachment(ref.id), ref) + + private fun sendPlaintext(toUserId: Long, text: String): Sent { val bundles = relay.fetchKeys(toUserId) val delivered = mutableListOf() val failed = LinkedHashMap() @@ -98,9 +152,12 @@ class Conversations( val envelope = Envelope.fromPayload(message.payload) val session = existingSession(message.fromDevice) ?: E2ESession.respond(identity, envelope) - val text = session.decrypt(envelope) + val plaintext = session.decrypt(envelope) vault.saveSession(message.fromDevice, session) - out += Received(message.id, message.fromUser, message.fromDevice, text) + out += Received( + message.id, message.fromUser, message.fromDevice, + Content.parse(plaintext), + ) } catch (_: Exception) { // Deliberately swallowed and skipped. One undecryptable message // is not a reason to stop reading the inbox. diff --git a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt index ab63574..ce6cc6b 100644 --- a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt +++ b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt @@ -114,11 +114,63 @@ class MessengerApi( if (blocked) post("blocks/$userId", JSONObject()) else delete("blocks/$userId") } + // -- The account itself -------------------------------------------------- + + data class Me(val userId: Long, val displayName: String, val handle: String) + + fun me(): Me = raw("api/wp/v2/users/me", "GET", null).let { + Me(it.getLong("id"), it.optString("name"), it.optString("slug")) + } + + fun updateDisplayName(name: String) { + raw("api/wp/v2/users/me", "POST", JSONObject().put("name", name)) + } + + /** + * ⚠️ Changing the password **invalidates other credentials**, which is the + * point of changing it — so this device has to sign in again afterwards. + * A UI that changes the password and carries on looks fine until the next + * request 401s for no visible reason. + */ + fun updatePassword(password: String) { + raw("api/wp/v2/users/me", "POST", JSONObject().put("password", password)) + } + + /** Irreversible, and on the relay side only — the vault is erased separately. */ + fun deleteAccount() { + raw("api/flamenet/v1/messenger/account", "DELETE", null) + } + + /** Exact-username lookup. Deliberately not a directory: no listing exists. */ + fun lookup(username: String): Me? = runCatching { + raw("api/wp/v2/users", "GET", null, query = mapOf("slug" to username)).let { + Me(it.getLong("id"), it.optString("name"), it.optString("slug")) + } + }.getOrNull() + // -- Internals ----------------------------------------------------------- private fun url(path: String) = base.newBuilder() .addPathSegments("api/flamenet/v1/messenger/$path").build() + /** For the routes that are not under the messenger prefix. */ + private fun raw( + path: String, + method: String, + body: JSONObject?, + query: Map = emptyMap(), + ): JSONObject { + val target = base.newBuilder().addPathSegments(path) + .also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } }.build() + val builder = Request.Builder().url(target).header("Accept", "application/json") + when (method) { + "GET" -> builder.get() + "DELETE" -> builder.delete() + else -> builder.post((body ?: JSONObject()).toString().toRequestBody(JSON)) + } + return send(builder) + } + private fun get(path: String) = send( Request.Builder().url(url(path)).get().header("Accept", "application/json")) diff --git a/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt index b0ae8ee..8e71d2b 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt @@ -174,6 +174,45 @@ class LiveConversationTest { } } + /** + * An image, end to end. + * + * ⚠️ This is the exact path that was **401 on iOS for months**: the + * attachment routes were sending the account password, and the relay + * refuses that on `/e2e/…`. The upload here goes through the same + * device-scoped token and per-request signature as everything else, and + * this test is what says so. + */ + @Test + fun `an image crosses end to end and the relay cannot read it`() { + val base = fleet ?: return + val alice = signUp(base, "i") + val bob = signUp(base, "j") + + // Not random bytes: a recognisable marker, so "the relay never saw it" + // is a claim this test can actually check. + val marker = "PNG-MARKER-7f3c-not-encrypted".toByteArray() + val picture = marker + ByteArray(4096) { (it % 251).toByte() } + + val sent = alice.conversations.sendAttachment( + bob.userId, picture, "image/png", width = 64, height = 48) + assertTrue(sent.reachedSomeone, "nothing delivered: ${sent.failed}") + + val received = bob.conversations.poll().single() + val ref = received.content.attachment + assertTrue(ref != null, "no attachment reference arrived; text was '${received.text}'") + assertEquals("image/png", ref!!.mime) + assertEquals(64, ref.width) + + val opened = bob.conversations.fetchAttachment(ref) + assertTrue(opened.contentEquals(picture), "the image came back different") + + // What the relay stores must not contain the marker. + val stored = bob.relay.downloadAttachment(ref.id) + assertTrue(!String(stored, Charsets.ISO_8859_1).contains("PNG-MARKER"), + "the relay is holding the plaintext") + } + private val appPasswords = HashMap() private fun appPasswordOf(person: Person): String = diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt new file mode 100644 index 0000000..39cd9cd --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -0,0 +1,361 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import java.security.SecureRandom + +/** + * What one decrypted message *means* (spec §6.6–§6.9). + * + * The ratchet stays content-agnostic: it moves opaque strings. A string that + * begins with the marker below **and parses** is a content envelope carrying a + * caption, an attachment reference, group routing, a reply, a reaction or a + * call signal. Anything else is a plain text message, byte for byte. + * + * ⚠️ **That fallback is load-bearing.** It is what makes every message sent + * before content envelopes existed still render, and it is why the marker + * check must be a prefix test on the serialised bytes rather than "try to + * parse it as JSON" — a message whose text happens to be valid JSON is still + * a text message. + * + * ⚠️ **`fnc` must be encoded first**, because the marker is a prefix. A JSON + * library that sorts keys puts `att` first and every envelope it produces + * silently becomes plain text on the far side, rendering as raw JSON in + * somebody's chat. The top level is therefore assembled by hand. + */ +data class Content( + val text: String, + /** + * What this envelope claims to be. Kept rather than collapsed into flags, + * because the caller — not the parser — is what knows whether an unknown + * kind should be shown, consumed, or logged. + */ + val kind: String = Kind.TEXT, + /** + * The whole envelope, for kinds this engine does not interpret. + * + * ⚠️ Control messages travel down the same channel as chat: the web client + * sends `delivery_key`, `kt_head` and `sent` copies this way. A client that + * renders every envelope it does not understand shows those to the user as + * a bubble full of JSON, which is exactly what happens without this. + */ + val raw: JSONObject? = null, + val attachment: Attachment.Ref? = null, + /** Group id, when this message is routed to a group (§6.7). */ + val groupId: String? = null, + /** A stable id for this message, so a reply or reaction can name it. */ + val messageId: String? = null, + /** What this replies to, or reacts to. */ + val about: Reference? = null, + /** Set iff this is a reaction: the emoji, or empty to take one back. */ + val reaction: String? = null, + val call: CallSignal? = null, +) { + + /** + * A pointer at an earlier message. + * + * ⚠️ Carries `preview` as well as `messageId` deliberately. A device that + * joined later, or cleared its history, has never seen the quoted message — + * and a reply rendering as "> [missing]" is worse than one that shows what + * was said. The preview is the sender's copy of their own words, the only + * copy anyone can be sure of. + */ + data class Reference(val messageId: String, val author: Long, val preview: String) { + fun toJson(): JSONObject = JSONObject() + .put("mid", messageId).put("author", author).put("preview", preview) + + companion object { + fun fromJson(json: JSONObject) = Reference( + messageId = json.optString("mid"), + author = json.optLong("author"), + preview = json.optString("preview"), + ) + } + } + + /** + * Call signalling riding the ratchet (§6.8). + * + * The SDP — and with it the DTLS fingerprint the media keys hang off — + * travels inside the ratchet, so the call is end-to-end authenticated with + * no extra key agreement. Never rendered as a message. + */ + data class CallSignal( + val action: String, + val callId: String, + val sdp: String? = null, + val reason: String? = null, + ) { + companion object { + const val OFFER = "call-offer" + const val ANSWER = "call-answer" + const val END = "call-end" + val ACTIONS = setOf(OFFER, ANSWER, END) + } + } + + /** + * True when this belongs in a thread. + * + * Reactions fold onto an existing bubble, call signals drive the call UI, + * and control messages are consumed silently. Everything else is a message. + */ + val isRenderable: Boolean + get() = reaction == null && call == null && kind !in Kind.CONTROL + + object Kind { + const val TEXT = "text" + const val REACT = "react" + + /** A contact's delivery key, so we can be reached sealed (§14.4). */ + const val DELIVERY_KEY = "delivery_key" + + /** A gossiped transparency-log head (§19.4). */ + const val KEY_LOG_HEAD = "kt_head" + + /** A copy of a message another of OUR devices sent (§17.2). */ + const val SENT_COPY = "sent" + + /** + * ⚠️ Consumed, never shown. Each of these is a real message the web + * client sends today, and rendering one puts raw JSON in somebody's + * chat. + */ + val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY) + } + + companion object { + /** A plaintext is an envelope iff it starts with exactly this AND parses. */ + private const val MARKER = "{\"fnc\":" + + // -- Encoding -------------------------------------------------------- + + /** + * Plain 1:1 text with no id and no reply stays **raw UTF-8**. + * + * Not an optimisation. A client that predates content envelopes shows + * an envelope as literal JSON, so the safe default for the simplest + * message is the format every client has always understood. + */ + fun text(body: String, messageId: String? = null, about: Reference? = null): String = + if (messageId == null && about == null) body + else assemble("text", caption = body, messageId = messageId, about = about) + + fun groupText( + body: String, + groupId: String, + messageId: String? = null, + about: Reference? = null, + ): String = assemble("text", caption = body, groupId = groupId, + messageId = messageId, about = about) + + fun attachment( + ref: Attachment.Ref, + caption: String? = null, + groupId: String? = null, + messageId: String? = null, + about: Reference? = null, + ): String = assemble( + kind = ref.kind, + caption = caption?.takeIf { it.isNotEmpty() }, + expires = ref.expires.takeIf { ref.kind == Attachment.STORY && it > 0 }, + groupId = groupId, + attachment = ref, + messageId = messageId, + about = about, + ) + + /** + * ⚠️ An empty emoji takes a previous reaction back. Removal has to be a + * message rather than an absence: the other device has already rendered + * the first one, and there is no other way to reach it. + */ + fun reaction(emoji: String, about: Reference, groupId: String? = null): String = + assemble("react", caption = emoji, groupId = groupId, about = about) + + fun callSignal(signal: CallSignal): String { + val call = JSONObject().put("id", signal.callId) + signal.sdp?.let { call.put("sdp", it) } + signal.reason?.let { call.put("reason", it) } + return """{"fnc":1,"kind":${quote(signal.action)},"call":$call}""" + } + + private fun assemble( + kind: String, + caption: String? = null, + expires: Double? = null, + groupId: String? = null, + attachment: Attachment.Ref? = null, + messageId: String? = null, + about: Reference? = null, + ): String { + val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}") + messageId?.let { parts += "\"mid\":${quote(it)}" } + about?.let { parts += "\"re\":${it.toJson()}" } + caption?.let { parts += "\"caption\":${quote(it)}" } + expires?.let { parts += "\"exp\":$it" } + groupId?.let { parts += "\"gid\":${quote(it)}" } + attachment?.let { parts += "\"att\":${it.toJson()}" } + return parts.joinToString(",", prefix = "{", postfix = "}") + } + + /** JSON string escaping, via the library, so control characters survive. */ + private fun quote(value: String): String = JSONObject.quote(value) + + // -- Decoding -------------------------------------------------------- + + /** + * ⚠️ An envelope of an unknown kind renders as a visible label rather + * than vanishing. The sender meant to say something, and a message that + * silently disappears is the worst possible way to handle a version + * this client does not know about. + */ + fun parse(plaintext: String): Content { + if (!plaintext.startsWith(MARKER)) return Content(plaintext) + val json = runCatching { JSONObject(plaintext) }.getOrNull() + ?: return Content(plaintext) + if (json.optInt("fnc") != 1) return Content(plaintext) + + val kind = json.optString("kind") + val groupId = json.optString("gid").takeIf { it.isNotEmpty() } + val messageId = json.optString("mid").takeIf { it.isNotEmpty() } + val about = json.optJSONObject("re")?.let(Reference::fromJson) + val caption = json.optString("caption") + + // A reaction is not a message and must never become a bubble. + // Checked before everything else so a field-ordering change cannot + // let one fall through and render as an empty line in the thread. + if (kind == Kind.REACT && about != null) { + return Content("", kind = kind, raw = json, groupId = groupId, + messageId = messageId, about = about, reaction = caption) + } + + // Control traffic. Returned with its envelope so the caller can act + // on it, and never renderable. + if (kind in Kind.CONTROL) return Content("", kind = kind, raw = json) + + if (kind in CallSignal.ACTIONS) { + val call = json.optJSONObject("call") ?: return Content("", kind = kind, raw = json) + return Content("", kind = kind, raw = json, call = CallSignal( + action = kind, + callId = call.optString("id"), + sdp = call.optString("sdp").takeIf { it.isNotEmpty() }, + reason = call.optString("reason").takeIf { it.isNotEmpty() }, + )) + } + + if (kind == Kind.TEXT) { + return Content(caption, kind = kind, raw = json, groupId = groupId, + messageId = messageId, about = about) + } + + val ref = json.optJSONObject("att") + ?.let { Attachment.Ref.fromJson(kind, it, json.optDouble("exp", 0.0)) } + ?: return Content( + // The sender meant to say something; a message that + // silently vanishes is the worst way to meet a version this + // client does not know. + caption.ifEmpty { "[unsupported attachment]" }, + kind = kind, raw = json, + groupId = groupId, messageId = messageId, about = about, + ) + return Content(caption, kind = kind, raw = json, attachment = ref, + groupId = groupId, messageId = messageId, about = about) + } + } +} + +/** + * Encrypted blobs (spec §6.6). + * + * A fresh key per attachment, and the key travels **only inside the ratchet**. + * The relay stores and serves a blob it cannot read; its id is a download + * capability and nothing more. + */ +object Attachment { + + const val IMAGE = "image" + const val AUDIO = "audio" + + /** An image for the story strip rather than a thread. */ + const val STORY = "story" + + private val random = SecureRandom() + + /** Everything needed to fetch, verify and open a blob. */ + data class Ref( + val kind: String, + val id: String, + val key: ByteArray, + /** SHA-256 of the uploaded **ciphertext**, so it can be checked before decrypting. */ + val digest: ByteArray, + val bytes: Int, + val mime: String, + /** Image and story only; the bubble reserves space before the bytes arrive. */ + val width: Int = 0, + val height: Int = 0, + /** Audio only, seconds. */ + val duration: Double = 0.0, + /** Story only: unix seconds after which it must not render. */ + val expires: Double = 0.0, + ) { + fun toJson(): JSONObject = JSONObject() + .put("id", id) + .put("key", Bytes.b64(key)) + .put("digest", Bytes.b64(digest)) + .put("bytes", bytes) + .put("mime", mime) + .put("w", width) + .put("h", height) + .also { if (kind == AUDIO) it.put("dur", duration) } + + companion object { + fun fromJson(kind: String, json: JSONObject, expires: Double): Ref? { + val key = runCatching { Bytes.unb64(json.optString("key")) }.getOrNull() + val digest = runCatching { Bytes.unb64(json.optString("digest")) }.getOrNull() + // Wrong-sized key material is a malformed reference, not a + // decryption to attempt. Returning null renders the caption + // with a label; trying anyway throws somewhere less useful. + if (key == null || key.size != 32 || digest == null || digest.size != 32) return null + return Ref( + kind = kind, + id = json.optString("id"), + key = key, + digest = digest, + bytes = json.optInt("bytes"), + mime = json.optString("mime"), + width = json.optInt("w"), + height = json.optInt("h"), + duration = json.optDouble("dur", 0.0), + expires = expires, + ) + } + } + } + + /** The blob to upload, and the key and digest that go in the message. */ + data class Sealed(val blob: ByteArray, val key: ByteArray, val digest: ByteArray) + + fun seal(data: ByteArray): Sealed { + val key = ByteArray(32).also(random::nextBytes) + val nonce = ByteArray(12).also(random::nextBytes) + val blob = Aead.seal(key, nonce, data) + return Sealed(blob, key, Bytes.sha256(blob)) + } + + /** + * Open a downloaded blob. + * + * ⚠️ **The digest is checked before decrypting.** The relay handed us these + * bytes and only the ratchet-delivered reference says what they should be. + * AES-GCM would catch a substitution too, but failing on the digest names + * the problem — "the server sent different bytes" — instead of reporting it + * as a decryption failure indistinguishable from a broken session. + */ + fun open(blob: ByteArray, ref: Ref): ByteArray { + if (!Bytes.constantTimeEquals(Bytes.sha256(blob), ref.digest)) { + throw SecurityException("attachment digest mismatch: the relay served different bytes") + } + return Aead.open(ref.key, blob) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt new file mode 100644 index 0000000..aad38a4 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt @@ -0,0 +1,201 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ContentTest { + + private fun ref(kind: String = Attachment.IMAGE) = Attachment.Ref( + kind = kind, + id = "blob-1", + key = ByteArray(32) { it.toByte() }, + digest = ByteArray(32) { (it * 3).toByte() }, + bytes = 1234, + mime = "image/jpeg", + width = 800, + height = 600, + ) + + /** + * ⚠️ The marker is a **prefix test on the bytes**, so `fnc` must be + * serialised first. A JSON library that sorts keys puts `att` first, every + * envelope it produces reads as plain text on the far side, and somebody's + * chat fills with raw JSON. + */ + @Test + fun `every envelope starts with the marker`() { + val envelopes = listOf( + Content.text("hi", messageId = "m1"), + Content.groupText("hi", groupId = "g1"), + Content.attachment(ref(), caption = "look"), + Content.reaction("👍", Content.Reference("m1", 7, "hi")), + Content.callSignal(Content.CallSignal(Content.CallSignal.OFFER, "c1", sdp = "v=0")), + ) + envelopes.forEach { assertTrue(it.startsWith("{\"fnc\":1,"), "bad prefix: $it") } + } + + /** + * ⚠️ Plain 1:1 text stays raw UTF-8. A client predating content envelopes + * shows an envelope as literal JSON, so the simplest message keeps the + * format every client has always understood. + */ + @Test + fun `plain text is not wrapped at all`() { + assertEquals("just a message", Content.text("just a message")) + assertEquals("just a message", Content.parse("just a message").text) + } + + @Test + fun `a message whose text happens to be JSON is still text`() { + val tricky = """{"kind":"react","caption":"nope"}""" + val parsed = Content.parse(tricky) + assertEquals(tricky, parsed.text) + assertNull(parsed.reaction) + } + + /** Not an envelope, because `fnc` is not 1. Must not be reinterpreted. */ + @Test + fun `an envelope from a future version reads as text rather than vanishing`() { + val future = """{"fnc":9,"kind":"hologram","caption":"hello"}""" + assertEquals(future, Content.parse(future).text) + } + + @Test + fun `an attachment survives the round trip`() { + val encoded = Content.attachment(ref(), caption = "a caption") + val parsed = Content.parse(encoded) + assertEquals("a caption", parsed.text) + assertEquals(Attachment.IMAGE, parsed.kind) + val got = parsed.attachment!! + assertEquals("blob-1", got.id) + assertEquals(800, got.width) + assertEquals("image/jpeg", got.mime) + assertContentEquals(ref().key, got.key) + assertContentEquals(ref().digest, got.digest) + assertTrue(parsed.isRenderable) + } + + @Test + fun `a reaction never becomes a message`() { + val about = Content.Reference("m1", 7, "the original") + val parsed = Content.parse(Content.reaction("🔥", about)) + assertEquals("🔥", parsed.reaction) + assertEquals("", parsed.text) + assertEquals("m1", parsed.about?.messageId) + assertFalse(parsed.isRenderable) + + // An empty emoji takes one back — a message, not an absence, because + // the other device has already rendered the first one. + assertEquals("", Content.parse(Content.reaction("", about)).reaction) + } + + /** + * ⚠️ The web client sends `delivery_key`, `kt_head` and `sent` down this + * same channel. A client that renders every envelope it does not recognise + * shows those to the user as a bubble full of JSON. + */ + @Test + fun `control messages are consumed rather than shown`() { + for (kind in listOf("delivery_key", "kt_head", "sent")) { + val parsed = Content.parse("""{"fnc":1,"kind":"$kind","key":"abc","to":3}""") + assertEquals(kind, parsed.kind) + assertFalse(parsed.isRenderable, "$kind would have been rendered") + assertEquals("", parsed.text) + // The envelope survives so a caller can act on it. + assertEquals("abc", parsed.raw?.optString("key")) + } + } + + @Test + fun `call signals are not messages`() { + val encoded = Content.callSignal( + Content.CallSignal(Content.CallSignal.OFFER, "call-9", sdp = "v=0\r\n")) + val parsed = Content.parse(encoded) + assertEquals("call-9", parsed.call?.callId) + assertEquals("v=0\r\n", parsed.call?.sdp) + assertFalse(parsed.isRenderable) + } + + @Test + fun `a reply carries its own preview`() { + val about = Content.Reference("m7", 42, "what was said before") + val parsed = Content.parse(Content.text("answering", messageId = "m8", about = about)) + assertEquals("answering", parsed.text) + assertEquals("what was said before", parsed.about?.preview) + assertEquals(42, parsed.about?.author) + } + + @Test + fun `group routing survives`() { + val parsed = Content.parse(Content.groupText("to everyone", groupId = "g-99")) + assertEquals("g-99", parsed.groupId) + assertEquals("to everyone", parsed.text) + } + + @Test + fun `an unknown attachment kind shows a label rather than disappearing`() { + val parsed = Content.parse("""{"fnc":1,"kind":"hologram"}""") + assertEquals("[unsupported attachment]", parsed.text) + assertTrue(parsed.isRenderable) + } + + @Test + fun `a malformed key in a reference is refused, not used`() { + val short = """{"fnc":1,"kind":"image","att":{"id":"x","key":"AAAA","digest":"AAAA"}}""" + val parsed = Content.parse(short) + assertNull(parsed.attachment) + assertEquals("[unsupported attachment]", parsed.text) + } + + @Test + fun `captions with quotes and newlines survive`() { + val awkward = "she said \"hello\"\nand \\ left" + assertEquals(awkward, Content.parse(Content.groupText(awkward, "g1")).text) + } + + // -- Blobs --------------------------------------------------------------- + + @Test + fun `a blob round-trips`() { + val data = ByteArray(5000) { (it % 251).toByte() } + val sealed = Attachment.seal(data) + val reference = ref().copy(key = sealed.key, digest = sealed.digest) + assertContentEquals(data, Attachment.open(sealed.blob, reference)) + } + + @Test + fun `two attachments never share a key`() { + val a = Attachment.seal(ByteArray(16)) + val b = Attachment.seal(ByteArray(16)) + assertFalse(a.key.contentEquals(b.key)) + } + + /** + * ⚠️ The digest is checked BEFORE decrypting. AES-GCM would catch this too, + * but failing on the digest names the problem — "the relay served different + * bytes" — instead of reporting it as a decryption failure indistinguishable + * from a broken session. + */ + @Test + fun `a substituted blob is refused by digest, not by the cipher`() { + val sealed = Attachment.seal("secret".toByteArray()) + val bent = sealed.blob.copyOf().also { it[it.size / 2] = (it[it.size / 2] + 1).toByte() } + val failure = assertFailsWith { + Attachment.open(bent, ref().copy(key = sealed.key, digest = sealed.digest)) + } + assertTrue(failure.message!!.contains("digest")) + } + + @Test + fun `the wrong key does not open a blob`() { + val sealed = Attachment.seal("secret".toByteArray()) + assertFailsWith { + Attachment.open(sealed.blob, ref().copy(key = ByteArray(32), digest = sealed.digest)) + } + } +}