Commit Graph
512 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 7ccec9e3d1 Every held poll on Android was failing at ten seconds
`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>
2026-09-14 18:19:02 -04:00
Paul HittandClaude Opus 5 7d8b721a49 Android 0.19, versionCode 21, published
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>
2026-09-14 15:20:15 -04:00
Paul HittandClaude Opus 5 5b3865c06a Reporting somebody in a group sent nothing at all
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>
2026-09-14 14:58:27 -04:00
Paul HittandClaude Opus 5 5945a87189 No call signal is lost to whichever poll loop happens to win
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>
2026-09-14 14:13:41 -04:00
Paul HittandClaude Opus 5 74429be153 An end wakes a phone; it does not ring one
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>
2026-09-14 09:33:37 -04:00
Paul Hitt 255a14b7c2 Merge branch 'chore/take-the-e2e-spec-update' into 'main'
Take the e2e spec update

See merge request paulhitt/flamenet-messenger-android!140
2026-09-12 20:19:27 +00:00
Paul HittandClaude Opus 5 393205ca85 Take the e2e spec update
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>
2026-09-12 16:14:56 -04:00
Paul Hitt 0f5f691e77 Merge branch 'fix/a-poll-shows-when-the-group-opens' into 'main'
A poll shows the moment the group opens

See merge request paulhitt/flamenet-messenger-android!139
2026-09-12 16:40:13 +00:00
Paul HittandClaude Opus 5 3fae491d10 A poll shows the moment the group opens
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>
2026-09-12 12:30:02 -04:00
Paul Hitt 8452f80a62 Merge branch 'chore/android-0-18' into 'main'
Android 0.18

See merge request paulhitt/flamenet-messenger-android!138
2026-09-12 16:20:50 +00:00
Paul HittandClaude Opus 5 c4b9c5c89d Android 0.18
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>
2026-09-12 12:14:57 -04:00
Paul Hitt fb3f9acde3 Merge branch 'feat/hand-a-call-to-another-device' into 'main'
Hand a call to another of your devices

See merge request paulhitt/flamenet-messenger-android!137
2026-09-12 16:05:05 +00:00
Paul HittandClaude Opus 5 d9813bd40a Hand a call to another of your devices
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>
2026-09-12 11:59:52 -04:00
Paul Hitt 25e83830a5 Merge branch 'feat/polls-nobody-can-attribute' into 'main'
Polls where nobody can tell who voted for what

See merge request paulhitt/flamenet-messenger-android!136
2026-09-12 15:49:02 +00:00
Paul HittandClaude Opus 5 315e6d328f Polls where nobody can tell who voted for what
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>
2026-09-12 10:10:01 -04:00
Paul Hitt 13f55076b9 Merge branch 'fix/a-dead-call-never-rings' into 'main'
A call that is over never rings again

See merge request paulhitt/flamenet-messenger-android!135
2026-09-12 13:31:43 +00:00
Paul HittandClaude Opus 5 6766b39165 A call that is over never rings again
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>
2026-09-12 09:13:46 -04:00
Paul Hitt ca720ce04d Merge branch 'fix/the-ring-stops-when-the-caller-hangs-up' into 'main'
The ring stops when the caller hangs up

See merge request paulhitt/flamenet-messenger-android!134
2026-09-12 13:07:49 +00:00
Paul HittandClaude Opus 5 c031532dc3 The ring stops when the caller hangs up, and two things on screen
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>
2026-09-12 09:00:25 -04:00
Paul Hitt 63a057a533 Merge branch 'chore/0-17-0' into 'main'
Version 0.17

See merge request paulhitt/flamenet-messenger-android!133
2026-09-12 12:09:13 +00:00
Paul HittandClaude Opus 5 0c161bdccb Version 0.17
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>
2026-09-12 08:06:05 -04:00
Paul Hitt 7625b5d668 Merge branch 'feat/anonymous-polls' into 'main'
Anonymous polls: the envelopes

See merge request paulhitt/flamenet-messenger-android!132
2026-09-12 02:23:07 +00:00
Paul HittandClaude Opus 5 1541407c3c Anonymous polls: the envelopes
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>
2026-09-11 22:21:12 -04:00
Paul Hitt 0774622f8f Merge branch 'feat/anonymous-polls' into 'main'
Anonymous polls: the arithmetic

See merge request paulhitt/flamenet-messenger-android!131
2026-09-12 02:18:35 +00:00
Paul HittandClaude Opus 5 c367d3e223 Anonymous polls: the arithmetic
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>
2026-09-11 22:16:38 -04:00
Paul Hitt ebae3d8937 Merge branch 'feat/off-the-record-ui' into 'main'
Off-the-record conversations: the control

Closes #27

See merge request paulhitt/flamenet-messenger-android!130
2026-09-12 01:45:03 +00:00
Paul HittandClaude Opus 5 f325b1c6ae Off-the-record conversations: the control
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>
2026-09-11 21:43:14 -04:00
Paul Hitt 140b872c66 Merge branch 'feat/off-the-record' into 'main'
Off-the-record conversations: the mechanism

See merge request paulhitt/flamenet-messenger-android!129
2026-09-12 01:40:25 +00:00
Paul HittandClaude Opus 5 ce7f50230b Off-the-record conversations: the mechanism
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>
2026-09-11 21:31:24 -04:00
Paul Hitt b9174079ba Merge branch 'feat/private-reaction-labels' into 'main'
Say which side of a private reaction you are on

See merge request paulhitt/flamenet-messenger-android!128
2026-09-12 01:19:56 +00:00
Paul Hitt 292f52f399 Merge branch 'feat/call-reactions' into 'main'
Reactions during a call

Closes #25

See merge request paulhitt/flamenet-messenger-android!127
2026-09-12 01:11:13 +00:00
Paul HittandClaude Opus 5 bfd86f9817 Say which side of a private reaction you are on
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>
2026-09-11 21:10:52 -04:00
Paul Hitt 740e8411cb Merge branch 'feat/private-reactions' into 'main'
Private reactions: react to one person inside a group

Closes #24

See merge request paulhitt/flamenet-messenger-android!126
2026-09-12 01:08:43 +00:00
Paul HittandClaude Opus 5 f147bbf6ae Reactions during a call
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>
2026-09-11 21:07:31 -04:00
Paul HittandClaude Opus 5 8037a299b5 Private reactions: react to one person inside a group
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>
2026-09-11 20:57:31 -04:00
Paul Hitt 1656c87491 Merge branch 'fix/answered-elsewhere' into 'main'
Stop the ring on this account's other devices when one answers

See merge request paulhitt/flamenet-messenger-android!125
2026-09-11 16:53:55 +00:00
Paul HittandClaude Opus 5 43baac80b7 Stop the ring on this account's other devices when one answers
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>
2026-09-11 12:50:59 -04:00
Paul Hitt f6506292e7 Merge branch 'fix/instrumented-script-reaches-main' into 'main'
Clear the app before every on-device run, as !122 said it did

See merge request paulhitt/flamenet-messenger-android!124
2026-09-11 16:21:45 +00:00
Paul Hitt 9e8c1005fe Merge branch 'fix/second-device-hangs-up-answered-call' into 'main'
A second device's ring no longer hangs up a call answered on the first

See merge request paulhitt/flamenet-messenger-android!123
2026-09-11 16:20:58 +00:00
Paul HittandClaude Opus 5 945897a742 Clear the app before every on-device run, as !122 said it did
!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>
2026-09-11 12:14:44 -04:00
Paul HittandClaude Opus 5 8f0fd77dae Guard the second-device call fix where Android CI actually runs it
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>
2026-09-11 12:14:43 -04:00
Paul HittandClaude Opus 5 ae84b949a3 A second device's ring no longer hangs up a call answered on the first
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>
2026-09-11 12:13:13 -04:00
Paul Hitt 668bd48415 Merge branch 'fix/instrumented-clears-a-release-build' into 'main'
The on-device sign-up suite runs again, and the bug it found is fixed

Closes #22

See merge request paulhitt/flamenet-messenger-android!122
2026-09-11 14:12:14 +00:00
Paul HittandClaude Opus 5 4412c0a0fa A second account on the same phone gets its own device (android#22)
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>
2026-09-11 10:09:43 -04:00
Paul HittandClaude Opus 5 77ca69b327 The on-device sign-up suite runs again, and sign-up was never broken
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>
2026-09-11 10:05:17 -04:00
Paul HittandClaude Opus 5 918f424ddd Keep build output out of Spotlight
The module build directories are rewritten every job on a machine that
is also the mac-local runner for both app repos, so indexing them lands
in the merge path for everything. `mds_stores` and `mdworker_shared` sit
at the top of CPU while no build process is near it, and the load
average goes into the hundreds.

Measured on the iPhone repo, same from-scratch build twice: it peaks at
load 12.5 with its output excluded and over a hundred without, and the
indexing work stops accumulating rather than trailing the build by
minutes.

The markers are written by the job rather than committed, because the
directories they belong in are created by the build and removed between
runs. The stable caches outside the repo were excluded on the host
itself: DerivedData, the Android SDK, the runner's builds directory and
the Gradle home, 11 GB between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:01:01 -04:00
Paul Hitt fcc81a678a Merge branch 'test/content-envelope-vectors' into 'main'
Check the content envelope against envelopes the iPhone emitted

See merge request paulhitt/flamenet-messenger-android!120
2026-09-11 11:51:28 +00:00
Paul HittandClaude Opus 5 19add570cb Check the content envelope against envelopes the iPhone emitted
The content layer had no vectors. Everything from section 6.6 onward was
guarded only by same-code round trips: this engine encodes and parses
through its own codec and would pass unchanged if it and the iPhone
disagreed about every key. That is how SPEC 6.18 specified `ment` for as
long as it has existed while both clients have always sent `men`.

`content-v1.json` holds fifteen envelopes generated by RUNNING the
iPhone's encoders, and this suite parses each one and asserts field by
field. "It did not crash" is what the old tests already proved.

One of them pins something easy to get wrong twice: the iPhone writes
`fwd` as the integer 1, not as `true`, and `optBoolean` does not coerce a
number. A parser reading only the boolean answers false and the message
quietly loses the fact that it was passed on.

The suite also fails if the vector file grows a case nobody asserts on.

Separately, and the reason any of this is trustworthy: the test task did
not track the vector files at all. They arrive by environment variable,
Gradle does not track one as an input, so a vector could change under a
build directory that already had results and the task reported
UP-TO-DATE without running. That is not hypothetical here, it is how
twenty vector tests once passed in CI for weeks without executing, on
this same runner, whose build directory survives between runs. All six
vector files are now declared inputs.

Proven rather than asserted: with the inputs declared, flipping `men` to
`ment` in one vector turns the suite red without `--rerun-tasks`. Before
the fix the same change left it UP-TO-DATE and green.

Full suite: 1294 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 07:20:17 -04:00
Paul Hitt 5d84ad180c Merge branch 'release/0-16' into 'main'
Android 0.16, versionCode 18

See merge request paulhitt/flamenet-messenger-android!119
2026-09-11 00:41:24 +00:00
Paul HittandClaude Opus 5 615658b561 Android 0.16, versionCode 18
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>
2026-09-10 20:23:14 -04:00