Every other media path here is a sealed blob — the relay stores ciphertext and
nobody else is involved. A live stream is not that: it is continuous and hosted
elsewhere, so there is nothing to seal, and watching one means this device
connects to that host, which learns the viewer's IP address and that they
watched.
🔴 **§6.12's answer does not transfer.** A link preview works because the sender
fetches it once and puts the result inside the ciphertext. A stream cannot be
fetched once. So this is disclosure rather than prevention.
The URL rides inside the ciphertext, so the relay learns nothing about what is
watched or by whom. Playback is an explicit tap with the host named and what it
learns spelled out — a card that started playing on its own would make that
disclosure after the fact, which is not a disclosure.
⚠️ The attachment is the POSTER, not the stream. Keeping a real blob means the
strip, the unseen rings and the expiry sweep all work unchanged, and the viewer
sees a still before deciding whether to connect.
⚠️ The host is derived from the URL, never the caption — a story captioned
"youtube.com" pointing elsewhere is the phishing shape link previews already
guard against. Non-http(s) URLs and ones with no host are refused: a `file:` URL
arriving from another person is not something to hand a media player, and a
destination nobody can name is one nobody can consent to.
The wording lives in the engine rather than the UI so it can be asserted.
engine 177, client 96, app 46.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A high-priority notification is a banner, not a ringing phone. On a locked
device the old one showed a line of text, and the only way to act was to unlock,
find the app and open it — by which time the caller has given up. A call is a
live event with seconds on the clock.
Now a full-screen intent with `CallStyle`, so the lock screen shows Answer and
Decline. ⚠️ `CATEGORY_CALL` and `CallStyle` are not decoration: Android 14
grants USE_FULL_SCREEN_INTENT at install only to apps that ring like this, and
without them the permission is refused and the call silently degrades to the
banner it used to be.
⚠️ Decline is a broadcast, not an activity. The point of answering from a lock
screen is that the phone stays locked for the choice the user did NOT make.
🔴 **The notification is `ongoing`**, so it cannot be swiped away by accident
mid-ring — which also means it never dismisses itself, and every path that ends
a call has to cancel it. Four of them: declined, answered, the caller hung up,
and nobody picked up. The last has no event behind it, so the service checks for
a parked offer past the dial timeout; without it the lock screen keeps offering
to answer a call that is long dead.
🔴 `isStale()` asks without consuming. `take()` is the handover and can only
happen once, so a staleness check built on it would swallow a live offer and
drop a call that was still ringing — the exact opposite of the bug it exists to
prevent. There is a test for that specifically.
Answering hands off through the SAME parked offer the app already uses on
launch. This session consumed the envelope, so the SDP in hand is the only copy
— a second answer path would be a second way to lose a call.
engine 168, client 96, app 46.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔴 **The obvious implementation is the one that must not be built.** Having each
recipient fetch the URL to draw a card tells the site the reader's IP address,
that they received a link, and roughly when they opened it — once per member on
a group message. A messenger that hides the message and then has every reader
announce its contents to a third party has given away what it was protecting.
So the SENDER fetches, and the preview travels inside the ciphertext. The
receiver renders from those bytes and contacts nobody.
⚠️ Sender-side is a smaller leak, not no leak: the site learns somebody is about
to share that URL, from this address. The setting exists for that, and the copy
says who learns what rather than implying it is free.
🔴 **The host is shown separately and derived from the URL**, because title and
description come from a page the sender did not write — a card claiming one
destination while the link points at another is a phishing primitive. Both
fields are bounded on the way IN as well as line-limited at render, so the cap
does not depend on a future renderer remembering one.
⚠️ `Content.text` still returns the raw body byte-for-byte when there is no
preview, so an ordinary message is unchanged on the wire and older clients see
exactly what they saw before.
⚠️ The fetch is short-timeout and best-effort, and runs on the IO context. A
preview is a decoration and must never be able to delay a send — every failure
path ends as a message with a plain URL in it.
engine 168, client 96, app 40.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay could hold a scheduled message and deliver it on time even with the
phone off. It would then know that a message exists, who it is for, and exactly
when it was meant to land — a richer piece of metadata than anything else this
design gives it, in exchange for a convenience. So it waits here, and the banner
states the cost rather than hiding it: "when the app next runs".
🔴 **"Not due yet" means KEEP, never fail.** The outbox loop is also the retry
queue, so a scheduled entry falling through the failure path would be dropped
outright and the message would simply never exist. That predicate is the whole
of the risk, so it has tests of its own — including the boundary, where an
off-by-one delays delivery by a cycle.
⚠️ `not_before` is absent on entries queued before scheduling existed, and they
send immediately rather than being held for a date they have no value for.
⚠️ Scheduling clears the composer, like an ordinary send. Text left sitting
there afterwards is how somebody sends the same message twice.
Also adds the real `org.json` to the app module's unit tests. The Android
framework's version is STUBBED there — every method returns null — so a test
that builds a JSONObject fails with a NullPointerException that says nothing
about why. The engine and client modules already pull it in for exactly this.
engine 163, client 96, app 40.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two halves of the same problem — a conversation you are not ready for, and one
you are waiting on — and both are local to this device on purpose. "I am
ignoring this person until Tuesday" and "remind me if they have not answered"
are statements about a relationship, and the relay has no business holding
either. The honest cost is that neither follows you to a new phone; the
alternative is handing a server the list of people you are avoiding.
🔴 **A reply arriving cancels the reminder, and it happens in `Session.record`**
— the one place every inbound message passes through. Hanging it off a screen or
a poll handler means the one path that forgot to call it produces a reminder
that fires after they answered, and that is the single thing that would teach
people to ignore reminders.
The service fires due reminders from its existing loop, deliberately AFTER
`receive()`: a reply that has just landed cancels its own reminder before it can
go off. Checking first would nudge about a conversation answered a moment ago.
Cleared as it fires, too — a reminder is one-shot, and leaving the row would
re-notify every half second for ever.
⚠️ Snoozes are recomputed, never cached as a flag. A snooze expires by the clock
passing and nothing runs at that moment, so a stored boolean leaves a
conversation muted past its own deadline.
⚠️ Same privacy rule as message notifications: a count, never a name. Who you
are waiting on is as sensitive as what they said.
engine 163, client 96, app 35.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The picker read the file and handed the ORIGINAL bytes to `sendImage` — no
downscale, no re-encode, nothing. A modern phone camera produces 12-50 MP and
often 5-15 MB of JPEG, so a photo either exceeded the relay's blob cap and
simply failed to send, or fit and made the recipient download tens of megabytes
nobody asked for. iOS has downscaled since it shipped, so the two platforms
disagreed about what sending a photo even means.
Adds `ImagePrep`, matching the iOS one: 2048px at quality 80 by default, 4096 at
95 with the new "Send full quality" switch, stepping down through size and
quality until the bytes are under budget. The budget sits under the server's cap
because the blob is encrypted before upload and the AEAD tag rides on top —
sizing to the exact limit produces a file that passes locally and is refused
remotely.
⚠️ Bounds are read with `inJustDecodeBounds` and the decode uses `inSampleSize`,
so the full-size bitmap never exists. Decoding a 50MP JPEG outright is ~200 MB
of bitmap, which is how a photo picker gets OOM-killed.
⚠️ "Full quality" is not "uncompressed", and the copy says so. A setting that
silently failed on a large photo would be a worse answer than the compression it
replaced.
engine 155, client 96, app 35.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`"Registered ${'$'}{fmt.format(instant)}"` — `${'$'}` emits a LITERAL dollar
sign, so the template was never evaluated and every device row read
Registered ${fmt.format(instant)}
Kotlin cannot warn about this: escaping a dollar is a legitimate thing to want,
just never here. And nothing could test it, because the string was assembled
inline inside a `Text(...)`.
Lifted into `registeredLine` so it can be asserted, with a test that fails if
template source ever reaches the screen again. That is the real fix — the
one-character correction would have left the next one just as invisible.
engine 155, client 96, app 35.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The edge geo-gate serves US, PE and BO for the relay and accounts hosts, and
answers 451 for anywhere else — before the request reaches the relay. Nothing in
either client knew what 451 meant, so it fell through to the generic branch.
⚠️ **The download site is deliberately not gated**, which makes this worse than
a bad error string. Someone outside those countries can read the page, install
the app, choose a password, tap sign up, and be told "relay returned 451".
Nothing in that mentions their country, and it is not a failure they can retry
past, so the only sensible reading is that the app is broken.
451 is the right status — this is a legal/policy refusal, not a 403 — so the
only thing missing was somebody translating it.
client 96.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relay has had per-conversation lifetimes since the port and **no client
could set one** — its own comment says so. For E2E conversations the server
timer would not have helped anyway: it expires rows in the legacy plaintext
table, not the envelopes these apps actually exchange. So this is client-side,
which is also the only place it can be honest.
**Symmetric.** Either party sets the timer; the other is told inside the ratchet
and adopts it. A timer only the setter honoured would be worse than none — they
would believe their words were gone from a phone still holding them.
**Inside the ratchet**, so the relay never learns that a conversation is timed,
let alone for how long. It rides as ordinary control traffic carrying the `ctl`
flag from §6.10, which is what makes it safe to add at all: without that, this
envelope would surface in every older install as an "[unsupported attachment]"
bubble.
**Expiry runs on read**, in `Vault.history`. A sweep on a timer leaves expired
messages on disk between runs, and a disappearing message still sitting in the
vault is a claim rather than a feature. Reading is the one path every caller
shares, so nothing can see — or keep — an expired message.
⚠️ **Ages come from this device's own timestamp**, not one the peer supplies.
"From send time" in practice means when this device sent or received it, which
differs by seconds and cannot be manipulated: a peer who chose the clock could
backdate a message so it vanished from your phone immediately, or forward-date
one so it never expired.
⚠️ **A message with no timestamp is kept.** Those predate the field, and an
unknown age is not evidence of an old one — deleting somebody's history on a
guess cannot be undone.
The panel sits on the thread rather than in a menu, because "fewer taps to turn
it on" and "clearer per-conversation state" are the same problem: a timer nobody
can see is one nobody trusts. Presets only — arbitrary durations are the ones
nobody can reason about later.
engine 155, client 94, app 32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`isRenderable` asked `kind !in Kind.CONTROL` — an allowlist of the four control
kinds that existed when it was written. A control kind from a newer peer is in
no set here, so it fell through and rendered as an "[unsupported attachment]"
bubble: a message the user can neither read nor act on, for something addressed
to a layer rather than a person.
Every control kind added from here would have shipped that to every older
install. `ctl: 1` is the flag that stops it; `isControl` trusts the flag as well
as the list, since the four that predate it do not carry it and never will.
⚠️ `needsNewerClient` is deliberately NOT part of `isRenderable`. Unknown
*content* already parses to a placeholder and is shown, which is right — the
first cut of this change suppressed it and an existing test caught that
immediately. Dropping a real message silently is the one failure a messenger
cannot make, so the flag decides rather than the unknown-ness.
engine 144, client 90, app 32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`setPresence` did `it.copy(presence = status)` on success — the value it had
requested. The relay answered 200 for statuses it did not recognise and stored
nothing, and `busy` and `offline` were both in that set, so tapping either lit
the chip and changed nothing. Someone who chose "offline" stayed visible to
every contact with no way to find out.
The relay is fixed too, but a client that reports its own request back to itself
is wrong regardless of how the server behaves — so `setStatus` now returns what
the relay says the status is, and the screen renders that.
⚠️ The reply is not always the word sent: `offline` is stored as `invisible`,
the same choice under two names, so it is mapped back to this screen's
vocabulary or no chip would appear selected right after a successful change.
`PRESENCE_CHOICES` moves to the companion object so the chips and the test read
one list. It is a contract with another repository that no compiler checks, and
the test says so: every entry must be a value the relay's `ChosenStatus`
accepts.
engine 141, client 90, app 32 — all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`Trust.headPayload` built the gossip envelope and nothing called it. The
receiving half was fully wired — `consumeControl` handles `kt_head`, compares
against our pin, requests a consistency proof and sets `forkDetected` — so
every part of transparency reported working while the mechanism was inert.
That is the failure mode that looks like success. §19.4 detects a forked relay
only when two clients COMPARE heads, and a client that never offers one is a
client no fork can be detected against. Each side independently confirmed the
relay was consistent *with itself*, which is exactly what a relay serving two
self-consistent branches wants. Only the JS reference sent it, so gossip
worked web→app and in no other direction; iOS had the identical dead function
and is fixed in the same pass.
Sends the head once per contact per session as a second prelude alongside the
delivery key, in memory rather than in the vault to match the JS reference: a
device that pins a new head after a restart has to be able to mention it.
Marked before the send, like the delivery-key prelude and for the reason
documented there — a repeat costs one envelope, a crash between sending and
marking repeats it for ever.
GossipHeadSentTest drives the real send path against MockWebServer and asserts
the head goes, that it rides the ratchet rather than the envelope, and that it
is not attached to every message. Verified it fails when the wiring is removed
— three of its four tests go red — because a test written after the fact
otherwise proves only that it compiles.
⚠️ The "not posted in the clear" assertion initially passed against an EMPTY
list: the request-path filter was wrong, and a loop over nothing satisfies
every claim inside it. It now asserts the count first.
engine 141, client 90, app 28 — all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The README said the engine is held to `engine/test/vectors/protocol-v1.json`,
a path from the pre-split subtree layout that does not exist here. The vectors
live in flamenet-e2e and are read from there rather than copied, because a
copied vector file drifts and a drifting vector file stops being a check.
PublishedVectorsTest already searches for it and fails loudly when it cannot
find it; the README now says so, and names FLAMENET_VECTORS.
CONTRIBUTING records what a JVM test cannot tell you: Conscrypt and the JVM's
JCA are different providers, so :engine:test passing proves nothing about a
handset, which is what interop/ is for. Also the exception escaping a WebRTC
callback that SIGABRTs rather than surfacing, the missing permission that
presented as a dead button, and `*/` inside a KDoc reporting an unclosed
comment hundreds of lines from its cause.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>