What this carries, all of it merged since 0.16: a call answered on one
of your devices stops the others ringing, reactions meant for one person
inside a group, reactions during a call, and conversations held off the
record by both phones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A switch in the conversation's own menu, and a line under the header
saying so while it is on.
The switch asks; it does not assert. The state shown is re-read from what
the peer has echoed, and the echo arrives during a poll rather than while
the menu is open, so the open conversation re-reads it there too. Without
that the banner would claim the conversation was off the record from the
moment it was asked for.
Group and remote-address conversations have no peer id, and this is a
one-to-one setting, so it reads false for them rather than pretending.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A thread held in memory and written to disk by neither side. Close the
app and it is gone: no file to seize, no backup to leak.
It is a proposal, not a switch. The device turning it on sends the
control envelope and keeps writing until the peer echoes it back. A build
that does not know the kind never echoes, so the thread stays on the
record on both sides rather than on one of them, which is worse than not
having the feature because both people would believe the first thing.
Every message reaches disk through one call, so one gate is enough, and a
test pins that count so a second write path cannot quietly walk around
it. Ratchet state is still persisted: messages stop decrypting without
it, and it is not message content.
The control for it comes next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chip read "private" to everybody who could see it, which says the
least useful thing to each of them. The person who sent it is now told
who saw it, and the author is told that nobody else did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tap a reaction while somebody is talking and it shows on their screen
for a moment. Agreement without interrupting, on a voice call as well as
video.
The signal rides the ratchet like every other call signal, is accepted
only for the call in progress and only once it is up, and is recorded
nowhere: when the call ends there is no trace of it. An older build
ignores the action it does not know.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
React to a message in a group and only its author sees it. A quiet note
without turning it into an event for everybody else.
The reaction is sent to the author and to nobody else, which is the whole
guarantee: asking the other members' apps not to display it would be a
request, and a request is not privacy. The envelope carries a flag so the
author's app can label what it got, not to ask anyone to hide anything.
Private reactions are kept apart from the public tally on both devices
that hold them, and drawn with a label, so one is never mistaken for
something the group watched arrive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The answer goes to the caller only, so a second device on the same
account kept ringing for its full minute after the first picked up, and
then filed the call as missed. The device that answers or declines now
sends call-end to its own account's other devices, reason
answered-elsewhere or declined-elsewhere. A device still ringing for that
call stops and files nothing; one already on the call ignores it.
A send to this account's own devices carries no delivery key or key-log
prelude and is not refused for going unsealed. Its failures are logged
and never touch the call on this device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The iPhone test reads this repo's CallManager too, but only when the two
checkouts sit side by side under the names it expects, which no CI build
directory has, so its Android half returns early there. This is the copy
that runs on every Android pipeline. Proven able to fail: against the
code before the fix, both checks fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With one account signed in on two devices, both ring. Answer on one and
the other keeps ringing, because nothing tells it; when its ring guard
fired it sent the caller an end with reason "timeout", and the caller
hung up the call in progress.
Two halves, both needed.
The ring guard now releases the ring locally and sends nothing. The
caller gives up on its own dial timer long before this fires, so the
message was redundant, and on a shared account it was destructive.
The end handler did not check the call id at all: any end, for any call,
hung up whatever call was current. It now requires the call id to match,
and once a call has connected it ignores an end whose reason means the
ring was never picked up, declined, busy or timeout. Those can only have
come from another device that was ringing: somebody silencing it, or a
build already in people's hands that still announces its timeout. A
plain hang-up carries no such reason and still ends the call.
The iPhone gets the same fix, and its test reads this file so the two
cannot drift apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign out normally, then create a second account, and the app refused it
with "Your session expired. Sign out and back in to keep going." That is
advice nobody can act on: you have already signed out, and the screen
saying it is the one you sign up from.
The cause is one line doing the right thing in the wrong case.
`authenticate` reuses the device identity stored in the vault, and the
comment above it says exactly why: keeping the device is what stops
signing out and back in costing every contact a fresh trust prompt.
Plain sign-out keeps the device on purpose; only the erase door destroys
it.
That reasoning holds for the same account and breaks for a different
one. The second sign-up reused a device that already belonged to the
first account and tried to register it against the new one. The relay
refused, correctly, and the refusal reached the person as a sentence
about a session they did not have.
The stored credential already carries `userId` and `relayBaseUrl`, so
the check is cheap: same relay and same user means the device is ours to
reuse, anything else is somebody else's account on a shared phone and
gets its own device. Which is what "a device belongs to an account"
already meant everywhere else.
Reproduced and fixed on hardware, not argued: `SignUpFlowTest` against
relay1 failed this way on every run before, and both of its tests pass
after. That suite is the regression guard, and !122 is what made it able
to run at all.
JVM suites unchanged: 1294 tests, no failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five things were wrong, each hidden behind the one in front of it, and
none of them was the app. Sign-up works: the run that finally got
through created `andt34972750` on relay1 and registered a device, and
the Account screen showed both.
1. The emulator still had a RELEASE-signed build on it, so the debug
test APK could not install: INSTALL_FAILED_UPDATE_INCOMPATIBLE. Zero
tests ran. The uninstall lived only in the "emulator already up"
branch, on the reasoning that a cold boot starts clean. A cold boot
starts with no snapshot; the disk image keeps what was installed.
The script already PRINTED the leftover package and carried on, which
is a check nobody acts on. It now clears on both paths and refuses to
continue if anything is left.
2. The sign-in form scrolls, and `toggle-register` is below the fold.
A tap aimed off-screen is not an error in Compose, it simply does not
happen, so the form stayed on sign-in and the test waited thirty
seconds for a register-only field. Same for `sign-out`, which is the
last section of a long Account screen.
3. It waited for `device-id` after sign-up. Sign-up lands on Home and
`device-id` is on Account, so it waited two minutes on a screen that
was never going to show it and reported that the app had not signed
in. It had: the dump said "Nobody here yet. Add someone by the
username they signed up with", which is the empty contact list of an
account created seconds earlier. It now waits for the nav pill and
checks the device id on the screen that has it.
4. `performTextInput` appends, and only the relay field was cleared, so
the second test in the class typed "19901990" into the age gate.
Submit is enabled on exactly four digits, a disabled button swallows
a tap, and the wait then blamed a screen the tap never reached. The
same shape as the age-gate bug this suite was fixed for once already.
`signingOutReturnsToSignIn` now passes, against a real relay, which it
has not done in a long time. The remaining failure is not a test
problem and is filed separately: creating a second account after signing
out is refused with "Your session expired", because `signOut` clears the
session in memory and leaves the stored credential on disk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things landed since 0.15 was built, two of which a person will
notice.
Backups are written in the shared format now, so one made on an Android
phone opens on an iPhone and the other way round. Before this each app
sealed its own storage and refused the other's file outright, so moving
between the two lost every conversation.
A file in a chat keeps its name and its label. They reached the far end
correctly, so a received document arrived named; it was the copy kept on
this phone that lost them on the first read back out of storage, which
looks like a damaged file rather than like the app forgetting.
And an expired story's bytes are actually dropped. The history path read
every stored story back as one that never expires, so they stayed on
disk after they stopped showing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 16 pins the envelope and says nothing about what is sealed, so
each app sealed its own storage verbatim and stamped the file with the
platform that wrote it. Each then refused the other's, in as many words:
"import it on a device of the same kind". Somebody moving between an
Android phone and an iPhone lost every conversation they had.
The iPhone half landed first. This is the other half.
`BackupPayload` translates rather than dumps, which is the entire point.
This app stores a message under the keys its wire format uses (`secs`,
`fwd`, `rx`), keeps one-to-one and group threads in one map under
prefixed ids, hangs the attachment kind beside `att` rather than inside
it, abbreviates the sizes, and holds expiries in unix seconds. The
shared shape spells all of that out and counts in milliseconds.
Exporting storage as-is is what made the two formats incompatible in the
first place, so a copy would have reproduced the bug with more steps.
Federated threads are the one thing that does not fit. This app keys
them by address and the iPhone's history is keyed by numeric user id,
with a decoder that skips anything else, so an address left in `threads`
would be dropped in silence. They travel under `addrThreads` instead: an
iPhone ignores the key, an Android phone gets them back, and nothing
disappears without somebody having decided it should.
Import tells the two formats apart by SHAPE, not by file name, because a
name is whatever the person saving it chose. Section 16 announces itself
with `alg` and `ct`; the older export announces itself with `magic`. A
wrong passphrase on a section 16 file reports a wrong passphrase and
does NOT fall through to the legacy reader, which would have answered
"that file is not a Flamenet history export" for a file that is one and
sent somebody hunting for corruption instead of a typo.
Nothing writes the old format any more, and everything still reads it.
Seventeen tests: the shared side against the key names the iPhone
writes, the round trip back into this app's own storage shape, the unit
conversions in both directions, and a payload transcribed from the
iPhone's encoder. That last one is the weak point and the test says so:
it proves this app reads the shape the iPhone is BELIEVED to write. Only
a vector both apps are built against proves they agree, and
`backup-v1.json` pins the envelope alone, which is exactly how the two
shapes drifted apart.
Full suite: 1271 tests, no failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both, as always: the code is what tells an existing install a newer
build exists, the name is what a person reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The handover stops this app's polling loop when the screen goes away,
because the background service takes over. That is right for messages
and wrong for a call already in progress: the loop is the only thing
that routes `call-end` into the manager owning the call screen. The
service polls too, but it turns offers into notifications and knows
nothing about a call that is already up.
So a call ended by the far end while this app was off screen had nowhere
to land. The screen stayed, still offering to answer, until the dial
guard gave up forty-five seconds later. Seen on a device: a call placed
at 13:04:13, no `call-end` ever logged, ICE closed at 13:04:58.
Polling now refuses to pause while a call is dialling, ringing,
connecting or active.
And a backstop for when the signal is genuinely lost. ICE DISCONNECTED
was a no-op, so nothing noticed a far end that simply vanished. It now
arms an eight second grace, cancelled if the connection recovers,
because WebRTC blips are ordinary. A call whose audio has stopped is
over whatever the signalling says.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notification's Accept button has carried EXTRA_ANSWER_CALL since it
was written, and nothing ever read it. Pressing Accept did what any
other tap did: it opened the app. Answering was left to the next turn of
the polling loop, which refuses a parked offer older than the caller's
patience, so a call taken a little late opened the app onto nothing and
looked like it had dropped the moment you answered it.
MainActivity reads the extra now, on a cold start and on a warm one,
and answers at once rather than waiting for a tick.
The notification also goes away on its own when the offer it shows has
expired. Before, it could outlive the offer by the gap between service
sweeps, which is longer since the loop started holding its poll open,
and during that gap it was a button that could only disappoint.
Seen on the emulator: Accept pressed, app opened, nothing on screen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two bugs found by calling a real emulator and watching what it did.
An unanswered ring never ended. Dialling and connecting each had a
timeout and ringing did not, so a call nobody picked up stayed RINGING
until the app was killed. Nothing showed it, because the notification
can be brushed off the top of the screen while the state behind it
survives, and the phone then answered every later caller "busy" while
looking completely idle. A guard now releases it after sixty seconds,
deliberately longer than the caller's own forty-five, so this end gives
up on its own only when their hang-up never arrived. It hangs up with
"timeout", which the far end already renders as "No answer"; an
invented word would have shown a flat "Call ended".
And the handover was half-built. MainActivity starts the background
service when the screen stops and stops it when the screen starts, under
a comment reading "exactly one poller at a time". Only the service half
existed. The view model's loop kept running beside it, both sessions
read the same inbox, and an envelope is consumed once, so whichever
asked first won it.
When the view model won a call offer the phone rang inside a UI nobody
was looking at, and the service, the only half that can post a
notification, never saw the call. Seen on a device: the offer arrived,
CallManager moved to RINGING, and the only notification posted was the
service's own "running" notice. That is why calls did not announce
themselves with the app off screen.
The loop now stops on ON_STOP and starts again on ON_START, so the
comment is finally true.
Verified on the emulator against relay1: an unanswered offer at
12:51:07 released itself at 12:52:07 and told the caller, where before
it would have refused every call after it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both, as always: the code is what tells an existing install a newer
build exists, the name is what a person reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things the iPhone got today and this did not.
A proof of possession is single use, and the request that spends one is
frequently not a request this code made: OkHttp retries across a
connection failure with the headers it already built, which is what a
phone moving between wi-fi and mobile data produces. The relay refuses
the replay, correctly, and now says so with its own code rather than a
bare "unauthorized". Reading that as a token problem meant discarding a
working token and minting another before carrying on, and during a call
that lands in front of the signalling. The request is signed again
either way; only the token is kept now. A relay too old to name the code
still says so in words, so this works against production as it stands.
And ios#73. A call refused by the trust gate already named the reason
and already offered a button. The button opened the conversation, which
is the same gap one screen along: the reason the conversation banner
does not cover this case is that a call placed from the Calls tab, or
answered from a notification, never passes through a conversation.
Sending somebody to one to find out why their call failed is the
complaint rather than the cure.
So the review is a dialog at the failure. It carries both readings the
chat banner carries, the ordinary one and the one that matters, and it
puts the safety number on screen rather than behind another tap, because
comparing the number is what actually settles the question.
The old test said accepting must not be offered here, on the argument
that a hurried decision is better made elsewhere. The issue argues the
other way and is right: an extra screen in front of the same tap slows
nobody down, it just costs somebody the explanation. The requirement
moves rather than disappears, from "no accept button" to "no accept
button without the sentence saying what accepting costs".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both, as always: the code is what tells an existing install a newer
build exists, the name is what a person reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notification was built correctly all along: CATEGORY_CALL, PRIORITY_MAX,
ongoing, autoCancel off, full-screen intent. Android was refusing it.
USE_FULL_SCREEN_INTENT: default; rejectTime=+2m52s ago
Since Android 14 that permission is auto-granted only to apps the system
already treats as calling apps. Declared and not granted, an incoming call is
demoted to an ordinary heads-up: about two seconds at the top, then gone,
which reads as the call notification being broken.
The system refuses silently, so nothing anywhere said so. Notification
settings now checks canUseFullScreenIntent and offers the one screen that can
grant it.
The fuller fix is a self-managed ConnectionService, which makes this a calling
app in the system's eyes and needs no permission prompt at all. That is a
larger change than telling somebody the truth today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The relay has always been willing to hold an inbox request until
something arrives, and the Android client has never asked it to. The
constant naming the hold was declared in MessengerService and read
nowhere, and the comment next to the 500 ms gap already described a
long poll that was not happening.
What that cost, measured against production: two devices sent 1,177
inbox requests in eight minutes, every one of them held open by the
relay for about 30 ms and answered "nothing". Every message and every
call signal waited out the gap to the next poll. Someone had already
noticed the effect on calls and shortened the gap to a second while one
was being set up, which made the polling denser without making the
signal arrive any sooner.
So `wait` reaches the wire. The signed-in mailbox is asked to hold; a
backup home never is, because two held reads in a row would put a
backup mailbox's latency in front of the one nearly all mail arrives
at. A held request needs longer than the ordinary call timeout, so it
gets a clone of the client with room for the hold.
Both loops now pace on whether the relay held the request rather than
on a fixed gap: a poll that took a while was itself the wait, and the
next one goes straight back out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The engine has always carried compartments and both apps sealed them in,
honoured them in clearance checks and rendered them on a received file. No
screen let anybody set one, so a label was half-settable: how sensitive, but
never which part of the business. The public page promised both.
The bits travel with the file. The names do not: they are this organisation's
words, so a file from somebody else shows its areas as numbers, which is
correct rather than a gap.
Nothing is named for you. The site says areas are whatever your organisation
already calls them, so this is where you say so.
Forgetting a name does not free the bit. A file already filed under it still
carries it, and handing that bit to a different area would silently relabel
that file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two of the six suites tapped submit without filling the age gate. Submit is
disabled until birth-year holds four digits, and performClick on a disabled
Compose node does not throw: it does nothing. Each run then failed up to two
minutes later looking for a screen the tap never reached, blaming whatever
it had waited for.
A JVM test now holds it: any suite that taps submit must fill birth-year, so
the next one written cannot quietly do the same thing.
android#21 also reports that nav-contacts and nav-account no longer exist
anywhere in the app. They do. They are declared in NavDestinations.tabs and
applied through a variable, so a grep for the literal testTag("nav-" finds
nothing, which reads as the navigation having been rebuilt without them. A
third test pins that too, so the claim cannot be made again from a grep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This screen showed chips and no explanation; iOS has carried one all along,
under a comment claiming both apps use the same words. They now do.
The last sentence is the boundary: a level decides who can open a file, not
where it can be put. Neither app stops somebody filing sensitive material
somewhere low, and saying so is the honest half of android#15.
Also repairs a comment the emoji sweep truncated, which left the test
saying 'the -property is NOT enforced'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Remove dates and "this used to say X" framing from prose and comments.
What the tree does now is the only thing that matters; the history is in
git.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ios#73. Android already named the reason: a call signal refused with
IdentityChangedException hung up and said "Their security code changed".
But it then told the person to go and find the conversation themselves,
and the banner that compares and accepts lives in the conversation
header, which a call started from the Calls tab or answered from a
notification never passes through. So the failure described the cure
without offering it.
The blocked peer is now carried on the phase rather than only named in
a sentence, and the failure draws a "Review their security code" button
that opens that conversation and clears the failure.
Compare and accept stay behind the existing banner. A second accept
button, phrased on a call screen in a hurry, is the thing the issue's
wording requirement exists to prevent.
4 new tests. 1217 tests pass, counted from the XML.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every comment is reduced to its first sentence plus @param, @return
and @throws tags. Comment lines: 20040 to 8526. ktlint 1.8 applied
with its default (official Kotlin) style across all modules; 262 files
reformatted. The 264 remaining ktlint findings are naming and filename
rules, which are code changes, and are left alone.
Comments and formatting only. 1208 tests pass, counted from the XML.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The skill's detector scored the README, PARITY.md and the extracted
comment prose. Fixed what it flagged: hollow intensifiers, "in order
to", "additionally", "the whole point", bold sentences in docs, and
bold that had wrapped across comment lines. Comment prose scores a
maximum of 4 out of 100 across the repo.
One sweep rule mangled four comments ("which is the entire point of X"
lost its clause); repaired, and the rule now keeps the clause.
Comments and strings only. 1208 tests pass, counted from the XML.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Emoji markers, markdown bold inside code comments, and em-dashes are
gone from source comments, string literals, scripts, Gradle files and
the markdown docs. Counted before and after:
emoji markers 2680 -> 1 (the emoji picker's own list, which is the feature)
em-dashes 2490 -> 9 (table placeholders and fenced code)
Two warning glyphs in user-facing strings in ChatScreens.kt are removed
as well; the sentences say the same thing without them. Two backtick
test names carried an em-dash and now carry a comma.
Nothing else changed. 1208 tests pass, counted from the result XML.
The three obfs4 interop tests are opted out with OBFS4_INTEROP_OPTOUT=1
for this run, as their own message instructs when no bridge is set up.
CONTRIBUTING gets a four-line rule so it does not grow back.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two reports from a real phone.
**A photo auto-rotated.** BitmapFactory does not apply EXIF orientation,
and every image this app sends is re-encoded to strip EXIF — which is
right, because that block carries the GPS coordinates the shot was taken
at. The same block carries the tag saying which way is up, and a phone
camera rarely writes upright pixels: it writes landscape pixels plus
"turn this 90°". Decoding ignored that, the re-encode dropped the tag,
and the only thing that knew which way was up was gone.
The tag is now read and applied to the pixels before it is discarded, on
all three paths: preparing to send, scrubbing metadata, and rendering an
image received from a peer. The mirrored tags are handled too — a front
camera writes them, and a selfie coming back flipped is the same bug
facing sideways.
🔴 iOS is NOT affected, which is why this survived: UIImage(data:)
applies orientation as it decodes, so the identical strip-and-re-encode
is safe there. Same design, opposite platform default — and the reason
"works fine on iOS" said nothing about Android.
**The keyboard covered what was being typed into.** The manifest has
said windowSoftInputMode="adjustResize" all along, and that stopped
meaning anything at targetSdk 35: Android 15 forces apps targeting it
edge-to-edge, and an edge-to-edge window does not resize for the IME. So
the line has been a no-op on every device this ships to (minSdk 33). The
root now consumes the IME inset with imePadding() — at the root rather
than per screen, because every screen with a text field has this and one
added later would too.
5 new tests, including one that fails if the manifest line ever becomes
the only thing relied on again. 1213 tests green, counted from the
result XML rather than from BUILD SUCCESSFUL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Android half, matching the iOS change.
**The give-up rule (ios#22).** `flushOutbox` used
`runCatching { … }.getOrNull()`, which threw the exception away — so the loop
could not tell a contact who is merely offline from one whose identity keys
changed, and retried both for ever while the bubble went on promising
"will send". The failure is caught now and classified: a refusal from the
trust layer stops, marks the bubble FAILED and records why. Everything else
still retries for ever, deliberately — a contact who is offline or has no
device yet gets the message whenever they return.
⚠️ Classified on the exception TYPE, never on message text. A string match
would break the first time somebody reworded an error, and would break
silently: the queue would simply go back to retrying for ever.
**The reading (ios#21, #23).** `DeliveryDiagnostics` counts envelopes seen,
drops by reason, session repairs and the last send failure, per conversation.
Three or more repairs against one peer is flagged — a client that repeatedly
repairs is a symptom, not a cure.
🔴 **Shape only.** Refusal messages name what was wrong, which sometimes
means naming a device id: "device ce0c0fb4 has published keys, but none
match". Those are normalised before being counted, so the reading is safe to
screenshot and the map cannot grow a row per device id. ⚠️ Short hex ids like
`9f0e` slip past the obvious rules; the test covers them, and covers that
"base64" survives unmangled.
The new outbox test was checked by disabling the give-up rule: it fails
without it.
581 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the iOS revert. Unpicks the nine commits that took Android from the
site-derived palette to purple-into-black dark-only: the purple ground, the
darkening passes, the single dark theme, the three-stop gradient, white text
boxes and white icons.
Two reconciliations, both because the surrounding work was worth keeping:
* `MainActivity`'s root `Surface` goes back to the plain one. The
transparent-Surface trap it documented -- Material deriving
`contentColor` from `contentColorFor(Transparent)`, getting Unspecified,
and falling back to BLACK text -- only existed because the Surface was
transparent to let a gradient through. No gradient, no trap.
* `CallScreen` painted `groundBrush()` to avoid stamping a flat rectangle
over that gradient. Back to `colorScheme.background`, since there is no
longer a gradient to clash with.
`PaletteMatchesIOSTest` goes with the theme it was written to hold in place.
574 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0.11 was built on 2026-09-07 and never published, so the public jumped
0.10 -> 0.12 and skipped a number. This closes that gap in the NAME, which
is the part a person reads.
The code does not go back to 11. Android refuses an APK whose versionCode
is lower than the installed one, and 12 is already published -- so code 11
would be uninstallable for anyone who took it, with no way out but an
uninstall that wipes their vault. The code is ordering; the name is the
label. They are allowed to differ, and a comment says why so it does not
get "corrected" later.
Verified: 0.11 (code 13) installs over 0.12 (code 12) with no downgrade
refusal, session intact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pair of translucent `primary` circles stood in for the kit's animation.
Against the gradient they read as banding -- two hard edges around the one
thing on screen -- which is what "the box around the center elements" was.
The avatar carries the centre on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The send path fetches full bundles only when a device needs a session
established, so it does not drain one-time prekeys on every message. The
contact's address rides with those bundles -- so every later send had no
address, computed the `local:` key-log subject instead of `fnid:`, matched
no leaf, and refused its own send. flamenet-relay!82 puts the address on
the directory; this reads it.
Two bugs the wiring exposed:
* `rememberFnid` silently overwrote a DIFFERENT address, the one thing
its own doc comment says it must never do. An fnid is derived from the
account key, so a new address is a different account, not a new device.
It went unnoticed while only the once-per-conversation bundle path fed
it; the directory feeds it every send. It marks CHANGED now.
* `guardIdentity` read the pinned address and passed it back into the
comparison against itself, so "a different address is a different
person" could never fire on a send. It compares the served one now.
And the copy: only `IdentityChangedException` was named, so
`KeyNotLoggedException` and `KeyLogForkException` reached the screen as
"Something went wrong reaching that server." -- sending a person to check
their signal for a decision this phone had made.
Also: the call screen painted a flat `colorScheme.background` rectangle
over the app's gradient, which read as a blue box around the call area.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The receiving half already worked. `Content.Reference` was in use for
reactions, an inbound reply was recognised and rendered, and `Session.Line`
already had an `about` field to hold one. What was missing was any way to START
one: no Reply in the long-press menu, no composer state, and `Session.send` had
nowhere to put a reference.
So this is narrow, as the ticket said: `Session.send(replyTo:)` passes the
reference to `Content.text(about =)` and records it locally, a Reply item sits
above Forward in the menu, and a dismissible strip above the composer says who
is being answered.
🔴 **The preview travels with the reply, and dropping it to save bytes is not a
free optimisation.** A recipient who joined the thread later or cleared their
history has never seen the message being answered — "> [missing]" is worse than
showing what was said. A test says so rather than a comment.
⚠️ **Visible before it is sent.** A reply that only appears in the sent message
is a mode nobody can see they are in, and the commonest way to answer the wrong
message is not to know you were answering one.
⚠️ **1:1 only, and the menu item is ABSENT rather than disabled in a group.**
The group send path does not carry a reply yet; accepting one there and dropping
it would be a half-feature that looks whole. The same reasoning the file already
applies to Edit and Pin on messages written before ids existed.
Three wire tests. One of them pins that a message which is NOT a reply still
goes out as a bare body — adding this must not have turned every plain message
into an envelope an older peer reads differently.
⚠️ Not included, and its own piece: tapping the quote to jump to the message it
names. That needs a scroll-to-id in the transcript list.
158 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches ios!146, format list included.
A photo sent through the picker had its EXIF removed — `ImagePrep` decodes to a
bitmap and re-encodes, so the output is pixels. The same photo saved into Files
and sent from the vault kept it: GPS, capture time, device serial. Somebody who
had learned "Flamenet strips location from my photos" had learned something true
of one button and false of the one beside it.
🔴 **Both doors, not one.** `sendFile` and `sendGroupFile` sit two functions
apart and had the identical gap. A leak fixed on one of two identical doors is
a leak.
🔴 **Sniffed by magic bytes, not by name.** The vault stores no MIME type, and a
file name is a guess in both directions.
⚠️ **Only images.** "Send this exact file" is what a file send means; a
scrubber that mangles somebody's PDF is worse than the leak it was fixing.
Undecodable bytes are returned as they came, for the same reason.
Five JVM tests, covering the DECISION rather than the pixels — `BitmapFactory`
needs a device, so the re-encode belongs to the instrumented suite, and iOS
covers the round trip with a real GPS-tagged JPEG. The decision is where the bug
was: the old code never asked the question at all.
⚠️ Two of the tests exist because a sloppier sniff passes the obvious ones: a
WAV file shares RIFF with WebP, and an MP4 shares the `ftyp` box with HEIC.
Falsified by matching only the RIFF header — "a WAV was mistaken for an image".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 The accent was being used the wrong way round. It is a mid purple chosen to
be readable as a FILL with white on top of it; as the INK for a glyph on a dark
ground it is a light purple mark on a dark blue field, which reads as a
disabled control rather than a button.
`Palette.OnDark` is white. Three sets of avatar initials and fourteen
accent-tinted icons.
⚠️ The selected tab keeps the accent. Its tint reads
`if (selected) MaterialTheme.colorScheme.primary else Palette.SecondaryText` —
a conditional, so the sweep did not catch it, and that is the right outcome:
the selected tab is the one place the accent as ink is doing a job nothing else
does.
⚠️ The accent still fills: primary buttons, badges, the pill's selected state.
Ink versus fill, not "purple is gone".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An input is the one surface a person types into and reads back character by
character, and a dark field on a dark ground gave it the least contrast on the
screen at the moment it mattered most. Typed text reads 18.3:1 on white; the
placeholder 4.6:1, because a placeholder is still text.
⚠️ **Its own colour, not `SurfaceAlt`.** That fill is shared with incoming
bubbles and the tab pill, which stay dark — a white message bubble on this
ground would read as somebody else's app.
🔴 **Every colour a field draws has to be named, not just the container.**
Material derives label, placeholder, cursor and outline from the colour scheme,
which is the DARK scheme here: setting only `containerColor` leaves a
light-on-dark placeholder on a white box, and it simply disappears. iOS had the
same failure in a different dialect — SwiftUI drew the placeholder and caret for
a dark background until the field was given `.environment(\.colorScheme, .light)`.
Two frameworks, one mistake: a white island in a dark app has to be told it is
an island.
One `fieldColors()` in `Theme.kt`, applied to all 34 `OutlinedTextField` call
sites. Mechanical and wide rather than a new wrapper composable, because a
wrapper would have to be adopted one screen at a time and the ones that were
missed would be exactly the ones nobody looks at.
⚠️ The message composer is a `BasicTextField` and stays dark. It is a pill in
the chat, not a form field, and turning it white would put a white bar across
the bottom of every conversation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#3A1E9E purple, #0B3080 blue, #070A1A black, top down. Less purple than the
two-stop version, because the purple is a third of the sweep rather than half.
🔴 One card colour against three stops has to lift off all three. #0E1745
looked right against the purple and the blue and measured 1.15:1 against the
black end — a card that disappears at the bottom of a long screen. #141C52
lifts 1.41 / 1.33 / 1.23.
The palette test checks the middle stop too. A three-stop gradient checked only
at its ends is two thirds measured, and the middle is where most of a screen
actually sits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>