b3daf0eb0f66c152dcb2aa4849899eeb92546212
179
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b3daf0eb0f |
Android 0.11
versionCode 11, versionName 0.11. 0.10 published 2026-09-05 18:12 (`39ccfbde…`) and everything below has merged since, so a rebuild at code 10 would have reached nobody — Android refuses an APK whose versionCode is not higher than the installed one, and the two builds would have been different bytes under the same number. User-visible in this build: - **A bridge, for networks that block Flamenet.** Account → Network takes a bridge line and carries relay traffic inside obfs4 to it. Off unless a line is pasted, validated before it is stored, and the screen reports whether a handshake has actually completed rather than what is saved. - **Sign-up could sit on "Checking what this server uses…" forever.** The relay probe had no deadline and no way to report failure, so a server that never answered left the form on that sentence permanently, with no error and nothing to act on. The rest is internals: the Kotlin obfs4 port itself, proven against real lyrebird in both directions, and CI that now actually runs the module's ~100 tests and the twenty protocol vector tests that had never passed there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6cd7a0d5dc |
A settings screen for the bridge line, and a path for it to reach
Two gaps, and the second was the bigger one. obfs4 was reachable from nothing. The socket-factory seam landed in !77, but every client in the app takes `defaultHttpClient()`'s null default — `RelayClient`, `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`, `RelayDiscovery` and the background service, ten sites in all, several from default parameters evaluated at field-initialisation time. The transport had been built, tested and proven against real lyrebird in both directions, and no user could switch it on. Threading a bridge through ten constructors would have worked until an eleventh was added, and the eleventh would connect directly, successfully, with nothing to show that it had. A missed site does not fail; it silently does not protect. So the factory is installed unconditionally and consults `Obfs4Routing` per socket. With no bridge it delegates to the platform's own factory, which is what OkHttp would have used anyway — nothing changes for the overwhelming majority who never set one. Pooled connections are the trap underneath that. OkHttp keeps a connection alive across requests, so one opened through the old bridge serves requests for minutes after the user switches — or after they turn a bridge OFF, which is the dangerous direction. The pool is shared so `Obfs4Routing.use` can empty it. The other gap is the one flagged when the wiring merged: a malformed line parses to nothing, nothing means no bridge, and no bridge is a direct connection. So `BridgeLine.parse` reports which part is wrong, and `Obfs4Settings` stores only what it accepted. Java's decoder made that worse here than on iOS. `fromCert` appended `==`, which THROWS for most truncation lengths — 20, 29, 30, 50 and 51 bytes among them — so a short paste came back as unreadable gibberish while iOS, whose decoder is padding-lenient, called the same paste "cut short". That is the likeliest real mistake by a distance: bridge lines wrap in every chat app and terminal, and copying only the first line is ordinary. Decoding the bare string works at every length and lets the size check give the honest answer. Both forms produce identical bytes for a real 52-byte cert, so nothing that worked before changes. The messages are the same sentences iOS shows, pinned by a test. The two apps are handed the same lines by the same operators, and one support answer should cover both. None of them says obfs4, base64 or node id — whoever reaches this screen was handed a line and told to paste it, and an error in the protocol's vocabulary reads as "you are not qualified to fix this". `iat-mode` 1 and 2 are a caveat, not a rejection: they ask the client to space its writes and the bridge does not enforce them, so such a line connects and works. Refusing it would take a working bridge away from somebody who has no other one. The status line reads a handshake counter, never the stored preference. A saved bridge that is not there leaves the app connecting directly, and only a completed handshake can tell you otherwise — so a bridge that has carried nothing says exactly that, and the words "protected", "secure", "hidden" and "safe" are asserted absent from it. Live interop re-run against real lyrebird after the factory change: both directions still pass. 448 unit tests green, 145 in the app, and reverting the unconditional factory reddens the routing test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
945978972a |
The on-device suite catches up with the app
🔴 **Nine defects, all of them in the test, none in the app.** These suites were written before the tab split, the age gate and the two sign-out doors, and had not run since. Each staleness hid behind the one before it, and every error message blamed the wrong thing. 1. **The age gate was never filled.** `submit` is `enabled = … && (!registering || birthYear.length == 4)`, and `performClick()` on a disabled Compose node does not throw — it does nothing. 2. **`submit` was below the fold.** Adding that field pushed the button off a 1080x2400 screen. An off-screen node still reports ENABLED — that is a state check, not a visibility one — so the gesture reached nothing. Proved by logging inside the app: `signIn` was never called, through 450 seconds of waiting. 3. **The peer's name was typed into a button.** `new-contact` is the FIELD; `lookup-contact` is the Look-up button. 4. **The lookup step was skipped**, so `confirm-contact` never appeared — the app refuses to turn a typed string straight into a contact, because a typo would silently become a stranger. 5. **`buddy-<id>` vs `buddy-circle-<id>`.** Home shows the circles row. The suite reported "never became a contact" about a contact on screen. 6. **RECORD_AUDIO was never granted.** The mic dialog is not in the app's Compose tree, so the failure read "No compose hierarchies found" — as though the app had died rather than a dialog covering it. 7. **Sign-out was a no-op**: the row needed scrolling to, AND the destructive rows confirm in place, so one tap only armed it. 8. **It used the wrong door.** A plain sign-out KEEPS the device and credential by design — right for a person, wrong for a suite whose next test wants a different account. `sign-out-erase` is the one that clears the vault. 9. **Groups and stories are on their own tabs**, and `stories` is the strip on Home, not the Stories tab, which carries `new-story`. ⚠️ `signOutIfSignedIn` no longer wraps itself in `runCatching`. Swallowing that failure is what made the damage appear three screens later in a different test. ⚠️ The two waits that span a relay POLL — a contact reaching the roster, a group reaching the list — are 150s. They are the only two that flake, because neither is refreshed by the response to its own write. All four tests pass. Not yet all in one run: the add-contact lookup is throttled per window, and four tests share it. |
||
|
|
77a7f3981c |
The instrumented suite could not sign up, and said nothing about it
🔴 **Sign-up requires a year of birth and no instrumented test filled it.**
`submit` is `enabled = … && (!registering || birthYear.length == 4)`, so
the button was DISABLED — and `performClick()` on a disabled Compose node
does not throw, it does nothing. Every sign-up in this suite has been
tapping a dead button since the age gate landed, failing two minutes later
with 'nav-contacts did not appear' and no error anywhere on screen.
Nothing was wrong with the app. The suite simply stopped being able to
create an account, and a failing instrumented suite reads as a flaky one.
Also fixes the skip counter in scripts/instrumented.sh: it grepped a bare
SKIPPED, which matches Gradle's own `Task … SKIPPED` lines, so a verdict
written to stop a skip being read as a pass was inventing skips instead.
|
||
|
|
d8d48d8651 |
"Checking what this server uses…" was also the failure state
🔴 **The relay probe had no deadline and no way to say it failed.** `discovered` is null both while the probe is in flight and after it has come back with nothing, so `identityDescription` shows "Checking what this server uses…" in both cases. A relay that never answers leaves the sign-up form on that sentence permanently — no error, no retry, nothing to act on. Found by the instrumented suite on a clean emulator: nine of ten tests failed with `nav-contacts did not appear within 120000ms`, and the screen dump showed the form still checking. ⚠️ **The reason it never answered is worth writing down.** The app was pointed at `http://10.0.2.2:18810`, and Android has blocked cleartext HTTP by default since API 28 — there is no `usesCleartextTraffic` and no network security config here. The request never left the device. 🔴 That refusal is CORRECT and is not what changed. `SiteConfig` already says why: a bare hostname becoming `http://` would hand key material to anyone on the path "and it would work, which is the worst outcome." What was wrong is that it failed silently, so somebody self-hosting who omits the scheme gets a dead screen and no idea why. Now: bounded at fifteen seconds — far longer than a healthy relay needs — and a failed probe is a distinct state that says "Couldn't reach that server. Check the address — and note it has to be https, not http." Same defect as android#19 one screen earlier, which I fixed this morning without noticing the probe had it too. Also adds `scripts/instrumented.sh`, which closes two ways an on-device run silently points at production: the suites DEFAULT to live hosts when no relay is named, and a saved emulator snapshot restores whatever the AVD was last doing — observed today as a snapshot signed in as `fnreviewbeta` against relay.flamenet.io, under a run told to use a local relay. The script has no default, refuses both live hosts by name, cold boots with `-no-snapshot-load`, and exits 3 on a skip because a skip is not a pass. |
||
|
|
9bba9d2555 |
Report a message, not just a story (android#20)
**The relay has always accepted `kind: "message"`. Nothing ever sent one.** Reporting existed on stories only — so the surface where unsolicited contact, spam and harassment actually arrive had no way to tell anybody. The person being messaged by a stranger had Block, which protects them and tells whoever runs the server nothing, so the account moves on to the next person. 🔴 **Android had `reportStory` wired all the way to the relay and no screen anywhere calling it.** The plumbing was complete and unreachable. This is the first report UI on this platform, and the sheet is not story-specific, so stories can use it too. 🔴 **The consent is the feature; the form is scaffolding.** Everything else this app sends is sealed and a report is not: the relay cannot open a message, so a report is actionable only if the reporter hands over what they saw. §6.25 requires the client to say so, and it says so in these words — what leaves the device, who can read it, and that it cannot be taken back. Turning the copy off states what that costs rather than quietly weakening the report. ⚠️ Block is offered beside it and never merged into it. A block takes effect immediately and is the reporter's own decision; a report is a request to somebody who may do nothing, and the sheet says which is which. ⚠️ One message travels, never the thread around it — §6.25 forbids including anything the reporter did not choose to. ⚠️ Report is absent, not disabled, on your own messages, and on the stranger banner it sits below both buttons rather than beside them. Most first messages are somebody you gave your address to; leading with Report would frame every one of them as a threat. ⚠️ A relay may decline reports entirely (`reports_not_accepted`, 501). That is a property of the server, not a mistake the reporter made, and the message does not read like one. |
||
|
|
1594ce84f6 |
Android 0.10 — published
sha256 39ccfbde…, signed CN=Flamenet Messenger with 791fc826… — the same
certificate as every build since 0.3, so 0.9 installs are offered this
rather than refused.
⚠️ APK into downloads/ BEFORE the manifest, and the served copy hashed
over HTTPS rather than trusted as copied. A manifest that lands first
announces a version the file is not, and every client checking in the gap
downloads the old one and rejects it against the new hash.
Second release through the notes gate, and it did its job unprompted:
`release-manifest.sh` read 0.10 back out of the APK and confirmed
`Resources/releases.json` names it before writing a byte.
|
||
|
|
d8df4a8dbf |
Sign-in that cannot finish now says so, and a missing log leaf heals
**android#19 — sign-in sat for four minutes and said nothing.** The failure path was always correct: anything `authenticate` throws is reported. What it could not report was a call that never RETURNS — observed 2026-09-05 with the requests demonstrably going out and the screen unchanged. Nothing was thrown, so nothing was shown. ⚠️ Sixty seconds, not ten. Sign-in derives a key from the passphrase and then talks to two services; an emulator or an older handset is legitimately slow at the first part. This is a backstop for "never", not a latency budget. **android#6 — a failed key-log append was permanent.** `signIn` calls `registerDevice`; `restore` did not. So a device registers exactly once, and if the transparency-log append did not land — a slow write, a relay mid-restart — it stays registered with no leaf, and every peer that enforces the log refuses it forever with an error that reads as an accusation of tampering. Relaunching did not help, which is the cruel part: the one thing anybody would try was the one thing that could not work. Restore now checks for a leaf and republishes if there is none. ⚠️ Checks first. Registering unconditionally would publish twenty fresh prekeys every launch and churn the log for nothing — iOS's `registerDeviceIfNeeded` tops up only when low for the same reason. Silent on failure, because this repairs a state almost nobody is in. **android#17 — a photo sent as a file keeps its GPS.** Same as ios#69 and the same answer: the picker strips through ImagePrep and MediaScrub, `Session.sendFile` passes the vault's bytes straight through, and the send screen now says which path you are on rather than leaving the safer one looking like a shortcut. |
||
|
|
581967289a |
Android 0.9 — published
🔴 **0.9, not 0.10.** The tree said 0.10 because "Put the app back to the last state that worked" bumped past 9 without publishing anything, so 9 belonged to nothing and the live manifest still said 8. Going back to 9 keeps the published line contiguous; the only rule that matters here is that `versionCode` never goes backwards from what is INSTALLED, and nothing carrying 10 was ever served. Published: sha256 b86219dc…, signed CN=Flamenet Messenger with 791fc826…, the same certificate as every build since 0.3 — an update installs over 0.8 rather than being refused. ⚠️ The APK went into downloads/ BEFORE the manifest, and the served copy was hashed over HTTPS rather than trusted as copied. A manifest that lands first announces a version the file is not, and every client that checks in the gap downloads 0.8 and rejects it against 0.9's hash. This is the first release through the notes gate that landed one commit earlier: `release-manifest.sh` refused to emit anything until `Resources/releases.json` in the relay named Android 0.9. |
||
|
|
76c548c727 |
Merge branch 'fix/files-uses-your-flamenet-passphrase' into 'main'
Files asks for your Flamenet passphrase, not a second secret See merge request paulhitt/flamenet-messenger-android!69 |
||
|
|
571c18571f |
Files asks for your Flamenet passphrase, not a second secret
Reported: "on android, the files passcode should just reuse the account like ios does." 🔴 **The difference was entirely in the words, and iOS has been right all along.** iOS says "your passphrase" and takes whatever is typed, so a person naturally uses their Flamenet one and it simply works. Android said the files were "encrypted with a passphrase of their own", labelled the field "Choose a passphrase", and later asked for "your Files passphrase" — three separate places telling somebody to invent, and then remember, a second secret. ⚠️ **Forgetting it is a one-way door.** The label keys are derived from the identity that passphrase protects and exist nowhere else, so a forgotten Files passphrase makes every vaulted file permanently unreadable. Sending people off to mint an extra secret for no reason put them one lapse of memory from that, and the app already has a paragraph explaining the consequence. ⚠️ **Nothing in the derivation changed, deliberately.** It is still a free-choice string protecting the identity record, so anyone who already set a different one keeps opening their files with it exactly as before. Making the account passphrase the ACTUAL key would be a migration with permanent data loss on the far side of any mistake, and it is not what was asked for. That also means the copy IS the feature here, so a copy guard is the only guard there can be. Proven by reverting the wording: both tests go red naming the file and the phrase. 1194 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
97961974dd |
Say when a call was refused because the key changed (android#20)
Reported from a live session: "the calls wouldn't connect because the keys were changed. it wasn't obvious why the call failed." Confirmed by the reporter — accepting the new key made everything work. 🔴 **It was not obvious because nothing said it.** The call's signalling lambda was `runCatching { session?.sendCallSignal(...) }` with no `onFailure`, so `IdentityChangedException` vanished. That exception is the send path REFUSING to encrypt to an unreviewed identity — TOFU working exactly as designed — and swallowing it meant the offer never left while the screen sat in DIALLING until it timed out. ⚠️ **`describe()` had no case for it either**, so anywhere it WAS caught the person got "Something went wrong reaching that server." The server was fine. That sentence sent somebody hunting a network problem that did not exist, which is the specific thing the neighbouring branches in that function exist to prevent — they all say what to DO. ⚠️ **And the fix is on a screen the caller is not looking at.** Accepting a changed key is a banner in the CHAT; somebody calling from the Calls tab never sees it. So the message names the conversation, not just the problem. This is a parity gap, not a new idea. iOS has named this on the call path since it was built — "Their security code changed", with the review opened — and its comment describes this exact bug: "sent someone hunting network problems when the actual cause was their contact reinstalling". `describeFailure` moves to file scope so the mapping can be tested without an `Application`. It never touched instance state, so that is a move rather than a rewrite — the alternative was a Robolectric run to assert a string. Proven by removing the branch: all three tests go red reporting the old sentence verbatim, "Something went wrong reaching that server." 1196 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8c2d7f1f65 |
"Unsend" did not unsend. Say what the button does (android#18)
The message menu labelled the delete `if (line.mine) "Unsend" else "Delete for me"`, and both branches call `deleteMessage` → `Session.deleteMessage` → `vault.deleteLine`. That is the whole implementation. No envelope leaves the phone; the recipient's copy stays exactly where it is. 🔴 **Worse than an overclaiming page**, which is why this got a test and not just an edit. A page that overclaims can be read sceptically. A button that reports success cannot: the feedback IS the claim, and it arrives at the moment the person is most relieved to believe it. "Delete for me" was already the other half of that ternary, and it was always the accurate half. ⚠️ Two tests, not one, because the first is worthless without the second: a source scan that walks the wrong directory finds no files and passes every assertion in it. `the scan actually reaches the chat screen` pins that the walk reaches the file the bug was on. The walk climbs to the module root rather than resolving a relative path, for the same reason. Proved by breaking it, not by watching it pass: label reverted → FAILED, naming ChatScreens.kt line ~1564 restored → 2 tests, 0 failures 1190 tests pass across the suite. ⚠️ This is ios#57 on the other platform, fixed there in the same pass. A real retract is a new `kind` on rails that already exist — §6.15's edit machinery is implemented and tested on both clients. But even then it is a request, not a guarantee: a client can ignore it and the message may already have been read. Whatever the button eventually says must not promise more than "we asked them to remove it." Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
56ec122405 |
Prove a group pin resolves, so a spec claim can be settled (relay#46)
The published spec says a group pin "is carried correctly on the wire but cannot resolve yet, because a group transcript does not record" a message id. `Session.sendGroup` mints a mid and passes it to `record(...)`, so the stated reason is already false — but relay#46's rule is that a claim is flipped by a run, not by reading the code, and nothing exercised the group case specifically. Five tests, on a group thread rather than a 1:1 one, because that is what the spec singles out: the transcript carries an id, the pin resolves to the message it names, and a group pin does not leak into a DM. ⚠️ One of them is about the design rather than the plumbing. The pin stores an ID and never a copy, so the banner follows an edit instead of quoting words that are no longer in the conversation — a stored copy would go stale the moment the message changed and would outlive it being deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c01d1ba32b |
Fix: ./gradlew test now passes on a clean tree (android#5)
Confirmed exactly as filed: 15 red `ScreenshotTest`s, all in the RELEASE variant, all "Unable to resolve activity for Intent". The diagnosis in the issue was right — `androidx.compose.ui:ui-test-manifest` is a `debugImplementation`, and it is what declares the `ComponentActivity` those tests launch, so the release variant's merged manifest has no such activity. ⚠️ **The tempting fix is to add that dependency to release as well. Do not.** It exists to inject a test-only activity into the manifest, so putting it on `releaseImplementation` would ship that into the APK. The tests belong to the variant that can run them. ⚠️ Excluded by name rather than by disabling `testReleaseUnitTest` wholesale — everything else still runs in both variants, so a genuine release-variant difference would still be caught. ⚠️ And the first green was not evidence: `clean` followed by `test` reported BUILD SUCCESSFUL in 3 seconds, which is far too fast to have run anything. Re-run with `--rerun-tasks` and counted: debug 129, release 114, JVM modules 933, 0 failures. The 15-test gap between the variants is exactly the excluded screenshot tests, which is the shape that proves the exclusion did what it says rather than taking the suite with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
327da06ed3 |
Prove an inbound edit is applied, so a spec claim can be corrected (relay#46)
The published spec says the Android client *"cannot yet apply one, because its transcript does not record a `mid` for each line"*, and that the same gap blocks replies and reactions. The transcript DOES record a mid, and `MessageReactionTest` already folds a reaction onto a message by id — so the stated reason is false and the consequence for §6.9 is false with it. ⚠️ But relay#46's rule is the point of this commit: **do not flip a claim because the code looks present; flip it because a run proved it, and say which run.** `applyEdit` was implemented and wired with nothing exercising it — `InboundControlTest` covers the codec and says in its own header that the app-side application needs a Vault and is out of scope there. This is that run, written so the correction has something behind it. Five tests, and the two that matter most are refusals: an edit from somebody who did not write the message, and an edit of our own message claiming another author. Without the author check a contact could rewrite another person's words in a transcript, which reads to the reader as the sender having said something they never said. Both proved by removing that check and watching exactly those two go red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
46fb23dbe1 |
Expired messages no longer leave their plaintext on disk (android#11)
`attachmentBytes` wrote DECRYPTED bytes to `cacheDir/att-<id>` and nothing ever removed them. So a disappearing message expired, the bubble went, and the photo or voice note stayed on disk indefinitely — the exact failure the feature exists to prevent. Spec §6.11: attachments MUST be purged with the message that referenced them. 🔴 **A sweep, not a purge at each deletion point, and that changed what the fix had to be.** FOUR paths drop a row — message expiry, story expiry, delete-for-me and clear-thread — so threading "here is what I removed" out of each would still miss anything a future path drops, and would never reach the orphans already on disk. Asking instead "what is still referenced?" is complete by construction and self-healing. 🔴 **Refs are found by SHAPE.** An attachment reference serialises with `id`, `key` and `digest` together and nothing else in the vault has all three, so `attachmentIdsInUse` walks every record and matches on that. A hand-written list of stores is wrong the day somebody adds a store and does not update it — and the sweep would then delete attachments still in use, which is far worse than the leak. ⚠️ **That is not hypothetical: enumerating by hand gets it wrong today.** The obvious sources are history and stories. The vault ALSO holds refs in the story archive, in per-contact avatars and in this account's own avatar. A sweep built on the obvious two would have deleted every avatar on the device the first time it ran, and blanked the user's own story archive — which is the one thing that archive exists to prevent. Three more safety properties, each with a test that fails without it: fails closed when the in-use set cannot be built (a partial answer is what turns a cleanup into data loss); a grace period so a sweep racing a fetch cannot delete bytes on their way to the screen; and a throttle inside the function, since it reads every vault record and is called from the poll loop. ⚠️ The cost is that a file survives to the next sweep rather than going at the instant of expiry — named in the code rather than left implied. 17 tests. Every guard proved by breaking it. ⚠️ One did NOT hold at first: removing the `/` -> `_` filename mangling broke nothing, because every id in the suite was slash-free — the guard was untested by the tests meant to be guarding it. Added the case; it now fails as it should. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6ec626cde2 |
Share a file into a chat, and save one back — with the label it carries
Both directions: pick a document from the vault inside a conversation, and save a received one into the vault at the classification it arrived with. 🔴 **This is the answer to android#15, and the codebase had already given it.** `Attachment.FILE` has carried this since before the lattice existed: *the label is a MARKING, not a permission — the content key is in the same envelope, so whoever can read the message can read the file.* That settles the ★-property question. The key travels with the file by design, because that is what makes sending work at all; there is no trusted computing base to enforce against, since the person holds every key on a device they control. A client-side refusal would be a suggestion, and **a suggestion dressed as a control is worse than none** — it is the one people plan around. So neither client implements the ★-property, `LabelTest` keeps asserting that absence, and PARITY.md now says plainly that Files is not mandatory access control. What it IS: cryptographic read separation, plus a classification that travels. The picker says so before the tap, in those words. Three things found on the way: 🔴 **A second copy of the vocabulary.** `classificationName` in ChatScreens listed the same four names as `FileLevels`, agreeing by coincidence with nothing making them agree. Rename a level for the Files screen and the chat bubble keeps showing the old word for the same number, in the same conversation, on the same phone. Collapsed to one. 🔴 **Attach and Camera did the same thing.** Both launched the gallery, and there was no way to send a document at all. Attach now opens a small panel — photo, or from Files — which keeps the kit's composer row at its four controls. 🔴 **iOS's picker lists the root only**, so a file inside a folder cannot be sent from a conversation, and nothing says so — it is simply absent, which reads as "it isn't there". `LocalVault.allFiles()` walks the whole tree. Filed as ios#68. ⚠️ The picker is the ONE place this vault hides a row it cannot read. Everywhere else it insists on showing them, because hiding one leaves somebody unable to see anything is there; a picker exists only for choosing, and a row that can never be chosen is furniture. ⚠️ A received file is always saved as an encrypted copy, never a link — there is no file on this phone to point at — and always at the label it arrived with. Re-labelling somebody else's document on receipt would launder its classification, and the number is the only thing that survived the trip. 9 new tests. Each guard proved by breaking the code: writing the label for every attachment kind fails the image test; dropping compartments fails the two that name them; removing the fallback caption fails the older-build test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fbf2c4c531 |
Files on Android: set up, unlock, browse, add, folders, delete, promote
The screen, the Storage Access Framework resolver behind `LocalVault.ReferenceResolver`, and the passphrase-protected identity the vault opens under. 🔴 **Android asks for a Files passphrase, as iOS does, and that was a decision rather than a port.** The Android wallet sits behind the Keystore-sealed vault alone, so an unlocked phone opens it. Files does not: the identity record is encrypted under a key stretched from a passphrase this app deliberately keeps no copy of, with the Keystore seal around it as the second layer. Dropping the inner one for convenience would be a real reduction and not a refactor — a vault that opens because the phone is open is a folder with a lock drawn on it. 🔴 **`OpenDocument`, never `GetContent`.** `GetContent` hands back a URI whose read grant dies with the process, so a linked file works for the rest of the session and is broken on the next launch — and whoever added it watched it work. `filesReference` REFUSES when the grant cannot be persisted and offers to copy the file in instead, rather than filing a row that will be dead tomorrow. 🔴 **Locking drops the identity; it does not hide a screen.** The open vault is a field, deliberately not part of `FilesState` — a state object gets logged, compared and copied into recompositions, none of which should ever have a private key in it. The classification ordinals are pinned by a change-detector test, on purpose: the NUMBER travels with a file and the word is only how it is drawn, so renumbering them would file a Restricted document as Internal on the other phone with nothing erroring and neither screen looking wrong. ⚠️ **Not yet run on a device.** The machine was carrying an iOS simulator at 99% CPU on 8 cores, and booting an emulator on top of that produces a flaky run rather than evidence. Two things need a real device before this is done: the SAF picker with its persistable grant, and whether a linked file still reads after a reboot. Neither can be faked on the JVM. ⚠️ Files is its own destination, not a section of the wallet. They share an unlock, which is true and is an implementation detail; iOS grouped them once and what it produced was somebody pressing "wallet" and arriving at a file list. Full build and all JVM suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d915001dcc |
The wallet screen (Android)
Create a wallet, restore one from a phrase, see the balance and the history, and take a fresh address to be paid at. 🔴 An unread balance is an em dash, never 0.00. Null means this app could not read the balance; zero means the account is empty. Drawing the first as the second tells somebody their money is gone — on every flaky connection, which is exactly when they are most likely to believe it. The same rule governs the activity list: "not read yet" and "nothing yet" are different sentences and the screen says whichever is true. Both cards are always present once a wallet exists, because a screen that hides them until it has something looks broken at the moment somebody wants reassurance nothing went wrong. 🔴 The phrase card sits ABOVE everything about the chain. Somebody who has just made a wallet has one job — write these words down — and putting a balance in front of it invites them to skip it and come back later, which for most people means never. The card says both halves at once: anybody who has these words owns the wallet, and anybody who loses them loses it. Saying only the first makes people hide it; saying only the second makes them screenshot it. ⚠️ Copy is offered for the phrase and is the SECOND option, not the first. It puts the words on a clipboard every other app can read — offered anyway because people who are not given it photograph the screen, which is worse and permanent. The text asks for paper. ⚠️ The receive card says the address changes BEFORE somebody writes one down. A fresh address per payment is the one linkability a wallet can prevent, and an address that silently changed would read as a bug to anybody who had saved it. Older addresses keep working, and the card says that too. ⚠️ Reached from Account, not the tab bar. The bar is four icons by design — the kit's artboard is "Bottom Bar/Without Label" — and Alerts was taken OUT of it in August for being a fifth. A wallet people open occasionally does not earn the slot that decision refused. 🔴 An account below the rent-exempt minimum is RECLAIMED by the network, so `spendable` holds back the fee and that minimum: emptying a wallet to the last lamport does not leave it empty, it deletes it. Named constants rather than an expression, so the reason survives. ⚠️ http endpoints are ignored rather than honoured. A balance read over plain http is one anybody on the path can rewrite, and a wallet showing a number an attacker chose is worse than one showing nothing. 8 display tests. App builds, every JVM suite green. Three PARITY wallet rows are now ✅. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e4eca84e3 |
Refuse a home record older than one already seen (§20.4, Android half)
Both remaining ⚪ protocol rows turned out to be stale. `AccountKdf.localAccountSalt` is pinned to the published vector and wired into sign-in for a local relay; `MailboxHoming` and `AccountIdentity.publishHomes` have shipped the publish and read halves for a while. PARITY.md was describing a gap that had closed, which is its own kind of wrong — a definition of done that overstates the work left is one people stop reading. 🔴 What was genuinely missing was on the READING side, and on BOTH clients. An older home record is GENUINELY SIGNED — the account holder published it, before they added a second relay. So a relay that wants to be somebody's only mailbox does not have to forge anything: it serves the record it saw first, that record verifies perfectly, and the sender delivers to one relay believing it is delivering to all of them. That is the attack multi-homing exists to survive, and the serial is what was added to stop it. The relay refuses a lower serial on WRITE. Nothing refused one on READ: `verifiedRelays` here and `verifyHomes` on iOS both checked the signature and stopped. Fixed on both — iOS in ios!116, this is the Kotlin half. The highest serial seen per address is remembered in the vault and a lower one is refused. Equal is fine; only strictly lower is a rollback. 🔴 The floor rises only for a record that VERIFIED, via a separate `verifiedSerial`. Otherwise a hostile relay could hand over an unsigned record carrying Int.MAX_VALUE, have the client raise its floor to it, and lock out every later genuine record — turning the rollback defence into a denial of service against the account holder. Tested. ⚠️ Sealed in the vault and keyed by a hash of the address: which addresses this device has looked up is exactly the metadata the vault exists to keep off a stolen phone. ⚠️ The refusal does not claim an attack. A relay that has genuinely fallen behind serves an old record for honest reasons, and nothing in a client can tell that from the other case. 8 unit tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
455cdcd818 |
Edit, forward and pin from the conversation (§6.15/§6.16/§6.19)
The outbound half of the three controls whose codecs were already back. The long-press menu gains Edit, Pin/Unpin and Forward; the thread gains a pinned banner; a bubble says `edited` and `Forwarded`. 🔴 An edit that silently replaces the words is worse than the typo. The reader has no way to tell a correction from their own misremembering, and no way to know the message they replied to is not the message that is there now. So the bubble is marked, on both ends, and the edit sheet says plainly that everyone in the conversation sees the new wording and a mark on their copy. 🔴 A forward carries the words and NOTHING about where they came from — no author, no thread, no quote — and the test asserts the ABSENCE of a reference rather than only the presence of the flag. The obvious "improvement", carrying a reference so the reader can see the original, would republish a private message's author to somebody who was never in that conversation. It also gets a NEW message id: reusing the original's would make the copy a target for edits and reactions aimed at the original, one of which would silently rewrite the other. ⚠️ Only your own message can be edited, checked in `Session` and not only in the menu. The receiving side enforces the same rule independently, so neither end is trusting the other. Anyone may PIN, by contrast: a pin is a shared banner rather than a permission, and restricting it to the author would mean the useful message in a group is exactly the one nobody but its writer can pin. ⚠️ Edits and pins are applied locally first and rolled back if the send fails, the same shape `react` already uses. Waiting on a round trip to see your own correction appear makes it feel broken on a slow connection; leaving one that exists only on this phone is worse, because you would believe you had fixed it. ⚠️ A forward does not carry an attachment yet. Re-sending a blob means re-uploading it under a new token, and passing on the ORIGINAL token would hand the new recipient a capability to a file in a conversation they are not part of. Text only until that upload exists. ⚠️ The pinned banner resolves the id against the lines on screen, so a pinned message that was since deleted shows nothing rather than a stale copy. Clearing a thread clears its pin. Two things found while wiring it: `Content.text` has no `groupId` parameter, so a group forward needs `groupText` — a compile error rather than a silent drop. And the edit/forward state is hoisted above the message list, because a row's own `remember` dies when it scrolls out and an edit box vanishing mid-typing is the worst way to lose a correction. 9 more unit tests. App builds, every JVM suite green. The three PARITY rows are now ✅. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e0420a00c6 |
Apply inbound edits and pins instead of filing them as messages (§6.15/#6.16/#6.19)
🔴 This was an interop bug, not a missing feature. The engine has parsed `editOf`, `pins` and `fwd` for as long as they have existed, and it exposes `isRenderable` — "does this belong in a thread" — as the single answer. `Session`'s inbound loop never consulted it. So three things arrived from the iPhone app and were filed as ordinary bubbles. An edit carries its new wording in `text`, so it drew as a DUPLICATE beside the typo it was meant to replace — a correction that made the thread worse than the mistake did. A pin carries only an id, so it drew as a blank bubble. And a forward was drawn as the sender's own words. Worst of the three is the one nobody had hit yet: a control envelope from a NEWER peer carries `ctl` and a kind this build has never heard of, and would have drawn a bubble full of JSON in somebody's conversation. That is precisely the failure `isControl` was written to prevent, defeated by nobody calling it — and it would have shipped to every older Android install the first time a control kind was added on iOS. The guard is `isRenderable` rather than a list of kinds this build knows, because an allowlist is only correct until the other platform adds one. What is applied now: - an edit rewrites the message it names and stamps `edited`, so a bubble can say so — silently replacing the text is worse than the typo, because the reader cannot tell a correction from their own misremembering; - a pin is stored per thread, sealed in the vault like the transcript, as an ID and never a copy: a copy goes stale against an edit and outlives a deletion; - `fwd` reaches the transcript so second-hand words are not drawn as the sender's own — THAT it was forwarded, never from whom. 🔴 An inbound edit is applied only when it comes from the person whose message it is. Without that check any contact sharing a session could rewrite anybody's words in the reader's transcript — a better attack than sending a message of their own, because the words would carry somebody else's name. ⚠️ Clearing a thread clears its pin. A banner pointing at a message that was just cleared reads as the clear not having worked. ⚠️ `fwd` is read as both a boolean and a number, for the same reason `isControl` accepts both: `org.json`'s `optBoolean` does not coerce a number, and that is how this class of bug shipped once already. 9 unit tests. App builds, every JVM suite green. The three PARITY rows stay 🟡: the inbound half is done, originating each still needs the UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f362db0ee7 |
Handle a wrong clock (ios#45, Android half)
🔴 A wrong device clock breaks a promise made to somebody else. A story disappearing after an hour is a promise the SENDER made, and it is enforced on the READER's device — so a reader whose clock runs behind keeps a message the sender was told would be gone, and nobody involved can tell. That asymmetry decides the design, and it is the whole of this change. Expiry takes the LATER of the device's clock and the relay's, never either alone. A device set back cannot extend a message's life, and a hostile relay claiming an earlier time cannot either, because the other source overrules it. The remaining failure — expiring early on a clock set forward — costs the reader something they were about to lose anyway. The skew is learned free, from the `Date` header every HTTP response already carries. Observed in `RelayClient.checked`, which is the one funnel every relay call passes through, and BEFORE the success check: a phone whose clock is wrong enough to be refused is exactly the phone that needs to be told why. Story and disappearing-message expiry on the app side now reads `DeviceClock.expirySeconds()` rather than `System.currentTimeMillis()`, at all seven sites. ⚠️ Android needs synchronisation iOS does not. The Swift version is `nonisolated(unsafe) static var` because its writers sit on one actor; here the observation is written from whichever OkHttp thread finished a call and read from the UI thread. The two fields are `@Volatile` and updated together under a lock — a torn read of "skew from now, timestamp from an hour ago" would age out an observation just made, or trust one that had not been. ⚠️ `SimpleDateFormat` is built per call rather than shared, because it is not thread-safe, with a fixed `Locale.US` and GMT: a formatter taking its locale from the device parses nothing on a phone set to most of the world, and the failure is silent — the header is simply never understood. 15 unit tests. Proven: replacing the rule with "trust the server" turns the hostile-relay test and the never-earlier test red. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fffab8e685 |
Back off on repeated app-lock attempts (ios#41, Android half)
Android had the lock and none of the back-off. iOS has counted failed unlocks and imposed a growing wait since ios#41; here every attempt was free, so somebody holding an unlocked phone could work through PINs as fast as the prompt would reappear. Two platforms disagreeing about a security guarantee is how one of them ends up being the one that leaks — the same argument that put the lock on Android in the first place. Same schedule as iOS: three free attempts, then 30 seconds doubling to a five-minute ceiling. Both ends matter. A mistyped PIN or a fingerprint that did not read is ordinary, and making the first slip cost a wait would punish the owner far more often than anybody else; and an unbounded back-off locks somebody out of their own messages permanently, which is worse than what it defends against — no reset, no support desk, and the data is on their phone. 🔴 One real divergence from iOS, and it is a bug the Swift version cannot have. iOS computes `30 * pow(2, steps)` in a Double, which saturates to infinity and still caps correctly. Kotlin shifting a Long does not: `30L shl 60` wraps NEGATIVE, and `minOf(negative, cap)` returns the negative — a "wait" in the past, which reads as no wait at all and silently removes the whole feature at exactly the point somebody is grinding attempts. The steps are clamped before the shift. Proven: removing the clamp turns that test red. 🔴 The refusal happens BEFORE the prompt is shown. Letting the system sheet appear and then discarding the answer would teach somebody that attempts are free, which is the opposite of the point. ⚠️ Only a genuine wrong answer counts. `onAuthenticationFailed` is a fingerprint that did not match; `onAuthenticationError` is a cancel, a platform lockout or a hardware problem. Only the first moves the counter, so dismissing the prompt cannot back the owner into a wait — and the platform's own lockout is not counted on top of ours, because two back-offs stacking produce a wait nobody can predict from either rule. ⚠️ Switching the lock off clears any outstanding wait. Leaving one behind strands somebody behind a countdown for a screen that no longer appears. 7 unit tests. App builds, every JVM suite green. PARITY.md gains a Hardening section listing the three that remain (ios#19, #39, #45). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
eff8cdb493 |
A 403 is not an expired session
Signing in on a fresh install said "Your session expired. Sign out and back
in to keep going." — advice that cannot work, because signing in again hits
the same rule and says the same thing. There is no way out of that loop from
inside the app.
RelayAccounts mapped 401 and 403 to the same Unauthorized. They are not the
same: 401 means the token was refused and signing in again IS the fix; 403
means a rule refused this — too many devices, a retired device, an
unverified address — and the relay writes a sentence naming which one and
what to do about it. RelayClient already made this distinction; RelayAccounts
did not, so which client a path happened to use decided whether its refusals
were legible.
The 403 sentence now reaches the screen instead of "That isn't allowed on
this server", which replaced an answer somebody could act on with one they
could not. Only when it is really a sentence: a JSON fragment or a bare code
keeps the generic line rather than putting a payload on screen.
Predates tonight's merges — checked against
|
||
|
|
3f0e36fffc |
Count the sealed-to-unsealed fallback, and offer to refuse it (ios#16)
🔴 **Off by default, and that is the honest default rather than the lazy one.** An unsealed send is still end-to-end encrypted; what leaks is that this account sent something to that one. Refusing turns a metadata leak into an undelivered message, which for most people is the worse outcome — so the default degrades and says so, and the refusal is there for the people who would rather a message did not go than go attributable. The counters answer a question nobody could ask before. `Sent.sealed` says whether one send was sealed; the record says how often this happens to you, which is what somebody actually needs to decide whether the switch is worth throwing. ⚠️ Refusing costs nothing: `sealed` is false exactly when neither a capability nor a key was found, so there is no token to hand back. ⚠️ Both the policy and the counters are wired in ONE helper called from both sign-in paths. Two call sites building a `Session` and only one applying the policy is how a setting silently stops working on one of them. Taken by hand rather than cherry-picked — the original also rewrote a ChatScreens that has since changed underneath it. The settings switch for it is UI work still to come. |
||
|
|
a0bc8e68d2 |
Report a story to whoever runs the server (§6.25)
The reporting path, taken by hand — the original commit also rewrote 318 lines of ChatScreens against a story feed that no longer looks like that. The UI control comes next; this is the part underneath it. 🔴 **A report is the one thing in this app that leaves readable.** The relay cannot open a sealed story, so a report about one is only actionable if the reporter hands over what they saw. That is the point and the cost, and it means the consent has to name the consequence — `reportStory` does not ask, and must never be reached from a control that does not. ⚠️ It sends only what it is passed, and never reaches into the thread for surrounding context. §6.25 forbids including anything the reporter did not choose to include. ⚠️ The reasons are enumerated to match the relay. A reason the relay rejects is a report somebody believed they had filed. The message afterwards says what will and will not happen: whoever runs the server sees it, and you are not told what they do. Promising an outcome we cannot observe would be worse than saying nothing. |
||
|
|
32e2f6b5df |
Post a story to a group (§6.24)
Taken by hand, like the two before it — the original was cut from a tree whose text stories and story replies differ from this branch's. 🔴 The group's roster is the audience, and the story audience does NOT apply. Those are two different lists making two different statements — who may see this account's stories, and who is in a conversation — and they will disagree. The roster wins, because a story addressed to a group that silently dropped members would be a group message part of the group cannot see. `groupStoryReach` returns that overlap rather than a warning. A sender who believes their story audience still applies is being misled by the interface, and a disclosure that cannot name the consequence is not one. The two categories deliberately do not double-count: somebody who is not a contact was never on that list to be excluded from. 🔴 On receipt, a claimed group id is checked against our own roster. A story is caught before the group-message branch, so without that check a sender could attach the id of a group they are not in and have their story shown as having come from it. The claim is dropped to null rather than the story refused — the story is legitimately theirs; only the claim about where it was posted is unverified. ⚠️ `gid` names the audience, never a destination. Rendering a group story into the group's messages would make a story into a message, and one that never expires out of it. |
||
|
|
bee63e0ef7 |
Keep your own stories in a private archive
Reinstates ios#54, taken by hand rather than cherry-picked. The original commit was cut from a tree where story mute, text stories and story replies all looked different from the versions rebuilt on this branch today, so applying it whole would have left two implementations of each under different names — no compile error, and exactly the kind of divergence that rots quietly. What came across is the archive and nothing else: the `archive()` rule, the store, the sweep, and the setting. 🔴 The rule refuses anything not the author's own, and it does so in the engine as well as at the caller. An archive holding somebody else's expired story breaks the promise the app made to them, and "it is private to you" does not repair that. ⚠️ The sweep runs at the one moment it can. Expired stories leave on write, so if they are not caught before `saveStories` they are gone — which is correct for everybody else's and wrong for your own. `archiveEnabled` is injected rather than read from a context, because this layer has no Android dependency and an unset value has to read as OFF. This is a feature that keeps something the interface said would disappear, so it stays off until somebody asks for it. Vault story serialisation is now shared between the live set and the archive rather than written twice. |
||
|
|
9ca2c352e7 |
Save a story you posted yourself, before it expires
Android half of ios#34. A story disappears from its author's own device too, so there was no way to keep something you made. 🔴 Only ever your own, and the rule is `Vault.isSaveable` rather than a condition on the menu item. Saving somebody else's story keeps a copy of something they were told would disappear — the opposite of what the feature promises them — and a guard living in a composable is one refactor away from being lost. Tested from both directions. ⚠️ Written through MediaStore, so no storage permission is asked for. An app inserting its own entry needs no runtime grant on any version this app supports; asking for WRITE_EXTERNAL_STORAGE in order to save one picture would request read access to everything the person has, to perform a write. ⚠️ The message says the copy has left the disappearing model and names where it went. "Saved" alone would be true and useless — somebody not told that may reasonably assume the expiry still covers it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
968a88789a |
Search inside messages, across every conversation
🔴 There is no index, and that is a decision rather than an omission. An index derived from decrypted history is plaintext by another name: it would have to be sealed as carefully as the vault, taken by forgetEverything, and kept consistent with the threads — a second copy of everything anybody ever wrote, with its own ways to go wrong. This reads what already exists and throws the plaintext away. ⚠️ It costs more here than on iOS, and the code says so. The iPhone app keeps the whole decrypted history in memory; Android decrypts each thread on demand. Hence a two-character minimum, a bound, a 250ms debounce, and Dispatchers.IO. 🔴 Folding is length-preserving on purpose: the snippet slices the ORIGINAL string at an index found in the folded one, so a fold that dropped characters would cut in the wrong place — and for an accented character, mid-character. There is a test with accents before the match. Accent-insensitive because somebody typing "cafe" means "café", and a search that knows the difference quietly fails in every language with accents — Spanish is next on the localisation list. Refs paulhitt/flamenet-messenger-ios#25 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
fc8d40ecf6 |
A timer on one message, not just the conversation (§6.17)
The rule that keeps this from making the product worse: the shorter timer always wins. Without it a sender could defeat the recipient's conversation timer by putting a long secs on every message — turning a setting the recipient chose, on their own device, into something the other party overrules. 🔴 pruneExpired returned early when the CONVERSATION had no timer, so a timed message in an untimed thread would have been kept for ever — the feature working on the sending phone and doing nothing on the receiving one. Pruning is per message now. ⚠️ Content.text returns raw bytes with no envelope when it has nothing to carry, so the timer had to join that decision or it would be dropped and the message would live for ever. The sender's own copy expires too: a timer that only cleared the recipient's phone would leave the message with the person who set it. Refs paulhitt/flamenet-messenger-ios#29 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
edd53bc771 |
Tell somebody when their disappearing message is screenshotted
Android half of ios#36, spec §6.27. `shot` joins the control kinds, so a build that does not know it stays quiet rather than rendering JSON. 🔴 The care is in refusing to let the absence of a notice mean anything. The device that took the screenshot reports it, so a modified build, a second phone, or a screen recorder produce nothing. A notice means one was probably taken; no notice means NOTHING, and nothing counts them. ⚠️ Android's half of the asymmetry is stated rather than hidden. There is no screenshot callback at all before API 34, and the one added there does not cover screen recording — so the disclaimer says that, in those words. Claiming a symmetry with iOS that does not exist would be worse than the gap, because somebody would rely on it. ⚠️ Only where a timer is set, and the timer check comes before the rate-limit check so a suppression window cannot make an untimed conversation start reporting. ⚠️ Last-notice times are in memory. Persisting them would store who screenshotted whom on disk to enforce a five-second window. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
3dd44ebc9a |
The transcript records a message id and a reply reference
🔴 The wire always carried both and Session.Line never stored them. Content.groupText has taken a messageId since §6.9 shipped and parse reads it back — the transcript was where it stopped. So nothing here could name an earlier message: no replies to resolve, no reactions to fold, no edits to apply. All three were "codec done" and none worked. A reply from the iPhone app arrived as a bare message with the quote silently removed, which is worse than the missing id: the reader had no way to know anything had been dropped. 🔴 Also fixes a real bug in sendGroup. It built the envelope twice — once to send, once for the outbox — so an id minted inside either would differ between them, and a member who was offline would eventually receive a DIFFERENT message from everybody else, which nothing could reconcile with a reply or a reaction. One id, minted by the caller, used by both. Both fields written only when present: a null on every message would grow the transcript for nothing and make "no id" indistinguishable from "an id of nothing". Refs paulhitt/flamenet-messenger-ios#58, #10 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
9a2ff570e9 |
Keep a half-written message across a relaunch
Sealed in the vault, not in SharedPreferences — and that is the whole point. A draft is plaintext the person has not decided to send, very often the most sensitive text in the app because it is the thing they are still weighing. SharedPreferences writes readable XML that survives into backups; the vault seals it under the conversation's own key with the record name bound into the AAD, and forgetEverything takes it on sign-out. There is a test that asserts the bytes on disk do not contain the text, because the wrong implementation would pass every other test here. Keyed by the same threadFor* ids the transcript uses, so a group called "7", the contact who is user 7, and the address "7" are three different drafts. Every write to the composer state goes through one setDraft: send clears it by assigning directly in three places, and any one of those that forgot to clear the stored copy would bring the message back next time as a draft of something already sent. Closes #47 on iOS side; Android half of the same feature Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
96b612597d |
A skipped live sign-up must report skipped, not passed
The two live sign-up tests guarded themselves with a bare `return`, so without `-e live true` they finished immediately and reported PASSED. A test that did nothing and says it passed is worse than one that did not run: a suite of those is indistinguishable from a suite that works, which is the only reason to run it. `assumeTrue` instead, with the message saying how to opt in. Assumed-out shows as skipped, so the count reflects what was actually exercised. The iOS side already had this reasoning written down; Android had the comment's opposite. Compiles: :app:compileDebugAndroidTestKotlin. |
||
|
|
8a1a51073e |
Read the relay's refusal, not just the HTTP status
🔴 **A 409 is not always a taken handle, and this said it was.** The relay answers 409 for a taken handle, an already-registered email, AND — since flamenet-relay!11 — a handle too close to an existing one. Three problems with three different fixes, all reported as "that handle is already taken". Somebody whose EMAIL was already registered was told to pick a different handle. Doing so cannot work, and nothing anywhere would have told them why. That is a pre-existing bug, not something the new refusals introduced; adding a third meaning to 409 is what made it visible. 400 was worse. Every format refusal — a short passphrase, an unusable email address, a disallowed handle — became "the account service returned 400", which is not a sentence anybody can act on. The status is the transport's opinion. The `code` in the body is the relay's, and it has always been sent; nothing here ever read it. ⚠️ **Unknown codes still refuse.** A relay newer than the app is the normal case, so an unrecognised code falls back to the status rather than to success — an account the server never created would be a much worse failure than a vague message. ⚠️ **An unparseable body cannot throw.** A proxy returning HTML on a 409 must still be a refusal, not a crash. Five tests, proven able to fail: collapsing 409 back to a single meaning turns four of them red. All three Android suites pass. Pairs with flamenet-relay!11 and flamenet-messenger-ios!45. |
||
|
|
26b4f20a5f |
Give every message an id, and reactions something to land on
Reactions, edits and pins have all sat in the codec with no way to reach them, and the reason was the same in each case: they say "this one", and an Android message had no id to point at. 1:1 text went out as the raw body — cheaper on the wire, and it cost all three features. Every message now carries one. The sent copy, the copy written into our own transcript, and the copy queued for retry all share it: three constructions that disagreed about the id would be three different messages, and anything said about one would land on nothing. Inbound messages keep the SENDER's id rather than one we invent, because that is what they will use when they refer back to it. Old lines keep no id and must not be given one on read. Two devices would invent different ids for the same message, and a reaction would land on nothing — or on the wrong thing. Those messages simply cannot be reacted to, and the emoji row is absent on them rather than inert. Reacting is one long-press and one tap: the five emoji are the menu's first row, not behind a "React" item that opens a second menu. One emoji per person, so tapping another replaces it and tapping the same one takes it back. Applied locally before the send, because waiting on a round trip to see your own tap register makes it feel broken — and a failed send restores what was there before, not "nothing", which would be wrong for somebody replacing or removing one. Reactions render under the bubble, not inside it. They are what other people said about the message, not part of what was written. 648 unit tests green across app, engine and client; 8 new ones pin the id and the one-emoji-per-person rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
2179615263 |
Stories you can answer, and an @ that finds people
The story features existed in the codec and nowhere a person could reach them. + went straight to the file chooser, so a story of words was unreachable even though the engine could carry one; there was no way to react, reply, mute, or see what anybody had said back. + is a menu now. Reactions are a fixed row of five — a story is glanced at and scrolled past, and anything that takes two taps to say "nice" does not get used. Your own story shows the tally instead, because you cannot react to yourself and a row of dead buttons is worse than none. Mentions get an `@` picker in the group composer, matching iOS down to the rule that matters: the id is recorded when somebody is PICKED, never re-derived from the text at send time. Deriving it would mean typing a name reached that person whether or not they were chosen, and renaming a contact would silently unmention them. Only the ones still named in the body are sent, so picking somebody and then deleting their name doesn't ring them about a message that no longer mentions them. A group member who isn't in your contacts has no name to show, so they appear under their id. Un-mentionable would be worse than ugly — it would mean a group where some people cannot be reached. Mentions on Android are carried and rendered but override nothing: there is no per-conversation notification mute here to override yet. 640 unit tests green across app, engine and client. PARITY.md updated — mentions and the story features move to ✅, and Files and Wallet get rows saying plainly that Android has none of either. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
fdaf06415e |
Draw a story that is only words — and fix a bubble that never compiled
🔴 The file bubble committed earlier today DID NOT COMPILE. It reached for Icons.Filled.InsertDriveFile in a file that imports no Material icons at all, and I reported it as building. The reason it went unnoticed matters more than the mistake: ANDROID_HOME was unset, so `:app:compileDebugKotlin` failed on "SDK location not found" before Kotlin ran — and the check for errors was a grep for lines starting `e:`, which a build that never reached the compiler does not produce. A green-looking check that never compiled anything. Every Android app-side claim I made today rested on it. It now uses the kit's own icon set, like the rest of the file, and the app compiles with the SDK on the path. Alongside: Vault.Story.ref is nullable, so a story can be words. Every path here assumed a picture, which is the same shape that made a text story impossible on iOS rather than merely unbuilt. ⚠️ `loadStories` used `getJSONObject("ref")`, which THROWS. A single text story would have taken down the load of every story — a feed that is simply empty, with nothing to say why. It reads the optional accessor now, and tells "no ref at all" (a text story) apart from "a ref that would not parse" (a corrupt row), because those deserve different treatment. Expiry moved onto the story, falling back to the attachment for everything already stored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
688a519e0c |
Understand a file somebody sent, rather than showing a line of text
iOS can now send a document from Files. This build treated `file` as a kind from a newer peer: legible, because a file envelope always carries a caption naming the document, but only as text. The engine now knows the kind, parses the additive `fn`/`cls`/`cmp` keys, and can assemble them — so sending works in both directions, not one. 🔴 The interop test parses an envelope produced by the iOS build rather than by this engine. Two implementations of one format that each only test against themselves agree right up to the day somebody sends a real file, and the symptom is "[unsupported attachment]" with no clue why. It also pins the two rules that keep older peers working: a file envelope is never captionless, and a photo envelope still carries none of the new keys. ⚠️ The bubble shows the classification as a MARKING. It says how the sender had it filed; it does not mean this device was cleared for it, and whoever can read the message already has the key. ⚠️ No "Save to Files" on this side yet — this build has no Files of its own, so the button would be one that cannot work. Showing what arrived is the honest half of parity while the other half is built. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
df00bea388 |
Grant notification permission before the activity launches
Every instrumented test died with "No compose hierarchies found in the
app". MainActivity.onCreate asks for POST_NOTIFICATIONS before it calls
setContent, and the permission dialog is a separate activity that takes
focus — so the Compose rule attached to a window with no hierarchy yet.
GrantPermissionRule at order = 0, outside the Compose rule at order = 1, so
the grant happens first and no dialog ever appears.
With this, the three fixes in the previous commit, and a relay on the host,
aPersonCanCreateAnAccountOnASelfHostedRelay passes: a real account created
on a real relay, driven by test tags. That is the first end-to-end pass this
harness has produced.
./gradlew :app:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810
|
||
|
|
16e0101daf |
Make the instrumented tests capable of failing
Three separate reasons these suites could never catch a regression, all
fixed here. None of them are about the app; all of them are about the tests
being unable to report the truth.
1. A skipped test reported as a pass. Thirteen guards were bare returns —
`if (!relayReachable()) return` — so with no relay the method returned
immediately and the runner recorded PASSED. Now `assumeTrue`, which
reports SKIPPED, and the run says "(2 skipped)" where it used to say
nothing at all. Only the guards that abandon a whole test were changed;
helper loop exits like `if (found) return` are control flow and were
left alone.
2. The relay was hardcoded to relay1.flamenet.io — production. Every run
that did work created real accounts there. Now `-e relay <host>`, still
defaulting to production so an unqualified run is unchanged.
3. Android blocks cleartext HTTP, so a debug build could not reach a relay
on the developer's machine even when one was running. A debug-only
network security config permits cleartext to 10.0.2.2, 127.0.0.1 and
localhost, and nothing else. src/debug, so no release APK is affected.
Together these took the sign-up suite from "2 skipped, reported green" to
"2 run, 2 failed" — which is worth more, because it is true. What it
fails on now (no compose hierarchy at launch) is the next thing to fix.
./gradlew :app:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810
|
||
|
|
aebf41ed95 |
Put the app back to the last state that worked
Tonight's merges left sign-in, messages, calls and stories broken. Restores
app, engine and client to
|
||
|
|
2f46263aff |
A 403 is not an expired session
Signing in on a fresh install said "Your session expired. Sign out and back
in to keep going." — advice that cannot work, because signing in again hits
the same rule and says the same thing. There is no way out of that loop from
inside the app.
RelayAccounts mapped 401 and 403 to the same Unauthorized. They are not the
same: 401 means the token was refused and signing in again IS the fix; 403
means a rule refused this — too many devices, a retired device, an
unverified address — and the relay writes a sentence naming which one and
what to do about it. RelayClient already made this distinction; RelayAccounts
did not, so which client a path happened to use decided whether its refusals
were legible.
The 403 sentence now reaches the screen instead of "That isn't allowed on
this server", which replaced an answer somebody could act on with one they
could not. Only when it is really a sentence: a JSON fragment or a bare code
keeps the generic line rather than putting a payload on screen.
Predates tonight's merges — checked against
|
||
|
|
4c3f30e180 |
Count the sealed-to-unsealed fallback, and offer to refuse it
Android half of ios#16. `Sent.sealed` already reported each send's outcome, but nothing accumulated, so nobody could answer "how often does this happen to me". 🔴 The copy distinguishes sender from content, with tests asserting it. An unsealed send is still end-to-end encrypted; what the relay learns is who sent it. Somebody who reads "sealed" as "encrypted" makes the wrong decision in both directions. ⚠️ Aggregate only. Per-contact counts would be more useful and would also build a record of who this account talks to and how often — the precise thing sealing exists to keep off a server. Refusing is offered and off by default: it turns a metadata leak into an undelivered message, which for most people is the worse outcome. It has its own exception type so the refusal can explain itself. ⚠️ Refusing spends nothing. `sealed` is false exactly when neither a capability nor a key was found, so `takePeerToken` returned null and there is no token to hand back — my first cut had a line handing one back, which was unreachable. ⚠️ The policy and the counter are injected as lambdas, since `client` and the engine have no Android dependency, and both are wired in one helper. Two sign-in paths build a session, and only one of them applying the policy is how a setting silently stops working on half the app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
6fe463d578 |
Report or block from a story
Android half of ios#56, spec §6.25. Needs the relay endpoint from flamenet-relay!24. 🔴 A report is the one thing this app sends that the server can read. Everything else is sealed, and the relay cannot open a story, so a report about one is only actionable if the reporter hands over what they saw. The dialog states that before anything is sent: what leaves the device, who can read it, and that it cannot be undone. Declining to include a copy is offered, and says plainly there may then be nothing to act on. ⚠️ `reportStory` sends only what it is passed. It does not reach into the thread for surrounding context — §6.25 forbids including anything the reporter did not choose to include. ⚠️ Blocking and reporting sit side by side and are never merged. Blocking is this person's own decision and takes effect now; a report is a request to somebody else. The copy says which is which, because a combined control would make one of the two invisible. The reasons are enumerated to match the relay, and a test asserts the two lists are equal — a reason the relay rejects is a report somebody believed they had filed. CSAM is its own reason rather than folded into abuse: it carries a different obligation, and an operator cannot triage what nobody can name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b43be1b780 |
Post a story to a group
Android half of ios#55, spec §6.24. An ordinary story envelope carrying `gid`, fanned out to the members and recorded in the story set. 🔴 `Vault.groupStoryReach` returns the overlap rather than a warning: who is excluded by this account's story audience and who is not a contact at all, counted separately. The roster decides either way — those are two different lists making two different statements and they will disagree — so a disclosure that cannot name the consequence is not one. 🔴 The group label is adopted only from an actual member. The roster check that guards group messages runs after the story branch, so without this a sender could attach the id of a group they are not in and have their story shown as having come from it. Dropped to null rather than refusing the story: the story is legitimately theirs, and only the claim about where it was posted is unverified. The story branch already sat before group routing, which is what keeps a group story out of the conversation — filed there it would be a story that is a message, and one that never expires out of the thread. That ordering is now written down rather than incidental. ⚠️ `gid` is persisted, or a reload forgets which story went where. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
8b719032c2 |
React to a story with one tap
Android half of ios#52, spec §6.22. 🔴 Decoded before §6.9's reaction path, which requires `about`. A story reaction carries `sre` instead — a story is not in the conversation and has no `mid` there, so one carrying `re` would fold onto whatever message happened to share that id, or onto nothing. Falling through would leave it unhandled and drawn as an empty line in the thread. Tested both ways, including that an ordinary thread reaction still decodes as before. ⚠️ Its own disclosure and its own stored key. A reaction reveals that you watched exactly as a reply does; what differs is the deliberation. A reply is typed. A reaction is one tap on a story already filling the screen, so an account told about replies has been warned about the considered case and not the careless one. ⚠️ The row does not share a gesture with anything that navigates: an acknowledgement delivered by a mis-tap is a disclosure the person did not make. Branched from feat/story-replies, which carries the `sre` plumbing this reuses. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |