43baac80b7b55efee74f06e380f4e6db053d6b7f
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |