Same as the iPhone. The rule lives in shouldAdoptRoster and the test asks it
rather than searching Session.kt for a line: empty never replaces held, empty
onto empty is allowed, anything non-empty always lands. A guard that refused
everything would have passed the old source-reading test and broken the app.
The ordering test stays, since where the guard sits relative to the write is a
real property: after it, the empty list is saved on the way past and the
fallback reads it back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android sent no User-Agent at all, so a relay could not tell an old build from
a new one, which is the check that has to pass before the contact graph can be
removed.
The version is set on the client module by the app layer at launch, because the
app layer is the one that knows it: this module is a library with no BuildConfig
of the app's own. Unset it reports "0", which a relay reads as an unknown build
rather than a recent one, so forgetting to set it fails safe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same guard as the iPhone. A relay that has stopped keeping the contact graph
answers with an empty list, and mirroring that over the stored roster would
erase somebody's contacts on the first poll after the switch was thrown, or on
any transient fault that answered empty.
The guard sits before the mirror is written, or the empty list would be saved
on the way past and the fallback would read it back. A test pins that ordering
rather than only the presence of the check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same fault the iPhone had. The dirty flag was cleared before the attempt, so a
refusal was lost for good: the token was already stored, nothing asked again,
and that contact would never have seen a presence.
The set is compared against what the relay last accepted instead, and
republished until the two agree. The route replaces rather than merges, so
repeating a set that already landed changes nothing, which is what makes
retrying free.
Verified on the live relay: both accounts publish their own digest, and the
digests differ, because a token is per pair.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both halves of 8.9c were written in different repositories on the same
afternoon, and the way that goes wrong is a field name: this client posts
"digests" and the relay reads "tags", both suites stay green, and presence
silently never works. RosterTagWireTest asserts the exact bytes against a mock
server, including the route paths and the token-keyed answer shape.
It also pins two behaviours worth keeping: no tokens means no request at all,
because a relay asked about nothing still costs a round trip and a rate-limit
unit, and a token the relay does not recognise comes back offline rather than
missing, since "offline" and "no answer" are different things to whatever
merges this with a roster.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spec 8.9c, the Android half, matching iOS. Each contact gets a random 32-byte
token, sent over the encrypted channel on the delivery-key envelope, which is
already the per-contact one. The relay is given only SHA-256 of it, and
presenting the token is what reads a presence.
Per pair rather than one shared secret, so taking one contact off the list
costs the others nothing. That is the difference from the sealed-sender
delivery key, where cutting somebody off replaces a key everybody holds.
Where a token is held its answer wins; a contact with no token yet keeps
whatever the roster said, so a half-migrated pair does not read as offline
while both ends catch up.
The HTTP stays out of the engine. Conversations has the vault and the e2e
surface but not the mobile one, so it mints the token and raises a flag, the
same arrangement avatarsUpdated already uses, and Session publishes.
A star inside a block comment opens a nested one in Kotlin, which is how a
doc comment naming a route path took out the whole file. The path is described
rather than written now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
versionCode 25, versionName 0.25, equal as they have been since 24.
Published APK sha256 33474c03bf5ee35a1ef33a0c112b53d6212f82fc72faf07f15247b4c4c502cd7,
signed with the same certificate as every release before it,
791fc826d572a70abfaf7db7fa7784dad0f86f204bb313f0d75ee676df8d3a1d. The outgoing
0.24 is kept at apk-rollback/flamenet-messenger-0.24-vc24.apk.
Proved rather than assumed: five strings this release introduces are present in
the new APK's dex and absent from the one the site was serving.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android 0.7 crashed on answering a call and it took somebody noticing. There
was nothing in the tree that read exit reasons, so a crash was something a
person had to report in prose.
ApplicationExitInfo is Android's own record: no library, no network call, no
identifier, the same shape of answer MetricKit is on the iPhone. It is stricter
here in one way. MetricKit delivers a payload once and it is gone, so that side
keeps a file; Android hands back the same list every time it is asked, so the
system is the store and this app writes nothing at all.
Ordinary exits are dropped rather than greyed out. A list of "you swiped the
app away" buries the one line somebody came to the screen for. REASON_SIGNALED
goes with them: in practice it is the system or a debugger sending SIGKILL, and
a real native crash arrives as REASON_CRASH_NATIVE.
setProcessStateSummary at launch stamps the running build into the 128 bytes
Android returns with an exit record, so a report can say which build died. By
the time anybody reads one the app has usually been updated.
Saved through the system file picker, not a share sheet. There is no
FileProvider here and a trace is too big to travel as intent text: past a few
hundred kilobytes that throws, which would fail on exactly the largest and most
useful report. Saving is also how a history backup leaves this app.
The dialog's layout came from the test. With everything in one scrolling column
the address and the save button sat below the explanation and off the bottom of
a small screen, so the two things somebody opened it to do were the two they had
to go looking for. Only the trace scrolls now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app sits inside an imePadding(), because windowSoftInputMode=adjustResize
stopped doing anything at targetSdk 35. So an opening keyboard SHORTENS the
message list rather than covering it, and a LazyColumn holds its offset from
the top: the newest messages slide up out of the shorter viewport and end up
behind the composer. The keyboard hid exactly the messages somebody opened it
to answer.
The list follows the inset now. Keyed on its height rather than on a
visible/hidden flag, and scrolling without animation, because the inset arrives
as a series of values while the keyboard slides and one animated jump fired at
the start of that lands short by however much the viewport shrank afterwards.
The test reads the source, because exercising this needs a real IME and a JVM
test has none. It guards the follow against deletion; it does not prove the
behaviour, which takes a device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opens flamenetmessenger.com/donate in the browser. Nothing is taken in the app,
nothing is unlocked by giving and nothing is withheld without it.
The address is the project's site rather than the signed-in relay, which is the
opposite of the rule the page links follow. Those open the server you are on,
because a self-hosted relay's operator writes the terms that bind its users. A
donation is not that: their users paying their operator's hosting bill is not
what the row offers, and a self-hosted relay serves that page with no form on
it unless its operator configured a processor.
openDownload gained a plainly named sibling, openLink, since a donation link is
not a download. The old name stays and delegates, so the update path is
untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>