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
@@ -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))
}
}
}