Commit Graph
388 Commits
Author SHA1 Message Date
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
Paul Hitt 090ca7ed57 Merge branch 'feat/new-brand-icon' into 'main'
The launcher icon is the product's mark

See merge request paulhitt/flamenet-messenger-android!88
2026-09-07 13:34:31 +00:00
Paul HittandClaude Opus 5 845254a58c The launcher icon is the product's mark
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>
2026-09-07 09:28:44 -04:00
Paul Hitt dd275939e3 Merge branch 'feat/android-report-a-story' into 'main'
Report a story on Android, which had no way to

See merge request paulhitt/flamenet-messenger-android!87
2026-09-07 01:56:06 +00:00
Paul Hitt 81ef0170c3 Merge branch 'feat/android-display-settings' into 'main'
Display settings, and one gap that is a missing feature rather than a toggle

See merge request paulhitt/flamenet-messenger-android!86
2026-09-07 01:56:03 +00:00
Paul HittandClaude Opus 5 900c91ca6f Report a story on Android, which had no way to (ios#12, android#12)
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>
2026-09-06 21:46:00 -04:00
Paul HittandClaude Opus 5 1b5e7068e4 Display settings, and one gap that is a missing feature rather than a toggle
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>
2026-09-06 21:18:04 -04:00
Paul Hitt 20d19e0de3 Merge branch 'feat/android-notification-settings' into 'main'
Notification settings, which Android never had

See merge request paulhitt/flamenet-messenger-android!85
2026-09-07 01:14:43 +00:00
Paul HittandClaude Opus 5 0e5460da41 Notification settings, which Android never had
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>
2026-09-06 21:06:14 -04:00
Paul Hitt c91e19300c Merge branch 'feat/share-bridge-ui' into 'main'
Give sharing a bridge a way in

See merge request paulhitt/flamenet-messenger-android!84
2026-09-07 00:51:16 +00:00
Paul HittandClaude Opus 5 9a20c06eda Give sharing a bridge a way in
`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>
2026-09-06 20:37:57 -04:00
Paul Hitt 4d915a237d Merge branch 'feat/share-a-bridge-with-a-contact' into 'main'
Hand a contact the bridge you use (§6.19)

See merge request paulhitt/flamenet-messenger-android!83
2026-09-06 23:59:34 +00:00
Paul HittandClaude Opus 5 1fd61ce124 Hand a contact the bridge you use (§6.19)
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>
2026-09-06 19:56:58 -04:00
Paul Hitt 9ab6384638 Merge branch 'release/0.11' into 'main'
Android 0.11

See merge request paulhitt/flamenet-messenger-android!82
2026-09-06 23:10:18 +00:00
Paul HittandClaude Opus 5 b3daf0eb0f Android 0.11
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>
2026-09-06 19:07:15 -04:00
Paul Hitt 10295e08f8 Merge branch 'ci/loud-advisory-at-e2e-head' into 'main'
Pair the e2e pin with a loud advisory at HEAD

See merge request paulhitt/flamenet-messenger-android!81
2026-09-06 21:37:15 +00:00
Paul HittandClaude Opus 5 625b4516c7 Pair the e2e pin with a loud advisory at HEAD
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>
2026-09-06 17:21:44 -04:00
Paul Hitt 49947bd585 Merge branch 'fix/pin-the-vector-clone' into 'main'
Pin the vector clone, rather than reddening on someone else's merge

See merge request paulhitt/flamenet-messenger-android!80
2026-09-06 19:57:42 +00:00
Paul HittandClaude Opus 5 a93efeb3ac Pin the vector clone, rather than reddening on someone else's merge
I cloned flamenet-e2e at tip an hour ago and wrote down the reasoning:
if the published protocol changes, this suite SHOULD go red without a
commit here, because that is a real divergence and not a stale pin.

flamenet-relay#56 is the write-up of what that costs in practice. The
same pattern there — a job cloning flamenet-e2e at HEAD — turned relay's
main red in a pipeline nobody was watching, blocked six MRs sitting on
merge-when-pipeline-succeeds, and was misdiagnosed as runner starvation
before anyone found the cause. It fired twice in one evening. The person
whose merge did it was working in a different project and had no reason
to look.

The argument for tip is real but it is the smaller one. A protocol
divergence that surfaces as a surprise failure in an unrelated repo does
not get read as a protocol divergence — it gets read as flaky CI, which
is how relay#56 went for three hours. A pin makes the same event a
deliberate bump here, reviewed by somebody who is actually thinking about
the protocol at the time.

Pinned at 9775fbfe (2026-09-05). Move it when the protocol moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:55:16 -04:00
Paul Hitt 5a648f33b5 Merge branch 'fix/ci-actually-runs-the-obfs4-suite' into 'main'
CI never ran the obfs4 suite, and replayed the others from a local cache

See merge request paulhitt/flamenet-messenger-android!79
2026-09-06 19:21:47 +00:00
Paul HittandClaude Opus 5 934704d784 Fetch the protocol vectors in CI, which never had them
Turning off the build cache exposed twenty failures that had been there
all along: AccountKdfTest, PQVectorTest and PublishedVectorsTest look for
a sibling `flamenet-e2e` checkout. That exists on a developer's machine
and has never existed in a build directory, so every one of them has been
failing in CI since it was written — and CI reported green, because the
cache was replaying passes computed here.

These are the cross-implementation conformance tests. They are what says
this engine derives the same keys as the one iPhone runs; twenty of them
silently not running is the worst possible thing to find in the suite
that guarantees the two platforms can talk.

They fail rather than skip when the file is missing — deliberately,
exactly so this could not hide. It still hid, one layer up, in a cache.

`flamenet-e2e` is public, so the job shallow-clones it and points the
three environment variables at the result. The paths are checked before
the suite runs: a variable pointing at nothing would put the tests back
where they were, and the failure would read as a protocol bug rather than
a missing file.

Cloned at its tip rather than pinned. The vectors are normative and
shared with iOS, so if the published protocol changes this suite SHOULD
go red without a commit here — that is a real divergence, not a stale
pin.

Verified the variables are actually honoured, rather than assuming: with
one pointed at an empty JSON file the test reddens even though the
sibling checkout is present. 539 engine tests pass against the cloned
vectors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:20:24 -04:00
Paul HittandClaude Opus 5 902e16cf92 CI never ran the obfs4 suite, and replayed the others from a local cache
Two holes, found by reading the trace of the pipeline that had just
merged android!78 rather than trusting its green.

`:obfs4:test` was not in the task list. The module shipped in !77 with
~100 tests — the Elligator2 port, the framing, the ntor handshake, the
socket factory — and not one of them has ever run in CI. A break anywhere
in the transport would have merged green. The bridge-line parser added in
!78 was in the same position.

The rest ran, but not in this pipeline. The runner is tagged `mac-local`,
so it is the developer's own machine and shares Gradle's build cache with
local runs. Every test task came back FROM-CACHE — the whole job finished
in 5 seconds — which means CI replayed results it never computed. The
outcomes were real, they were just mine from ten minutes earlier. That is
not independent verification, and nothing in the log distinguishes it: a
cached pass and a real pass both print "BUILD SUCCESSFUL". `--rerun-tasks`
now forces execution.

And the job asserted nothing. It ended on gradle's exit status, which is
the same shape of mistake the iOS gate was carrying when it ended in
`grep ... || exit 1` and went green on a red suite. `:app` makes it worse
by printing no per-test names at all, so a filter matching nothing looks
exactly like 145 passing tests.

So the gate reads the results XML: per-module floors at roughly 90% of
today's counts, zero failures, and a total. Old results are deleted first
— the runner reuses its build directory, and XML from a previous pipeline
would otherwise be counted by the very check meant to notice a suite had
stopped running. Hiding one module's results reddens it; that is how the
floors were checked.

1162 tests across the four modules, 0 failures, 44 tasks executed and
none from cache.

⚠️ The interop tests are opted out here and only here — they need a real
lyrebird from the flamenet-elligator2 checkout, which is not in the build
directory. They fail rather than skip when unconfigured, deliberately, so
the opt-out is written down where it can be seen. Run
`scripts/obfs4-interop.sh` before merging obfs4 changes: every other test
in that module compares against recorded vectors or against itself, and
two identically-wrong ends agree perfectly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:16:26 -04:00
Paul Hitt 96cbbb7746 Merge branch 'feat/obfs4-bridge-settings-ui' into 'main'
A settings screen for the bridge line, and a path for it to reach

See merge request paulhitt/flamenet-messenger-android!78
2026-09-06 19:13:20 +00:00
Paul HittandClaude Opus 5 6cd7a0d5dc A settings screen for the bridge line, and a path for it to reach
Two gaps, and the second was the bigger one.

obfs4 was reachable from nothing. The socket-factory seam landed in !77,
but every client in the app takes `defaultHttpClient()`'s null default —
`RelayClient`, `MessengerApi`, `AccountsClient`, `RelayAccounts`,
`SignupProofOfWork`, `RelayDiscovery` and the background service, ten
sites in all, several from default parameters evaluated at
field-initialisation time. The transport had been built, tested and
proven against real lyrebird in both directions, and no user could
switch it on.

Threading a bridge through ten constructors would have worked until an
eleventh was added, and the eleventh would connect directly,
successfully, with nothing to show that it had. A missed site does not
fail; it silently does not protect. So the factory is installed
unconditionally and consults `Obfs4Routing` per socket. With no bridge it
delegates to the platform's own factory, which is what OkHttp would have
used anyway — nothing changes for the overwhelming majority who never set
one.

Pooled connections are the trap underneath that. OkHttp keeps a
connection alive across requests, so one opened through the old bridge
serves requests for minutes after the user switches — or after they turn
a bridge OFF, which is the dangerous direction. The pool is shared so
`Obfs4Routing.use` can empty it.

The other gap is the one flagged when the wiring merged: a malformed line
parses to nothing, nothing means no bridge, and no bridge is a direct
connection. So `BridgeLine.parse` reports which part is wrong, and
`Obfs4Settings` stores only what it accepted.

Java's decoder made that worse here than on iOS. `fromCert` appended
`==`, which THROWS for most truncation lengths — 20, 29, 30, 50 and 51
bytes among them — so a short paste came back as unreadable gibberish
while iOS, whose decoder is padding-lenient, called the same paste "cut
short". That is the likeliest real mistake by a distance: bridge lines
wrap in every chat app and terminal, and copying only the first line is
ordinary. Decoding the bare string works at every length and lets the
size check give the honest answer. Both forms produce identical bytes for
a real 52-byte cert, so nothing that worked before changes.

The messages are the same sentences iOS shows, pinned by a test. The two
apps are handed the same lines by the same operators, and one support
answer should cover both. None of them says obfs4, base64 or node id —
whoever reaches this screen was handed a line and told to paste it, and
an error in the protocol's vocabulary reads as "you are not qualified to
fix this".

`iat-mode` 1 and 2 are a caveat, not a rejection: they ask the client to
space its writes and the bridge does not enforce them, so such a line
connects and works. Refusing it would take a working bridge away from
somebody who has no other one.

The status line reads a handshake counter, never the stored preference. A
saved bridge that is not there leaves the app connecting directly, and
only a completed handshake can tell you otherwise — so a bridge that has
carried nothing says exactly that, and the words "protected", "secure",
"hidden" and "safe" are asserted absent from it.

Live interop re-run against real lyrebird after the factory change: both
directions still pass. 448 unit tests green, 145 in the app, and
reverting the unconditional factory reddens the routing test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:12:12 -04:00
Paul HittandClaude Opus 5 11f0fe9fee Count handshakes at the bridge, so the socket-factory test proves something
This fix was made when the iOS end-to-end wiring test was found passing
while BYPASSED, and the same flaw was confirmed here — but it never got
committed, so the assertion has been sitting in a working tree while the
merged test went on proving nothing.

The test fetches `http://127.0.0.1:<upstream>/probe`. That URL points at
the upstream directly, so a request that ignored the socket factory
entirely reaches the same server and returns the same body. Asserting on
the response proved the HTTP server worked; it said nothing at all about
whether obfs4 was in the path.

The bridge now counts sessions that completed a handshake, and the test
asserts the count rose. Removing the socket factory reddens it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:11:53 -04:00
Paul Hitt 9bc7dcf762 Merge branch 'feat/obfs4-kotlin-elligator2' into 'main'
obfs4 for Android, part 1: Elligator2

See merge request paulhitt/flamenet-messenger-android!77
2026-09-06 18:09:36 +00:00
Paul Hitt dff6eb29dd Merge branch 'fix/instrumented-suite-catches-up' into 'main'
The on-device suite catches up with the app

See merge request paulhitt/flamenet-messenger-android!76
2026-09-06 18:09:16 +00:00
Paul HittandClaude Opus 5 dd412b8680 obfs4 for Android, part 7: the OkHttp seam, off by default
RelayClient.defaultHttpClient(bridge) now carries every request inside
obfs4 when a bridge is configured. The parameter defaults to null, which
is the shipping configuration: nothing changes for anyone who does not
ask for it.

A real HTTP request now travels through obfs4 end to end via OkHttp.
That test is deliberately Kotlin on both ends, and says so: the wire
format is already pinned to real lyrebird in both directions by
InteropTest. What is unproven until here is the plumbing -- that the
handshake happens before any stream is handed out, that bytes arriving
alongside the handshake are not dropped, and that a frame decoding to
nothing does not read as end-of-stream. Each of those loses data without
producing a wrong byte anywhere.

The handshake runs inside connect(), not on first write. OkHttp starts
writing a request the moment it has a connected socket, so a deferred
handshake would interleave with request bytes and desynchronise the frame
decoder on the first request of every connection.

Obfs4Socket ignores the endpoint OkHttp asks for. That endpoint is the
RELAY's address; the socket goes to the bridge, which proxies on.
Connecting to the endpoint would defeat the entire transport while
appearing to work.

All five SocketFactory overloads are implemented. Leaving any of them
returning a plain socket means some path silently bypasses the transport
-- and the traffic still works, which is exactly why nobody would notice.

A wrong bridge key fails as a refusal rather than hanging. obfs4 bridges
stay silent for anyone they cannot verify, so without the timeout this is
an app that never finishes a request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:39:24 -04:00
Paul HittandClaude Opus 5 a7ce010403 obfs4 for Android, part 6: live interop with real lyrebird, both directions
scripts/obfs4-interop.sh builds a real lyrebird bridge and a real
lyrebird client from the flamenet-elligator2 checkout and runs the Kotlin
port against both. The bridge accepts the Kotlin client's handshake and
echoes data; lyrebird's own client accepts the Kotlin bridge and its
echo. 61 tests.

Both directions are needed, and the falsification shows why: swapping the
framing key halves by role leaves the CLIENT test green -- a client takes
the first half either way -- and fails only the reverse one. Encoding the
epoch hour as binary rather than a decimal string fails both. The
probe-resistance test, which asserts the bridge stays silent for garbage,
passes throughout, as it should.

Both clients are pinned to the SAME reference rather than to each other.
Two independent implementations agreeing with lyrebird is a real
statement; agreeing with each other is not, and would hide anything they
share.

The runner's name check earned its keep twice in ten minutes. First it
reported failure while all three tests had passed, because Gradle prints
`name() PASSED` with parentheses and the pattern omitted them. Then, with
that fixed, it caught the tests not running AT ALL: Gradle's up-to-date
check does not know the Go counterparty changed, so a second run skips
the task silently. Hence --rerun-tasks. An exit code alone would have
called both of those green.

The tests never skip quietly: absent configuration is a hard failure
unless OBFS4_INTEROP_OPTOUT=1 is set explicitly. Verified both ways --
the bare run fails, the opted-out run passes 61.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:33:44 -04:00
Paul HittandClaude Opus 5 ac5802a839 obfs4 for Android, part 5: handshake messages, packets, and the connection
The protocol is now complete in Kotlin, short of a socket. 58 tests.

Handshake messages are checked against RECORDINGS, not computed values:
the vectors are real lyrebird-to-lyrebird handshakes captured off a pipe,
because the message carries random padding chosen inside lyrebird and a
MAC over the current epoch hour. The padding turns out to be recoverable
-- it sits between a fixed-length prefix and the mark -- so rebuilding
from the recovered representative, padding and hour must reproduce the
recording byte for byte. That makes these a test of generation, not only
of parsing, and both directions pass.

The epoch hour is a parameter, never read from the clock inside the
library, because a MAC with a hidden clock dependency cannot be tested
against a recording.

The asymmetry that catches people is preserved: the server searches only
the TAIL for the mark, because a client cannot send anything valid past
its MAC; the client must SUBSTRING-search, because a real server sends
framed data immediately after its handshake. The symmetric reading fails
against every real bridge.

GoRandom reproduces Go's math/rand quirks exactly -- float64 dividing an
int63 by 2^63 rather than the cleaner form Go rejected, and perm's
useless first swap -- so the packet-length distribution derived from a
seed matches lyrebird's. That buys indistinguishability, NOT
interoperability: padding is padding, and any distribution talks to a
real bridge. What a different one costs is looking like other obfs4
traffic, which is the entire point.

The client handshake returns leftover BYTES rather than an offset,
carrying forward the fix the Swift port needed: an offset indexes a
buffer accumulated across all calls, which a caller feeding one socket
read at a time does not have, and it only misfires when the server
handshake spans two reads -- routine over a network, almost never over
loopback.

HONEST GAP: the connection layer here is self-consistency-tested only.
Everything below it is checked against lyrebird's bytes, but two
identically-wrong ends agree perfectly. The Swift port has a live interop
test against a real lyrebird bridge; this one does not yet, and that is
the next thing that matters -- more than the OkHttp seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:26:53 -04:00
Paul HittandClaude Opus 5 799886f35c obfs4 for Android, part 4: framing
Whole runs of frames now match lyrebird byte for byte and decode back
byte-at-a-time. 33 tests.

XSalsa20 and Poly1305 both come from BouncyCastle. The Swift port
hand-writes both because CryptoKit's AEADs are AES-GCM and ChaChaPoly and
it exposes neither primitive; Poly1305 especially is a MAC, so a bug in
it is a forgery rather than a wrong answer, and taking a vetted
implementation is the right call wherever one exists.

Added standalone Poly1305 and Salsa20 vector tests, because the comment
above them claimed those vectors sat beside the frame ones and nothing
was using them. They do not test BouncyCastle's arithmetic -- that is
vetted -- they test that it is being CALLED correctly, key and parameter
setup being exactly where a wrapper goes wrong.

The frame tests run a sequence, not a single frame: the nonce counter and
the DRBG both advance per frame, so a one-frame test passes for an
implementation whose counter never increments and whose length mask is
recomputed from scratch each time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:16:09 -04:00
Paul HittandClaude Opus 5 430f167fc0 obfs4 for Android, part 3: SipHash, the length DRBG, and ntor
64/64 handshakes agree with lyrebird on KEY_SEED, AUTH and KDF output,
from both the client and the server side. 23 tests.

SipHash is the one primitive hand-written here despite BouncyCastle
shipping one, and that is worth justifying because everything else in
this module is BouncyCastle's. BC's SipHash is a Mac: doFinal RESETS.
obfs4's DRBG writes eight bytes, finalises, writes eight more and
finalises again -- without ever resetting. Emulating that with a
resetting MAC means re-hashing the whole history per block, O(n^2) in the
frames on a connection. So the state machine is written out, and `sum`
finalises a COPY.

The DRBG is checked ten blocks deep, not one. obfs4's comment says each
block is a fresh H(K, previous); the code never resets, so blocks are
cumulative. The two readings agree on block one and diverge afterwards --
an implementation built from the comment decodes the first frame of a
connection perfectly and then fails.

ntor carries the same trap the Swift port hit: the transcript is
B | B | X | Y | PROTOID | ID, with B twice, while the comment directly
above that code claims ID | B | Y | X | PROTOID. Both orderings produce a
well-formed 32-byte KEY_SEED. Only one interoperates.

HMAC via javax.crypto, HKDF via BouncyCastle's HKDFBytesGenerator, X25519
via the ladder from part 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:13:02 -04:00
Paul HittandClaude Opus 5 44e06adf92 obfs4 for Android, part 2: the cofactor-dirty scalar multiplication
256/256 private keys now produce lyrebird's exact public key, and the
whole keypair -- private key to public key and representative -- matches
end to end. 13 tests.

BouncyCastle's X25519.scalarMult cannot be used for this: it clamps
internally and runs 255 bits, while the dirty variant needs the COMBINED
scalar fed to a 256-bit ladder with no re-clamping. The combined scalar
can exceed 2^255 once lsb*L is added, so a 255-bit ladder silently drops
the top bit for a fraction of keys. Only the ladder and the scalar
handling are written out; the field arithmetic underneath is still
BouncyCastle's.

The danger here is that the wrong answer looks entirely fine. A clean
X25519 public key agrees on shared secrets and round-trips; it is just
confined to the prime-order subgroup and therefore distinguishable, and
clamping the private key before the call is all it takes to get one. So
there is a structural test beside the byte comparison: the dirty key must
differ from the clean key exactly when the low three bits of the private
key are non-zero, with both arms asserted to actually occur so neither
passes vacuously.

cswap here uses an explicit mask rather than any BouncyCastle
conditional. Nothing in this module assumes those take a boolean-shaped
condition, after X25519Field.cmov was found to do nothing at all when
handed a plain 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:06:42 -04:00
Paul HittandClaude Opus 5 db35c7ce55 obfs4 for Android, part 1: Elligator2, against the same vectors as Swift
A new `:obfs4` module, a plain JVM library like `:engine` so its tests run
without an emulator -- which matters here because the only tests that mean
anything are vector comparisons against lyrebird.

Far smaller than the Swift port, for a reason worth recording: the Swift
implementation hand-writes SipHash-2-4, XSalsa20, Poly1305 and its own
field arithmetic because CryptoKit exposes none of them. The JVM does not
have that hole. BouncyCastle -- already a dependency of :engine, for the
same class of reason -- has X25519Field, XSalsa20Engine, Poly1305 and
SipHash, vetted and constant-time where it matters.

So the two clients do NOT share an implementation. They share the vectors
and the live interop harness, which is what actually keeps them honest:
two independent implementations agreeing with the same reference is a
stronger statement than one agreeing with itself twice.

Two things BouncyCastle would not do, both found by vectors rather than
by reading.

X25519Field.sqrtRatioVar is not a drop-in. Elligator needs the root even
when the ratio is NOT square -- it is sqrt(i*u/v) then, and the algorithm
uses it to pick a branch rather than to detect an error. BC only promises
a usable root in the square case, and handing its output to the map
produced zeros for 120 of 256 vectors. The exponent chain and branch
selection are written out here; the field arithmetic underneath is still
BouncyCastle's.

X25519Field.cmov did nothing at all with a condition of 1. Both tweak
parities produced the SAME representative -- a map that still
round-trips, still looks uniform, and only ever emits one of the two
valid answers. The failing assertion said "wrong bytes"; it took a
diagnostic printing both candidates to see they were identical. Replaced
with an explicit mask so the convention is stated rather than assumed.

Behind no setting yet because nothing calls it: this is the crypto
foundation only. The transport, the OkHttp socket factory and the
user-facing switch come next, and the switch defaults to off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:46:25 -04:00
Paul Hitt 945978972a The on-device suite catches up with the app
🔴 **Nine defects, all of them in the test, none in the app.** These
suites were written before the tab split, the age gate and the two
sign-out doors, and had not run since. Each staleness hid behind the one
before it, and every error message blamed the wrong thing.

1. **The age gate was never filled.** `submit` is `enabled = … &&
   (!registering || birthYear.length == 4)`, and `performClick()` on a
   disabled Compose node does not throw — it does nothing.
2. **`submit` was below the fold.** Adding that field pushed the button
   off a 1080x2400 screen. An off-screen node still reports ENABLED —
   that is a state check, not a visibility one — so the gesture reached
   nothing. Proved by logging inside the app: `signIn` was never called,
   through 450 seconds of waiting.
3. **The peer's name was typed into a button.** `new-contact` is the
   FIELD; `lookup-contact` is the Look-up button.
4. **The lookup step was skipped**, so `confirm-contact` never appeared —
   the app refuses to turn a typed string straight into a contact,
   because a typo would silently become a stranger.
5. **`buddy-<id>` vs `buddy-circle-<id>`.** Home shows the circles row.
   The suite reported "never became a contact" about a contact on screen.
6. **RECORD_AUDIO was never granted.** The mic dialog is not in the app's
   Compose tree, so the failure read "No compose hierarchies found" — as
   though the app had died rather than a dialog covering it.
7. **Sign-out was a no-op**: the row needed scrolling to, AND the
   destructive rows confirm in place, so one tap only armed it.
8. **It used the wrong door.** A plain sign-out KEEPS the device and
   credential by design — right for a person, wrong for a suite whose
   next test wants a different account. `sign-out-erase` is the one that
   clears the vault.
9. **Groups and stories are on their own tabs**, and `stories` is the
   strip on Home, not the Stories tab, which carries `new-story`.

⚠️ `signOutIfSignedIn` no longer wraps itself in `runCatching`. Swallowing
that failure is what made the damage appear three screens later in a
different test.

⚠️ The two waits that span a relay POLL — a contact reaching the roster,
a group reaching the list — are 150s. They are the only two that flake,
because neither is refreshed by the response to its own write.

All four tests pass. Not yet all in one run: the add-contact lookup is
throttled per window, and four tests share it.
2026-09-06 06:51:25 -04:00
Paul Hitt 518c39682b Merge branch 'fix/relay-probe-says-when-it-fails' into 'main'
"Checking what this server uses…" was also the failure state

See merge request paulhitt/flamenet-messenger-android!75
2026-09-05 22:25:12 +00:00
Paul Hitt 77a7f3981c The instrumented suite could not sign up, and said nothing about it
🔴 **Sign-up requires a year of birth and no instrumented test filled it.**
`submit` is `enabled = … && (!registering || birthYear.length == 4)`, so
the button was DISABLED — and `performClick()` on a disabled Compose node
does not throw, it does nothing. Every sign-up in this suite has been
tapping a dead button since the age gate landed, failing two minutes later
with 'nav-contacts did not appear' and no error anywhere on screen.

Nothing was wrong with the app. The suite simply stopped being able to
create an account, and a failing instrumented suite reads as a flaky one.

Also fixes the skip counter in scripts/instrumented.sh: it grepped a bare
SKIPPED, which matches Gradle's own `Task … SKIPPED` lines, so a verdict
written to stop a skip being read as a pass was inventing skips instead.
2026-09-05 16:15:29 -04:00
Paul Hitt 1a7c25386d Allow the test fleet in the instrumented runner, refuse only production
relay1/relay2 are what the fleet is FOR, and the suites need https because
Android blocks cleartext — so a local http relay cannot serve them at all.
Refusing both left no way to run the suite as designed.

Production stays refused outright. The test fleet is allowed with a loud
line saying accounts will be created on it, because they will be.
2026-09-05 15:42:29 -04:00
Paul Hitt d8d48d8651 "Checking what this server uses…" was also the failure state
🔴 **The relay probe had no deadline and no way to say it failed.**
`discovered` is null both while the probe is in flight and after it has
come back with nothing, so `identityDescription` shows "Checking what
this server uses…" in both cases. A relay that never answers leaves the
sign-up form on that sentence permanently — no error, no retry, nothing
to act on.

Found by the instrumented suite on a clean emulator: nine of ten tests
failed with `nav-contacts did not appear within 120000ms`, and the screen
dump showed the form still checking.

⚠️ **The reason it never answered is worth writing down.** The app was
pointed at `http://10.0.2.2:18810`, and Android has blocked cleartext
HTTP by default since API 28 — there is no `usesCleartextTraffic` and no
network security config here. The request never left the device.

🔴 That refusal is CORRECT and is not what changed. `SiteConfig` already
says why: a bare hostname becoming `http://` would hand key material to
anyone on the path "and it would work, which is the worst outcome." What
was wrong is that it failed silently, so somebody self-hosting who omits
the scheme gets a dead screen and no idea why.

Now: bounded at fifteen seconds — far longer than a healthy relay needs —
and a failed probe is a distinct state that says
"Couldn't reach that server. Check the address — and note it has to be
https, not http."

Same defect as android#19 one screen earlier, which I fixed this morning
without noticing the probe had it too.

Also adds `scripts/instrumented.sh`, which closes two ways an on-device
run silently points at production: the suites DEFAULT to live hosts when
no relay is named, and a saved emulator snapshot restores whatever the
AVD was last doing — observed today as a snapshot signed in as
`fnreviewbeta` against relay.flamenet.io, under a run told to use a local
relay. The script has no default, refuses both live hosts by name, cold
boots with `-no-snapshot-load`, and exits 3 on a skip because a skip is
not a pass.
2026-09-05 15:41:49 -04:00
Paul Hitt d2d5a0b496 Merge branch 'feat/report-a-message' into 'main'
Report a message, not just a story

See merge request paulhitt/flamenet-messenger-android!74
2026-09-05 18:35:11 +00:00
Paul Hitt 02edf3bc45 Merge branch 'ship/android-0-10' into 'main'
Android 0.10 — published

See merge request paulhitt/flamenet-messenger-android!73
2026-09-05 18:35:05 +00:00
Paul Hitt 9bba9d2555 Report a message, not just a story (android#20)
**The relay has always accepted `kind: "message"`. Nothing ever sent one.**
Reporting existed on stories only — so the surface where unsolicited
contact, spam and harassment actually arrive had no way to tell anybody.
The person being messaged by a stranger had Block, which protects them
and tells whoever runs the server nothing, so the account moves on to the
next person.

🔴 **Android had `reportStory` wired all the way to the relay and no
screen anywhere calling it.** The plumbing was complete and unreachable.
This is the first report UI on this platform, and the sheet is not
story-specific, so stories can use it too.

🔴 **The consent is the feature; the form is scaffolding.** Everything
else this app sends is sealed and a report is not: the relay cannot open
a message, so a report is actionable only if the reporter hands over what
they saw. §6.25 requires the client to say so, and it says so in these
words — what leaves the device, who can read it, and that it cannot be
taken back. Turning the copy off states what that costs rather than
quietly weakening the report.

⚠️ Block is offered beside it and never merged into it. A block takes
effect immediately and is the reporter's own decision; a report is a
request to somebody who may do nothing, and the sheet says which is
which.

⚠️ One message travels, never the thread around it — §6.25 forbids
including anything the reporter did not choose to.

⚠️ Report is absent, not disabled, on your own messages, and on the
stranger banner it sits below both buttons rather than beside them. Most
first messages are somebody you gave your address to; leading with Report
would frame every one of them as a threat.

⚠️ A relay may decline reports entirely (`reports_not_accepted`, 501).
That is a property of the server, not a mistake the reporter made, and
the message does not read like one.
2026-09-05 14:28:38 -04:00
Paul Hitt 1594ce84f6 Android 0.10 — published
sha256 39ccfbde…, signed CN=Flamenet Messenger with 791fc826… — the same
certificate as every build since 0.3, so 0.9 installs are offered this
rather than refused.

⚠️ APK into downloads/ BEFORE the manifest, and the served copy hashed
over HTTPS rather than trusted as copied. A manifest that lands first
announces a version the file is not, and every client checking in the gap
downloads the old one and rejects it against the new hash.

Second release through the notes gate, and it did its job unprompted:
`release-manifest.sh` read 0.10 back out of the APK and confirmed
`Resources/releases.json` names it before writing a byte.
2026-09-05 14:12:56 -04:00
Paul Hitt 1baa661b63 Merge branch 'fix/silent-signin-and-log-leaf' into 'main'
Sign-in that cannot finish now says so, and a missing log leaf heals

See merge request paulhitt/flamenet-messenger-android!72
2026-09-05 18:10:47 +00:00
Paul Hitt d8df4a8dbf Sign-in that cannot finish now says so, and a missing log leaf heals
**android#19 — sign-in sat for four minutes and said nothing.** The
failure path was always correct: anything `authenticate` throws is
reported. What it could not report was a call that never RETURNS —
observed 2026-09-05 with the requests demonstrably going out and the
screen unchanged. Nothing was thrown, so nothing was shown.

⚠️ Sixty seconds, not ten. Sign-in derives a key from the passphrase and
then talks to two services; an emulator or an older handset is
legitimately slow at the first part. This is a backstop for "never", not
a latency budget.

**android#6 — a failed key-log append was permanent.** `signIn` calls
`registerDevice`; `restore` did not. So a device registers exactly once,
and if the transparency-log append did not land — a slow write, a relay
mid-restart — it stays registered with no leaf, and every peer that
enforces the log refuses it forever with an error that reads as an
accusation of tampering. Relaunching did not help, which is the cruel
part: the one thing anybody would try was the one thing that could not
work. Restore now checks for a leaf and republishes if there is none.

⚠️ Checks first. Registering unconditionally would publish twenty fresh
prekeys every launch and churn the log for nothing — iOS's
`registerDeviceIfNeeded` tops up only when low for the same reason. Silent
on failure, because this repairs a state almost nobody is in.

**android#17 — a photo sent as a file keeps its GPS.** Same as ios#69 and
the same answer: the picker strips through ImagePrep and MediaScrub,
`Session.sendFile` passes the vault's bytes straight through, and the
send screen now says which path you are on rather than leaving the safer
one looking like a shortcut.
2026-09-05 14:08:27 -04:00
Paul Hitt ba47041d03 Merge branch 'ship/android-0-9' into 'main'
Android 0.9 — published

See merge request paulhitt/flamenet-messenger-android!71
2026-09-05 16:05:36 +00:00
Paul Hitt 581967289a Android 0.9 — published
🔴 **0.9, not 0.10.** The tree said 0.10 because "Put the app back to the
last state that worked" bumped past 9 without publishing anything, so 9
belonged to nothing and the live manifest still said 8. Going back to 9
keeps the published line contiguous; the only rule that matters here is
that `versionCode` never goes backwards from what is INSTALLED, and
nothing carrying 10 was ever served.

Published: sha256 b86219dc…, signed CN=Flamenet Messenger with
791fc826…, the same certificate as every build since 0.3 — an update
installs over 0.8 rather than being refused.

⚠️ The APK went into downloads/ BEFORE the manifest, and the served copy
was hashed over HTTPS rather than trusted as copied. A manifest that
lands first announces a version the file is not, and every client that
checks in the gap downloads 0.8 and rejects it against 0.9's hash.

This is the first release through the notes gate that landed one commit
earlier: `release-manifest.sh` refused to emit anything until
`Resources/releases.json` in the relay named Android 0.9.
2026-09-05 12:02:34 -04:00
Paul Hitt 155168a244 Merge branch 'ship/release-notes-gate' into 'main'
An APK no longer ships without a word about what changed in it

See merge request paulhitt/flamenet-messenger-android!70
2026-09-05 15:56:54 +00:00