b9174079baa7b55db29dea0fb4b6f6efc09f789f
240
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
86550f3f61 |
Stories as a feed you scroll, which is the kit's Timeline
A screen, not a strip. Stories were a horizontal row of circles on the Messages screen -- enough to tell you something exists and nothing more. The kit's Timeline is a column of full-width cards: author over a description line, the image, the caption. A card feed cannot sit above a contact list without pushing it off the phone, so it gets a tab; the strip stays on Messages as the entry point. ⚠️ No like-or-comment bar, and the kit's post card has one -- two emoji reactions and a share icon. A story here has none of those: no comment thread, no reaction, nothing to share it to. Drawing the bar and wiring it to nothing would be a lie told in pixels. What a story DOES have that the kit's post does not is a deadline, so that is what sits in the slot the kit gives a description -- the one fact about a story that changes while you are looking at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
87e96761cc |
The kit's palette, not just its layout
I kept Flamenet orange and defended it as brand continuity. That was wrong on the facts: the orange had already been retired and the public pages carry none of it, so the apps were the last thing wearing a brand colour the brand had dropped — on the authority of a comment in Theme.kt that nobody had rechecked. The comment is corrected in place so nobody restores it for the same stale reason. The values are measured out of the Sketch file rather than sampled from a render. The two text colours are the ones that actually appear in its symbols — #090710 on 165 text layers, #505C69 on 164 — and the accent #2298FF is what it uses at 12pt as well as on buttons, so it is their link colour and not only a fill. The constants were called Ember and held orange. They are Accent now: a constant named for a colour it no longer holds is how a palette drifts back. ⚠️ Recorded because it will come up: #2298FF on white is about 3.3:1 — fine for a bold button label, not for small text. The small text on these screens is grey, and it needs to stay that way. Also in this commit, the Android sign-in takes the kit's Welcome layout: a logo mark, a 40pt greeting and one line saying what this is, centred with room above them. It was a 24pt product name jammed against a Server field, which is the shape of a login dialog rather than the first thing anyone sees of the app. The submit button is 240 wide and centred, as the kit draws it — a button spanning the phone reads as the bottom of a form; one that does not reads as a choice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5a592e9858 |
Design pass with the screens actually in front of me
The JVM harness renders whole screens now, not just parts. The screens take their state as `phase` and only touch the view model when something is tapped, so an unstarted model is enough — it opens the Keystore vault in `init`, which fails inside a runCatching on a background dispatcher under Robolectric. That is why this works rather than by design; if it starts hanging, look there first. With the screens visible at the kit's own 375x812 canvas, four things were obvious that were not from inside the code: 🔴 The large title said "fnreviewalpha" over a relay and a device id. The kit's title names the SCREEN — "Messages". Three lines about who you are, at the top of a list about other people, and it dominated because a handle is longer than a title. Identity belongs on Account, where it already was. Contact rows read "fnreviewbeta / fnreviewbeta · online" — the name twice and a status — because the handle is usually the same string as the display name. It is dropped when it matches. "No groups yet. Start one with your buddies." was a line of prose in the middle of a list, with a section label above it, for the ordinary state of a new account: two lines of furniture for zero rows. Both go when the section is empty AND loaded. The failure case still speaks, because the reason that message existed is real — an empty list rendering nothing gave no way to tell "you have none" from "it did not load". Alerts offered "Mark all read" on an empty screen, where its only possible effect was nothing, and it was the most prominent thing there. "Nothing new." sat against the heading like a subtitle of it. Proper empty state now, and the button appears only when something is unread. Alert rows drew initials of the KIND, so "contact request" became "CR" — two letters that look like a person's initials and name nobody. Address card: the QR was the largest thing on the Account screen and pushed everything below the fold, and the address wrapped mid-hostname as "…@rela / y.flamenet.io". A relay name split across a line break is the kind of thing somebody retypes wrong, so the host is its own line now. Two fields whose placeholder repeated the label above them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d8b8d63687 |
Render Android screens without an emulator
Verifying a layout meant booting an 8 GB AVD, installing an APK, tapping to a screen and pulling a screencap — and the emulator was killed by memory pressure four times in one session, each time mid-check. Robolectric in native-graphics mode draws the same Compose pixels on the JVM. `Scripts/screenshots.sh` now renders the screens in about four seconds with no device. Two things had to be got right and both fail in a way that reads as something else: `isIncludeAndroidResources` is not optional — without it the theme cannot resolve and every render throws on a missing resource, which looks like a Compose problem rather than a configuration one. 🔴 And the capture. `captureToImage()` goes through PixelCopy and waits for a frame-commit callback Robolectric never delivers, so every render died as "Condition still not satisfied after 2000 ms" — which reads as a hung composition and is nothing of the sort. Drawing the decor view into a software canvas produces the same pixels with no window involved. `createComposeRule()` has the same problem for the same reason; it needs the Activity rule. ⚠️ These are output, not assertions: they write files and pass unless something throws. A screenshot test that diffs against a stored baseline is a different thing, worth adding once the design stops moving — right now it is moving, and a baseline would be re-blessed on every change, which proves nothing. This immediately paid for itself. The address card had only ever been seen in its empty state, because the emulator's stored session predates the fnid and a fresh sign-in erases that phone's keys. It renders populated here, and the QR in it — encoded by our own Kotlin encoder, drawn by the app's own Compose code — decodes back to the exact address through CoreImage's detector, which shares no line with any of it. Rendered at the kit's own 375x812 canvas, so a PNG can be put beside the Sketch artboard rather than mentally rescaled from a 1080x2400 phone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0a452b9657 |
Controls, not a page of orange words
Reported as "looks like a bunch of text links", with the honest caveat of not knowing whether that is normal for Android. It is not, and it is not the kit either: almost every action in the app was a bare TextButton -- Back, Call, Safety number, Record a voice note, Mark all read, Look up, Leave, Create group, and the four presence states -- all the same undifferentiated orange text with nothing around it. That is not an Android convention; it is the absence of one. The kit ships three buttons, all 64 tall at radius 32 with a 14 medium label: a filled primary, an outlined secondary, a grey tertiary. Plus a small chip for something sitting in a row of its peers. Presence was the worst of it -- four orange words that gave no sign of being a single choice with one answer; the selected one was merely bold. Chips now. Accepting a changed identity key is the most consequential tap in the app and sat as matching orange text beside "Show number", so the two read as interchangeable; it is an outlined danger button now and the other is a chip. Bar actions became glyphs in tappable circles with real accessibility labels, rather than words. No icon set is pulled in for eight buttons. Also in this pass: section headings shrank to 12sp labels and their buttons moved into the app bar as icons. Two heading rows -- "Groups / New" and "Contacts / Add" -- took the top third of a screen whose whole content was two rows, and the kit's list has no headings at all. iOS gets the same treatment: the tab bar is the kit's floating pill rather than a TabView strip (PillTabBar records what the system bar was doing for us that now has to be done by hand, including the one thing that does not come back -- scroll-to-top), the list screen lost its cards, and settings moved off .insetGrouped, which draws every section as a floating card, onto plain rows separated by the kit's 16pt band. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7218a9b175 |
Android: the layout, not just the parts inside it
The last pass changed row internals and left the screen architecture alone, and the screen architecture is what makes the kit look like the kit. Reported back as "looks exactly the same and nothing like the sketch", which was accurate. Four cards -- a presence picker, a paragraph about what stories are, contacts, groups -- each on its own white surface with a heading. The kit's messages screen is one flat list under a title, a search box and a strip of stories. No amount of restyling the rows inside those cards was going to get there; the cards were the thing. 🔴 The type scale was the other half, and it was invisible from inside a single screen. Material 3's defaults are considerably larger than the kit's, so a section heading rendered at 24sp where the kit says 16 and every screen inherited it. Set once in the theme, which fixes screens nobody has touched. Stories was a heading, a Post button, and -- with nothing posted, which is the normal state -- three lines of prose explaining what a story is. Prose where the design has a row of circles is the loudest possible signal that this is not the same app. It is a strip of faces now, with the add-circle always first so there is always somewhere to tap. The tab bar is the kit's floating pill rather than a row of text buttons; it is the most recognisable single element in the design. Group rows carry the same avatar and the same 72dp pitch as contact rows, so the two read as one list rather than two that follow each other. The presence picker moved to Account, where the kit would put it, instead of being dropped with the card it lived in. The buddy list is plain rows in the page's own scroll now. It was a lazy list bounded at 320dp -- a small scrolling window inside a scrolling page, which is exactly what a flat list is not. The bound existed because a lazy list in a scrolling Column is measured with infinite height and crashes; rendering rows directly sidesteps that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b48d71a75a |
Take the design kit's layout, keep Flamenet orange
Measured out of `Messenger App V2.sketch` by reading the file's JSON rather than eyeballing a render, and written down in DESIGN.md so both apps cite one source instead of drifting. The layout is theirs; the colour is ours. The kit's primary is blue and adopting it would mean the app no longer looks like the product it belongs to, so everywhere the kit says primary, read Flamenet orange. Only #F3F3F3 is taken directly, as the received-bubble and pill fill. The typeface is not adopted either -- bundling DM Sans is a licensing decision, not a layout one -- so the sizes, weights and line heights land on the system face. 🔴 The bubble corners are asymmetric and that is their whole character: radius 16 everywhere except one corner at 2, which points at its author -- bottom-right on a message you sent, top-left on one you received. Rounding all four equally is what makes a chat app look like every other chat app, and it is exactly what gets lost when a spec is taken from a screenshot. Only reading the per-corner radii out of the JSON found it; the symbol's fixedRadius reports a single number and is misleading. Conversation rows now have the kit's 48pt avatar, 16pt gutter and 16/14 type. Android had no avatar at all -- a coloured dot beside two lines of text -- and no amount of adjusting the type would have made that read like the design. The Android chat screen needed more than bubbles. Back and the name sat on one line, Call on a line of its own beneath, and the identity panel under that, so a third of the screen was chrome before a message. One bar now. The composer was an outlined field with two labelled buttons beside it, which was the last part of the app that looked like a form; it is the kit's pill, built on BasicTextField because a Material text field reserves room for a floating label and will not go below 56dp whatever shape it is given. The timestamp moved outside the bubble, where the kit puts it. Inside, a one-word message became a tall two-line box -- "sup" rendered taller than it was wide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e46d51430a |
Android can show an address, and the Account screen looks like the app
Two gaps, one in each direction. Android could ENCODE an address and had no way to DERIVE one, so it could not show yours and nobody on another relay could be given it -- the cross-relay work was iOS-only in practice. The engine was missing `localAccountSalt` and a way to get both sign-in secrets from one stretch; asking for the account key separately re-runs 650,000 PBKDF2 iterations for a value already in hand. The salt is pinned against the JavaScript engine, which is normative -- a Kotlin test asserting what Kotlin computes would prove only that Kotlin is self-consistent, and the failure to guard against is three engines disagreeing, which shows up as one person having two addresses depending on which phone they used. The address is stored in the vault because it can only be derived at sign-in: the passphrase exists for that one moment and is never kept, so a restored session had nothing to recompute from. Caught by the empty state saying so on the emulator rather than showing a blank. A QR encoder, written rather than depended on. Android has no encoder in the platform, and this is one fully specified algorithm for one fixed job -- turning a short ASCII address into a bitmap. It is verified by DECODING: thirteen codes, every version from 1 to 10 at exact capacity, read back by CoreImage's detector, which shares no line with it. That found the bug that mattered. The symbol was structurally perfect -- finders, timing, alignment, data placement and mask all confirmed correct against a reference symbol -- and nothing could read it, because the fifteen format bits were written least-significant first. It fails identically at every version with no clue where. The Account screen was a flat stack of fields, buttons and paragraphs with dividers between them, so a security setting carried the same weight as a text box and "Delete account" the same as "Save display name". Cards now, matching the Contacts screen that already had them, with the destructive pair on their own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
28d7485c26 |
The service was declared under the wrong package and never resolved
The manifest said ".MessengerService" while the activity beside it says
".app.MainActivity" — so the class resolved to
io.flamenet.messenger.MessengerService, which does not exist. `dumpsys
package` reported "No services found" and starting it would have thrown
ClassNotFoundException at the moment the app was backgrounded, which is
the moment nobody is watching.
Verified on the emulator after the fix, end to end:
- backgrounding the app starts it, isForeground=true, types=0x200
(REMOTE_MESSAGING), ongoing notice on flamenet.ongoing
- iOS sent PUSH2 at 932.474; the Android notification posted at 931.466
on a device whose clock matches the host — so the alert arrived about a
second BEFORE the sender finished rendering its own copy
- repeated with a fresh marker to prove it was not a stale notification:
when= moved 848.867 -> 931.466
That is a closed app on Android alerted in about a second with nobody in
the middle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
30d4829ad0 |
Alert people when the app is not on screen
Two platforms, two mechanisms, because iOS leaves no choice. Android gets MessengerService, a foreground service holding the long-poll with nobody in the middle. It rebuilds its session from the vault rather than sharing the ViewModel's — by the time it runs the Activity may be gone, which is the situation it exists for. Notifications for messages, and a high-priority one naming the caller for an incoming call, because an unnamed ringing phone is not answerable. ⚠️ The persistent notification is not a bug. Android requires a visible notice for a foreground service; removing it means the OS kills the connection. That is the price of not routing wake-ups through Google. ⚠️ Exactly one poller at a time: the ViewModel polls while the app is on screen, the service while it is not, handed over in onStart/onStop. Two would race for the same envelopes and double-notify, so the pair has to stay symmetrical. iOS gets APNs, and that IS Apple on the delivery path — there is no alternative, because iOS suspends a backgrounded process and provides exactly one way to wake it. What Apple learns is metadata: that a device was woken and when. The push is content-free by construction, since the relay holds ciphertext it cannot read. ⚠️ Nothing may later put the message into the push payload; anything that could would mean the relay had read it. Relay stores one token per DEVICE, not per user — messages are sealed per device, so waking the wrong one wakes an app with nothing to decrypt. Re-registering replaces rather than accumulates, because APNs reissues tokens and stale ones wake devices that no longer exist. An empty token means forget: a sign-out must reach this, or a handset goes on being woken for an account it no longer holds. Android tokens are refused outright — it receives directly and needs no row. Not yet done: the APNs sender itself, which needs a .p8 auth key that only a human with the developer account can mint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
66da4352b8 |
A group could be named on Android and never created
Tapping New opened the form, the name field took text, and then nothing: no member checkboxes, no Create group button, Cancel the only thing that worked. The controls were there all along, below the fold on a screen that could not scroll. The scroll exemption was deliberate and its reason had expired. It read "only the screens WITHOUT a lazy list may scroll — Contacts owns a LazyColumn, and a lazy list inside a vertically scrolling Column is measured with infinite height and crashes outright." True of an UNBOUNDED list; the buddy list has carried heightIn(max = 320.dp) since it was moved into its own card. Groups sits below it, so everything the form added fell off the bottom. Measured on a 1080x2400 emulator: the name field at y~1879, Create group past the tab bar at y~2280, and a uiautomator dump finding no scrollable container and no Create group node at all. Checked every lazy list on the screen before changing it — the buddy list is bounded, Stories is a LazyRow. Driven on the emulator afterwards: the checkbox and button are reachable, a group was created, and it arrived on iOS as "Demo Group, 2 members". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f6f2fbd283 |
Finish the swallowed-failure sweep, and stop stories being wiped
Completing the four sites the previous commit named rather than leaving them. 🔴 **Stories were CLOBBERED by a failed fetch, not merely mis-described.** Both `refreshStories` and the poll loop did `runCatching { current.stories() }.getOrDefault(emptyList())` and then wrote that straight into state — so one hiccup emptied the strip the user was looking at, and the poll repeated it on every tick. A poll that cannot reach the relay has learned nothing, and nothing is not "no stories". Both now keep what is on screen and skip that tick. ⚠️ Groups, both platforms. Android rendered NOTHING for an empty list — no guidance and no way to tell "you have no groups" from "the list did not load". It now says which, matching the contacts card. iOS already had copy and it had the same flaw: `refreshGroups` uses `try?`, so a failure left the list empty and the section confidently said "No groups yet. Start one with your buddies." `groupsLoaded` gates that on both sides. That shape is worth naming, because it produced three separate findings today: a swallowed failure, written into state, rendered as a confident empty state. The group DECODE bug hid inside it for months — groups were invisible on iOS and working on Android, and nothing anywhere said a word. Android 194 tests green, iOS 46 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
62b4798881 |
"Nobody here yet" was also what a failed load looked like
🔴 **A claim about the account, made about a request that failed.** Both roster load sites swallow failures with `runCatching { … }.getOrDefault(emptyList())`, so a network hiccup, an expired token or a rate limit all produced an empty list — and the contacts card then said "Nobody here yet. Add someone by the username they signed up with." Someone whose contacts had simply failed to fetch was told they had none. Found by auditing the shape that hid the iOS group bug: a swallowed failure that renders as a confident empty state. Six such sites exist on Android; the two on the sign-in and restore paths are the ones a user meets first. `Phase.SignedIn` now carries `buddiesLoaded`, defaulting to FALSE so a phase built without saying so cannot assert the fetch succeeded. The empty state only claims emptiness when the list actually loaded; otherwise it says the load failed and what to do. Verified on the emulator with the radios off: the card reads "Couldn't load your contacts. Check the connection and sign in again." With the network back, the roster loads and the contact shows as before. ⚠️ Four sites remain that still default to empty on failure — groups, stories, and two others. They deserve the same treatment; contacts came first because it is the screen somebody lands on. Android suites green (194 tests). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6527f76151 |
Android acquired the microphone on the UI thread too
Porting the iOS recorder fix back the other way, and the same bug was here. `MediaRecorder.prepare()` and `start()` acquire the microphone, and acquiring hardware blocks. `startRecording` was a plain function called straight from the UI, so that happened on the main thread — as did `File.createTempFile` beside it. `cancelRecording` released the recorder the same way. ⚠️ **The tell was the inconsistency.** `stopAndSendRecording` already went through `withContext(Dispatchers.IO)`, so someone had thought about this for stopping and not for starting — and starting is the half that waits for the audio route to come up. On iOS the identical mistake froze the main thread for SIXTY SECONDS on a simulator; here it did not misbehave on the emulator, which is not the same as being correct on a device whose audio route is busy. All three paths now agree. The UI state on cancel is cleared immediately rather than after the hardware is handed back, because the user pressed Cancel and should not watch the bar linger. Verified on the emulator: recording still starts (timer ticking at 8.4s), cancel still stops it and the composer returns, app alive throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ea338ea81a |
The tab bar floated mid-screen, and the Account tab could not be reached
Found by opening the two tabs nothing had ever opened. ⚠️ **The tab bar was not pinned.** It and the screen content were plain siblings in a Column with no weight between them, so the bar sat immediately under whatever had been drawn: near the TOP of an empty Alerts tab, above a screen of blank, which reads as a broken layout rather than as an empty state. 🔴 **On the Account tab it was pushed off the bottom entirely, and the screen could not scroll.** Account is taller than the viewport, so the sign-out explanation was unreadable and there was no way to switch tabs from it at all. The content area now takes the weight and the bar sits below it. ⚠️ Only the screens WITHOUT a lazy list scroll — Contacts owns a LazyColumn, and a lazy list inside a vertically scrolling Column is measured with infinite height and crashes outright, which is what the comment there has always warned about. ⚠️ Not a bug, for whoever sees it next: "Flamenet Messenger isn't responding" on the emulator during this work was the SYSTEM starving, not the app. The ANR reason names `Taskbar (server) is not responding, waited 33979ms` with the host at load 35. The app's own restore does its network work inside `withContext(Dispatchers.IO)`; it was not blocking the main thread. ⚠️ Also seen and left alone: the group row shows "You, user 32" for a few seconds after a cold start, because the buddy list has not loaded yet and the fallback prints the raw id. It corrects itself once buddies arrive. Naming it properly would mean carrying member handles on the group record, which the relay does not send today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f90c6477bb |
Calling killed the app, twice over
🔴 **Three separate defects, and calling could not work past any of them.** Found by tapping the Call button, which is not covered by any test. **1. The button did nothing.** `requestCall` sets `callPrivacyPrompt`, and the only thing that renders it is `CallBanner` — which was composed inside `SignedIn`, the HOME screen. The chat screen REPLACES that. So the only Call button in the app set a flag nothing was on screen to show, and since the prompt gates placing the call, calling could not be started at all. The button looked simply dead. That also means an incoming call arriving while somebody was reading a thread displayed nothing — directly against the comment above it, which says an incoming call must be visible whatever screen somebody is on. CallBanner is now hoisted above the screen switch. **2. Placing a call SIGABRTed the process.** The manifest never declared ACCESS_NETWORK_STATE. WebRTC's NetworkMonitor calls ConnectivityManager.getNetworkState() on its own network thread, that throws without the permission, and the JNI layer immediately asserts RTC_CHECK(!env->ExceptionCheck()) — turning a Java exception into abort(). No AndroidRuntime trace, nothing catchable, the process just vanishes: Check failed: !env->ExceptionCheck() Fatal signal 6 (SIGABRT) in tid network_thread **3. Hanging up SIGABRTed it again, re-entrantly.** `onIceConnectionChange` treated CLOSED as a reason to hang up — but CLOSED is exactly what our own `close()` produces. So Hang up called hangUp, which closed the connection, which fired CLOSED on the signaling thread, which re-entered hangUp, which did a BLOCKING NETWORK SEND and disposed the connection it was running inside. hangUp is now re-entrancy-guarded, CLOSED is a no-op (we caused it), FAILED still hangs up, `dispose()` is guarded like the `close()` beside it already was, and the courtesy "call ended" signal is best-effort rather than something that can take the teardown down with it. The observer body cannot throw at all now, because WebRTC treats any escaping exception as fatal. Verified on the emulator: dialling brings up Mute/Hang up and the app survives both dialling and hanging up. ⚠️ Still unproven: whether AUDIO actually flows. That needs two live endpoints and a TURN server, and none is deployed. What is fixed is that calling is reachable and no longer kills the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fb099442a0 |
Stories verified, and errors stop blaming Contacts
Stories driven end to end on Android against relay.flamenet.io: Post opens the scoped photo picker, a selected image is encrypted and uploaded, it appears in the strip as "You", and opening it decrypts and renders the picture. Together with groups, that leaves iOS voice notes as the only feature still unverified — and that one needs a physical device, because a simulator has no audio input. ⚠️ **The screen has ONE error slot and it lived inside BuddyList**, so everything that failed anywhere reported itself underneath the Contacts heading. A rate-limited story post read as a contacts problem, which is how somebody ends up re-adding a contact to fix a story. It now renders at screen level, under the call banner, where it belongs to the screen rather than to one section of it. ⚠️ Worth knowing for later runs: the relay enforces per-account rate limits, and a session that has been driving both apps hard will hit them. "Too many attempts. Wait a few minutes." is the app reporting a real server response, not a bug — the story posted on the retry after the window cleared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5c8060b690 |
Groups verified, and stop calling the user a number
Groups now driven end to end on Android against relay.flamenet.io: created "Testers" with a member, it appears in the list, it SURVIVES a full `pm clear` and re-sign-in (restored from the relay), it opens, and a message sends into it. This was one of the original asks and had never been exercised. Two things it turned up: 🔴 **The relay's 401 also blamed the passphrase.** The AccountsClient fix in the previous commit was correct and incomplete: `describe()` mapped `RelayException.Unauthorized` to "That handle or passphrase isn't right" too, and that is the path that actually fires when a relay token ages out. Same lie, same consequence — a user changing a password that was never wrong. It now says the session expired, which is what happened. ⚠️ **You are never in your own buddy list**, so every "find the name among the buddies" fallback rendered the signed-in user as `user 31`. The group row read "user 31, fnreviewbeta". `Phase.SignedIn` now carries its own `userId` and both fallbacks say "You". Verified live: the row reads "You, fnreviewbeta". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cda64d8c2e |
A contact who reinstalled could never be messaged again
🔴 **The chat header said "keys unchanged since you met" while every send was refused with "identity keys changed".** Found by driving two real apps: the iOS side relaunched with -FNMSGResetState, which is a genuinely new device with new keys, and Android correctly refused to send to it. Correct — and then there was no way out. `Trust.state()` could return UNKNOWN, VERIFIED or PINNED and nothing else. CHANGED was computed inside `record()` while a send was in flight and thrown away, because `Vault.Trust` had nowhere to remember it. So: - the header kept reporting the OLD pinned keys as unchanged, - the "Accept new keys" button, which only renders for CHANGED, never appeared, - `acceptIdentityChange()` was unreachable from the UI, - and the contact stayed unmessageable for ever, with the app insisting nothing was wrong. That is precisely what a tester does on day one: reinstall, and discover their friend can no longer reach them. `Vault.Trust` now carries `changed`, `record()` persists the detection without adopting the offered keys and drops the verified tick (whatever was compared in person was compared against the old key), `state()` reports it, and accepting clears it. Keys that match again also clear it, so a transient wrong answer does not strand somebody behind a banner for ever. Verified live on the emulator: the banner appears with its full text, Accept new keys is reachable, and the header returns to "keys unchanged" afterwards. Four tests in TrustTest; three of them fail against the old `state()`. --- 🔴 **An expired session told the user their passphrase was wrong.** Hit immediately afterwards: a send failed with "That handle or passphrase isn't right", ten hours after a successful sign-in, because AccountsClient mapped EVERY 401 and 403 to BadCredentials. The natural reading is that the password stopped working or was changed by someone else, and the natural response is to change a password that was never wrong. Only `sessions` and `accounts` actually present a passphrase, so only those can report one as wrong. Everything else now raises SessionExpired, which the app states as what it is. Two tests cover both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3c66d3e9bd |
Take the palette from the site instead of inventing one
flamenetmessenger.com declares its colours as CSS custom properties, so they
were there to be read rather than guessed:
--bg: #0a0e1a --panel: #121a2e --border: #1f2b45
--ink: #e8edf7 --muted: #9aa7c2
--flame: #ff5a1f --blue: #3b6fff --accent: #2dd4a7
⚠️ The site is dark and the apps are light, so only the accents transfer
literally. --ink and --muted are light-on-dark values that would be invisible
on a light ground; the neutrals here are the same navy family resolved for
light instead. Dark mode DOES use the site's real --bg and --panel.
The apps had been warm — ember on parchment. They are now the site's flame on
a cool navy-tinted ground, with the site's blue and teal available for
informational and success states rather than a second invented accent.
⚠️ Two flames, on purpose. White on #ff5a1f is about 3.1:1 — acceptable for a
bold button label under AA Large and not acceptable for small text. Small text
and links take a darkened #c4400f, which clears 5:1 on white. An accent used
uniformly is how it quietly becomes unreadable.
`Palette.Parchment` is now `Palette.Mist`: it is a cool near-white and the old
name described a colour that no longer exists here.
Also fixes the contacts card reserving a slab of empty white. BuddyList's
LazyColumn carried weight(1f), which always expands to whatever height it is
offered — so bounding the CARD just moved the problem, and one contact still
reserved 340dp. The list now caps itself with heightIn and the card sizes to
its content, scrolling only once the list is genuinely long.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4fd648382f |
The mic button froze the app, and one design system for both
🔴 **Tapping "record a voice note" hung the iOS main thread.** VoiceRecorder is @MainActor and ran AVAudioSession.setCategory/setActive and AVAudioRecorder's init and record() directly on it. setActive(true) blocks until the audio route is up. Measured under XCUITest: the tap was followed by SIXTY SECONDS of "App event loop idle notification not received", after which every accessibility query cost 31 seconds because the main thread was still stuck — which is why the failure had been reading as a slow test rather than as a frozen app. The blocking calls now run in a detached task; published state is still assigned on the main actor afterwards. The stall is gone: zero idle-loop warnings, and the test fell from 224s to 103s. Two deadlines, because moving work off the main thread stopped it freezing the UI without making it finish: - The permission ask. `AVAudioSession.requestRecordPermission` is deprecated since iOS 17; on 17+ we now use AVAudioApplication. Either way it is raced against 5 seconds, because a permission API that never calls back would otherwise suspend start() for ever. - Opening the route, against 6 seconds, for the same reason. Without these, start() returned neither true nor false, so the caller showed NO recording bar and NO error: a mic button that does nothing at all when tapped, with no way to tell broken from slow. ⚠️ **iOS voice notes are still not verified.** The freeze is fixed and proven gone, but recording does not start on a simulator and I could not get the app to surface a reason there. It needs a physical device before it can be claimed. Android voice notes ARE verified — recorded and delivered, 29s. --- **One design system, for real this time.** Theme.kt already claimed to be "matched to the iOS client's Theme.swift so the two apps look like the same product" while iOS was indigo and Android was ember. Nothing checks that claim, so it had been false for a while. Ember wins — the product is called Flamenet. Both files now carry the same palette under the same names, plus a shared scale for radii, spacing, elevation and type, as hex literals that can be compared by eye across the two. Light parchment ground, white cards, one soft shadow, one accent. Applied to the Android home screen: each section on its own card. The sections themselves are untouched — same composables, same testTags, same order — because this is a skin over screens that work. ⚠️ The contacts card is height-bounded on purpose. BuddyList's LazyColumn carries weight(1f), which used to resolve against the whole signed-in column; inside a card with no height of its own it resolved against infinity, grew to fill the screen, and pushed Groups and the tab bar off the bottom. Also: tapRow now scrolls before declaring a settings row missing. A List does not instantiate cells below the fold, so "Privacy & Security" was correctly absent and read as a deleted feature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7ad6e7be29 |
The safety number was unreachable until you left the thread
Trust on first use pins the peer's keys during the send itself, so before a first message the header correctly reads "No keys seen yet". sendMessage refreshed the trust badge on FAILURE — with a comment explaining that the header must not go on claiming "pinned" when a send just failed because it is not — and did not refresh it on SUCCESS, which is the case where the pin actually happens. The result: send your first message to someone and the header still says no keys have been seen, with no Safety number button, until you leave the thread and come back. On a product whose claim is that you can compare a number in person, the number was not reachable at the moment someone would look for it. Group threads are left alone — they have no single peer identity, and recomputing one into that field would produce something meaningless. Verified on an emulator against relay.flamenet.io: fresh install, fresh contact, first message, badge flips to "Keys unchanged since you met" with the Safety number button, without leaving the thread. Also, three harness fixes, all confirmed on an idle machine rather than assumed to be load: - waitInBursts in UITestSupport, for screens that never stop repainting. A single long waitForExistence holds ONE query open against a moving hierarchy; the voice recorder ticks its elapsed time, so the query never got a settled snapshot and died with "Timed out while evaluating UI query" after 231 SECONDS against a 15-second wait. That is not a timeout and does not name what went wrong. - SwiftUI puts a confirmationDialog's buttons in the tree more than once, so even a unique identifier needs .firstMatch to be tappable. - The emoji fix from the previous commit is confirmed: that test now passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
053cefbce4 |
Two apps, three defects that only driving them could find
The relay is asked what it is, and the answer went nowhere. SiteConfig is a
data class whose only constructor property was `baseUrl`; `discovered` was a
`var` in the body, so the generated equals() ignored it. MutableStateFlow
conflates on equality, which made
_site.value = config.also { it.discovered = found }
a no-op against the value already there. The descriptor was fetched correctly
and discarded. The sign-in screen sat on "Checking what this server uses…"
forever — the one line whose whole job is to say, BEFORE the passphrase is
typed, whether that passphrase leaves the device — and accountProvider stayed
LOCAL for a relay that had just answered SHARED. Now a constructor property,
written with copy(), with the first JVM unit tests the :app module has ever
had. Confirmed live: the screen reads "One Flamenet account, shared with
Flamenet Files."
Two XCUITest queries matched more than one element and so could tap neither:
- "👍" is in the emoji picker's Gestures row, in its Recent row once picked,
and in ChatView's quick-reaction bar. Picker buttons now carry a
section-scoped identifier.
- "Sign out" is both the row in ProfileView and the destructive button in the
confirmation it opens; SwiftUI keeps both in the tree, so the query was
ambiguous before the dialog was even reached. Both now identified.
Neither was load — both fail the same way on an idle machine.
Also adds completeSignIn to UITestSupport, the counterpart to completeSignUp.
Sign-ups are rate limited at three an hour per address, so a test that only
needs AN identity should reuse one rather than mint another and fail with a
429 that reads like a broken sign-up screen. CrossPlatformUITests takes
FN_TEST_PASSWORD and signs in when given one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2855711bc5 |
Android could never find anybody: the relay answers with an array
Adding a contact was impossible on Android. Every lookup reported "No account
called X here" while the relay was returning the account.
RoutesAccount.lookup answers with an ARRAY — empty for a miss, one element for a
hit — deliberately, so a miss and a hit differ only in length and neither timing
nor shape is an oracle. MessengerApi.lookup sent that through send(), which does
JSONObject(text) and throws on an array, and then swallowed the throw with
runCatching { }.getOrNull(). A parse failure and a genuine miss became the same
thing.
Measured against relay.flamenet.io, before and after, in the running app:
relay: [{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]
before: "No account called appreview2demo here."
after: the contact card, with Add contact enabled
⚠️ Three layers kept this quiet: the relay was correct, the failure was
swallowed, and a miss and a crash render identically. CrudSurfaceTest asserts
the lookup CONTROL exists — and it does. **A control being present says nothing
about the feature working**, which is the whole reason this was found by driving
the app rather than by reading the suite.
This is the ONLY array-returning route (grepped), so rawText() is deliberately
narrow: everything else keeps send()'s typed error mapping.
Guarded by MessengerApiLookupTest — hit, miss, and a 500 that must not be
mistaken for a miss. **Verified load-bearing**: restoring the old implementation
fails it. 182 Android unit tests, 0 failures.
Also adds FeatureUseTest, which USES voice notes, groups, stories and calls
rather than asserting their buttons exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
103a2dda23 |
Sign the messenger with its own key, not Files'
Every release build before this went out as CN=Flamenet Files. Not a decision —
app/build.gradle.kts read ~/.flamenet-android-signing.properties, a path with no
app in its name, so any Flamenet Android app built on that Mac inherited the
Files identity.
It matters because an Android signing key is permanent. Once a device installs a
build signed with one key it REFUSES every later build signed with another, so
the only remedies after the fact are a new package name — a different app,
orphaning every install — or Play App Signing, which has to be set up before
first publication. Sharing one key across products also means one compromised
keystore takes out both.
Now prefers ~/.flamenet-messenger-signing.properties and falls back to the
shared file, so a machine with only the old setup still produces a signed build
rather than failing in a confusing way.
Verified in the artifact:
Signer #1 certificate DN: CN=Flamenet Messenger, O=Extant 2000 LLC, C=US
RSA 4096, SHA-256 791fc826d572a70abfaf7db7fa7784dad0f86f204bb313f0d75ee676df8d3a1d
⚠️ The keystore and its password live outside the repo and there is NO recovery
for either. Losing them means the app can never be updated again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b4aadda162 |
Android: voice calls, peer to peer by default
The one third-party library in this app, and unavoidable: the alternative to `io.github.webrtc-sdk:android` is hand-rolling ICE, DTLS-SRTP and an audio pipeline. Worth naming rather than absorbing quietly, because everything else here is deliberately auditable and this is a large native blob that is not. 🔴 **Peer to peer by default.** ICE tries a direct path first — same network, then each side's address as seen through NAT — and only falls back to relaying through TURN when both ends sit behind NAT that refuses one. `iceTransportsType` is ALL rather than RELAY: forcing every call through the server is the opposite of the default chosen here. **Signalling rides the ratchet, not the relay.** The offer and answer are ordinary content envelopes, so the SDP — codecs, and every candidate address — is end-to-end encrypted like any other message. A relay that could read signalling would learn both parties' addresses even on a call it never carries. ⚠️ **A direct call shows the other person your IP address.** That is what makes it direct, and it is not something to find out afterwards. Explained before the FIRST call, and it waits for an answer — once, not every time, because a prompt people see repeatedly is one they stop reading. The acknowledgement lives in ordinary preferences rather than the vault: it is not a secret, and storing it encrypted would re-show the warning on every sign-out, which is how a one-time prompt becomes one people click past. ⚠️ **No TURN server is deployed** — `FN_TURN_SECRET` and `FN_TURN_HOST` are unset, so `/e2e/turn` answers 503. Handled rather than hidden: STUN alone still connects through most NATs, and the banner says plainly that this server has no relay so the call connects only if a direct path exists. "Calling did not connect", with no reason, is the kind of failure people blame on the app for ever. Trickle ICE is off. Candidates are gathered before the offer is sent, so signalling is one encrypted envelope each way rather than a stream of them — a second or two slower to connect, and far simpler over a channel that is polled rather than pushed. ⚠️ The microphone is held by the audio source, not the peer connection. Closing one without disposing the other leaves the mic live with no call attached, which is the worst failure this particular feature has available. ⚠️ **WebRTC's native libraries dominate the APK**: ~41 MB across four ABIs, of which x86 and x86_64 (~24 MB) exist only for emulators. Release is restricted to arm64-v8a and armeabi-v7a — 102 MB debug becomes a 22 MB release — while debug keeps all four so it still runs on an emulator. Restricted rather than split per-ABI on purpose: this app is handed to testers as one file, and per-ABI outputs turn "send them the APK" into "send them the right APK". A second offer while busy is declined rather than dropped, so the caller hears something. Group calls are deliberately absent: different key handling, and an enabled-looking button that cannot work is worse than none. Tested: 108 engine, 67 client, none failing; debug and release APKs both build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c1284aab5f |
Android: member lookup, and the group gaps
**Member lookup.** `MessengerApi.lookup` already existed and nothing called it. Adding a contact now takes two steps: resolve the handle, see who it is, then add — and the add button stays disabled until the lookup succeeds. ⚠️ One step is worse than it looks. Adding straight from a typed string means a typo silently becomes a contact who is a stranger, and the first thing sent to them is a message meant for somebody else. Seeing a display name first is what catches that. ⚠️ A miss says "no account called that here" and nothing more. The relay answers identically for an account that does not exist and one that has not enrolled — deliberately, so it cannot be used to enumerate accounts — and this client must not invent a distinction the relay refused to make. The result also says plainly that it only proves the handle exists, and that identity is checked by comparing safety numbers once there is a conversation. This gate broke five existing instrumented call sites, which is the point of having them: each now looks up before confirming, the way a person does. **Group images.** Sealed and uploaded ONCE, then one envelope per member — same reasoning as a story. Uploading per member would multiply the bytes by the group size and hand the relay a set of blobs it could correlate. 🔴 The composer's photo button called `sendImage(chat.peerId!!, …)` regardless of thread. In a group `peerId` is null, so sending a photo to a group did not send to the wrong place — it crashed. Now routed on whether the thread is a group. **Group membership** is editable after creation; `updateGroupMembers` existed with no way to reach it. The copy states what membership actually does: adding someone lets them read what is sent from now on, removing them stops future messages, and neither is retroactive. A group cannot be un-read, and a UI implying otherwise would be the dangerous kind of wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
acee4597d5 |
Android: stories
An image sealed and uploaded ONCE, then one ordinary story envelope down each contact's existing ratchet. Uploading per recipient would multiply the bytes by the size of the contact list for nothing, and would hand the relay a set of distinct blobs it could correlate by size and timing. ⚠️ **There is no story feed on the relay, and there should not be.** A server-side story needs the relay to know who may read it — which means giving it your contact list for a feature that works without one. The cost is that posting is a fan-out and partial delivery is normal: a contact with no registered device cannot receive anything, story or not, so the count is reported rather than the post failing. ⚠️ **A story bypasses the thread entirely.** It belongs to the strip, and filing it as a message would drop every story somebody posted into a conversation they did not have. One that arrives already expired — posted while this device was offline — is dropped rather than shown late. ⚠️ **Expired stories are deleted on WRITE, not filtered on read.** Filtering on read leaves a store that grows for ever and still holds every image anybody posted, which is the opposite of what an expiring story promises. The bytes actually leave the device. 🔴 **Expiry is a promise, not a guarantee, and the empty-state copy says so.** This client deletes the bytes when the clock runs out because it chooses to. Nothing stops a recipient's client keeping them. A UI implying otherwise would tell people a story is safe to post when it is not, which is worse than not having the feature. Unseen is marked rather than styled away — a strip where read and unread look alike is a strip nobody returns to. Stories are read back on every poll rather than only when a thread was touched, because a story files itself outside the thread map and would otherwise not appear until something else arrived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4728928d98 |
Android: voice notes
The engine and the client already carried audio — `Attachment.AUDIO`, a `duration` field, and a `sendAttachment` that takes both. What was missing was entirely the phone: something to record with, something to play with, and two controls. So this is app-layer only, and adds no dependency: `MediaRecorder` and `MediaPlayer` are platform APIs, and a voice note is an ordinary encrypted attachment the moment it is bytes. AAC in an MP4 container, mono, 32 kbit/s. Speech rather than music: clearly intelligible, and roughly a tenth the size of the stereo default nobody asked for — which matters when every byte is uploaded encrypted and stored by somebody paying for it. ⚠️ **Tap to start, tap to send — not hold-to-talk.** Hold-to-talk demonstrates well and behaves badly on a real phone: the gesture competes with scrolling, and letting go early silently discards what was said. Two deliberate taps cannot be lost by accident. ⚠️ **`MediaRecorder.stop()` throws when it captured no frames** — a tap instead of a hold, or a permission granted and then denied by policy. The exception is a bare `RuntimeException` and it reads as "recording is broken" when the truth is "there was nothing to record". Treated as an empty recording, and anything under half a second is dropped rather than delivered: a note that plays silence looks like a delivery bug, and the person who fumbled the button already knows they did. ⚠️ **The microphone permission is requested when somebody asks to record**, not at startup. A microphone prompt on first launch, before anything has been said, is one people deny on reflex — and a denied permission is far harder to recover from than one that was never asked for. ⚠️ Received notes are fetched and decrypted **on tap, never on render**. A thread of twenty would otherwise download and decrypt all twenty on scroll: bytes nobody asked for, and twenty decrypted files in the cache for a list nobody has listened to. Playback stops on leaving the thread, because a MediaPlayer that outlives its composable holds audio focus and a file handle. Every temporary file — the recording, and the decrypted copy playback needs because `MediaPlayer` cannot take a byte array — is deleted on both the success and the failure path. Plaintext audio left in a cache directory is the exact thing this product is not supposed to do. `imageBytes` became `attachmentBytes`: photos and voice notes take the same path, and a method called `imageBytes` that audio also calls is a name that has quietly stopped being true. Also retires buddy commands in the parity table — no longer a feature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a213ad8d5d |
Android: sealed sender, so the relay stops learning who talks to whom
Key transparency stopped the relay substituting keys. It did nothing about the relay watching who writes to whom, which is the metadata most people actually leak. §14 moves the sender's identity inside the ciphertext: the relay learns who a message is for and nothing about who it is from. Verified against relay1 rather than asserted. A sealed submission is accepted unauthenticated, the stored row comes back with `from_user = 0` — the relay saying it does not know — and the recipient still reads the message with the sender recovered from inside the seal. Cross-engine verified in BOTH directions against the normative web engine, and that mattered: the seal is asymmetric in practice, since one side derives from an ephemeral it generated and the other from one it was handed, so a byte-order or AAD mistake can easily break only one direction and a single-direction test picks the half that works. The interop harness gained `seal` and `open_sealed` ops for it; 43 assertions, none failing. 🔴 **The seal is confidentiality, never authentication.** The recipient's `IK_dh` is published to the world, so anyone can produce a well-formed seal claiming to be from anybody. The claimed sender picks a session and nothing else; the ratchet opening it is what makes it a fact, and the identity pin is what refuses an impostor. There is a test that forges a sender deliberately, so that if somebody later renames these fields into something confident the reason they are claims is still written down and still executing. ⚠️ **The first message to a new contact cannot be sealed.** Sealing needs the recipient's delivery key and that key travels inside an E2E message, so a prior exchange is required. `Sent.sealed` reports which happened rather than leaving the UI free to imply anonymity that was not achieved — a silent fall back to an unsealed send is exactly what §14.5 warns about. ⚠️ **Rotation is blunt, and the copy says so.** There is no sender to block, so the only lever is replacing the delivery key, which cuts off every contact until the next time they are written to. Reachable from Account, asserted by `CrudSurfaceTest`, and live-tested: a submission presenting a retired key is refused by the relay. The relay stores only `SHA-256(key)`. The key itself never passes through the relay from its owner, which is the only reason an unauthenticated sealed submission can be authorised without naming who is submitting. Tested: 108 engine, 67 client, 43 interop, none failing, live suites enabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cfeefa0616 |
Contacts carry the peer's address, so an honest key verifies
The relay files a key-log leaf under an account's fnid once it has one, and
under `local:<audience>/<user_id>` before that. The subject is inside the
hashed bytes, so a verifier that does not know the address cannot recompute the
leaf — and enforcement refused honest keys from any account that had claimed an
identity. A false positive on a real conversation is how a security setting
gets switched off and left off, so this is the half that decides whether the
feature is usable at all.
No relay change was needed. `GET /e2e/account/identity/{handle}` already
resolves a handle to an address, unauthenticated, and the buddy list already
carries the handle. The contact record now learns the address once and pins it
beside the identity keys, and `enforceKeyLog` recomputes against the pinned one
by default.
⚠️ **Pinned, not looked up per check**, and that is the entire point. Reading it
fresh each time would let a relay that wants to substitute a key simply also
change the name it files that key under, and every verification would agree
with the new answer. Recomputing against the address this contact has always
had is what makes the check about a person rather than about whatever the relay
says today.
First contact is still trust-on-first-use. Taking the relay's word once is not
a check; what it buys is that every later answer has to match it, and the
safety number is what covers the first sighting.
🔴 A CHANGED address is an identity change, for a stronger reason than a
changed key: an fnid is derived from the account key, so a new address is not a
new device — it is a different account key, and therefore a different person.
Accepting a change re-resolves rather than keeping the old address, because if
the address is what changed, keeping it would leave every later check
recomputing against a name the account no longer publishes under, and honest
keys would stay refused for ever.
⚠️ An unreachable relay pins NOTHING rather than pinning "no address".
`rememberIdentity` does nothing once an address is pinned, so a wrong answer
would be permanent — and the next successful lookup would then read as the
address changing, which is the alarm, for a network blip.
Proven against relay1 in three states, because only all three make it legible:
refused with nothing pinned, accepted once the contact carries the right
address, and refused again with a wrong one — so what makes it pass is the
address being right, not the check having been loosened. A changed address is
asserted to come back CHANGED against the real relay too.
Tested: 100 engine, 65 client, none failing, live suites enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0330a7b84a |
Android: refuse a key we have reason to distrust
Android had the transparency log's verifier and nothing that called it. The parity table listed trust state, verification, identity-change acceptance, and key-log enforcement as absent, and they were — so the relay could hand out a key it minted, the conversation would encrypt to it, and every byte on the wire would be correctly encrypted to the wrong person. A log nothing checks records the substitution faithfully and is never read. Two independent checks, because neither is sufficient alone. **Pinning, always on.** Every identity key a peer offers is compared against what was pinned on first contact; a send to a changed identity is refused until a person accepts it. The comparison is "every offered key is already pinned", not "any" — a relay adding one key of its own ALONGSIDE the real ones is the attack, and the fact that all the genuine devices still work is exactly what makes it invisible. There is a test for that case specifically. **The log, opt-in.** `require-key-log` on the Account screen makes a send demand that the relay prove the recipient's key appears in the log it publishes. Off by default for the same reason the web engine's is: a relay predating the log has none, and refusing every peer on those deployments is a worse failure than the one being prevented. `KeyLog.verifyConsistency` did not exist in the Kotlin engine. Inclusion alone is worth very little — a relay that rewrites history reissues perfectly valid inclusion proofs against its new tree, honest proofs about a dishonest log. Consistency against a head this device already pinned is the one thing it cannot produce. Ported from `engine/src/keylog.js`, which is normative, rather than re-derived: a re-derivation that accepts too much passes every honest test and fails only against an attacker. Head gossip rides along with messages and compares a peer's view against ours, which is what turns a per-device check into a detectable fork — alone, each client only knows the relay was consistent with itself. Tested, 100 engine / 52 client, none failing. Consistency is checked over EVERY pair of prefix sizes in the published vectors, because the traversal has a special case when the old size is a power of two: an implementation that gets that wrong still passes 3 -> 5 and fails 2 -> 5. The proof builder the test uses is trustworthy because the tree it builds is the one whose roots already matched the published vectors at every size. The refusals are what carry the weight, so each has its own test: a head going backwards, two roots at one size, a tampered proof node, a served key absent from the log, an inclusion proof for somebody else's leaf, and a rewritten log that cannot be reconciled with a pinned root. A trust check that accepts everything passes every happy-path test ever written. Reachable from the UI, per the standing rule. The thread shows identity state and the safety number; the changed state offers accept and nothing else, because an "ignore" affordance would be a way to silence the one signal in this product that means interception. `CrudSurfaceTest` asserts the controls exist. ⚠️ Not yet exercised against a live relay — the tests drive a real HTTP server with hand-built responses. Implemented and unit-tested, not proven in the field. ⚠️ Sealed sender is still absent, so the relay still learns who talks to whom; these protect different things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
388e3001af |
The three fleet suites, and Android sign-up on the two shapes it never tried
Four files that were proving things nobody could re-run. Three of them existed
only as loose scripts in a scratchpad — they are the only tests of federation,
of the fleet's invariants, and of multi-homing that exist anywhere, and none of
them was in the repository. All three pass against the live fleet as committed:
federation 11, invariants 10, multi-homing 12.
What they cover, which is why they are worth keeping rather than rewriting:
federation.mjs relay1 → relay2 across the boundary, including that the
envelope carries no sender attribution once it crosses,
that a relay off the allowlist is refused, and that an
unknown address is reported back rather than dropped.
fleet-invariants the things one relay cannot show: a claim signed for
relay1 refused at relay2, a device token that does not
travel, and a handle resolving only within its own relay.
multi-homing one send, fanned out to both of Bob's homes though only
one was named, readable from either, sharing the id that
marks the copies as one message.
`LiveSignUpFleetTest` is the Android half of the two shapes the parity table
listed as built but never exercised: the managed relay, and a self-hosted relay
keeping its accounts in the shared service. iOS has had `LiveSignUpUITests` for
both; Android had only the local-accounts shape.
The assertion that carries the weight there is the ABSENCE of the email field.
A relay with its own account table asks for an address to send a reset code to;
the shared service never does. An email field still on screen means discovery
did not land and the passphrase is about to be stretched with the wrong salt —
which does not error. It derives a different account from the same passphrase,
and the person finds out on their next device.
Opt-in through an instrumentation argument, not an environment variable: env
vars do not reach the instrumentation process, so an env-gated skip stays
skipped however it is invoked and the suite reports success having run nothing.
The file also records the sign-up budget these share with the iOS suite — three
per hour from one address — because exceeding it looks like a broken sign-up.
Verified: `:app:compileDebugAndroidTestKotlin` clean. The instrumented test
itself is unrun; it needs a device and the hourly budget was spent proving the
iOS side.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ff975bf36d |
Trust the relay you typed, pin the relays that are ours
Correcting a rule I got wrong earlier today. I had the client accept a named
account service only if it was on a pinned list containing exactly
accounts.flamenet.io — which is safe and breaks the case the server already
supports: a self-hoster running Messenger and Files together, with their own
account service holding the one account across both. The file server has taken
ENCLAVE_ACCOUNTS_URL for exactly that since it shipped, and main's relay makes
FN_ACCOUNTS_URL configurable for the same reason. A client refusing the answer
would make "run both" mean "run them with two separate logins".
The rule is asymmetric, and the asymmetry is the point:
- For flamenet.io and its aliases the account service is PINNED in the client
and whatever those relays advertise is ignored. Compromising one of ours
must not let it redirect its users' passphrase-derived secrets.
- Every other relay is believed. The user typed that hostname themselves; a
server they chose naming its own account service is a choice they already
made, and it cannot redirect anybody else's users.
Two tests, one for each half: a pinned relay advertising an attacker's service
still authenticates against ours, and a self-hosted relay may name its own.
|
||
|
|
9ced7dc3f0 |
All three deployment shapes, and the one that was unreachable
"Self-hosted with shared identity" could not work. Both clients decided where accounts lived from a hardcoded list of relay hostnames, and a stranger's relay is never on that list -- so somebody self-hosting could have the shared FlameNet account service configured on their relay and the app would still authenticate against the relay itself. Mode 2 existed on the server and nowhere else. The relay already published the answer at `/.well-known/flamenet/relay`: `accounts: shared|local` and the service URL. Nothing read it. Now the client asks, and then checks the answer against a pinned list of account SERVICES rather than of relays. That keeps the property the hostname list was protecting -- an operator cannot point their users' passphrase-derived secrets at a host they control, because a service the client does not already trust is treated as local -- while letting any relay adopt the shared service. Unknown or unreachable descriptor also means local, which is the safe direction: the passphrase goes only to the server the user typed in. relay2 is now configured as that third shape, so all three exist to test: managed (relay.flamenet.io), self-hosted shared (relay2), self-hosted local (relay1, relay3). `LiveShapesTest` covers sign-up three rounds per shape, a conversation per shape, that each relay reports what it actually is, and that a relay naming an untrusted account service is refused. Fresh handles every round, so rounds are repeatable by construction rather than by cleanup. ⚠️ Two things worth keeping: Skips are announced. `return` from an unmet precondition makes a test that did nothing report PASSED -- I had four of those reporting green before switching to `assumeTrue`, which is the same silent-skip trap I had already criticised in the Swift interop harness. A suite of silent skips is indistinguishable from a suite that works. The handle travels beside the shared-session token, never inside it. The account service will not put a handle in a token, so a relay cannot correlate its users with another product's; the relay keys on the product-scoped pseudonym and uses the handle only the first time it sees one. Tested by signing in twice and asserting the same relay account comes back. Also deploys the discovery-document fix to relay.flamenet.io, with a database backup and a rollback image. The transparency-log root is byte-identical after the deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e07660559c |
No more WordPress, and the bug that made iOS unusable
The iOS app could not sign up against a Swift relay at all. Sign-up died on a bare "Server returned HTTP 404." with nothing pointing at the cause. The cause: the relay served its route index under BOTH `api` and a legacy prefix, while serving the actual routes only under `api`. The app settles its REST prefix by probing for that index and keeping the first prefix that answers -- so it adopted the legacy prefix, and every call 404'd. ⚠️ This was supposed to have been fixed yesterday, and was not. `f5614ab` (Aug 21, "Serve the four routes the app was calling into a 404") is what INTRODUCED the two-prefix index loop; it was the cause, not the cure. The rule got written down and the code change never landed. My own notes asserted a fix that no branch contained. Believe grep. The rule, which is more general than this bug: **serve a discovery document only where the things it describes exist.** A document listing routes a prefix does not serve is worse than no document, because it is believed. Fixed in three places, because one was not enough: - the relay serves the index only under `api`; - the client probes only `api` -- there is one prefix, and probing for others only creates ways to be wrong; - the index no longer advertises routes that are not mounted. And the purge the owner asked for: no `WordPress`, `wp-json`, `wp/v2`, `wp-admin`, `wp-login`, `wp_` or `wp-cron` anywhere in the relay, the iOS app, the Android app or the tests. `/api/wp/v2/users/me` is now `/api/flamenet/v1/messenger/me`; the user lookup moves alongside it; the dead `ajaxURL` and the two error cases only a web login form could produce are gone. ⚠️ Recorded because it cost time: the deployed relay source on the box is NOT in any branch. It carries multi-homing `envelope_id` work that exists nowhere in git, so copying local files over it broke the build. Restored from the prod tree, then patched in place. That divergence needs closing. Adds CrossPlatformUITests (iOS) and CrossPlatformTest (Android) -- each driven by arguments so the two platforms can be interleaved from a shell, because two iOS accounts in one simulator prove only that the app agrees with itself. With this, item 5 is done: a message typed in the iOS app arrived and rendered in the Android app, through relay1, end-to-end encrypted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f2d74cff0c |
Android: groups, and the wire shape iOS got wrong
Groups were 100% broken on iOS -- not partly, entirely -- because the client
required a `name` on each member that the relay has never sent. Every group
decode failed, so the feature looked implemented and did nothing.
So this one was built by reading the relay's wire types first and asserting
them against live bytes second. `Member` is `{user_id}` and only that: the relay
has no user table and deliberately will not grow one, because a relay caching
display names is a relay that leaks them. Names are resolved client-side from
the contact list, and a member you have never added shows as an id, which is
honest.
Create, list, open, add and remove members, leave, and send -- all verified
against relay1, including that a group message carries its routing so it files
under the group rather than under whoever sent it.
Two things worth stating plainly:
The relay does no fan-out. A group is a membership list it keeps so clients
agree who is in it; the encryption is still pairwise, one ratchet per recipient
device. An n-person group is n sends. That is the honest cost of the relay not
being able to read anything, and the client is what knows it.
Thread history is now keyed by an opaque thread id -- `user:<id>` or
`group:<gid>` -- rather than by user. Filing a group message under its sender
scatters one conversation across as many threads as it has participants.
Leaving a group also clears its local thread. Keeping it would leave a
conversation nobody can add to and no way to remove it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e65d1d987b |
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> |
||
|
|
d30a77dd1e |
Android: contacts, conversations, and a message that made the round trip
The app now holds a conversation. A message typed into it reaches a different
program through the real relay, that program answers, and the answer appears in
the thread -- verified three times, end to end, encrypted the whole way.
96 JVM tests, 37 cross-engine, 11 on-device, plus live round trips against the
throwaway fleet.
What landed: a fan-out send that reaches every device a person has, inbox
polling, message history encrypted at rest, contacts, presence, and a session
that survives a restart. Plus `:interop:peer` -- a Flamenet account you can
drive from a shell, so the correspondent on the other end is not this app
talking to itself.
Three wire shapes were wrong, and only a real relay could say so:
- `prekeys`, not `opks`. The relay answered 200 and stored nothing, so the
device was published and could not be started with.
- The send body names the recipient once at the top, not per envelope.
- The buddy list is `id`/`presence`/`screen_name`, not `user_id`/`status`.
The second and third at least failed loudly. The first did not, which is why
the live tests exist.
Decisions worth stating:
A send returns partial success rather than throwing. One of someone's three
devices being unreachable must not stop the message reaching the other two, and
the UI says "delivered to 2 of 3" rather than "sent".
The inbox watermark advances per message, after it is decrypted AND stored --
never once for the batch, which would lose everything after a crash. An
undecryptable message advances it too: it is never going to decrypt, and
leaving it would re-fail on every poll for ever.
A sent line is recorded only once a device took it. Writing it first produces a
thread showing messages the recipient never got, which is worse than an error --
the sender believes they said something.
Opening a thread marks it read locally and tells the relay nothing. A read
receipt is a disclosure, and this product does not make one by default.
History is keyed by person, not device. Someone's phone and tablet are one
conversation, and filing by device would split it into threads each holding
half the story.
Also improves how instrumented failures report: the assertion now prints what
the screen was showing. That turned a bare 90-second timeout into "No value for
user_id" and then "Failed to connect to relay1" -- one a bug, one the emulator's
network, and previously indistinguishable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
5f983c1455 |
Android: the app, and the two things only a phone could tell us
There is now a Flamenet Messenger app that installs, launches, creates an account on a self-hosted relay, registers a device, publishes prekeys, and stores all of it in an Android Keystore-backed vault. Verified on an emulator through the actual screens, not by unit test. 92 JVM tests, 37 cross-engine, 9 on-device, 3 against live relays. Two findings, both of which the JVM suite was structurally incapable of producing. **Android has no ordinary Ed25519 provider.** `KeyPairGenerator.Ed25519` and `KeyFactory.Ed25519` come from `AndroidKeyStore` and from nowhere else -- probed on API 35 rather than assumed -- and Keystore only makes keys it keeps. Every Ed25519 key in this engine is one we must hold: a device identity in our own vault, an account key derived from a passphrase. So the JCA route could never have worked. It compiled, passed 81 tests on a laptop, and threw "Not initialized" on the first launch on a phone. Worse, it was LATENT: a wrong password failed before any key was minted, so the error path worked perfectly while the happy path was broken. Ed25519 now uses BouncyCastle's `math.ec.rfc8032` core directly -- the engine's one real dependency, present because the platform leaves a hole, and a strictly better answer than the non-constant-time keygen it replaces. ProviderProbeTest records the measurement so nobody guesses again. **The prekey field is `prekeys`, not `opks`.** I guessed. The relay accepted the registration, returned 200, and stored nothing -- so the device was published and could not be started with, a failure with no error anywhere. Caught by a live test against the throwaway fleet, which exists because the same path through the emulator takes four minutes to say "timed out" and nothing about where. Two testing traps, recorded where they bite: An indeterminate progress spinner animates for ever, so Compose's test clock never goes idle and `waitUntil` never evaluates its condition -- it fails on the full timeout, reading as "sign-up is broken" when sign-up works. Auto-advance is now disabled around the wait only; disabling it for the whole test is the next mistake, because the app opens on a spinner while it reads the vault. Wiping the Keystore in `@Before` deletes the key the already-launched activity is holding, and every later write fails against a key that no longer exists. The test signs out through the UI instead, which is both safe and what a person does. Failure messages now include what the screen was showing. A bare timeout sends you to the network, the relay and the crypto in turn while the app has been displaying the reason the whole time. Also corrects a note that said this Mac had no Android SDK. It does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |