Commit Graph
7 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 e5815d27e2 Android app lock, matching iOS
The last of the four privacy features Android was missing that iOS already had.
The sealed history file protects a phone that is off or locked; it does nothing
about the phone somebody just handed to a friend. iOS asked for Face ID; on
Android anyone holding an unlocked handset read every conversation.

Biometrics OR the device credential, deliberately - a phone with a PIN and no
fingerprint reader should still be able to use this, the same reason iOS asks
for deviceOwnerAuthentication rather than biometrics specifically. Offered only
when the device can actually authenticate: a switch that turns on and then
cannot show a prompt locks somebody out of their own messages with no way back.

Two traps worth naming. MainActivity had to become a FragmentActivity -
BiometricPrompt hosts a fragment, so on a plain ComponentActivity it silently
cannot show and the callback never fires, which looks like a broken lock rather
than a missing base class. And the gate wraps Root rather than drawing over it:
composed underneath, the message list still renders for a frame, and a frame is
all a screenshot or a shoulder needs.

The lock screen deliberately says nothing - no sender, no preview, no count. A
lock screen reporting "3 new messages from Dana" has given away the two facts
that usually matter most.

The setting states what it does NOT do. This stops someone who picks up your
unlocked phone; it adds no encryption, because the vault and ratchet keys are
already sealed by the keystore either way. Claiming more would be selling a
screen as if it were a key.

Android engine, client and app suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:21:03 -04:00
Paul HittandClaude Opus 5 94337cbc07 Hold an undelivered message and send it when they come back
Asked: how does the sender know to try again when the other person has no
registered device - especially since they are friends. They should not have to.
Marking it undelivered and stopping there moves the problem onto the person who
did nothing wrong: they would have to remember, notice their friend was back,
and retype it.

So the message goes into an outbox and is retried on every poll tick. Nothing
expires and nothing is discarded - a contact who reinstalls in a month gets it
in a month. Timing out would mean deleting something somebody wrote in order to
tidy up a label.

The queue has to live on the sender's phone, and that is forced by the
encryption rather than chosen: every envelope is sealed to a particular
device's keys, so somebody with no device has no keys to seal to, and the relay
could only queue plaintext. The consequence is that it goes out the next time
THIS app runs and polls, not the instant the recipient signs in - so the label
says "Undelivered - will send" rather than implying it is in flight.

Delivery is now a tri-state. PENDING is quiet and grey because it is the normal
thing that happens when a friend is signed out and there is nothing to do about
it; only FAILED is a warning, and the outbox never produces one.

OutboxTest covers it end to end, including delivery against a real second
identity rather than a hand-written bundle: queued while away, sent when they
register, and the bubble restated - a version that delivered the message but
left it reading "undelivered" would pass a test that only checked the queue.

Engine, client and app suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:06:19 -04:00
Paul HittandClaude Opus 5 d8b8d63687 Render Android screens without an emulator
Verifying a layout meant booting an 8 GB AVD, installing an APK,
tapping to a screen and pulling a screencap — and the emulator was
killed by memory pressure four times in one session, each time
mid-check. Robolectric in native-graphics mode draws the same Compose
pixels on the JVM. `Scripts/screenshots.sh` now renders the screens in
about four seconds with no device.

Two things had to be got right and both fail in a way that reads as
something else:

`isIncludeAndroidResources` is not optional — without it the theme
cannot resolve and every render throws on a missing resource, which
looks like a Compose problem rather than a configuration one.

🔴 And the capture. `captureToImage()` goes through PixelCopy and waits
for a frame-commit callback Robolectric never delivers, so every render
died as "Condition still not satisfied after 2000 ms" — which reads as
a hung composition and is nothing of the sort. Drawing the decor view
into a software canvas produces the same pixels with no window
involved. `createComposeRule()` has the same problem for the same
reason; it needs the Activity rule.

⚠️ These are output, not assertions: they write files and pass unless
something throws. A screenshot test that diffs against a stored
baseline is a different thing, worth adding once the design stops
moving — right now it is moving, and a baseline would be re-blessed on
every change, which proves nothing.

This immediately paid for itself. The address card had only ever been
seen in its empty state, because the emulator's stored session predates
the fnid and a fresh sign-in erases that phone's keys. It renders
populated here, and the QR in it — encoded by our own Kotlin encoder,
drawn by the app's own Compose code — decodes back to the exact address
through CoreImage's detector, which shares no line with any of it.

Rendered at the kit's own 375x812 canvas, so a PNG can be put beside
the Sketch artboard rather than mentally rescaled from a 1080x2400
phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:24:50 -04:00
Paul HittandClaude Opus 5 053cefbce4 Two apps, three defects that only driving them could find
The relay is asked what it is, and the answer went nowhere. SiteConfig is a
data class whose only constructor property was `baseUrl`; `discovered` was a
`var` in the body, so the generated equals() ignored it. MutableStateFlow
conflates on equality, which made

    _site.value = config.also { it.discovered = found }

a no-op against the value already there. The descriptor was fetched correctly
and discarded. The sign-in screen sat on "Checking what this server uses…"
forever — the one line whose whole job is to say, BEFORE the passphrase is
typed, whether that passphrase leaves the device — and accountProvider stayed
LOCAL for a relay that had just answered SHARED. Now a constructor property,
written with copy(), with the first JVM unit tests the :app module has ever
had. Confirmed live: the screen reads "One Flamenet account, shared with
Flamenet Files."

Two XCUITest queries matched more than one element and so could tap neither:

- "👍" is in the emoji picker's Gestures row, in its Recent row once picked,
  and in ChatView's quick-reaction bar. Picker buttons now carry a
  section-scoped identifier.
- "Sign out" is both the row in ProfileView and the destructive button in the
  confirmation it opens; SwiftUI keeps both in the tree, so the query was
  ambiguous before the dialog was even reached. Both now identified.

Neither was load — both fail the same way on an idle machine.

Also adds completeSignIn to UITestSupport, the counterpart to completeSignUp.
Sign-ups are rate limited at three an hour per address, so a test that only
needs AN identity should reuse one rather than mint another and fail with a
429 that reads like a broken sign-up screen. CrossPlatformUITests takes
FN_TEST_PASSWORD and signs in when given one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:42:21 -04:00
Paul HittandClaude Opus 5 103a2dda23 Sign the messenger with its own key, not Files'
Every release build before this went out as CN=Flamenet Files. Not a decision —
app/build.gradle.kts read ~/.flamenet-android-signing.properties, a path with no
app in its name, so any Flamenet Android app built on that Mac inherited the
Files identity.

It matters because an Android signing key is permanent. Once a device installs a
build signed with one key it REFUSES every later build signed with another, so
the only remedies after the fact are a new package name — a different app,
orphaning every install — or Play App Signing, which has to be set up before
first publication. Sharing one key across products also means one compromised
keystore takes out both.

Now prefers ~/.flamenet-messenger-signing.properties and falls back to the
shared file, so a machine with only the old setup still produces a signed build
rather than failing in a confusing way.

Verified in the artifact:

    Signer #1 certificate DN: CN=Flamenet Messenger, O=Extant 2000 LLC, C=US
    RSA 4096, SHA-256 791fc826d572a70abfaf7db7fa7784dad0f86f204bb313f0d75ee676df8d3a1d

⚠️ The keystore and its password live outside the repo and there is NO recovery
for either. Losing them means the app can never be updated again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:15:36 -04:00
Paul HittandClaude Opus 5 b4aadda162 Android: voice calls, peer to peer by default
The one third-party library in this app, and unavoidable: the alternative to
`io.github.webrtc-sdk:android` is hand-rolling ICE, DTLS-SRTP and an audio
pipeline. Worth naming rather than absorbing quietly, because everything else
here is deliberately auditable and this is a large native blob that is not.

🔴 **Peer to peer by default.** ICE tries a direct path first — same network,
then each side's address as seen through NAT — and only falls back to relaying
through TURN when both ends sit behind NAT that refuses one. `iceTransportsType`
is ALL rather than RELAY: forcing every call through the server is the opposite
of the default chosen here.

**Signalling rides the ratchet, not the relay.** The offer and answer are
ordinary content envelopes, so the SDP — codecs, and every candidate address —
is end-to-end encrypted like any other message. A relay that could read
signalling would learn both parties' addresses even on a call it never carries.

⚠️ **A direct call shows the other person your IP address.** That is what makes
it direct, and it is not something to find out afterwards. Explained before the
FIRST call, and it waits for an answer — once, not every time, because a prompt
people see repeatedly is one they stop reading. The acknowledgement lives in
ordinary preferences rather than the vault: it is not a secret, and storing it
encrypted would re-show the warning on every sign-out, which is how a one-time
prompt becomes one people click past.

⚠️ **No TURN server is deployed** — `FN_TURN_SECRET` and `FN_TURN_HOST` are
unset, so `/e2e/turn` answers 503. Handled rather than hidden: STUN alone still
connects through most NATs, and the banner says plainly that this server has no
relay so the call connects only if a direct path exists. "Calling did not
connect", with no reason, is the kind of failure people blame on the app for
ever.

Trickle ICE is off. Candidates are gathered before the offer is sent, so
signalling is one encrypted envelope each way rather than a stream of them —
a second or two slower to connect, and far simpler over a channel that is
polled rather than pushed.

⚠️ The microphone is held by the audio source, not the peer connection.
Closing one without disposing the other leaves the mic live with no call
attached, which is the worst failure this particular feature has available.

⚠️ **WebRTC's native libraries dominate the APK**: ~41 MB across four ABIs, of
which x86 and x86_64 (~24 MB) exist only for emulators. Release is restricted
to arm64-v8a and armeabi-v7a — 102 MB debug becomes a 22 MB release — while
debug keeps all four so it still runs on an emulator. Restricted rather than
split per-ABI on purpose: this app is handed to testers as one file, and
per-ABI outputs turn "send them the APK" into "send them the right APK".

A second offer while busy is declined rather than dropped, so the caller hears
something. Group calls are deliberately absent: different key handling, and an
enabled-looking button that cannot work is worse than none.

Tested: 108 engine, 67 client, none failing; debug and release APKs both build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:13:54 -04:00
Paul HittandClaude Opus 5 5f983c1455 Android: the app, and the two things only a phone could tell us
There is now a Flamenet Messenger app that installs, launches, creates an
account on a self-hosted relay, registers a device, publishes prekeys, and
stores all of it in an Android Keystore-backed vault. Verified on an emulator
through the actual screens, not by unit test.

92 JVM tests, 37 cross-engine, 9 on-device, 3 against live relays.

Two findings, both of which the JVM suite was structurally incapable of
producing.

**Android has no ordinary Ed25519 provider.** `KeyPairGenerator.Ed25519` and
`KeyFactory.Ed25519` come from `AndroidKeyStore` and from nowhere else -- probed
on API 35 rather than assumed -- and Keystore only makes keys it keeps. Every
Ed25519 key in this engine is one we must hold: a device identity in our own
vault, an account key derived from a passphrase. So the JCA route could never
have worked. It compiled, passed 81 tests on a laptop, and threw "Not
initialized" on the first launch on a phone. Worse, it was LATENT: a wrong
password failed before any key was minted, so the error path worked perfectly
while the happy path was broken.

Ed25519 now uses BouncyCastle's `math.ec.rfc8032` core directly -- the engine's
one real dependency, present because the platform leaves a hole, and a strictly
better answer than the non-constant-time keygen it replaces. ProviderProbeTest
records the measurement so nobody guesses again.

**The prekey field is `prekeys`, not `opks`.** I guessed. The relay accepted the
registration, returned 200, and stored nothing -- so the device was published
and could not be started with, a failure with no error anywhere. Caught by a
live test against the throwaway fleet, which exists because the same path
through the emulator takes four minutes to say "timed out" and nothing about
where.

Two testing traps, recorded where they bite:

An indeterminate progress spinner animates for ever, so Compose's test clock
never goes idle and `waitUntil` never evaluates its condition -- it fails on the
full timeout, reading as "sign-up is broken" when sign-up works. Auto-advance is
now disabled around the wait only; disabling it for the whole test is the next
mistake, because the app opens on a spinner while it reads the vault.

Wiping the Keystore in `@Before` deletes the key the already-launched activity
is holding, and every later write fails against a key that no longer exists. The
test signs out through the UI instead, which is both safe and what a person does.

Failure messages now include what the screen was showing. A bare timeout sends
you to the network, the relay and the crypto in turn while the app has been
displaying the reason the whole time.

Also corrects a note that said this Mac had no Android SDK. It does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:02:16 -04:00