Same rule as iOS: the live refresh replaced the whole map, so a refresh
while the buddy list was briefly empty produced an empty map that blanked
every picture. Merge, and ignore an empty result.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
listDevices returns identity-only stubs — enough for a device we already
have a session with, useless for one we do not, because initiate()
verifies the signed prekey's signature and a stub carries empty bytes.
Choosing between the two lists meant that whenever ANY device already had
a session, every device got a stub, so a device still needing one could
drop out of the fan-out with no partial-delivery warning (the other
device succeeded, so was non-empty).
The rule is union: full bundle where the key route returned one, stub
otherwise. Found while verifying a two-device pair by hand; the live
suite's 'a message reaches every device a person has' passes either way,
which is why it needed the hand test to surface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same empty-offer and attachment-id rules as iOS. The QR scanner now
binds a scanned code by its fnid rather than the handle even on the home
relay — the fnid certifies itself, a handle is a name the relay maps,
so resolving by handle handed the relay the choice of who you scanned.
And a skipped message key is consumed only after its tag verifies: it
was defused today by the caller discarding the session, but any future
in-memory ratchet cache would have turned it into real message loss.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror of iOS, byte-identical by construction: both keys folded under
one label when both sides have a PQ identity, classical pairs unchanged,
and the exact digits pinned against an independent implementation so the
two platforms cannot drift into showing different numbers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The engine had no head-signature function at all and Trust.head() threw
the signature away, so a fork was an accusation rather than evidence.
KeyLog.verifyHead now checks the same string the relay signs, the issuer
key is pinned in the vault on first sight, and a head that fails under
the pin raises the standing fork alarm. Four tests pin the string and
the refusals — including a head signed by a different key, which is the
substitution the pin exists to stop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Header and read both bounded — contentLength is -1 on a chunked reply,
so trusting it alone would leave the hole open. Same reasoning as iOS:
the size came through the ratchet, so the relay cannot inflate it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Filing on the sender's word let a contact conjure a group thread we are
not in, and let anyone sharing a session with us — including a member we
removed — post into a group they had left. The relay's roster decides,
cached briefly so a poll pass carrying many group messages costs one
round trip rather than one each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
C1 (Android-only, critical): building a session from a prekey envelope
accepted whatever identity key it offered — no pin check, where iOS has
always had one. Anyone could hand us a handshake with their own key
while claiming to be a pinned contact, and every later message from that
session rendered as that contact. The same gate the send path uses now
runs on receive.
C2 (both platforms): a device binds first-use-wins to its user, the
claim is checked against that binding on every decrypt, and control
traffic (delivery key, avatar) is refused from an unproven sender — a
poisoned delivery key forces the next send UNSEALED and deanonymizes it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Long-press any bubble for Copy and Delete for me (Unsend on your own),
in 1:1 and group threads. Vault.deleteLine rewrites the thread minus the
line, keyed by timestamp+side+text — messages carry no stable id here.
Leaving a group was already wired.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror of iOS: the poll loop runs a throttled, best-effort key-log
consistency sweep so a fork between bundle fetches is caught on a live
conversation; and KeyChangeDescription turns a log entry into a named,
dated sentence for the changed-keys warning. Both pure pieces are
unit-tested.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unit suites run on the project's own runner before main moves. And
scripts/verify-build.py proves what two clean builds already showed:
every byte of the APK's contents reproduces from this source — only the
signing block differs, because signatures are randomized. Rebuild,
compare contents, check the certificate: that is the whole trust chain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session.roster() reads the vault once the list is off-server, mirrors
the relay's copy into the vault while one exists, and falls back to the
sealed copy when the network fails. The roster rides the history backup.
Settings gains the one-way door with the costs stated where the button
is: presence goes dark, and a new device restores contacts from the
encrypted backup rather than the server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Launcher badges on the message notification (the service notice
explicitly never badges). Stories accept picked videos — validated
against a 30s/9MB cap rather than transcoded, said honestly when
refused — and play looping in the card via the framework player. The
wire's dur field travels for any attachment that has one.
Parental controls mirror iOS: a parent's PIN guards adding contacts,
stories, and calls; incoming calls answer busy; sign-up gains an
on-device age gate that stores nothing. The server learns nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The default launch mode stacked a second MainActivity — with a second
ViewModel, session and CallManager — whenever the app was re-entered.
The visible screen's Hang up then commanded a manager with no call while
the real call rang on underneath, and the peer never got the END.
singleTask, plus a 30s connect guard so an answered call whose ICE never
finds a path gives up out loud on both screens instead of sitting in
Connecting forever.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same design as iOS: the vault's history re-sealed under a passphrase-
derived key (PBKDF2 600k rounds, AES-256-GCM), saved through SAF, merged
back by content identity so the same backup twice adds nothing. Expired
stories stay expired — a backup is not a way back in. The passphrase is
stored nowhere, deliberately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same fix as iOS: RelayAuth latched 'no token endpoint' off one 404, and
an outage's proxy-level 404s wedged the client into tokenless requests.
invalidate() now un-latches — the 401 is proof the conclusion was wrong.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror of the iOS change: a 410 from the poll cancels polling, destroys
the vault (Android's sign-out is already a full wipe), and lands on the
sign-in screen with a sentence that says what happened and what signing
in again means.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirror of the iOS change: the poll path un-marks a toldPeer whose
message arrived unsealed — their copy of our key is gone, and the next
send re-tells it. Same staleness class, and same cure, as the avatar
marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RelayException.RateLimited has parsed the header all along; the relay
never sent it until today. The message now says "Try again in about
N minutes" when the clock is known, with the old copy as fallback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same contract as iOS: the relay's roster for this account, oldest
first, the current install marked and not removable from itself (that
is Sign out), removal behind an in-place "Really remove" confirmation
per this screen's house style. RelayClient grows revokeDevice
(DELETE /e2e/devices/{id}); Session surfaces ownDevices()/revokeDevice
and parses the directory's new created_at.
The relay caps accounts at three devices, so this card is the only way
off the cap when registration refuses a fourth.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The size-keyed follow effect animated from the very top on first
composition — a tour of the history before landing. A Unit-keyed instant
jump runs first; the animated follow keeps handling arrivals.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Session.Line carries `author` (record always persisted it; the read
side never surfaced it) — group bubbles now wear the WRITER's name and
picture instead of the group's initial for every message.
- Story cards use the author's real picture (your own from myAvatar).
- avatarShared marker stamps the peer's device set, not just the blob
id: a bare marker outlived their reinstall and they saw initials for
ever. Old markers mismatch once and self-heal with one resend.
- QR: AddressCard embeds ?u=<handle> so a same-relay scan resolves
through the ordinary username lookup, and the Home + Messages bars
gain a scanner — WebRTC's own Camera2 capture rendered to a
SurfaceViewRenderer with each frame's Y plane offered to zxing:core
(the one new dependency; decode only, no second camera framework).
parseScannedAddress mirrors iOS ContactCard.decode's refusals.
- Messages screen matches iOS: bare title with scan + add-contact
actions, New group beside the Groups heading (GroupsSection's own
label, always drawn now, carries it), New chat beside CHATS with a
contact picker, and the duplicated GROUPS label is gone.
- Story composer gains a per-story audience: Everyone / Only… /
All except… chips with a member checklist, seeded from the account
setting, never written back to it. postStory takes the override.
- AddContactForm accepts a prefill so a scan lands in the field —
looked up, then added, never auto-added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Undelivered - will send" on a group bubble was a lie: group text and group
stickers recorded PENDING when members were unreachable and nothing queued
them. The 1:1 outbox could not be stretched over it - a group send fails per
member - so the entry grew the thread it restates, and the design questions got
answers instead of a shrug:
WHO is queued: exactly the members nothing reached. Group text reads them out
of the failure map, where the no-device guard files them as "user:<id>"; the
sticker loop sees per-member results directly. A member whose devices took the
message is NOT queued - resending to them would duplicate it.
WHERE it restates: the group thread, carried on the entry. Restating
threadForUser(member) would touch a 1:1 thread that may not even exist.
WHEN the bubble flips: only when the LAST queued member is reached. Flipping on
the first success would mark the message delivered while other members are
still owed it - "will send" would quietly stop being true for exactly the
people it was still true for. Until the set drains, PENDING remains the honest
label, because the queue genuinely will keep sending.
The test walks the whole rule: two members away, one returns (bubble still
PENDING, one entry left), the second returns (bubble DELIVERED, group thread
redrawn, the prose body not replaced by the wire form).
⚠️ iOS sends to groups too (sendGroupText/sendGroupImage) and still has no
group outbox - it stays on the roadmap with this as the reference
implementation.
Engine, client and app suites green; seven outbox tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was a flat grid while iOS kept a recents row and grouped the set the way
people think about them - a gap found when a duplicate iOS picker nearly
shadowed the better one. Two platforms offering the same emoji through
different furniture is the drift nobody files a bug about and everybody
notices.
Mechanics mirror the iOS file: the last twelve picked, most recent first, in
ordinary SharedPreferences - which emoji somebody reaches for is not a secret
worth a vault entry, and the vault cannot be read before sign-in anyway. The
set and its grouping are byte-identical to iOS's, with a note in both files to
change them in the same sitting. Test tags are scoped by section, as on iOS,
because the same emoji legitimately appears in Recent and in its own group.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sticker to somebody signed out was marked "Undelivered - will send" and then
never entered the outbox, so the label was a lie for exactly one content kind.
iOS got this fix earlier via a wire field on its outbox entry; Android's queue
turned out not to need one - conversations.send transmits its argument as the
ratchet plaintext verbatim, and a sticker's wire IS its plaintext, so the
existing replay path delivers both kinds once the entry stores the encoded
envelope instead of prose.
OutboxTest gains the sticker version of the end-to-end promise: queued while
they are away, delivered AS A STICKER once a device exists (a real second
identity, not a fake bundle), and the bubble restated without turning into
text. Six outbox tests green; engine, client and app suites green.
⚠️ The same lie still stands for GROUP sends, text and sticker both: they
record PENDING when nobody was reachable and nothing queues them. That needs a
thread-aware outbox (per-member retry, a completion rule, restating the group
thread) - designed work, not a patch, and it is on the roadmap rather than
half-built here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The third of three: this README was rewritten upstream after the split was cut,
so the standalone repository still opened with 'Nothing here is an app yet'
above the finished app - the exact staleness the rewrite existed to fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The engine job failed on its first real run: PublishedVectorsTest and
AccountKdfTest search sibling checkouts for the canonical vector files, and the
standalone repository has no ../../engine and no flamenet-e2e clone beside it
on a runner. Both tests already honour an env override, so CI fetches both
files from flamenet-e2e at HEAD and points the tests at them.
Deliberately NOT a copied vector file in this repo: a copy drifts, and an
engine tested against its own copy has quietly stopped being tested against
the published ones. The job now fails if flamenet-e2e is unreachable, which is
the correct failure - an engine that cannot see the vectors must not report
green.
Verified by reproducing CI locally: standalone checkout, env vars only,
:engine:test passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every job failed with ci_quota_exceeded on first run. GitLab.com shared minutes
are spent and a public project does not get them free on this plan. The ovh-
runner already serving the iOS repository was not locked to it, so it now serves
all four projects.
⚠️ It is registered run_untagged=false, so a job WITHOUT the tag is never picked
up - it sits pending forever rather than failing, which is the more confusing of
the two outcomes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 The find: the secret sweep was failing closed on false positives, so the
mirror job refused to publish. flamenet-e2e has been stale since 2026-08-21 and
flamenet-relay since 08-25, and this is why. A gate that blocks every release on
nothing is a gate somebody switches off, and then it is not a gate.
Two exclusions, neither weakening the shape probes. Interpolated values -
`let token = "\(selector).\(secret)"` builds a string from variables and has no
literal to leak. And `sweep:allow`, an explicit annotation for genuine
exceptions, deliberately visible so an exception is written on the line it
applies to rather than hidden in the sweep script.
⚠️ It must be on the SAME line as the match. I put one on the line above first
and it silently did nothing - documented, because the next person will do the
same.
The test fixture that tripped it sixteen times is now one named constant, which
is better code regardless: one place to change instead of sixteen.
Pipelines for the three mirrored repos, each of which is a mirror and therefore
needs its own - a green pipeline upstream proves the code passes THERE, next to
siblings it does not ship with, and says nothing about whether what landed is
complete. Each has a `complete` job asserting the shape of the tree, because
what a mirror gets wrong is missing files, not failing logic: a test that cannot
import a module is a test that does not run, and a partial copy passes
everything.
And the mirror job whose absence let flamenet-messenger-android rot. It was a
hand-made extract carrying only :engine, from before :client, :app and :interop
existed; its settings.gradle.kts still declared one module while this tree
declared four. Its `complete` job now fails on exactly that drift.
Upstream also runs :client:test, which was never in CI - the module where the
silent message loss lived, whose regression test is worth nothing unless
something runs it. And the server is no longer copied into flamenet-e2e as
well: two public copies of one server means one of them is stale with no way to
tell which.
All four validate against GitLab's linter. Engine offline tests: 87, 32 and 124
passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last of the four privacy features Android was missing that iOS already had.
The sealed history file protects a phone that is off or locked; it does nothing
about the phone somebody just handed to a friend. iOS asked for Face ID; on
Android anyone holding an unlocked handset read every conversation.
Biometrics OR the device credential, deliberately - a phone with a PIN and no
fingerprint reader should still be able to use this, the same reason iOS asks
for deviceOwnerAuthentication rather than biometrics specifically. Offered only
when the device can actually authenticate: a switch that turns on and then
cannot show a prompt locks somebody out of their own messages with no way back.
Two traps worth naming. MainActivity had to become a FragmentActivity -
BiometricPrompt hosts a fragment, so on a plain ComponentActivity it silently
cannot show and the callback never fires, which looks like a broken lock rather
than a missing base class. And the gate wraps Root rather than drawing over it:
composed underneath, the message list still renders for a frame, and a frame is
all a screenshot or a shoulder needs.
The lock screen deliberately says nothing - no sender, no preview, no count. A
lock screen reporting "3 new messages from Dana" has given away the two facts
that usually matter most.
The setting states what it does NOT do. This stops someone who picks up your
unlocked phone; it adds no encryption, because the vault and ratchet keys are
already sealed by the keystore either way. Claiming more would be selling a
screen as if it were a key.
Android engine, client and app suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
⚠️ NOT BUILT. The machine has been at load 550-640 for three hours with 180
iOS-27 simulator processes on it; xcodebuild was killed rather than failing on
its merits. Committed so the work is not lost, and must be compiled before it
is trusted.
FLAG_SECURE on Android. Blocks screenshots and recording, but the half that
matters is the recents thumbnail: without it Android captures the open
conversation and WRITES IT TO DISK, so a thread existed as a plaintext bitmap
outside the ratchet, the sealed history file and the device-only key. iOS has
had the equivalent since app lock landed; Android had nothing.
iOS notification previews now default to OFF, and the sender is hidden too.
Correcting what I told the owner earlier: Android does NOT leak notification
content - it shows "New message" and never the sender or body. I misread the
count string for the message text. iOS was the leaky one, and worse than it
looked: content.title was set to the sender unconditionally, so turning
previews off hid what was said and still announced who said it. On a lock
screen that is often the more sensitive half, and the setting was labelled
"Show previews".
No incognito-keyboard flag, deliberately: Compose has no stable API for it, and
FLAG_SECURE already makes most keyboards including Gboard suppress
personalised learning. A second mechanism that might be a no-op is worse than
the note.
docs/PADDING.md designs envelope padding rather than implementing it. It is a
wire-format change on a live protocol; the group-decode mismatch in this
repository's history is what that looks like when it goes wrong. Includes the
three-stage rollout, why stripping cannot be inferred, and why the relay needs
no change at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android's Session.sendCallSignal called the send path and threw the result
away. That result is the same Sent every other send returns - how many devices
took it, which failed - so an offer that reached NOBODY was indistinguishable
from one that rang. The caller watched "Calling..." for a signal that had gone
nowhere, and neither device had anything to say so. The same shape as the
message-loss bug fixed earlier today, on the one path where the UI never shows
a delivery banner because a call is placed from inside a WebRTC callback.
Also instruments the drops. An incoming offer can be discarded in five places
in iOS's CallManager - already-ended, older than the dial timeout, busy, wrong
state, unknown id - and every one was a bare `return`. Working out which of the
five happened costs more than the logging does. Same treatment E2EInbox.dropped
already gives ingest, and for the same reason.
Not a fix for "Android calls iOS and nothing happens" - I have not reproduced
that. It rules out and instruments: the wire shapes match, iOS builds its
CallManager eagerly at sign-in so onCallSignal is always hooked, Android sets no
from_addr so the federated fork is not swallowing it, and every clock (mac,
host, relay, db) agrees within a second so the staleness check is not
misfiring. The next attempt will say where it stopped instead of nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three modes, stored in the vault, applied at post time.
🔴 The audience is enforced by who is SENT the bytes, not by a flag. A story is
sealed to each recipient's devices through the ratchet, so somebody outside the
list is never sent it - there is no copy on the relay for them to be denied.
Every mainstream product holds one server-side copy and decides who is shown
it; this cannot work that way even if somebody wanted it to, and that is the
feature rather than a limitation.
Which also answers "make sure stories are only visible to friends": they
already were, structurally. There is no public audience and there cannot be
one - a story has to be encrypted to somebody in particular.
⚠️ The saved list is intersected with the live contact list in every branch,
including "only chosen". An id left behind after that person was removed as a
contact must not resurrect them as a recipient.
The screen says what will actually happen - "your next story goes to 3
contacts" - resolved the same way, and refuses a post that would reach nobody
rather than silently succeeding at nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stickers send, arrive, file into the right thread - group messages under the
group, not scattered across one thread per member - and draw bare. No bubble:
the kit draws none, and a glyph inside a grey rounded rectangle reads as a
broken image. The timestamp and avatar stay, because that is the line saying
who and when.
Two controls, not one, because they are two different things. An emoji goes
INTO the draft and is indistinguishable from any other character once sent -
nothing on the receiving end needs to understand it. A sticker is its own
message kind that is never rendered as text. Behind one button they would imply
they behave the same way. Hence also: the emoji panel stays open on tap, since
picking three in a row should put three characters in the box, while a sticker
sends and closes because it IS the message.
The emoji set is a short curated list, said plainly in the code: a full picker
is search, skin tones, categories and recents - a real feature the system
keyboard already ships. This is what a thumb can reach.
docs/ROADMAP.md now holds the queue, including the security and privacy work.
Written down because this project has already lost a finished feature to a
parked branch. Two items there are real and current: Android spends a one-time
prekey on EVERY message via fetchKeys, silently costing initial forward secrecy
once the pool drains - iOS fixed this and Android has the unused listDevices
sitting next to it - and a passphrase IS the account, with no second factor and
no recovery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sticker is a content envelope carrying an id, never an image: sending the
picture would cost an upload and a download of bytes both ends already ship
with, on every sticker. This is about sixty bytes.
Recognised before the text path on both sides, because a sticker is renderable
but is NOT text - it draws as a glyph with no bubble, and falling through would
render it as an empty message. An unknown pack or id has to degrade to the
pack's fallback rather than break, which is what the envelope format exists for.
The pack is one JSON manifest read by both clients, so there is a single source
of truth for what exists. docs/STICKERS.md says plainly what the first cut is:
the glyphs are Unicode, because real sticker art is a design job, not a code
one. The protocol, picker, rendering, fallback and tests are real and finished;
swapping in artwork is a change to the manifest plus an image loader, not to
any of that. It is not a claim to have drawn a sticker set.
Emoji are deliberately NOT this. They go into the text of an ordinary message
and are indistinguishable from any other character once sent - no new kind, no
new field, nothing for the receiver to understand. That picker is a keyboard
convenience, not a protocol feature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The kit's bottom bar is four icons and one of them is a phone; its
"08 Calls-Listing" is a real screen. This app had a live call UI, a missed-call
notification from the relay, and no history at all - so "who rang me yesterday"
had no answer anywhere.
So the log had to exist first. It lives in the vault with everything else: who
somebody called and when is precisely the metadata this app exists to keep off
a server, and encrypting the words while leaving the call log in the clear
would be an odd place to stop. Bounded at 500, because the whole log is
re-sealed on every write.
Filed once, when a call ends. WebRTC walks DIALLING -> RINGING -> CONNECTING ->
ACTIVE on the way, and recording each transition would turn one call into five
rows; ENDED is usually followed by IDLE, hence the guard. Whether it ever
connected is captured BEFORE the manager is torn down - asking afterwards
always says no, which would file every answered call as missed.
The row is measured from item/call/White: 48dp avatar, 16pt name, a 12dp
direction icon before a 12pt line. Missed calls carry the kit's pink so the row
somebody is actually looking for is the one that stands out, and the tab badges
missed incoming calls only - an answered call is not news.
No video icon, though the kit's row has one at x=255: there is no video path in
either CallManager.
relativeTime is shared with the chat list rather than reimplemented - two
screens formatting times differently is drift nobody files a bug about and
everybody notices.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured out of "Light Mode - iOS/07 Messages-Details 1" rather than eyeballed:
the App Bar is 88 tall with back at x=24, title 16pt Medium over a 12pt line
with a dot beside it, and Call at x=279. The Message Box is 84 tall: plus at
x=16, an F3F3F3 pill from x=56, camera at x=287, more at x=335.
The composer is one row now. Voice recording was a chip hanging underneath it -
the last thing on the screen that did not belong to that row - and the mic is
an icon in the row, with the permission plumbing hoisted to where it is used.
Send appears only when there is something to send; at rest the row is the kit's.
iOS tab bar drops its labels, matching the kit's "Bottom Bar/Without Label" and
Android. Titles survive as accessibility names - dropping text from the screen
must not drop it from VoiceOver.
PresenceDot was still an olive, a mustard and a rust: the previous brand's
palette, left behind when the accent changed. Now teal, blue and pink from the
kit.
Two things the kit draws that this deliberately does not: a video-call button
and an emoji key. Neither CallManager has any video path at all, and there is
no emoji picker to open - the system keyboard already carries one. A mockup can
draw a feature; the app cannot draw one it does not have, and this codebase has
already shipped one dead call button once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked: how does the sender know to try again when the other person has no
registered device - especially since they are friends. They should not have to.
Marking it undelivered and stopping there moves the problem onto the person who
did nothing wrong: they would have to remember, notice their friend was back,
and retype it.
So the message goes into an outbox and is retried on every poll tick. Nothing
expires and nothing is discarded - a contact who reinstalls in a month gets it
in a month. Timing out would mean deleting something somebody wrote in order to
tidy up a label.
The queue has to live on the sender's phone, and that is forced by the
encryption rather than chosen: every envelope is sealed to a particular
device's keys, so somebody with no device has no keys to seal to, and the relay
could only queue plaintext. The consequence is that it goes out the next time
THIS app runs and polls, not the instant the recipient signs in - so the label
says "Undelivered - will send" rather than implying it is in flight.
Delivery is now a tri-state. PENDING is quiet and grey because it is the normal
thing that happens when a friend is signed out and there is nothing to do about
it; only FAILED is a warning, and the outbox never produces one.
OutboxTest covers it end to end, including delivery against a real second
identity rather than a hand-written bundle: queued while away, sent when they
register, and the bubble restated - a version that delivered the message but
left it reading "undelivered" would pass a test that only checked the queue.
Engine, client and app suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked: how do we prevent ghost messages. Fixing the one cause does not, so
this fixes the thing that makes any cause invisible.
A message could only be a ghost because a delivered bubble and an undelivered
one were the same picture. The code had one bit - record it or do not - and
both answers were wrong, and the app shipped one of each:
1:1 text recorded only on success, so a message sent while the other person
was signed out vanished out of the box with no bubble and no error. That is
why it took sending twice to get one to appear.
Group text recorded unconditionally, so it left a bubble that looked exactly
like a delivered one and had reached nobody.
Now every send records, carrying the outcome, and a bubble nobody received is
marked. Null stays null for received messages and for anything written before
the field existed - absence of a claim, not a claim of failure - so old
threads do not sprout warnings.
Correcting the previous commit message: it said the lost 1:1 message was
written into the sender's own thread. It was not; nothing was written at all.
The ghost bubble was the group path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported: a message sent from Android while the other end was signed out on
iOS was lost, and a group text vanished the same way.
Envelopes are addressed per device, and signing out deregisters the device.
sendPlaintext looped over an empty bundle list, made no request at all, and
returned Sent(delivered=[], failed={}) - which every caller reads as "it
went". The message was written into the sender's own thread and existed
nowhere else. Group sends call this once per member, so the same path lost
group text.
iOS never had this: transmit throws on an empty device list. The platforms
disagreed about a delivery guarantee for as long as both have existed, and
nothing caught it, because the tests that covered sending had both ends
signed in - and a happy-path test cannot tell a delivery from a no-op, since
both are silent.
Recorded in the failure map rather than thrown, so a group send still reaches
the members who ARE reachable. The banner now says the message was not queued
and has to be sent again, instead of "Delivered to 0 of 1 devices".
SendToNobodyTest pins it; verified failing with the guard removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported: profile pictures do not appear in the chat boxes. They did not.
iOS had an avatar slot in MessageBubble that hand-rolled only the initials
fallback - it never had the user id or the inbox, so it could not reach the
picture that AvatarView already downloads and decrypts everywhere else.
Android reserved the kit's 40dp received inset for an avatar and drew nothing
in it at all.
Both now pass the author and the picture through. Sent bubbles get none: you
know who you are, and a picture on both sides halves the width a thread has.
Also gives the iOS list row a real last-message preview and a timestamp,
matching the Android row - it was showing a screen name and the word 'Online',
a second copy of the dot already on the avatar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The kit's row is four things - avatar, name, last message, time - plus an
unread count on the right. Ours was a name and the word 'online', which is
most of why a two-contact screen read as empty.
The preview comes from the local vault, not the relay: the relay sends none
and asking it to would leak which conversations are active, while the history
is already decrypted on this device. Presence moves to the dot on the avatar,
where the kit draws it and where it costs no line.
Section headings now appear only when both contacts and groups are on screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every icon in the Android app was a text character or an emoji — +, →, ‹, ⋯,
☎️, 🎤, ⊞ — and material-icons-extended had been a dependency the whole time
with zero Icon() calls against it. Reported three times as bare/text-links;
each time I adjusted spacing and type, because the structure genuinely did
match the kit and the code read correctly.
Adds KitIcons.kt as the vocabulary and migrates the tab bar, bar actions, row
overflow, composer, call controls, contact rail and alert glyphs. The tab bar
loses its text labels to match the kit's 'Bottom Bar/Without Label' artboard
and keeps them as accessibility names.
Also fixes the iOS composer sitting under the tab pill: HidesPillTabBar was a
PreferenceKey, and preferences do not travel out of a NavigationStack
destination, so the chat screen's request never reached the bar watching for
it. Now an observable with balanced retain/release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>