Files and Wallet are bar destinations, like iOS #116

Merged
paulhitt merged 12 commits from feat/files-and-wallet-in-the-bar into main 2026-09-07 19:41:31 +00:00
12 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 ca0d3f781d A photo sent as a file loses its location too (android#17)
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>
2026-09-07 15:32:12 -04:00
Paul HittandClaude Opus 5 17495659eb Icons and initials are white, not the accent — matches ios!144
🔴 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>
2026-09-07 14:57:40 -04:00
Paul HittandClaude Opus 5 8fa66cd603 White text boxes — matches ios!144
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>
2026-09-07 14:11:20 -04:00
Paul HittandClaude Opus 5 b824e626e7 Three stops — purple into blue into black — matches ios!144
#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>
2026-09-07 13:37:35 -04:00
Paul HittandClaude Opus 5 15e678d593 Bright, but still dark — matches ios!144
#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>
2026-09-07 12:44:55 -04:00
Paul HittandClaude Opus 5 28d10bec4d The recovery button did not recover, and half the text went black
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>
2026-09-07 12:37:03 -04:00
Paul HittandClaude Opus 5 c688ed21b4 One theme, and it is dark — matches ios!144
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>
2026-09-07 12:26:42 -04:00
Paul HittandClaude Opus 5 37143c5b6e Darker again — matches ios!144
#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>
2026-09-07 12:13:36 -04:00
Paul HittandClaude Opus 5 4444e7e8e2 The ground shades from purple into a medium blue
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>
2026-09-07 11:54:25 -04:00
Paul HittandClaude Opus 5 49854ac222 Darker ground, the mark on sign-in, and a dark mode that was never readable
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>
2026-09-07 11:21:33 -04:00
Paul HittandClaude Opus 5 563e9efed7 The same purple ground, and a test that checks it stays the same
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>
2026-09-07 11:09:04 -04:00
Paul HittandClaude Opus 5 832cbf9214 Files and Wallet are bar destinations, like iOS (android)
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>
2026-09-07 10:04:57 -04:00