Every comment is reduced to its first sentence plus @param, @return
and @throws tags. Comment lines: 20040 to 8526. ktlint 1.8 applied
with its default (official Kotlin) style across all modules; 262 files
reformatted. The 264 remaining ktlint findings are naming and filename
rules, which are code changes, and are left alone.
Comments and formatting only. 1208 tests pass, counted from the XML.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The skill's detector scored the README, PARITY.md and the extracted
comment prose. Fixed what it flagged: hollow intensifiers, "in order
to", "additionally", "the whole point", bold sentences in docs, and
bold that had wrapped across comment lines. Comment prose scores a
maximum of 4 out of 100 across the repo.
One sweep rule mangled four comments ("which is the entire point of X"
lost its clause); repaired, and the rule now keeps the clause.
Comments and strings only. 1208 tests pass, counted from the XML.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Emoji markers, markdown bold inside code comments, and em-dashes are
gone from source comments, string literals, scripts, Gradle files and
the markdown docs. Counted before and after:
emoji markers 2680 -> 1 (the emoji picker's own list, which is the feature)
em-dashes 2490 -> 9 (table placeholders and fenced code)
Two warning glyphs in user-facing strings in ChatScreens.kt are removed
as well; the sentences say the same thing without them. Two backtick
test names carried an em-dash and now carry a comma.
Nothing else changed. 1208 tests pass, counted from the result XML.
The three obfs4 interop tests are opted out with OBFS4_INTEROP_OPTOUT=1
for this run, as their own message instructs when no bridge is set up.
CONTRIBUTING gets a four-line rule so it does not grow back.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two reports from a real phone.
**A photo auto-rotated.** BitmapFactory does not apply EXIF orientation,
and every image this app sends is re-encoded to strip EXIF — which is
right, because that block carries the GPS coordinates the shot was taken
at. The same block carries the tag saying which way is up, and a phone
camera rarely writes upright pixels: it writes landscape pixels plus
"turn this 90°". Decoding ignored that, the re-encode dropped the tag,
and the only thing that knew which way was up was gone.
The tag is now read and applied to the pixels before it is discarded, on
all three paths: preparing to send, scrubbing metadata, and rendering an
image received from a peer. The mirrored tags are handled too — a front
camera writes them, and a selfie coming back flipped is the same bug
facing sideways.
🔴 iOS is NOT affected, which is why this survived: UIImage(data:)
applies orientation as it decodes, so the identical strip-and-re-encode
is safe there. Same design, opposite platform default — and the reason
"works fine on iOS" said nothing about Android.
**The keyboard covered what was being typed into.** The manifest has
said windowSoftInputMode="adjustResize" all along, and that stopped
meaning anything at targetSdk 35: Android 15 forces apps targeting it
edge-to-edge, and an edge-to-edge window does not resize for the IME. So
the line has been a no-op on every device this ships to (minSdk 33). The
root now consumes the IME inset with imePadding() — at the root rather
than per screen, because every screen with a text field has this and one
added later would too.
5 new tests, including one that fails if the manifest line ever becomes
the only thing relied on again. 1213 tests green, counted from the
result XML rather than from BUILD SUCCESSFUL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Android half, matching the iOS change.
**The give-up rule (ios#22).** `flushOutbox` used
`runCatching { … }.getOrNull()`, which threw the exception away — so the loop
could not tell a contact who is merely offline from one whose identity keys
changed, and retried both for ever while the bubble went on promising
"will send". The failure is caught now and classified: a refusal from the
trust layer stops, marks the bubble FAILED and records why. Everything else
still retries for ever, deliberately — a contact who is offline or has no
device yet gets the message whenever they return.
⚠️ Classified on the exception TYPE, never on message text. A string match
would break the first time somebody reworded an error, and would break
silently: the queue would simply go back to retrying for ever.
**The reading (ios#21, #23).** `DeliveryDiagnostics` counts envelopes seen,
drops by reason, session repairs and the last send failure, per conversation.
Three or more repairs against one peer is flagged — a client that repeatedly
repairs is a symptom, not a cure.
🔴 **Shape only.** Refusal messages name what was wrong, which sometimes
means naming a device id: "device ce0c0fb4 has published keys, but none
match". Those are normalised before being counted, so the reading is safe to
screenshot and the map cannot grow a row per device id. ⚠️ Short hex ids like
`9f0e` slip past the obvious rules; the test covers them, and covers that
"base64" survives unmangled.
The new outbox test was checked by disabling the give-up rule: it fails
without it.
581 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the iOS revert. Unpicks the nine commits that took Android from the
site-derived palette to purple-into-black dark-only: the purple ground, the
darkening passes, the single dark theme, the three-stop gradient, white text
boxes and white icons.
Two reconciliations, both because the surrounding work was worth keeping:
* `MainActivity`'s root `Surface` goes back to the plain one. The
transparent-Surface trap it documented -- Material deriving
`contentColor` from `contentColorFor(Transparent)`, getting Unspecified,
and falling back to BLACK text -- only existed because the Surface was
transparent to let a gradient through. No gradient, no trap.
* `CallScreen` painted `groundBrush()` to avoid stamping a flat rectangle
over that gradient. Back to `colorScheme.background`, since there is no
longer a gradient to clash with.
`PaletteMatchesIOSTest` goes with the theme it was written to hold in place.
574 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0.11 was built on 2026-09-07 and never published, so the public jumped
0.10 -> 0.12 and skipped a number. This closes that gap in the NAME, which
is the part a person reads.
The code does not go back to 11. Android refuses an APK whose versionCode
is lower than the installed one, and 12 is already published -- so code 11
would be uninstallable for anyone who took it, with no way out but an
uninstall that wipes their vault. The code is ordering; the name is the
label. They are allowed to differ, and a comment says why so it does not
get "corrected" later.
Verified: 0.11 (code 13) installs over 0.12 (code 12) with no downgrade
refusal, session intact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pair of translucent `primary` circles stood in for the kit's animation.
Against the gradient they read as banding -- two hard edges around the one
thing on screen -- which is what "the box around the center elements" was.
The avatar carries the centre on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The send path fetches full bundles only when a device needs a session
established, so it does not drain one-time prekeys on every message. The
contact's address rides with those bundles -- so every later send had no
address, computed the `local:` key-log subject instead of `fnid:`, matched
no leaf, and refused its own send. flamenet-relay!82 puts the address on
the directory; this reads it.
Two bugs the wiring exposed:
* `rememberFnid` silently overwrote a DIFFERENT address, the one thing
its own doc comment says it must never do. An fnid is derived from the
account key, so a new address is a different account, not a new device.
It went unnoticed while only the once-per-conversation bundle path fed
it; the directory feeds it every send. It marks CHANGED now.
* `guardIdentity` read the pinned address and passed it back into the
comparison against itself, so "a different address is a different
person" could never fire on a send. It compares the served one now.
And the copy: only `IdentityChangedException` was named, so
`KeyNotLoggedException` and `KeyLogForkException` reached the screen as
"Something went wrong reaching that server." -- sending a person to check
their signal for a decision this phone had made.
Also: the call screen painted a flat `colorScheme.background` rectangle
over the app's gradient, which read as a blue box around the call area.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The receiving half already worked. `Content.Reference` was in use for
reactions, an inbound reply was recognised and rendered, and `Session.Line`
already had an `about` field to hold one. What was missing was any way to START
one: no Reply in the long-press menu, no composer state, and `Session.send` had
nowhere to put a reference.
So this is narrow, as the ticket said: `Session.send(replyTo:)` passes the
reference to `Content.text(about =)` and records it locally, a Reply item sits
above Forward in the menu, and a dismissible strip above the composer says who
is being answered.
🔴 **The preview travels with the reply, and dropping it to save bytes is not a
free optimisation.** A recipient who joined the thread later or cleared their
history has never seen the message being answered — "> [missing]" is worse than
showing what was said. A test says so rather than a comment.
⚠️ **Visible before it is sent.** A reply that only appears in the sent message
is a mode nobody can see they are in, and the commonest way to answer the wrong
message is not to know you were answering one.
⚠️ **1:1 only, and the menu item is ABSENT rather than disabled in a group.**
The group send path does not carry a reply yet; accepting one there and dropping
it would be a half-feature that looks whole. The same reasoning the file already
applies to Edit and Pin on messages written before ids existed.
Three wire tests. One of them pins that a message which is NOT a reply still
goes out as a bare body — adding this must not have turned every plain message
into an envelope an older peer reads differently.
⚠️ Not included, and its own piece: tapping the quote to jump to the message it
names. That needs a scroll-to-id in the transcript list.
158 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches ios!146, format list included.
A photo sent through the picker had its EXIF removed — `ImagePrep` decodes to a
bitmap and re-encodes, so the output is pixels. The same photo saved into Files
and sent from the vault kept it: GPS, capture time, device serial. Somebody who
had learned "Flamenet strips location from my photos" had learned something true
of one button and false of the one beside it.
🔴 **Both doors, not one.** `sendFile` and `sendGroupFile` sit two functions
apart and had the identical gap. A leak fixed on one of two identical doors is
a leak.
🔴 **Sniffed by magic bytes, not by name.** The vault stores no MIME type, and a
file name is a guess in both directions.
⚠️ **Only images.** "Send this exact file" is what a file send means; a
scrubber that mangles somebody's PDF is worse than the leak it was fixing.
Undecodable bytes are returned as they came, for the same reason.
Five JVM tests, covering the DECISION rather than the pixels — `BitmapFactory`
needs a device, so the re-encode belongs to the instrumented suite, and iOS
covers the round trip with a real GPS-tagged JPEG. The decision is where the bug
was: the old code never asked the question at all.
⚠️ Two of the tests exist because a sloppier sniff passes the obvious ones: a
WAV file shares RIFF with WebP, and an MP4 shares the `ftyp` box with HEIC.
Falsified by matching only the RIFF header — "a WAV was mistaken for an image".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 The accent was being used the wrong way round. It is a mid purple chosen to
be readable as a FILL with white on top of it; as the INK for a glyph on a dark
ground it is a light purple mark on a dark blue field, which reads as a
disabled control rather than a button.
`Palette.OnDark` is white. Three sets of avatar initials and fourteen
accent-tinted icons.
⚠️ The selected tab keeps the accent. Its tint reads
`if (selected) MaterialTheme.colorScheme.primary else Palette.SecondaryText` —
a conditional, so the sweep did not catch it, and that is the right outcome:
the selected tab is the one place the accent as ink is doing a job nothing else
does.
⚠️ The accent still fills: primary buttons, badges, the pill's selected state.
Ink versus fill, not "purple is gone".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An input is the one surface a person types into and reads back character by
character, and a dark field on a dark ground gave it the least contrast on the
screen at the moment it mattered most. Typed text reads 18.3:1 on white; the
placeholder 4.6:1, because a placeholder is still text.
⚠️ **Its own colour, not `SurfaceAlt`.** That fill is shared with incoming
bubbles and the tab pill, which stay dark — a white message bubble on this
ground would read as somebody else's app.
🔴 **Every colour a field draws has to be named, not just the container.**
Material derives label, placeholder, cursor and outline from the colour scheme,
which is the DARK scheme here: setting only `containerColor` leaves a
light-on-dark placeholder on a white box, and it simply disappears. iOS had the
same failure in a different dialect — SwiftUI drew the placeholder and caret for
a dark background until the field was given `.environment(\.colorScheme, .light)`.
Two frameworks, one mistake: a white island in a dark app has to be told it is
an island.
One `fieldColors()` in `Theme.kt`, applied to all 34 `OutlinedTextField` call
sites. Mechanical and wide rather than a new wrapper composable, because a
wrapper would have to be adopted one screen at a time and the ones that were
missed would be exactly the ones nobody looks at.
⚠️ The message composer is a `BasicTextField` and stays dark. It is a pill in
the chat, not a form field, and turning it white would put a white bar across
the bottom of every conversation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#3A1E9E purple, #0B3080 blue, #070A1A black, top down. Less purple than the
two-stop version, because the purple is a third of the sweep rather than half.
🔴 One card colour against three stops has to lift off all three. #0E1745
looked right against the purple and the blue and measured 1.15:1 against the
black end — a card that disappears at the bottom of a long screen. #141C52
lifts 1.41 / 1.33 / 1.23.
The palette test checks the middle stop too. A three-stop gradient checked only
at its ends is two thirds measured, and the middle is where most of a screen
actually sits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#3A0CA3 to #062C8C, saturation 0.93/0.96 where the old ground was 0.72/0.83.
🔴 Cards go DARKER than the ground, not lighter. Against a vivid ground a
raised card has to be lighter than a saturated violet, and secondary text then
falls below AA on it — every candidate measured did. Recessed panels invert it:
the gradient is the bright thing, content sits in dark wells.
card #190B4E (1.47:1 vs ground) · field #2A1875 · secondary #B4BAD4
avatar #31207F · danger #FF819F (was #FF6B92, 4.45:1 on the ground)
⚠️ The section band is a gap now, not a fill. It was 16pt of `surfaceVariant`,
which on the light theme was a barely-there grey seam — on the dark theme it
became a light purple slab drawing more attention than the sections it was
dividing. The panels either side of it are the separator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bugs from testing on the emulator, one of them mine from this morning.
## "Erase and start fresh" erased nothing
🔴 The `VaultUnreadable` screen — shown when the keys on this phone cannot be
opened — offered exactly one action, and it called `model::signOut`. `signOut`
defaults to `erasingDevice = false`: the door that deliberately KEEPS the device
so coming back is silent. So it set the phase to SignedOut, left the unreadable
vault exactly where it was, and the next `restore()` opened it, failed again,
and came straight back to the same screen.
⚠️ It looked like it worked, because the screen changed. That is the whole
problem with a recovery action that does not recover: the feedback IS the claim,
and the person only finds out when they try to sign in and land back where they
started. Which is what happened, twice, to the person testing it.
`discardUnreadableVault()` destroys the vault, opens a fresh one, and lands on
sign-in with a sentence saying what happened and that contacts will see the
security keys change. Deliberately NOT `signOut(erasingDevice = true)`, which
would also have worked: that path first tries to revoke the device on the relay
using a session that by definition does not exist here, and its comments are
about handing a phone on. The keys are already lost; the only question is
whether the app can get past them.
Three tests, in the same source-scan shape as `DestructiveLabelsTest`. Falsified:
putting `model::signOut` back fails with the reason spelled out.
## Black text on the dark ground
🔴 `Surface(color = Color.Transparent)` with no `contentColor`. Material derives
it with `contentColorFor(color)`, which has no answer for Transparent and
returns Unspecified — so `LocalContentColor` falls back to its own default,
which is BLACK. Every `Text` that does not name a colour of its own drew black
on the dark ground.
⚠️ It read as "some of the text is hard to read" rather than as a broken theme,
because everything that DOES set a colour was fine and only the inheriting ones
went dark. That is what made it easy to ship: the screens I looked at longest
were the ones that name their colours.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same values, same commit, as the file at the top of both palettes has always
asked for.
ground #241650 purple -> #0B2140 blue, top down
card #302F5C lifts off the ground at 1.29:1
field/bubble #3B3A6E lifts off a card at 1.20:1
ink #EDEFF9 · secondary #A8AEC9 · links #B99BFF
🔴 `onBackground`/`onSurface` are `Ink`, not `Mist`. The dark scheme used to set
them to `Mist`, which was the near-white page ground; `Mist` is the DARK ground
now, so leaving that alone would have been dark text on a dark background. That
is the exact shape of a swap that survives a rename, and it would have looked
like the app failing to draw rather than like a colour being wrong.
🔴 `MessengerTheme` no longer branches. There was a light scheme, a dark scheme,
and a stored preference choosing between them; with one palette the light branch
would draw light-mode Material chrome under dark artwork. `DisplaySettings.appearance`
is left in place so an existing stored value is not a crash — nothing reads it —
and the picker is gone from the Display section, on both platforms.
⚠️ The palette test caught one on the way in: secondary text on the avatar fill
was 4.48:1, two hundredths under. #3E3C7A became #3B3974.
The test now checks every surface text is read on — ground, card, field, avatar
— and that cards and fields visibly lift off what is behind them. A gradient
that passes and a bubble that does not is still an unreadable app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#C0A4EC to #5A8BCC, dark #170D2E to #050F1E. Same values, same commit.
🔴 Close to the floor for a light theme. One more step (bottom stop #477BC2)
puts ink itself at 4.65:1 and leaves secondary text unable to clear 4.5:1 even
at pure near-black. Below that, dark text on this ground fails and the answer is
a theme flip, not a darker shade.
⚠️ SecondaryText is now #1B1822, very close to Ink at #090710 — contrast is
satisfied and hierarchy is not. The palette test measures ratios; it cannot see
that two text colours have converged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches ios!144, value for value: #D6C2F4 to #82ABE0 top down, and
#1C1036 to #071426 in dark mode.
⚠️ The blue end is not decoration. The mark is a blue globe with an orange
flame, and a purple-only app left the icon as the odd one out — the one thing on
screen from a different palette. Shading into blue puts the icon's own colour on
the page instead of beside it.
🔴 SecondaryText moves again, for the third step in a row: #413A4D read 4.6:1 on
the new bottom stop, inside the margin rather than clear of it. #332E3D reads
5.5:1. The bottom stop always binds and it is where the explanatory copy sits.
The palette test checks all of it — both ends of both gradients, and that the
two platforms still agree. 4 tests, 0 failures, read from the results XML with
its age printed: an earlier run of this suite reported two failures that were
stale output from a build that had not recompiled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches ios!144's second pass, value for value.
**Darker.** #E9DDFB → #C6AFF2, a clear step down from the whisper it was. 🔴
That forced a second change: secondary text at #505C69 read 5.0:1 on the old
bottom stop and only 3.5:1 on the new one, below AA, and most of the
explanatory copy is set in it. #413A4D now — 8.4:1 top, 5.6:1 bottom.
🔴 **And dark mode never had a secondary text colour at all.** It used the light
one, which measures **2.41:1** on the dark ground. iOS has had a light/dark pair
since it was written; Android used one value for both and nothing noticed —
because a dim grey on a dark background looks deliberate. Found by measuring
while changing the palette, not by looking. #AFA4C9 now: 7.0:1 on the lightest
dark stop, 8.5:1 on the darkest. The palette test covers the dark grounds too,
so it cannot come back.
**The mark replaces the 'F' box** on the sign-in screen, which is also the
sign-up screen here — `registering` is a flag on one composable, so both get it.
**The launcher icon was cropped.** The adaptive foreground had the mark at 72%
of the canvas, and the guaranteed-visible area is a 66dp CIRCLE: a mark that is
widest at its vertical middle gets clipped at exactly the circle's widest point.
59% now, and it is measured rather than eyeballed — compositing the foreground
under both a circle and a squircle mask clips **0 artwork pixels**.
⚠️ `R` needed an explicit import: the namespace is `io.flamenet.messenger` and
this code is in `io.flamenet.messenger.app`, so the generated class is not in
scope by package.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
iOS moved to a purple gradient in ios!144. This is the other half, in the same
values, plus the thing that has been missing since both files were written.
🔴 **`Theme.swift` has always opened with "one palette, two apps — change a
value here and change it there in the same commit", and nothing enforced it.**
Its own comment records what happened last time: the file claimed the two were
matched while iOS was indigo and Android was ember. A promise in a comment is a
promise nobody keeps, because nothing goes red when it is broken.
`PaletteMatchesIOSTest` reads `../flamenetmessenger/Sources/Theme/Theme.swift`
and compares the hex values for the accent, both gradient stops, and the deep
accent. ⚠️ It SKIPS when the iOS checkout is not beside this one — a red test
on a machine that only has Android would train people to ignore it.
It also measures contrast, because "still readable" is the actual requirement:
- white on the button gradient's LIGHT end must clear 4.5:1 (it is 5.50:1)
- ink and secondary text must clear 4.5:1 on BOTH ends of the page gradient
(18.6/14.8 and 8.7/6.9)
Secondary text is the one that fails first — most of the explanatory copy on
these screens is set in it.
Falsified both ways: putting the old blue back in `Accent` fails with
"primary differs: iOS says 7A3FF2, Android says 2298FF", and lightening the
gradient's top stop fails with "white on the gradient's light end is 2.28:1".
Mechanics worth knowing: the ground is painted ONCE on the root `Surface`, with
`Scaffold(containerColor = Transparent)` under it — Scaffold's default container
colour paints straight over a gradient, and a gradient applied per screen
restarts at every navigation, which reads as the background flickering. The
button's gradient is on the modifier with a transparent `Button`, clipped to the
same shape or the corners show square.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both apps had both features. iOS carried them in its tab bar; Android had them
as buttons most of the way down the Account screen, behind a comment arguing
the kit's bar is four icons and "a wallet people open occasionally does not
earn the slot".
What that produced: somebody testing the Android build reported Files and
Wallet as missing. That is a fair reading of a feature you have to scroll past
a dozen settings sections to find, and two apps disagreeing about where a
feature lives is worse than either answer.
Seven destinations now, in iOS's order — Home, Messages, Calls, Stories, Files,
Wallet, Account. The Account sections are gone rather than kept alongside: two
routes to one screen is two things to keep in step, and the bar is the one
people look at.
🔴 **Tapping a tab calls `navigateTo`, not `showScreen`.** Wallet and Files each
read the vault before they have anything to draw, so a bare `showScreen` lands
somebody on an empty screen with nothing loading and no error. That is now one
method with the loading in it, so no future caller can navigate past it.
🔴 **The bar's contents are data now, and a test reads them.** It was a
hand-written run of `NavButton` calls, so nothing could assert what was in it —
a destination could vanish in a refactor and no test would go red. That is
exactly how this divergence survived. `NavDestinations.tabs()` returns the list;
five tests pin the order, the tags the instrumented suite looks for, the
accessibility labels, and that supervision removes Stories and only Stories.
⚠️ The kit's artboard is four icons and this is seven. Deliberate departure,
said in the comment rather than left looking like an oversight.
Verified from `test-results/*.xml` — 5 tests, 0 failures — not from BUILD
SUCCESSFUL, which prints no test names for `:app`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The adaptive icon's foreground was a vector — a speech bubble with a flame
knocked out of it — drawn before there was a logo. There is one now, so the
launcher shows it.
🔴 **A bitmap replaces the vector, and that is a real trade.** The artwork is a
painted globe with gradients and a flame arc; no path describes it. Five
density buckets are generated instead, mdpi through xxxhdpi, by the relay
repo's `Scripts/make-brand-assets.py`. Do not edit the PNGs — the script
overwrites them, and one hand-edited density is an icon that looks different on
one class of phone and nowhere else.
⚠️ Content sits inside the adaptive-icon safe zone at 72% of the canvas, so a
circular mask cannot cut the flame arc. Checked against both a circle and a
squircle mask before this landed, not assumed.
⚠️ `<monochrome>` still points at the old vector silhouette, deliberately. A
themed icon is tinted flat by the launcher, so the colour artwork cannot serve
as one, and the alpha silhouette of a filled globe is a disc. The existing
speech-bubble shape is the right kind of thing for that slot and is still on
brand; a silhouette drawn from the new mark is separate work.
`minSdk` is 33, so there are no legacy square mipmaps to keep in step.
Verified with `:app:processReleaseResources`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apple's Guideline 1.2 and Play's user-generated-content policy both
require a way to report. Android had the whole stack — `submitReport` on
the client, `Session.reportStory`, `MessengerViewModel.reportStory` — and
no screen called any of it. For those rules, a capability with no way in
is the same as no capability, and a story is the most public thing this
app carries, which is exactly the surface they are aimed at.
Message reporting was already wired and complete. I nearly rebuilt it
from scratch: a truncated grep showed no hits, I concluded Android had no
reporting at all, and got as far as a second `ReportSheet` before finding
the existing one. Discarded. The lesson is the boring one — `head -8` on
a grep is not evidence of absence.
Reuses that sheet rather than adding a second. Two would be two places
for the consent wording to drift apart, and the consent is the feature:
sending a copy hands the operator something readable, which is the one
thing this app otherwise never does. It was `private` to `ChatScreens`,
which is why the stories feed could not reach it; widened to `internal`.
⚠️ Not offered on your own story. Reporting yourself to the operator is
not something anybody means to do, and offering it invites a misfire.
⚠️ The author's name is passed in from `StoryCard` rather than resolved
again. It has the buddy list and has already done it, and two resolutions
of the same name are two chances to disagree about who is being reported.
1179 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rest of what the parity scrub found on iOS and not here: show
whether contacts are online, show the time on messages, larger message
text, and light/dark/system. All four are wired to the thing they name —
the presence dot, the timestamp above a bubble, the body size, and the
theme — because a switch that stores a value and changes nothing is the
failure this whole exercise keeps turning up.
⚠️ **There is no "last seen" setting, and that is deliberate.** Android
does not show last seen anywhere; the only `seenAt` in the app is about
missed calls. iOS does show it. So the gap there is a missing FEATURE,
and adding a switch for something the app never draws would be exactly
the decoration the scrub exists to catch. Written down in
`DisplaySettings` so the next person does not "fix" it by adding one.
🔴 **Named for what they do.** iOS shipped "Show online status" on a
Privacy & Security screen, where every messenger uses those words for the
reciprocal thing — it only ever hid other people's dots from your own
phone. That was renamed today in ios!140; this is named that way from the
start, and the section says out loud that contacts are still told when
you are online whichever way it is set.
Two details worth the words:
- The base body size is **16sp because the kit says so** for a message
body, and `ChatScreens` says so at the point it draws one. A larger-
type switch that restyled every message when turned OFF would be a
worse bug than the absence it fixes. Pinned by a test.
- With timestamps off the `Text` is omitted rather than drawn empty. An
empty `Text` still takes its line height, so the setting would have
left a blank gap above every message.
1183 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The parity scrub found eight preferences on iOS with no Android
counterpart. This is the notification group, and it is the one worth
doing first, because "show who it is from" is a privacy control rather
than a preference: it decides whether a name reaches a lock screen.
`MessengerService` had already written down what was missing, at the only
place it could —
// Count only, until there is a setting for it.
— so notifications said "New message" or "New messages in 3 chats" and
nothing else. That is still the DEFAULT. Nobody acquires a name on their
lock screen by installing an update; somebody who wants one asks.
Four switches: notify at all, new messages, show who it is from, and
sound while the app is open. Same defaults as iOS, which is the part that
must not drift — the storage differs because the platforms differ, but a
person carrying both phones should not find them behaving differently.
⚠️ Several waiting threads are counted and never named, even with the
setting on. Naming one of three is arbitrary and naming all three turns a
notification into a contact list on a lock screen. A thread that cannot
be resolved to a person — a group, a cross-relay peer — falls back to the
count, because a wrong name is worse than no name.
🔴 **The first version of the test was worthless and passed.** It
reimplemented the rules against a fake and asserted nothing about the
code that ships — two identically-wrong ends agreeing, the same shape
that made the protocol vector tests necessary. The decision now lives in
pure functions on the companion object, taking the preference as an
argument, because a `Context` is not available in a JVM unit test and the
logic had to leave the class that needs one. The test calls those.
Checked by breaking it: make the text ignore the preference and exactly
one test reddens — the one asserting no name reaches the lock screen.
1179 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`shareBridge` shipped in the last change with nothing calling it. The
receiving half was complete — an offer arrives, is shown, is accepted by
a button — and the sending half existed only as a method. Half a feature,
and the half that was missing is the one the whole argument rests on: a
bridge nobody can hand out is a bridge nobody has.
The Account screen now lists contacts under the bridge, with a Send
beside each, and only when a bridge is actually configured. Sharing from
a screen with nothing set would have posted an empty line onto somebody
else's settings screen.
⚠️ The button says "Sent" afterwards but does not disable. An operator
rotates a bridge and the same person needs the new line; a control that
never re-arms would make that impossible from here. And "Sent" is not
persisted — whether somebody still has a working bridge is not something
this phone can know, so it is a confirmation for this sitting rather than
a record.
The copy says who learns what, because neither party is nothing: whoever
runs the bridge sees one more person connecting, and the contact learns
you use it.
1175 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A bridge only works while whoever wants to block it does not have the
line. Every ordinary way of distributing one — a web page, an email, a
channel — is a place a censor can go and collect them, which is why Tor
needs BridgeDB, moat and domain-fronted rendezvous and still gets its
bridges enumerated. This app already has the thing that solves it: an
encrypted channel between people who know each other. A line handed over
that rail is invisible to the relay, and enumerating it means infiltrating
one friendship at a time.
🔴 **An offer, never a setting.** A bridge line tells a phone to route
every relay connection through a machine somebody else chose. Applying
one on arrival would let a contact — or whoever has taken their account —
move another person's traffic onto a host they control. They could not
read it, because the ratchet does not care what carries it, but they
would learn who is online and when, and could cut them off at will. So
the transport stores it and a button applies it, pressed by the person it
was sent to.
The test that holds that line asserts storing an offer writes ONLY the
offers record. Make `saveBridgeOffer` also write a configured bridge and
it reddens, alone, while every other test in the file still passes —
which is what makes it worth having.
The line is shown in full on the screen, not hidden behind "Accept".
Somebody may want to check the fingerprint against another source before
routing their traffic through it, and they cannot check what they cannot
see. A line that arrived broken says which part is wrong, using the same
validator the paste field uses, rather than failing when accepted.
⚠️ **`"ctl":1`, so 0.10 swallows it.** A build that has never heard of
this kind consumes the envelope and shows nothing. The alternative — a
bubble full of JSON in somebody's conversation — is the exact bug the
`ctl` flag was introduced to stop, and it would have shipped to every
older install. Asserted on the wire as the NUMBER one, because iOS
decodes it as a number and a boolean fails the whole envelope's decode
rather than merely failing to set the flag.
1175 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
versionCode 11, versionName 0.11. 0.10 published 2026-09-05 18:12
(`39ccfbde…`) and everything below has merged since, so a rebuild at code
10 would have reached nobody — Android refuses an APK whose versionCode
is not higher than the installed one, and the two builds would have been
different bytes under the same number.
User-visible in this build:
- **A bridge, for networks that block Flamenet.** Account → Network takes
a bridge line and carries relay traffic inside obfs4 to it. Off unless
a line is pasted, validated before it is stored, and the screen reports
whether a handshake has actually completed rather than what is saved.
- **Sign-up could sit on "Checking what this server uses…" forever.** The
relay probe had no deadline and no way to report failure, so a server
that never answered left the form on that sentence permanently, with no
error and nothing to act on.
The rest is internals: the Kotlin obfs4 port itself, proven against real
lyrebird in both directions, and CI that now actually runs the module's
~100 tests and the twenty protocol vector tests that had never passed
there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I pinned the vector clone earlier today on flamenet-relay#56's evidence,
and stopped a step short. flamenet-relay did not: it pins AND keeps
`conformance-head` / `doc-pages-head` running against HEAD with
`allow_failure: true`, and its `.e2e-ref.md` says why in a sentence I had
read and not acted on — "a relay tested against a pinned old harness is
tested against a pinned old spec, and that argument still holds."
A pin alone is worse than HEAD for exactly that reason. HEAD couples this
board to another repository's merges; a bare pin means the vectors can
move and this suite goes on checking last month's protocol, reporting
green, with nothing anywhere saying so. The first failure is loud and
wrong; the second is quiet and wrong, and quiet is worse in a conformance
suite whose whole job is to notice a divergence.
So: `.e2e-ref` holds the commit, `unit-tests` checks against it and
blocks, and `vectors-head` runs the same vector suites against HEAD and
is allowed to fail. Being behind is loud and non-blocking. Being broken
is still a hard stop.
Verified it would actually notice, rather than assuming — pin and HEAD
are the same commit today, so a green advisory run proves nothing. With
one expected value in `account-kdf-v1.json` mutated, `AccountKdfTest`
reddens. `.e2e-ref.md` records the procedure and the deadlock story
behind it.
⚠️ `vectors-head` runs `:engine:test` only. The rest of the suite has
nothing to say about whether flamenet-e2e moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>