Android: photos, and a door for every capability

Owner feedback, and it was fair: capabilities 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. All
built, none reachable.

So this is the CRUD pass, plus attachments.

New in the UI: contact actions (block, unblock, clear chat, remove), a presence
picker, a notifications screen with per-item and mark-all-read, an account
screen with display name, password change and account deletion, password reset
on the sign-in screen, bottom navigation, and photos -- sent through the system
picker, rendered inline.

`CrudSurfaceTest` walks the app and asserts the controls exist. It checks
reachability rather than behaviour, because behaviour is what the live tests
cover and the door is what was missing. It found two real gaps immediately:
sign-out was reachable only from the contact list rather than from Account,
where people look for it, and several controls sat below the fold.

Underneath: the §6.6-§6.9 content envelope, which is one codec for attachments,
replies, reactions, group routing and call signalling. 17 unit tests.

Two things it has to get right, and both are one-line mistakes:

`fnc` must serialise FIRST, because the marker is a prefix test on the bytes. 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. The top
level is assembled by hand.

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 recognise shows those to the user. They are now
parsed, marked unrenderable, and handed to the caller with their payload.

Attachments verified end to end against a real relay, including that the relay
holds only ciphertext. That path was 401 on iOS for months because the
attachment routes sent the account password, which the relay refuses on
`/e2e/…`; this one uses the device-scoped token like everything else, and the
test is what says so.

Also: the instrumented suite passed one test at a time and failed as a set. The
Compose rule gives each test a fresh Activity but the process and vault survive,
so the app spends its first moments reading the vault behind a spinner -- and a
reset that checks for a signed-in control immediately concludes "already signed
out" while the session restores underneath it.

114 JVM tests, 37 cross-engine, 15 on-device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-23 18:47:45 -04:00
co-authored by Claude Opus 5
parent d30a77dd1e
commit e65d1d987b
13 changed files with 1736 additions and 58 deletions
+35 -13
View File
@@ -15,8 +15,8 @@ screen renders.
| | iOS | Android | | | iOS | Android |
|---|---|---| |---|---|---|
| Protocol engine | `Sources/E2E`, 15 files | `engine`, 10 files | | Protocol engine | `Sources/E2E`, 15 files | `engine`, 10 files |
| App layer | ~9,700 lines, 45 files | accounts, contacts, chat | | App layer | ~9,700 lines, 45 files | accounts, contacts, chat, photos |
| Tests | XCUITest + JS interop | 96 unit + 37 cross-engine + 11 on-device + live round trips | | 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 | | 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** 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 | | Transparency log (`KeyLog`) | ✅ | ✅ | canonical leaf + inclusion proofs, cross-engine verified |
| Account KDF / fnid | ✅ | ✅ | both pinned to the published vector file | | Account KDF / fnid | ✅ | ✅ | both pinned to the published vector file |
| Sealed sender | ✅ | ⚪ | `E2E/SealedSender.swift` | | 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 | | Envelope + wire codec | ✅ | ✅ | cross-engine verified at the envelope level |
| Session archive / restore | ✅ | ✅ | cross-engine verified, in-flight messages included | | Session archive / restore | ✅ | ✅ | cross-engine verified, in-flight messages included |
| Device identity + prekey rotation | ✅ | ✅ | retired prekeys retained past the relay window | | 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 | | 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-KEM-768 (PQ agreement) | ⚠️ | ⚪ | see below |
| ML-DSA-65 (PQ identity) | ⚠️ | ⚪ | 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 up — managed / shared self-hosted | 🟡 built, not yet exercised end to end |
| Sign in, session restore | ✅ | | Sign in, session restore | ✅ |
| Sign out (erases the vault key) | ✅ | | Sign out (erases the vault key) | ✅ |
| Password reset request + confirm | ⚪ | | Password reset request + confirm | ✅ on the sign-in screen |
| Change password | ⚪ | | Change password | ✅ signs the device out, because that is what it does |
| Delete account | ⚪ | | Delete account | ✅ confirms in place, erases the phone too |
| Relay picker / `SiteConfig` equivalent | ⚪ | | Relay picker / `SiteConfig` equivalent | ⚪ |
| Deterministic local-relay KDF salt | ⚪ | | Deterministic local-relay KDF salt | ⚪ |
| Multi-homing (publish + read home records) | ⚪ | | 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 | | Multi-device fan-out | ✅ one envelope per device, partial delivery reported |
| History at rest | ✅ encrypted, keyed by person not device | | History at rest | ✅ encrypted, keyed by person not device |
| Session restore across restarts | ✅ credential and device both survive | | Session restore across restarts | ✅ credential and device both survive |
| Reactions | ⚪ | | Reactions | 🟡 codec done, no UI |
| Photos | ⚪ | | Photos | ✅ send via the system picker, render inline |
| Voice notes (record + play) | ⚪ | | Voice notes (record + play) | ⚪ |
| Stories, and seen-state | ⚪ | | Stories, and seen-state | ⚪ |
| Groups: create, list, members, leave | ⚪ | | Groups: create, list, members, leave | ⚪ |
@@ -100,15 +102,15 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
| Delivery keys + rotation | ⚪ | | Delivery keys + rotation | ⚪ |
| Trust state, verification, identity-change acceptance | ⚪ | | Trust state, verification, identity-change acceptance | ⚪ |
| Key-log enforcement and head gossip | ⚪ | | 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) | ⚪ | | 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 | ✅ | | Buddy list, add by username | ✅ |
| Remove, block | ⚪ built in `:client`, no UI | | Remove, block, unblock | ✅ |
| Member lookup | ⚪ | | Member lookup | ⚪ |
| Buddy commands | ⚪ | | Buddy commands | ⚪ |
| Voice calls (WebRTC, DTLS-SRTP, TURN) | ⚪ | | Voice calls (WebRTC, DTLS-SRTP, TURN) | ⚪ |
| Notifications list, read state | ⚪ | | Notifications list, read state | ✅ |
| Services (AIM/ICQ via `flamenet-oscar`) | ⚪ | | Services (AIM/ICQ via `flamenet-oscar`) | ⚪ |
⚠️ Calls need a WebRTC dependency on Android as they do on iOS. That is the one ⚠️ 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.~~ ✅ 3. ~~**Vault** — Android Keystore, encryption at rest.~~ ✅
4. ~~**`:app` scaffolding** — Compose, sign-in/up/out, on-device tests.~~ ✅ 4. ~~**`:app` scaffolding** — Compose, sign-in/up/out, on-device tests.~~ ✅
5. ~~**Buddy list and chat.**~~ ✅ 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 7. **More instrumented tests** — the JVM tests prove nothing about Conscrypt. This
engine has already been bitten once by exactly that. 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 were findable without a real relay, which is why the live tests exist and why
they are opt-in rather than absent. 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 ## Known flakiness
⚠️ **The emulator's network drops occasionally** — seen twice as "Failed to ⚠️ **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 reachable from the host at the same moment. A rerun succeeds. Worth knowing
before treating a red instrumented run as a regression. 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 ⚠️ **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 first takes the message and the other reports zero, which reads as "nothing was
sent". Run a single `autoreply` at a time. sent". Run a single `autoreply` at a time.
@@ -43,10 +43,7 @@ class ChatFlowTest {
@Before @Before
fun startSignedOut() { fun startSignedOut() {
handle = "andc" + (System.currentTimeMillis() % 100_000_000) handle = "andc" + (System.currentTimeMillis() % 100_000_000)
if (nodesWithTag("sign-out").isNotEmpty()) { signOutIfSignedIn()
compose.onNodeWithTag("sign-out").performClick()
awaitTag("submit", 30_000)
}
} }
@Test @Test
@@ -138,6 +135,54 @@ class ChatFlowTest {
awaitTag("device-id", 120_000) 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<String>, 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<Any> = runCatching { private fun nodesWithTag(tag: String): List<Any> = runCatching {
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
@@ -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<MainActivity>()
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<String>, 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<Any> = 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)
}
@@ -53,9 +53,54 @@ class SignUpFlowTest {
// //
// Signing out through the UI is both safe and a truer test: it is what // 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. // a person does, and the view model reopens the vault afterwards.
if (nodesWithTag("sign-out").isNotEmpty()) { 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() compose.onNodeWithTag("sign-out").performClick()
awaitTag("submit", 30_000) awaitTag("relay", 30_000)
}
/** Wait until any one of these tags exists. */
private fun awaitAny(tags: List<String>, 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 if (!relayReachable()) return
aPersonCanCreateAnAccountOnASelfHostedRelay() aPersonCanCreateAnAccountOnASelfHostedRelay()
compose.onNodeWithTag("sign-out").performClick() signOutIfSignedIn()
awaitTag("submit", 30_000)
compose.onNodeWithTag("handle").assertIsDisplayed() compose.onNodeWithTag("handle").assertIsDisplayed()
} }
} }
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -21,6 +22,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -33,6 +35,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -96,6 +99,8 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
return return
} }
var expanded by remember { mutableStateOf<Long?>(null) }
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f).testTag("buddies")) { LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f).testTag("buddies")) {
items(phase.buddies, key = { it.userId }) { buddy -> items(phase.buddies, key = { it.userId }) { buddy ->
Row( Row(
@@ -133,12 +138,238 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
.testTag("unread-${buddy.userId}") .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() 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<android.graphics.Bitmap?>(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 @Composable
private fun PresenceDot(status: String) { private fun PresenceDot(status: String) {
val colour = when (status.lowercase()) { val colour = when (status.lowercase()) {
@@ -177,18 +408,42 @@ fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.P
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"), modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
items(chat.lines) { line -> Bubble(line) } items(chat.lines) { line -> Bubble(line, model) }
} }
error?.let { error?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error")) 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
TextButton(
onClick = { pickImage.launch("image/*") },
modifier = Modifier.testTag("attach"),
) { Text("Photo") }
OutlinedTextField( OutlinedTextField(
value = draft, value = draft,
onValueChange = { draft = it }, onValueChange = { draft = it },
@@ -207,7 +462,7 @@ fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.P
} }
@Composable @Composable
private fun Bubble(line: Session.Line) { private fun Bubble(line: Session.Line, model: MessengerViewModel) {
val time = remember(line.at) { val time = remember(line.at) {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(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), .padding(horizontal = 12.dp, vertical = 8.dp),
) { ) {
line.attachment?.let { AttachedImage(it, model) }
if (line.text.isNotEmpty()) {
Text( Text(
line.text, line.text,
color = if (line.mine) MaterialTheme.colorScheme.onPrimary color = if (line.mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface, else MaterialTheme.colorScheme.onSurface,
) )
}
Text( Text(
time, time,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -74,15 +75,24 @@ private fun Root(model: MessengerViewModel = viewModel()) {
is MessengerViewModel.Phase.SignedIn -> is MessengerViewModel.Phase.SignedIn ->
Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) {
val chat = current.openChat val chat = current.openChat
if (chat == null) SignedIn(model, current) if (chat != null) {
else ChatScreen(model, chat, current.error) 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 @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() val relay by model.relayInput.collectAsStateWithLifecycle()
var handle by remember { mutableStateOf("") } var handle by remember { mutableStateOf("") }
var passphrase 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") 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 @Composable
@@ -186,21 +302,12 @@ private fun ColumnScope.SignedIn(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("device-id")) modifier = Modifier.testTag("device-id"))
PresencePicker(model, phase.presence)
BuddyList(model, phase) 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 @Composable
private fun VaultUnreadable( private fun ColumnScope.VaultUnreadable(
model: MessengerViewModel, model: MessengerViewModel,
phase: MessengerViewModel.Phase.VaultUnreadable, phase: MessengerViewModel.Phase.VaultUnreadable,
) { ) {
@@ -39,6 +39,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val relay: String, val relay: String,
val status: String, val status: String,
val buddies: List<MessengerApi.Buddy> = emptyList(), val buddies: List<MessengerApi.Buddy> = emptyList(),
val blocked: Set<Long> = emptySet(),
val notifications: List<MessengerApi.Notification> = 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. */ /** People with unread mail, so the list can say so without opening each thread. */
val unread: Set<Long> = emptySet(), val unread: Set<Long> = emptySet(),
val openChat: Chat? = null, val openChat: Chat? = null,
@@ -46,6 +51,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val error: String? = null, val error: String? = null,
) : Phase ) : Phase
enum class Screen { CONTACTS, NOTIFICATIONS, ACCOUNT }
/** An open conversation: who with, and everything said so far. */ /** An open conversation: who with, and everything said so far. */
data class Chat( data class Chat(
val peerId: Long, val peerId: Long,
@@ -274,6 +281,182 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
fun closeChat() = update { it.copy(openChat = null) } 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<Application>().cacheDir) }
.getOrNull()
}
}
/** Run something against the API and refresh the contact list from the result. */
private fun act(block: (Session) -> List<MessengerApi.Buddy>) = 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 { fun sendMessage(text: String) = viewModelScope.launch {
val current = session ?: return@launch val current = session ?: return@launch
val chat = signedIn()?.openChat ?: return@launch val chat = signedIn()?.openChat ?: return@launch
@@ -3,6 +3,8 @@ package io.flamenet.messenger.app
import io.flamenet.client.Conversations import io.flamenet.client.Conversations
import io.flamenet.client.MessengerApi import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient import io.flamenet.client.RelayClient
import io.flamenet.e2e.Attachment
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault import io.flamenet.e2e.Vault
import org.json.JSONObject import org.json.JSONObject
@@ -25,19 +27,70 @@ class Session(
) { ) {
val conversations = Conversations(identity, vault, relay).also { it.myUserId = userId } 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<Line> { fun history(peerUserId: Long): List<Line> {
val stored = vault.history(peerUserId) val stored = vault.history(peerUserId)
return (0 until stored.length()).map { return (0 until stored.length()).map {
val m = stored.getJSONObject(it) 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()) { fun record(
vault.append(peerUserId, JSONObject().put("text", text).put("mine", mine).put("at", at)) 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 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<Long> { fun receive(): Set<Long> {
val arrived = conversations.poll() val arrived = conversations.poll().filter { it.content.isRenderable }
arrived.forEach { record(it.fromUser, it.text, mine = false) } arrived.forEach {
record(it.fromUser, it.content.text, mine = false, attachment = it.content.attachment)
}
return arrived.map { it.fromUser }.toSet() return arrived.map { it.fromUser }.toSet()
} }
} }
@@ -1,5 +1,7 @@
package io.flamenet.client package io.flamenet.client
import io.flamenet.e2e.Attachment
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.E2ESession import io.flamenet.e2e.E2ESession
import io.flamenet.e2e.Envelope import io.flamenet.e2e.Envelope
@@ -26,16 +28,28 @@ class Conversations(
private val relay: RelayClient, private val relay: RelayClient,
) { ) {
/** One decrypted message, and where it came from. */ /** One decrypted message, interpreted, and where it came from. */
data class Received( data class Received(
val id: Long, val id: Long,
val fromUser: Long, val fromUser: Long,
val fromDevice: String, 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. */ /** What happened to one send. Partial success is the normal case, not an error. */
data class Sent(val delivered: List<String>, val failed: Map<String, String>) { data class Sent(
val delivered: List<String>,
val failed: Map<String, String>,
/**
* 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() 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 * failure would turn "your tablet did not get it" into "the message was
* not sent", which is both wrong and unrecoverable. * 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 bundles = relay.fetchKeys(toUserId)
val delivered = mutableListOf<String>() val delivered = mutableListOf<String>()
val failed = LinkedHashMap<String, String>() val failed = LinkedHashMap<String, String>()
@@ -98,9 +152,12 @@ class Conversations(
val envelope = Envelope.fromPayload(message.payload) val envelope = Envelope.fromPayload(message.payload)
val session = existingSession(message.fromDevice) val session = existingSession(message.fromDevice)
?: E2ESession.respond(identity, envelope) ?: E2ESession.respond(identity, envelope)
val text = session.decrypt(envelope) val plaintext = session.decrypt(envelope)
vault.saveSession(message.fromDevice, session) 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) { } catch (_: Exception) {
// Deliberately swallowed and skipped. One undecryptable message // Deliberately swallowed and skipped. One undecryptable message
// is not a reason to stop reading the inbox. // is not a reason to stop reading the inbox.
@@ -114,11 +114,63 @@ class MessengerApi(
if (blocked) post("blocks/$userId", JSONObject()) else delete("blocks/$userId") 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 ----------------------------------------------------------- // -- Internals -----------------------------------------------------------
private fun url(path: String) = base.newBuilder() private fun url(path: String) = base.newBuilder()
.addPathSegments("api/flamenet/v1/messenger/$path").build() .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<String, String> = 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( private fun get(path: String) = send(
Request.Builder().url(url(path)).get().header("Accept", "application/json")) Request.Builder().url(url(path)).get().header("Accept", "application/json"))
@@ -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<String, String>() private val appPasswords = HashMap<String, String>()
private fun appPasswordOf(person: Person): String = private fun appPasswordOf(person: Person): String =
@@ -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)
}
}
@@ -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<SecurityException> {
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<Aead.DecryptionFailed> {
Attachment.open(sealed.blob, ref().copy(key = ByteArray(32), digest = sealed.digest))
}
}
}