e5815d27e26abfa10bb6022b7b779cf381a5b151
88
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e5815d27e2 |
Android app lock, matching iOS
The last of the four privacy features Android was missing that iOS already had. The sealed history file protects a phone that is off or locked; it does nothing about the phone somebody just handed to a friend. iOS asked for Face ID; on Android anyone holding an unlocked handset read every conversation. Biometrics OR the device credential, deliberately - a phone with a PIN and no fingerprint reader should still be able to use this, the same reason iOS asks for deviceOwnerAuthentication rather than biometrics specifically. Offered only when the device can actually authenticate: a switch that turns on and then cannot show a prompt locks somebody out of their own messages with no way back. Two traps worth naming. MainActivity had to become a FragmentActivity - BiometricPrompt hosts a fragment, so on a plain ComponentActivity it silently cannot show and the callback never fires, which looks like a broken lock rather than a missing base class. And the gate wraps Root rather than drawing over it: composed underneath, the message list still renders for a frame, and a frame is all a screenshot or a shoulder needs. The lock screen deliberately says nothing - no sender, no preview, no count. A lock screen reporting "3 new messages from Dana" has given away the two facts that usually matter most. The setting states what it does NOT do. This stops someone who picks up your unlocked phone; it adds no encryption, because the vault and ratchet keys are already sealed by the keystore either way. Claiming more would be selling a screen as if it were a key. Android engine, client and app suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
98e2580d02 |
Android FLAG_SECURE, iOS notification privacy, and the padding design
⚠️ NOT BUILT. The machine has been at load 550-640 for three hours with 180 iOS-27 simulator processes on it; xcodebuild was killed rather than failing on its merits. Committed so the work is not lost, and must be compiled before it is trusted. FLAG_SECURE on Android. Blocks screenshots and recording, but the half that matters is the recents thumbnail: without it Android captures the open conversation and WRITES IT TO DISK, so a thread existed as a plaintext bitmap outside the ratchet, the sealed history file and the device-only key. iOS has had the equivalent since app lock landed; Android had nothing. iOS notification previews now default to OFF, and the sender is hidden too. Correcting what I told the owner earlier: Android does NOT leak notification content - it shows "New message" and never the sender or body. I misread the count string for the message text. iOS was the leaky one, and worse than it looked: content.title was set to the sender unconditionally, so turning previews off hid what was said and still announced who said it. On a lock screen that is often the more sensitive half, and the setting was labelled "Show previews". No incognito-keyboard flag, deliberately: Compose has no stable API for it, and FLAG_SECURE already makes most keyboards including Gboard suppress personalised learning. A second mechanism that might be a no-op is worse than the note. docs/PADDING.md designs envelope padding rather than implementing it. It is a wire-format change on a live protocol; the group-decode mismatch in this repository's history is what that looks like when it goes wrong. Includes the three-stage rollout, why stripping cannot be inferred, and why the relay needs no change at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b74cc1907f |
Make a call that goes nowhere say so
Android's Session.sendCallSignal called the send path and threw the result away. That result is the same Sent every other send returns - how many devices took it, which failed - so an offer that reached NOBODY was indistinguishable from one that rang. The caller watched "Calling..." for a signal that had gone nowhere, and neither device had anything to say so. The same shape as the message-loss bug fixed earlier today, on the one path where the UI never shows a delivery banner because a call is placed from inside a WebRTC callback. Also instruments the drops. An incoming offer can be discarded in five places in iOS's CallManager - already-ended, older than the dial timeout, busy, wrong state, unknown id - and every one was a bare `return`. Working out which of the five happened costs more than the logging does. Same treatment E2EInbox.dropped already gives ingest, and for the same reason. Not a fix for "Android calls iOS and nothing happens" - I have not reproduced that. It rules out and instruments: the wire shapes match, iOS builds its CallManager eagerly at sign-in so onCallSignal is always hooked, Android sets no from_addr so the federated fork is not swallowing it, and every clock (mac, host, relay, db) agrees within a second so the staleness check is not misfiring. The next attempt will say where it stopped instead of nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
780dc50652 |
Story audiences (Android): all contacts, only chosen, or all except
Three modes, stored in the vault, applied at post time. 🔴 The audience is enforced by who is SENT the bytes, not by a flag. A story is sealed to each recipient's devices through the ratchet, so somebody outside the list is never sent it - there is no copy on the relay for them to be denied. Every mainstream product holds one server-side copy and decides who is shown it; this cannot work that way even if somebody wanted it to, and that is the feature rather than a limitation. Which also answers "make sure stories are only visible to friends": they already were, structurally. There is no public audience and there cannot be one - a story has to be encrypted to somebody in particular. ⚠️ The saved list is intersected with the live contact list in every branch, including "only chosen". An id left behind after that person was removed as a contact must not resurrect them as a recipient. The screen says what will actually happen - "your next story goes to 3 contacts" - resolved the same way, and refuses a post that would reach nobody rather than silently succeeding at nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
85d6825fdf |
Stickers and emoji in the composer (Android), and a written queue
Stickers send, arrive, file into the right thread - group messages under the group, not scattered across one thread per member - and draw bare. No bubble: the kit draws none, and a glyph inside a grey rounded rectangle reads as a broken image. The timestamp and avatar stay, because that is the line saying who and when. Two controls, not one, because they are two different things. An emoji goes INTO the draft and is indistinguishable from any other character once sent - nothing on the receiving end needs to understand it. A sticker is its own message kind that is never rendered as text. Behind one button they would imply they behave the same way. Hence also: the emoji panel stays open on tap, since picking three in a row should put three characters in the box, while a sticker sends and closes because it IS the message. The emoji set is a short curated list, said plainly in the code: a full picker is search, skin tones, categories and recents - a real feature the system keyboard already ships. This is what a thumb can reach. docs/ROADMAP.md now holds the queue, including the security and privacy work. Written down because this project has already lost a finished feature to a parked branch. Two items there are real and current: Android spends a one-time prekey on EVERY message via fetchKeys, silently costing initial forward secrecy once the pool drains - iOS fixed this and Android has the unused listDevices sitting next to it - and a passphrase IS the account, with no second factor and no recovery. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3b195c730c |
Teach both engines the sticker wire
A sticker is a content envelope carrying an id, never an image: sending the picture would cost an upload and a download of bytes both ends already ship with, on every sticker. This is about sixty bytes. Recognised before the text path on both sides, because a sticker is renderable but is NOT text - it draws as a glyph with no bubble, and falling through would render it as an empty message. An unknown pack or id has to degrade to the pack's fallback rather than break, which is what the envelope format exists for. The pack is one JSON manifest read by both clients, so there is a single source of truth for what exists. docs/STICKERS.md says plainly what the first cut is: the glyphs are Unicode, because real sticker art is a design job, not a code one. The protocol, picker, rendering, fallback and tests are real and finished; swapping in artwork is a change to the manifest plus an image loader, not to any of that. It is not a claim to have drawn a sticker set. Emoji are deliberately NOT this. They go into the text of an ordinary message and are indistinguishable from any other character once sent - no new kind, no new field, nothing for the receiver to understand. That picker is a keyboard convenience, not a protocol feature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8ba0020785 |
Add a Calls tab, and the call log it needed
The kit's bottom bar is four icons and one of them is a phone; its "08 Calls-Listing" is a real screen. This app had a live call UI, a missed-call notification from the relay, and no history at all - so "who rang me yesterday" had no answer anywhere. So the log had to exist first. It lives in the vault with everything else: who somebody called and when is precisely the metadata this app exists to keep off a server, and encrypting the words while leaving the call log in the clear would be an odd place to stop. Bounded at 500, because the whole log is re-sealed on every write. Filed once, when a call ends. WebRTC walks DIALLING -> RINGING -> CONNECTING -> ACTIVE on the way, and recording each transition would turn one call into five rows; ENDED is usually followed by IDLE, hence the guard. Whether it ever connected is captured BEFORE the manager is torn down - asking afterwards always says no, which would file every answered call as missed. The row is measured from item/call/White: 48dp avatar, 16pt name, a 12dp direction icon before a 12pt line. Missed calls carry the kit's pink so the row somebody is actually looking for is the one that stands out, and the tab badges missed incoming calls only - an answered call is not news. No video icon, though the kit's row has one at x=255: there is no video path in either CallManager. relativeTime is shared with the chat list rather than reimplemented - two screens formatting times differently is drift nobody files a bug about and everybody notices. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7719ae062e |
Match the kit's chat bar and message box
Measured out of "Light Mode - iOS/07 Messages-Details 1" rather than eyeballed: the App Bar is 88 tall with back at x=24, title 16pt Medium over a 12pt line with a dot beside it, and Call at x=279. The Message Box is 84 tall: plus at x=16, an F3F3F3 pill from x=56, camera at x=287, more at x=335. The composer is one row now. Voice recording was a chip hanging underneath it - the last thing on the screen that did not belong to that row - and the mic is an icon in the row, with the permission plumbing hoisted to where it is used. Send appears only when there is something to send; at rest the row is the kit's. iOS tab bar drops its labels, matching the kit's "Bottom Bar/Without Label" and Android. Titles survive as accessibility names - dropping text from the screen must not drop it from VoiceOver. PresenceDot was still an olive, a mustard and a rust: the previous brand's palette, left behind when the accent changed. Now teal, blue and pink from the kit. Two things the kit draws that this deliberately does not: a video-call button and an emoji key. Neither CallManager has any video path at all, and there is no emoji picker to open - the system keyboard already carries one. A mockup can draw a feature; the app cannot draw one it does not have, and this codebase has already shipped one dead call button once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
94337cbc07 |
Hold an undelivered message and send it when they come back
Asked: how does the sender know to try again when the other person has no registered device - especially since they are friends. They should not have to. Marking it undelivered and stopping there moves the problem onto the person who did nothing wrong: they would have to remember, notice their friend was back, and retype it. So the message goes into an outbox and is retried on every poll tick. Nothing expires and nothing is discarded - a contact who reinstalls in a month gets it in a month. Timing out would mean deleting something somebody wrote in order to tidy up a label. The queue has to live on the sender's phone, and that is forced by the encryption rather than chosen: every envelope is sealed to a particular device's keys, so somebody with no device has no keys to seal to, and the relay could only queue plaintext. The consequence is that it goes out the next time THIS app runs and polls, not the instant the recipient signs in - so the label says "Undelivered - will send" rather than implying it is in flight. Delivery is now a tri-state. PENDING is quiet and grey because it is the normal thing that happens when a friend is signed out and there is nothing to do about it; only FAILED is a warning, and the outbox never produces one. OutboxTest covers it end to end, including delivery against a real second identity rather than a hand-written bundle: queued while away, sent when they register, and the bubble restated - a version that delivered the message but left it reading "undelivered" would pass a test that only checked the queue. Engine, client and app suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f711dd279b |
Every message carries whether it was delivered, and says so
Asked: how do we prevent ghost messages. Fixing the one cause does not, so this fixes the thing that makes any cause invisible. A message could only be a ghost because a delivered bubble and an undelivered one were the same picture. The code had one bit - record it or do not - and both answers were wrong, and the app shipped one of each: 1:1 text recorded only on success, so a message sent while the other person was signed out vanished out of the box with no bubble and no error. That is why it took sending twice to get one to appear. Group text recorded unconditionally, so it left a bubble that looked exactly like a delivered one and had reached nobody. Now every send records, carrying the outcome, and a bubble nobody received is marked. Null stays null for received messages and for anything written before the field existed - absence of a claim, not a claim of failure - so old threads do not sprout warnings. Correcting the previous commit message: it said the lost 1:1 message was written into the sender's own thread. It was not; nothing was written at all. The ghost bubble was the group path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
80b903e168 |
A message nobody can receive must not report success
Reported: a message sent from Android while the other end was signed out on
iOS was lost, and a group text vanished the same way.
Envelopes are addressed per device, and signing out deregisters the device.
sendPlaintext looped over an empty bundle list, made no request at all, and
returned Sent(delivered=[], failed={}) - which every caller reads as "it
went". The message was written into the sender's own thread and existed
nowhere else. Group sends call this once per member, so the same path lost
group text.
iOS never had this: transmit throws on an empty device list. The platforms
disagreed about a delivery guarantee for as long as both have existed, and
nothing caught it, because the tests that covered sending had both ends
signed in - and a happy-path test cannot tell a delivery from a no-op, since
both are silent.
Recorded in the failure map rather than thrown, so a group send still reaches
the members who ARE reachable. The banner now says the message was not queued
and has to be sent again, instead of "Delivered to 0 of 1 devices".
SendToNobodyTest pins it; verified failing with the guard removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0983e760b4 |
Draw the author's picture beside a received bubble
Reported: profile pictures do not appear in the chat boxes. They did not. iOS had an avatar slot in MessageBubble that hand-rolled only the initials fallback - it never had the user id or the inbox, so it could not reach the picture that AvatarView already downloads and decrypts everywhere else. Android reserved the kit's 40dp received inset for an avatar and drew nothing in it at all. Both now pass the author and the picture through. Sent bubbles get none: you know who you are, and a picture on both sides halves the width a thread has. Also gives the iOS list row a real last-message preview and a timestamp, matching the Android row - it was showing a screen name and the word 'Online', a second copy of the dot already on the avatar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5db9f8f7af |
Give the Android list row a preview, a time and a badge
The kit's row is four things - avatar, name, last message, time - plus an unread count on the right. Ours was a name and the word 'online', which is most of why a two-contact screen read as empty. The preview comes from the local vault, not the relay: the relay sends none and asking it to would leak which conversations are active, while the history is already decrypted on this device. Presence moves to the dot on the avatar, where the kit draws it and where it costs no line. Section headings now appear only when both contacts and groups are on screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
682d691a04 |
Draw icons instead of typing them
Every icon in the Android app was a text character or an emoji — +, →, ‹, ⋯, ☎️, 🎤, ⊞ — and material-icons-extended had been a dependency the whole time with zero Icon() calls against it. Reported three times as bare/text-links; each time I adjusted spacing and type, because the structure genuinely did match the kit and the code read correctly. Adds KitIcons.kt as the vocabulary and migrates the tab bar, bar actions, row overflow, composer, call controls, contact rail and alert glyphs. The tab bar loses its text labels to match the kit's 'Bottom Bar/Without Label' artboard and keeps them as accessibility names. Also fixes the iOS composer sitting under the tab pill: HidesPillTabBar was a PreferenceKey, and preferences do not travel out of a NavigationStack destination, so the chat screen's request never reached the bar watching for it. Now an observable with balanced retain/release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c9de1f26b8 |
Profile pictures, end to end, and the chat bar's second line
🔴 **An avatar is an attachment, not a field on the account.** The obvious design — upload a JPEG and let the relay serve one per user — hands the server everybody's face, on a product whose whole claim is that it cannot see what you send. This rides the same encrypted blob path as a photo message: the relay stores ciphertext, and the key travels to each contact inside the ratchet exactly as the delivery key already does. ⚠️ **The cost, stated on the screen rather than discovered.** It is per contact. Somebody who has never messaged you cannot see your picture, and changing it means telling every contact again. Sealed sender already makes this bargain; a different answer for avatars would mean two answers to the same question. The pieces: a new `avatar` control kind, which is parsed WITH its attachment ref rather than falling through the CONTROL early-return — otherwise a profile picture arrives in the conversation as though somebody had sent you their face as a photo. Encrypted at rest in the vault like everything else, because a face on disk in the clear is the one thing a stolen phone should not hand over about your contacts. Sharing is keyed on the blob id, not a boolean: a flag would say "told" for ever and a new picture would never reach anyone who had the old one. ⚠️ Contacts are told on OPENING a conversation, not when the picture is chosen. Somebody added afterwards would otherwise never be told, would see initials for ever, and nothing on any screen would explain why. ⚠️ Partial delivery is reported. "Sent, but 2 of 5 could not be told" is the truth when two contacts have a changed key, and silence would let somebody believe it had gone out. Also: the chat bar gained the kit's second line — a presence dot and a 12sp fact, or a member count for a group, which shows no dot because a group has many presences and one dot cannot speak for them. And the reassuring identity states dropped to 12sp grey; they were as loud as the title bar, which is the wrong weight for "nothing has happened". The CHANGED state is untouched and stays loud. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7295f43a13 |
Android gets a contact screen
🔴 **There was none.** Everything you could do to a person was an inline row that unfolded under their list entry — Block, Clear chat, Remove, three text buttons in a line — with nowhere to explain what any of them did and nowhere to put a safety number. iOS has the kit's version; this is the same screen: a cover, a name plate, and a rail of actions floating at the seam between them. ⚠️ The rail carries only actions that do something. The kit's has a "personal page" icon; there is no such page here, and a fourth control that opens nothing is worse than a rail of three. ⚠️ Presence sits on the name's own line, not the plate's trailing edge. The rail floats over the right-hand third of the plate, so anything put there is behind it — a thing only visible once rendered, and it was caught on iOS the same way an hour earlier. Removing a contact confirms in place rather than in a dialog, matching the Account screen: a dialog is dismissed reflexively; a row that changes to say what it is about to do is read. 🔴 The on-device test asserted `block-$id`, `clear-$id`, `remove-$id`, which no longer exist — those actions moved off the row and are not keyed by peer id any more. Updated to follow them, plus an assertion that there is a way back, or the screen is a trap. That suite needs a device and cannot run here, so it would have failed silently until somebody next ran it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b39c992115 |
Android gets a real call screen; iOS gets its tab bar out of the way
🔴 **Android had no call screen — it had a banner.** A line of text and two TextButtons stacked above whatever you happened to be on, for the single most attention-demanding thing this app does. iOS has had a full screen throughout and the kit draws one. Same split as iOS: the look takes plain values so the screenshot harness can photograph it, because a call screen nobody can look at is a call screen nobody has checked. ⚠️ The privacy prompt stays a banner deliberately. It is a question asked BEFORE a call exists, and a full screen for it would look like a call already happening. 🔴 **And a bug I introduced: the floating tab pill was sitting on the message box.** Reported from the iOS app. The pill overlaps content by design — that is what makes it float rather than reserve a strip — and the composer is exactly where it lands, so the field could not be tapped. `TabView` hid its own bar on a push and the replacement had nothing to do that with. Both conversation screens now declare `hidesPillTabBar()`, which is what the kit's own Messages-Details artboard shows: a composer at the bottom and no tab bar at all. ⚠️ The iOS fix is written but NOT yet rendered — the machine is pinned copying Xcode out of iCloud Drive and I am not running builds into that. It compiles; it has not been looked at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
b051814a6c |
Show the relay's sentence, not its JSON
Creating a group with a member who has never signed in put this on screen:
relay returned 400: {"code":"bad_request","message":"No reachable
device for user: 30.","data":{"status":400}}
The relay had written a perfectly readable sentence and the app showed the
envelope around it. Found by creating a group on an emulator, not by reading
code.
⚠️ The REFUSAL is correct — requireReachable declines a group whose member has
no device, because there is nothing to encrypt to. Only the presentation was
wrong.
Cause: MessengerApi.send and RelayAccounts already unwrap `message` from the
error body; RelayClient.checked did not. So which client a feature happened to
be built on decided whether its errors were legible, which is not a decision
anyone made. Now all three unwrap.
182 Android unit tests still green.
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>
|
||
|
|
10ae4ec180 |
Merge branch 'chore/messenger-signing-key' into 'main'
Sign the messenger with its own key, not Files' See merge request paulhitt/flamenet-messenger-ios!37 |
||
|
|
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>
|
||
|
|
6ab4741f8c |
Merge branch 'test/ios-followup-fixes' into 'main'
Six harness defects the full sweep found, and one of mine See merge request paulhitt/flamenet-messenger-ios!36 |
||
|
|
e8d566b3f2 |
Record what was actually checked for TestFlight
Verified against the built product rather than the source tree, which is what caught the one blocker: no PrivacyInfo.xcprivacy. Apple requires it for any app touching a required-reason API and this one uses UserDefaults in five files, so the upload would have come back ITMS-91053 before reaching TestFlight. The first archive silently omitted the file because it was added after the project was generated — hence the note to check the bundle, not the repo. Also green: archive for generic/platform=iOS, 38 unit tests, launch screen, usage strings, 1024 icon, ATS with no leftover dev exception, WebRTC's own manifest, and no tracking SDKs (grepped, not assumed). Two items left for a human: the collected-data declaration is a legal statement that must match the ASC privacy label, and versionCode 1 needs bumping after the first upload. And a warning that cost an archive: plutil -extract writes back in place without -o -, so using it to inspect a plist destroys it. Use plutil -p. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3ef8de63b9 |
Merge branch 'test/ios-two-simulator-and-surface' into 'main'
One sign-up helper, because five copies had the same bug See merge request paulhitt/flamenet-messenger-ios!35 |
||
|
|
02e5c45cd4 |
A test that could actually have caught it
`a retired session is kept and still opens its chain` passed while the fix was broken, because it retires a PRISTINE session — which is not what the receive path holds. By the time that path decides to retire anything it has just proved the peer is on another chain by handing this session an envelope it could not open, and E2ESession is a class, so that attempt consumed skipped keys and stepped the chain. The new test reproduces that state and asserts both halves: the damaged instance must NOT still open its chain (or the test proves nothing), and the copy re-read from the vault must. It fails against the first version of the fix and passes against this one. 13 VaultTest cases, 112 engine tests, 0 failures. Both platforms compile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4f15beb160 |
Retire the pristine session, not the one a failed decrypt mutated
The retained-session fix was necessary and did not work, and a live two-peer
reproduction is what showed it: both second-exchange messages still vanished
with delivered:1, failed:0.
Cause. `DoubleRatchet` is a final class on iOS and `E2ESession` is a class on
Android, so the failed decrypt that PROVES the peer is on another chain mutates
the instance it was handed — skipped keys consumed, chain stepped. Both fixes
then archived that same instance. Storing a part-advanced chain is
indistinguishable from storing nothing: it cannot open what the peer sends. The
vault copy was never handed to a decrypt, so re-read it before retiring it.
Second cause, Android only. Which slot the winner belongs in is not the same
question as which chain opened it. When the retired chain opens an envelope it
has to be written back to the RETIRED slot; promoting it swaps the two and
discards the chain we are sending on. Each branch now writes back deliberately
instead of sharing one `if (session !== existing)`.
Verified against relay1, two peer accounts, no simulators:
before p1/p2 poll -> {"messages":[]} / {"messages":[]}
after p1/p2 poll -> second-from-Q2 / second-from-Q1
The repro is in PARITY.md and takes about two minutes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e62317b6f3 |
Reproduce the loss without simulators, and show the fix is not sufficient
Two :interop:peer accounts against relay1, both running the retired-session fix.
Neither polls before sending, which is the simultaneous-initiation case:
p1 send / p2 send -> delivered:1, failed:0 (each on its own chain)
p1 poll / p2 poll -> both first messages arrive
p1 send / p2 send -> delivered:1, failed:0
p1 poll / p2 poll -> {"messages":[]} / {"messages":[]}
The second exchange is lost in both directions, silently, with the relay
reporting success. Same failure as the two-simulator run with every iOS variable
removed, and it reproduces in about two minutes instead of forty.
So retaining the retired session is necessary and not sufficient. The receive
side can now open the old chain; the loop still does not close. The remaining
gap looks to be on the send side — after adopting the peer's chain each client
encrypts on the adopted one while the peer still reads the one it kept, so
neither is listening where the other is talking. Resolving this properly means
both sides converging on the SAME chain by a deterministic tie-break, not each
adopting the other's.
Recorded so the earlier commit is not read as having closed it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|