Commit Graph
10 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 4.8 b8ffe2ea71 Answer a call without opening the app first
A high-priority notification is a banner, not a ringing phone. On a locked
device the old one showed a line of text, and the only way to act was to unlock,
find the app and open it — by which time the caller has given up. A call is a
live event with seconds on the clock.

Now a full-screen intent with `CallStyle`, so the lock screen shows Answer and
Decline. ⚠️ `CATEGORY_CALL` and `CallStyle` are not decoration: Android 14
grants USE_FULL_SCREEN_INTENT at install only to apps that ring like this, and
without them the permission is refused and the call silently degrades to the
banner it used to be.

⚠️ Decline is a broadcast, not an activity. The point of answering from a lock
screen is that the phone stays locked for the choice the user did NOT make.

🔴 **The notification is `ongoing`**, so it cannot be swiped away by accident
mid-ring — which also means it never dismisses itself, and every path that ends
a call has to cancel it. Four of them: declined, answered, the caller hung up,
and nobody picked up. The last has no event behind it, so the service checks for
a parked offer past the dial timeout; without it the lock screen keeps offering
to answer a call that is long dead.

🔴 `isStale()` asks without consuming. `take()` is the handover and can only
happen once, so a staleness check built on it would swallow a live offer and
drop a call that was still ringing — the exact opposite of the bug it exists to
prevent. There is a test for that specifically.

Answering hands off through the SAME parked offer the app already uses on
launch. This session consumed the envelope, so the SDP in hand is the only copy
— a second answer path would be a second way to lose a call.

engine 168, client 96, app 46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 13:05:51 -04:00
Paul HittandClaude Opus 4.8 53ba8ea42e One MainActivity, ever: a stacked instance made hang-up a no-op
The default launch mode stacked a second MainActivity — with a second
ViewModel, session and CallManager — whenever the app was re-entered.
The visible screen's Hang up then commanded a manager with no call while
the real call rang on underneath, and the peer never got the END.
singleTask, plus a 30s connect guard so an answered call whose ICE never
finds a path gives up out loud on both screens instead of sitting in
Connecting forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 21:11:51 -04:00
Paul HittandClaude Opus 4.8 cf3f43fdbf video calls: capture/render/signal + video buttons; story card sizing; Alerts off the nav; avatar live refresh; sealed-key eviction
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:11:52 -04:00
Claude 28d7485c26 The service was declared under the wrong package and never resolved
The manifest said ".MessengerService" while the activity beside it says
".app.MainActivity" — so the class resolved to
io.flamenet.messenger.MessengerService, which does not exist. `dumpsys
package` reported "No services found" and starting it would have thrown
ClassNotFoundException at the moment the app was backgrounded, which is
the moment nobody is watching.

Verified on the emulator after the fix, end to end:
  - backgrounding the app starts it, isForeground=true, types=0x200
    (REMOTE_MESSAGING), ongoing notice on flamenet.ongoing
  - iOS sent PUSH2 at 932.474; the Android notification posted at 931.466
    on a device whose clock matches the host — so the alert arrived about a
    second BEFORE the sender finished rendering its own copy
  - repeated with a fresh marker to prove it was not a stale notification:
    when= moved 848.867 -> 931.466

That is a closed app on Android alerted in about a second with nobody in
the middle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:09:19 -04:00
Claude 30d4829ad0 Alert people when the app is not on screen
Two platforms, two mechanisms, because iOS leaves no choice.

Android gets MessengerService, a foreground service holding the long-poll
with nobody in the middle. It rebuilds its session from the vault rather
than sharing the ViewModel's — by the time it runs the Activity may be
gone, which is the situation it exists for. Notifications for messages,
and a high-priority one naming the caller for an incoming call, because an
unnamed ringing phone is not answerable.

⚠️ The persistent notification is not a bug. Android requires a visible
notice for a foreground service; removing it means the OS kills the
connection. That is the price of not routing wake-ups through Google.

⚠️ Exactly one poller at a time: the ViewModel polls while the app is on
screen, the service while it is not, handed over in onStart/onStop. Two
would race for the same envelopes and double-notify, so the pair has to
stay symmetrical.

iOS gets APNs, and that IS Apple on the delivery path — there is no
alternative, because iOS suspends a backgrounded process and provides
exactly one way to wake it. What Apple learns is metadata: that a device
was woken and when. The push is content-free by construction, since the
relay holds ciphertext it cannot read. ⚠️ Nothing may later put the
message into the push payload; anything that could would mean the relay
had read it.

Relay stores one token per DEVICE, not per user — messages are sealed per
device, so waking the wrong one wakes an app with nothing to decrypt.
Re-registering replaces rather than accumulates, because APNs reissues
tokens and stale ones wake devices that no longer exist. An empty token
means forget: a sign-out must reach this, or a handset goes on being woken
for an account it no longer holds. Android tokens are refused outright —
it receives directly and needs no row.

Not yet done: the APNs sender itself, which needs a .p8 auth key that only
a human with the developer account can mint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:54:25 -04:00
Paul HittandClaude Opus 5 f90c6477bb Calling killed the app, twice over
🔴 **Three separate defects, and calling could not work past any of them.**
Found by tapping the Call button, which is not covered by any test.

**1. The button did nothing.** `requestCall` sets `callPrivacyPrompt`, and the
only thing that renders it is `CallBanner` — which was composed inside
`SignedIn`, the HOME screen. The chat screen REPLACES that. So the only Call
button in the app set a flag nothing was on screen to show, and since the
prompt gates placing the call, calling could not be started at all. The button
looked simply dead.

That also means an incoming call arriving while somebody was reading a thread
displayed nothing — directly against the comment above it, which says an
incoming call must be visible whatever screen somebody is on. CallBanner is
now hoisted above the screen switch.

**2. Placing a call SIGABRTed the process.** The manifest never declared
ACCESS_NETWORK_STATE. WebRTC's NetworkMonitor calls
ConnectivityManager.getNetworkState() on its own network thread, that throws
without the permission, and the JNI layer immediately asserts
RTC_CHECK(!env->ExceptionCheck()) — turning a Java exception into abort().
No AndroidRuntime trace, nothing catchable, the process just vanishes:

    Check failed: !env->ExceptionCheck()
    Fatal signal 6 (SIGABRT) in tid network_thread

**3. Hanging up SIGABRTed it again, re-entrantly.** `onIceConnectionChange`
treated CLOSED as a reason to hang up — but CLOSED is exactly what our own
`close()` produces. So Hang up called hangUp, which closed the connection,
which fired CLOSED on the signaling thread, which re-entered hangUp, which did
a BLOCKING NETWORK SEND and disposed the connection it was running inside.

hangUp is now re-entrancy-guarded, CLOSED is a no-op (we caused it), FAILED
still hangs up, `dispose()` is guarded like the `close()` beside it already
was, and the courtesy "call ended" signal is best-effort rather than something
that can take the teardown down with it. The observer body cannot throw at all
now, because WebRTC treats any escaping exception as fatal.

Verified on the emulator: dialling brings up Mute/Hang up and the app survives
both dialling and hanging up.

⚠️ Still unproven: whether AUDIO actually flows. That needs two live endpoints
and a TURN server, and none is deployed. What is fixed is that calling is
reachable and no longer kills the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:05:31 -04:00
Paul HittandClaude Opus 5 cda64d8c2e A contact who reinstalled could never be messaged again
🔴 **The chat header said "keys unchanged since you met" while every send was
refused with "identity keys changed".** Found by driving two real apps: the
iOS side relaunched with -FNMSGResetState, which is a genuinely new device with
new keys, and Android correctly refused to send to it. Correct — and then
there was no way out.

`Trust.state()` could return UNKNOWN, VERIFIED or PINNED and nothing else.
CHANGED was computed inside `record()` while a send was in flight and thrown
away, because `Vault.Trust` had nowhere to remember it. So:

- the header kept reporting the OLD pinned keys as unchanged,
- the "Accept new keys" button, which only renders for CHANGED, never appeared,
- `acceptIdentityChange()` was unreachable from the UI,
- and the contact stayed unmessageable for ever, with the app insisting
  nothing was wrong.

That is precisely what a tester does on day one: reinstall, and discover their
friend can no longer reach them.

`Vault.Trust` now carries `changed`, `record()` persists the detection without
adopting the offered keys and drops the verified tick (whatever was compared in
person was compared against the old key), `state()` reports it, and accepting
clears it. Keys that match again also clear it, so a transient wrong answer
does not strand somebody behind a banner for ever.

Verified live on the emulator: the banner appears with its full text, Accept
new keys is reachable, and the header returns to "keys unchanged" afterwards.
Four tests in TrustTest; three of them fail against the old `state()`.

---

🔴 **An expired session told the user their passphrase was wrong.** Hit
immediately afterwards: a send failed with "That handle or passphrase isn't
right", ten hours after a successful sign-in, because AccountsClient mapped
EVERY 401 and 403 to BadCredentials. The natural reading is that the password
stopped working or was changed by someone else, and the natural response is to
change a password that was never wrong.

Only `sessions` and `accounts` actually present a passphrase, so only those can
report one as wrong. Everything else now raises SessionExpired, which the app
states as what it is. Two tests cover both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 07:09:08 -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 4728928d98 Android: voice notes
The engine and the client already carried audio — `Attachment.AUDIO`, a
`duration` field, and a `sendAttachment` that takes both. What was missing was
entirely the phone: something to record with, something to play with, and two
controls. So this is app-layer only, and adds no dependency: `MediaRecorder`
and `MediaPlayer` are platform APIs, and a voice note is an ordinary encrypted
attachment the moment it is bytes.

AAC in an MP4 container, mono, 32 kbit/s. Speech rather than music: clearly
intelligible, and roughly a tenth the size of the stereo default nobody asked
for — which matters when every byte is uploaded encrypted and stored by
somebody paying for it.

⚠️ **Tap to start, tap to send — not hold-to-talk.** Hold-to-talk demonstrates
well and behaves badly on a real phone: the gesture competes with scrolling,
and letting go early silently discards what was said. Two deliberate taps
cannot be lost by accident.

⚠️ **`MediaRecorder.stop()` throws when it captured no frames** — a tap instead
of a hold, or a permission granted and then denied by policy. The exception is
a bare `RuntimeException` and it reads as "recording is broken" when the truth
is "there was nothing to record". Treated as an empty recording, and anything
under half a second is dropped rather than delivered: a note that plays silence
looks like a delivery bug, and the person who fumbled the button already knows
they did.

⚠️ **The microphone permission is requested when somebody asks to record**, not
at startup. A microphone prompt on first launch, before anything has been said,
is one people deny on reflex — and a denied permission is far harder to recover
from than one that was never asked for.

⚠️ Received notes are fetched and decrypted **on tap, never on render**. A
thread of twenty would otherwise download and decrypt all twenty on scroll:
bytes nobody asked for, and twenty decrypted files in the cache for a list
nobody has listened to. Playback stops on leaving the thread, because a
MediaPlayer that outlives its composable holds audio focus and a file handle.

Every temporary file — the recording, and the decrypted copy playback needs
because `MediaPlayer` cannot take a byte array — is deleted on both the success
and the failure path. Plaintext audio left in a cache directory is the exact
thing this product is not supposed to do.

`imageBytes` became `attachmentBytes`: photos and voice notes take the same
path, and a method called `imageBytes` that audio also calls is a name that has
quietly stopped being true.

Also retires buddy commands in the parity table — no longer a feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:37:31 -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