The adaptive-icon foreground was sized so the mark was 69 percent of the
canvas wide, against a safe zone of 72 of 108. That reads as a fit and is
not one: the safe zone is a CIRCLE, so what has to fit is the mark's
diagonal, which came to 86 of 108. A launcher with a round mask cut the
flame off at both ends, which is the thing the comment in ic_launcher.xml
says must not happen.
The mark is also the whole emblem now rather than a crop that sheared the
arc at both sides before it ever reached a mask. Regenerated by the relay
repository's Scripts/make-brand-assets.py, which now measures the diagonal;
every density comes out between 65 and 66 of 108.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android carries a versionCode, which decides whether an install counts as
an upgrade, and a versionName, which is what a person reads. They had been
one apart since 0.11 was built and never published: the build after it went
out as code 12 before the name was settled, and a code can never come back
down. So the download page said 0.22 while every number underneath said 23,
and somebody comparing the page against their own app had to know which of
the two was ordering and which was a label.
This release closes it by skipping the name 0.23. Code 24 with name 0.24,
and from here they move together and match. Skipping a name costs nothing,
because a name is a label; skipping a code would be the thing that cannot
be undone. A test reads both out of the build file and fails if they ever
name different releases, or if the code drops to or below the published 23.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The relay can now suspend an account, and it refuses one with a single
sentence at every door. That sentence has to reach the person: nothing
else they do will work until the suspension is lifted, and "that isn't
allowed on this server" sends them reinstalling. A 403 whose body names
account_suspended shows the relay's sentence; every other 403 reads as
before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On Android 14 and later a foreground service has to declare the
microphone, and for video the camera, or the system silences them the
moment the app leaves the foreground. The only service here declared
remoteMessaging, for the poll loop. So a call whose phone locked, or
whose person switched apps, went quiet at the far end while the screen
on this one still said the call was up. The app targets 35 and runs on
nothing older than 33, so every phone it ships to behaves this way.
The service re-declares itself with the microphone type when call media
starts, camera too for video, and back to the plain declaration when the
call ends. It is done the moment the audio track exists rather than at
connect, because the microphone is open from there. If the system
refuses the extra types, the plain declaration keeps the poll loop alive
rather than crashing the service mid-call. A source-reading test pins
the manifest, the service and both call-side hooks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A conversation with a person on another server has an address and no
peer id, and only the text path knows how to reach it. The picture path
asserted the peer id anyway, and the attach button is offered on every
conversation, so one tap on a picture in that thread was a crash. Files
and stickers did not crash: they did nothing, which is the same tap with
less honesty.
All four paths now resolve the peer the same way, and the three that
cannot go there refuse with one sentence that says so, in place of the
generic "Something went wrong reaching that server". A source-reading
test pins the absence of the assertion and the presence of the refusal,
ignoring comments, and checks the sentence a person is shown.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The download page said 0.21 and the owner asked why it did not say 22. It now
says 0.22, and so does the app, because the build behind it really is 0.22.
WHAT THIS RELEASE IS, PLAINLY
Nothing in it behaves differently from 0.21. Proven rather than assumed: the
0.21 APK on the site and this build were pulled apart and compared, and they
carry the same 8733 strings, the same 7543 classes and methods, and the same
timeout fix (const-wide 40, TimeUnit.SECONDS) that made calls collect in under
a second. The release note on the site says exactly that, so nobody reads a
changelog that promises them something they already have.
AND WHAT WAS TAKEN OUT BEFORE IT SHIPPED
The one real difference was mine. The build carried the `fnmsg-poll` line I
added to the view model while chasing the held-poll bug, two strings that were
in this tree and not in 0.21: "vm cycle " and "got=". Diagnostic logging earns
its place while a bug is being hunted and not afterwards, and it does not
belong in a build a stranger installs. The service keeps its own poll line,
which was already shipped in 0.21 and is unchanged here.
After removing it the string pools and symbol tables match 0.21 exactly.
The comment above the version numbers claimed they would line up. They cannot:
the code was ahead before the name was settled and a code is never reused. It
now says what is true, that they stay one apart and move together.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`heldHttp` raised `callTimeout` to the hold plus twenty and left `readTimeout`
at OkHttp's default of TEN SECONDS. A held poll asks the relay to say nothing
for up to twenty. So every one died at ten with a SocketTimeoutException, and
the generous call timeout above it never got the chance to matter.
Nothing said so. The failure was swallowed into a retry, the loop looked busy,
and the only trace was a number on the relay: this device took an average of
112 seconds to collect what was waiting for it, against 5 for an iPhone. A
message written into a connection this end had already abandoned waited for
the next poll instead of arriving.
That is why calling an iPhone took forever, why "connecting..." never
resolved, and why the call then ended: the answer was on the relay the whole
time and the dial guard gave up at forty-five seconds waiting for a fetch that
could not succeed.
Measured on the device, before and after, once the loop was made to say what
it was doing:
vm cycle 10125ms held=20s call=false failed=SocketTimeoutException
vm cycle 20304ms held=20s call=false got=0
TWO POLLS, AND I FIXED THE ONE THAT WAS SWITCHED OFF
`MessengerService` and the view model both poll, and the activity stops the
service on the way in -- "exactly one poller at a time". Placing a call is
exactly when the app is on screen, so the service is stopped and the view
model is the poller. The twenty-second hold during a call was fixed in the
service first. The loop that was running kept it.
Both ask for no hold during a call now.
AND THE LOOP CAN SAY WHAT IT COST
One line per cycle: how long it took, how long it was allowed to hold, whether
a call was up, and what came back or what failed. Two rounds were spent
reading an empty trace as "the loop is not running" when it was "the loop has
no voice", and the bug above was invisible for as long as it was because a
failing poll and a quiet one look identical from outside the process.
1619 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
versionCode 21 rather than 20, and the bump was not bookkeeping. The website
was already serving versionCode 20 as 0.18, so shipping today's build under
that number would have reused a code: nobody already on 0.18 would ever be
offered this, and this file's own rule is that a code is never reused.
Signed with the same certificate as every build before it, checked against
the outgoing APK rather than assumed -- 791fc826..., CN=Flamenet Messenger --
because a mismatch there makes the update uninstallable for everybody who
already has the app, and it is found out by users rather than by us.
The outgoing build is kept as apk-rollback/flamenet-messenger-0.18-vc20.apk,
outside the served directory. Android never downgrades, so that copy is a
record rather than an undo: once a code is public the only way out is a
higher one.
`scripts/release-manifest.sh` refused to write the update manifest until the
releases page named Android 0.19, which is exactly what it is for. The notes
went up first.
Carries what landed today: a call signal is no longer lost to whichever poll
loop wins, a call polls without a twenty second hold, every envelope carries
an id so a repeat is recognised as one, reporting in a group actually sends,
Report and Block are in the conversation menu, and closing the app during a
call ends it for the other person.
sha256 38ba0a251deb2f26a3c3bf63d47a7e2c64520326f602a40cad2d2ae7be564e80
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The menu item was offered on any message somebody else sent, group included.
The sheet's send handler was `chat.peerId?.let { … }`, and a group has no
peerId: `openGroup` never sets one. So the form opened, a reason was chosen,
Send was tapped, the sheet closed, and the block ran zero times. Nothing sent,
nothing said, no error.
Silent, on the surface where somebody is most likely to need it: a group is
where a person who is not your contact can reach you. Block in the same sheet
was dead identically, and the sheet was handed the GROUP's name, so it offered
to "Block Family chat".
It reports the author now, worked out the same way the list already works it
out to decide whose avatar to draw. Where the author genuinely cannot be
resolved it says so, because a form that swallows what somebody typed is the
bug, not a smaller version of it.
REPORTING AND BLOCKING WERE BOTH UNREACHABLE FROM A CONVERSATION
Reporting lived only behind a press and hold on a message. That is the right
place to report one message and it stays there, but nobody finds it by
looking -- the person who wrote this app went looking, following the safety
page, and could not find it. Block was only ever on the banner a stranger
gets, so once somebody had been kept as a contact there was no way to block
them from the conversation at all.
Both are in the conversation menu now, first, above four headings of
preferences, because somebody opening that menu for this reason is not in the
mood to read past disappearing messages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two loops poll one mailbox: the service's, which keeps it watched while
nothing is on screen, and the view model's. An envelope goes to whichever
asks first -- the relay marks it delivered and does not hand it out again.
The service's handler knew what to do with an offer and an end and had
`else -> Unit` for everything else, so an ANSWER it happened to fetch was
thrown away. Measured, not inferred: an iPhone answered, the relay accepted
the answer, this side logged the envelope decrypting, and the call manager's
own "call signal in" line never appeared. The signal reached the process and
stopped one layer short of the call. The caller then sat on "Calling..." for
the full forty-five seconds and gave up, every time the race went that way.
An END fared no better: it cleared a notification and left a live call
running.
A call now registers itself so either loop can reach it, and the service
forwards every signal after its `when` rather than inside a branch of it --
in a branch it would cover only the actions somebody remembered, which is the
bug restated, so there is a test holding it outside.
A CALL CANNOT WAIT ON A TWENTY SECOND HOLD
The same loop asked the relay to hold every request open for twenty seconds.
Right for messages, wrong for the two signals somebody is watching a screen
for. iOS polls hard for the length of a call and this never had the same
thing, so an answer sent at once was accepted twenty seconds later: offer at
:07.9, answer accepted at :28.5, with the relay holding it the whole time.
It polls with no hold at a one second cadence while a call is up, and goes
back to the hold when there is not one. The same pair now measures 2.8s.
ENVELOPES THAT ARRIVE TWICE
`hasSeenEnvelope` is guarded on the id being present and nothing sent one on
the ordinary route, so it was asked about nothing. One offer on the relay was
delivered twice 2.4 seconds apart and the second was answered "busy" by the
phone ringing for the first. Every send carries an id now, with a default on
the field so a new call site cannot forget one, and an offer for the call this
phone is already on is no longer mistaken for a second caller.
CLOSING THE APP DURING A CALL
`onTaskRemoved` is what fires when the app is swiped out of Recents, and it
runs while the process is still alive, which is the point: by `onDestroy`
there may be no time left to reach the network. On a thread with a bounded
join rather than a coroutine, because the scope is cancelled a moment later
and a launched coroutine would be killed before the envelope left.
1606 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The matching half of the iOS fix, and this side is where the ghost was
actually made for an iPhone receiving an Android hangup.
iOS gives no choice: every VoIP push must report a call to CallKit or the app
is killed. Ends were marked urgent, urgent means a VoIP push, so every hangup
and every busy sent from here arrived on an iPhone as an incoming call.
Ends were marked for a real reason: hanging up before the other phone was
picked up left it ringing for a caller who had gone. What that missed is that
the phone which needs to hear a hangup is, by definition, ringing, and
therefore awake: it has a call up, the system keeps it running for one, it is
already polling, and the relay answers a held poll through Redis in under half
a second.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pin was nine commits behind, all of them spec: off the record, anonymous
polls, handing a call to another device, the private-reaction flag and the
call-reaction kind, a call answered on one device stopping the others, and
the harness fix that reads a page's stylesheet href rather than naming the
file.
No vector moved, so AccountKdfTest, PQVectorTest and PublishedVectorsTest
check the same bytes they did before. This accepts no protocol change.
flamenetmessenger pins the same commit and moves in step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening a group built the chat without its polls, so one waiting on your vote
was blank until the next poll tick came round, up to five seconds later. A
poll waiting on your vote is often the reason somebody opened the group.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
versionCode 20, because 19 is already public and Android refuses to install
anything at or below a code it already has.
Carries polls nobody can attribute and handing a call to another of your own
devices, on top of the call fixes that went out in 0.17.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On a call on the phone, walk to the desk, take it on the tablet, and keep
talking. The other person hears a short gap and nothing else.
This rides the channel that already exists: the device that answers a call
tells this account's others to stop ringing. Handoff is that channel carrying
two more signals. Nothing new reaches the relay.
Every rule here is about not breaking a call that is currently working.
The peer is never hung up on. No end is sent to them at any point in either
direction. An end would make the far end drop the call and redial, which is
the failure this replaces.
The order in `takeHandoff` is load-bearing and has a test on it: the peer gets
the offer first, this account's other devices are told second. The device that
had the call is still carrying it until that line, so a failure while taking
it leaves somebody holding a working call rather than nobody.
An offer nobody takes lapses at both ends rather than being withdrawn. A
withdrawal envelope can be lost and leave a device believing an offer still
stands; a timeout cannot.
And whether the far end can take a renegotiating offer is known before any of
this starts, from a flag both ends set during call setup. A build that
predates handoff sets nothing, absent reads as no, and the control is absent
rather than disabled.
An offer for the call already in progress is answered where it stands: no
ring, no missed call filed, and the call screen stays.
13 tests. 639 in the engine, 425 in the client, 210 in the app, all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The secure-aggregation core has been in the engine since it was written and
nothing used it: there was no way to ask a poll, vote in one, or see a
result. This is the rest.
`PollBook` is the sequencing, deliberately the same shape as the iOS one. A
poll crosses between the two engines, so a rule enforced on one side and not
the other is a poll that tallies differently depending on who is looking at
it. No sum is published until every member has voted: a member who has not
voted has handed nobody a piece, so nobody holds a complete set, and a sum
published early is noise rather than a partial answer.
Asking a poll sends the same envelope to the group. Voting does not: each
piece is addressed to the member it belongs to and sent to them alone. That
is the one group envelope in the app which is not the same bytes to
everybody, and a test pins it, because sending one share to the group would
let any member subtract it from the tally and read the vote, with the counts
still coming out right and nothing at runtime to notice.
Kept in the vault, or a poll could never finish: it waits for everybody, and
the pieces gathered would be gone the first time the app closed.
The card says what the maths actually gives. Everyone else would have to work
together to know how one person voted, never that nobody can know. A poll of
two is not anonymous at all, because the other person holds a piece of the
vote and sees the tally, so the word is not offered for a pair and the card
says why.
22 new tests. 647 in the engine and 430 in the client, all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Envelopes are fetched in batches, so the caller's hang-up is regularly
decrypted before their offer. The end was checked against the call in
progress and, when it matched nothing, dropped and forgotten. The offer
behind it then rang a moment later for a call that was already over.
Answering that ring did nothing, because nobody was on the other end, and
the next queued offer arrived behind it.
An end is now filed whether or not it matches the call in progress, and
every signal is checked against that before it can ring, connect, or wait
on anything. The call this device ends is filed the same way, so a late
signal from the peer cannot restart it.
The memory is bounded oldest-first at 200. A phone left running for weeks
must not keep every call id it has ever seen.
A stale offer is filed too: a second delivery of the same dead offer used
to record a second missed call for one ring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only a call OFFER was marked time-critical, so only an offer got the VoIP
push that reaches a locked, suspended phone. Hanging up sent the end as
ordinary traffic, which a sleeping phone does not wake for, and it went on
ringing for a caller who had already gone. The end of a call is as
time-critical as its start. The rule moves to the companion so the test can
ask it what it decides rather than read how it is written.
The sign-in screen showed a letter in a box where the app has a mark of its
own. It now shows the artwork and the navy the launcher icon uses, so the
screen someone lands on matches the icon they tapped.
And setting up Files asked for the passphrase twice. A second box saying
"type it again" is how you confirm a secret somebody is inventing on that
screen; this is the passphrase they already sign in with, which the
paragraph above the box says in as many words. Asking twice only suggested
it was a different one. iOS asks once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Three kinds (§6.29): the poll itself, one piece of one vote, and the sum
a member publishes. A piece goes to a single member and to nobody else,
which is what makes a vote unattributable; a sum is what everybody sees.
All three are marked as control traffic. They are deliberately not in the
CONTROL kind set, because that check returns a bare envelope before the
specialised branches and these carry structure worth reading, but without
the flag they satisfied every renderable test and would have drawn as
empty bubbles.
The poll carries the member set it is counted over, hashed. Pieces split
across a different set do not add up, and that should be visible rather
than arriving as a wrong tally.
Also adds call-reaction to the known kinds. It went into the call actions
when reactions during a call were built and never here, so an arriving
one was classed as needing a newer client. It is the same file as this
change, and a parallel branch for one line would only collide with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A vote is never sent to anybody. It is split into random pieces that sum
to it, one per member, and each member publishes only the sum of the
pieces they were handed. Adding those sums gives the counts.
Every member other than the voter would have to collude to recover one
vote. That is the guarantee, and it is not the same as nobody being able
to know, so the interface will have to say the true thing rather than the
shorter one.
A missing publisher does not undercount, it gives a wrong answer: the
total keeps a random number in it. That is why a poll waits for everybody
rather than showing what it has, and a test pins it so the waiting is not
mistaken later for timidity.
Tested by doing the arithmetic rather than by reading the source that
does it: the counts, that no single piece is the vote, and that all but
one of the pieces reveal nothing.
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>
!122 described this change and did not contain it. Its commit carries
only SignUpFlowTest.kt; the script edit stayed in the working tree,
because the file is tracked as `scripts/` and was staged as `Scripts/`,
the same file on a case-insensitive disk. The on-device run that passed
used the uncommitted script, so the pass was real and main never had
the fix behind it.
The fix itself is unchanged from what !122 described. The uninstall used
to run only when the emulator was already up, on the reasoning that a
cold boot starts clean. A cold boot starts with no snapshot; the disk
image keeps whatever was installed. An AVD used to try a release build
kept that build, and the debug test APK could not install over it:
INSTALL_FAILED_UPDATE_INCOMPATIBLE, zero tests run. It now clears on
both paths and refuses to continue if anything survives.
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>