aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-03 13:41:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-03 14:24:54 +0200
commiteac753c436d64adf921ae2cbdd0d09e095a5e5a3 (patch)
tree63117b0a9439b825b70c73ac28ac358d6249c43b
parent1f19ccaa583734951d5fcccebb06b073a2b1a111 (diff)
downloadmeshbay-eac753c436d64adf921ae2cbdd0d09e095a5e5a3.tar.gz
docs: the Android client
Design §11.3/§11.4/§15 state what is built and what the phone found; the user guide drops 'no Android client'; CLAUDE.md gains the package's locators and the lessons casting from a phone taught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md55
-rw-r--r--docs/MESHBAY_DESIGN.md65
-rw-r--r--docs/USERGUIDE.md17
3 files changed, 128 insertions, 9 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 1ea823f..4f39e36 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -28,7 +28,9 @@ meshbay/
├── packages/
│ ├── meshbay-common/ # Shared crypto + protocol — python3-meshbay-common RPM
│ ├── meshbay-hub/ # Hub server (FastAPI + PostgreSQL) — meshbay-hub RPM
-│ └── meshbay-node/ # Node daemon + local UI — meshbay-node RPM
+│ ├── meshbay-node/ # Node daemon + local UI — meshbay-node RPM
+│ ├── meshbay-client/ # Desktop client (Electron)
+│ └── meshbay-android/ # Android client (WebView shell, Kotlin) — README.md
├── poc/ # POC spike scripts (reference, not production)
├── docs/ # Architecture drafts and POC plans
├── packaging/ # RPM spec files, DEB control files, systemd units
@@ -882,6 +884,43 @@ do. Read them before writing anything that touches the same mechanism.
message later by `update_groups`, which assigned its list verbatim. A limit
enforced on one path is not enforced
+- **Chromium freezes a hidden page sixty seconds after hiding it**, in an
+ Android WebView as in a tab, and nothing about the *process* changes that: a
+ media-playback foreground service, a partial wake lock, a Wi-Fi lock and
+ `setRendererPriorityPolicy` all failed to keep a casting page alive with the
+ screen off (spike, measured by a heartbeat and the page's own `freeze`
+ event). What works is the shell telling the WebView its window is still
+ visible (`ShellWebView.keepVisible`) — set while a cast runs and only then.
+ Audible audio also exempts a page; a phone humming in the room is not a fix
+
+- **A first chunk is not a header.** The relay took the page's first decrypted
+ chunk as the stream's header. On a real film that chunk was by turns 64 KB
+ (header plus film), 28 bytes (the ftyp alone, the moov in the next push), or
+ the header exactly — depending on when the node read ffmpeg's output — so the
+ same film cast on the third try and not the first. The header is everything
+ before the first moof; boxes, not chunks, are the unit
+
+- **A seek's first segments came during its own landing and were thrown away.**
+ `reinitAt` waits on the SourceBuffer; the new stream's first segments arrived
+ in that gap and the `awaitingInit` flag dropped them as the old film's. The
+ local player never showed it — its SourceBuffer kept the header from the
+ start of the film — and a cast relay restarted at that landing received a
+ stream with no header. Ordering, not the flag, says which stream a segment
+ belongs to: everything after `stream_init` is held and replayed
+
+- **A drop threshold sized for small chunks drops whole segments.** The relay
+ dropped a fragment once 8 MB waited for a receiver; at a film's bitrate one
+ fragment is 5–10 MB, so a receiver simply reading at playback speed lost
+ fragments and the picture froze for each. Nothing logged it until a drop
+ counter was added. The lead now waits in a spool file
+
+- **When a receiver stops answering, prove which leg failed before reading code.**
+ A first cast failed every time and two code fixes changed nothing; the cast
+ framework's own log said `onSocketConnectionFailed … IO Error` to the
+ receiver's port 8009, and the phone could not ping the receiver while this
+ machine could. The receiver was half-crashed; a power cycle fixed it. A
+ machine that reaches the receiver proves nothing about the phone
+
**Corrections that used to live here** — `punch_nat()` is not a traversal stack,
the node keystore's Argon2id parameters, what group chat actually uses, and what
is sealed on the wire — are now design statements in `docs/MESHBAY_DESIGN.md`
@@ -989,6 +1028,20 @@ here are kept only where they are a rule about *editing* the code.
| Node page | `node-page.js` | §6.7 |
| i18n | `i18n.js`, `locales/*.js` | `en.js` is the source; **ten catalogues, and a new key goes in all ten** |
+### Android (`packages/meshbay-android/`)
+
+Built with `./gradlew assembleDebug` / `assembleRelease` (JDK 17+, an Android
+SDK); `./gradlew testDebugUnitTest` runs the JVM tests, which pytest also runs
+when `ANDROID_HOME` is set (`test_android_shell.py`).
+
+| Need | File | Note |
+|---|---|---|
+| The shell, the asset loader, the CSP | `MainActivity.kt`, `shell/UiAssets.kt` | the CSP is `main.js`'s, held equal by `test_android_shell.py` |
+| The bridge | `assets/bridge/meshbay-bridge.js` (the preload's counterpart), `bridge/Bridge.kt`, `Channels.kt`, `KeyChannels.kt` | **absent, not refusing**, for what a phone lacks; every channel the shim names is the preload's |
+| Keys | `keys/Kdf.kt`, `Keyring.kt`, `Transcripts.kt`, `SecretStore.kt`, `DeviceKey.kt` | **held to `meshbay-hub/tests/vectors/keyring.json`**; regenerate it with `gen_keyring_vectors.js` only for a deliberate format change |
+| Downloads | `save/SaveSinks.kt`, `SaveNames.kt` | `OPENABLE` is `downloads.js`'s, compared by `test_android_downloads.py` |
+| Casting | `cast/CastRelay.kt`, `CastControl.kt`, `CastChannels.kt`, `CastService.kt`, `shell/ShellWebView.kt` | `MeshBayCast` in logcat says what the receiver and the relay did |
+
### Test harnesses that drive the real thing
| Need | File |
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 3ce0a67..58bbbdb 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -3343,6 +3343,45 @@ A client, not a host. The platform is hostile to *hosting* a node — background
execution, storage, battery — and fine as a *client*, which is one of the reasons
enrichment is node-side (§6.5).
+**The application is the desktop client's design in a system WebView**
+(`packages/meshbay-android/`). What §8.2 depends on is not Electron but an
+engine with `RTCPeerConnection`, WebCrypto X25519/Ed25519 and MSE, the
+interface loaded from the package, and a privileged side reached through a
+narrow bridge — and Android has all three:
+
+- **The interface is the hub's `static/`, copied at build time** (§8.3) and
+ served from the APK by an asset loader under
+ `https://appassets.androidplatform.net` — a secure context, so `crypto.subtle`
+ exists. Nothing the hub serves is ever loaded into the WebView; the policy is
+ the desktop's, sent as a header.
+- **The bridge offers the desktop preload's `window.meshbay`** wherever it
+ offers anything. What a phone does not have — the node, shared folders, the
+ tray — is **absent, not a function that refuses**: `platform.js` decides what
+ to show from whether an object exists. The bridge answers the packaged
+ origin's top-level document only (`addWebMessageListener`, `isMainFrame`); a
+ same-origin child frame does get the port, and is refused there.
+- **Keys are held natively** (§3.7, §14.1 #20): device key, bundle key and every
+ node identity, under an Android Keystore key, never handed to the page. The
+ Kotlin keyring is a third implementation of the bundle format and the
+ transcripts, so **`tests/vectors/keyring.json` — generated from the desktop
+ keyring and checked against the specification — is what every implementation
+ must reproduce**; a list the vectors cannot hold (the admin operations that
+ may be signed) is compared by source.
+- **Hub calls leave from native code**, to the signed-in hub only, as on the
+ desktop. **Downloads go to disk** through the Storage Access Framework or the
+ Downloads collection, as pending files until complete; uploads come through
+ the system picker.
+- **A hidden page is frozen by Chromium sixty seconds after it is hidden** —
+ measured, and whatever the process's importance: a foreground service, wake
+ locks and the renderer's priority policy do not prevent it. A cast's pipeline
+ lives in the page (WebRTC → decrypt → relay), so while one runs the shell
+ keeps the WebView reported visible and holds a media-playback foreground
+ service; nowhere else, because a page never hidden is never throttled.
+
+Release builds are signed with the development key until the release key exists
+(Stage D12); §2.3's sentence about who holds a signing key applies to whichever
+store distributes them.
+
### 11.4 Casting
An HTTP relay in the desktop client serves a standard fragmented-MP4 stream that
@@ -3357,6 +3396,20 @@ within two. The main process holds what the scan has found and the page polls it
waiting on one call for the whole scan; a poll uses the same checked `handle()`
door as every other call, where a pushed event would be a second one.
+**The Android relay is the desktop's, with three things the phone found.**
+(1) **The header is everything before the first moof**, however many chunks it
+arrives in: the node's first chunk can be the 28-byte ftyp alone, and served as
+the header it left the receiver without a moov. (2) **What a receiver has not
+read waits in a spool file, not in memory.** A fragment is a segment — 5 to
+10 MB at a film's bitrate — and the page runs ahead of the television by its
+whole read-ahead; dropped past the desktop's 8 MB bound, each lost fragment
+froze the picture for its length. (3) **A seek's first segments are held while
+it lands** (`video-player.js`): they are the new stream, its header first, and
+they arrived while `reinitAt` waited on the SourceBuffer and were dropped as the
+old film's — the local player never noticed, a relay restarted there did. The
+local element keeps playing while a cast runs, because its playhead paces the
+stream, and is muted.
+
**Subtitles are rebased onto the relay's clock before they are sent.** The node
extracts a track whole, so its cues carry the film's timeline, and the player
can use them unchanged because its SourceBuffer is given `timestampOffset =
@@ -3814,7 +3867,9 @@ pause and resume, the group-application framework with Chat, Files, Videos,
Music and Photos, cross-group search with source merging, per-account playlists,
casting to a Chromecast with subtitles rebased onto the relay's clock, the
operator CLI and loopback control API, the desktop client through its identity
-and download stages, account recovery, and the Windows port through packaging.
+and download stages, account recovery, the Windows port through packaging, and
+the Android client — the shell, native keys, downloads and uploads, and casting
+(§11.3).
The packages install: a machine has been taken from the built artefacts to a
running hub and node on **Ubuntu 26.04 (`.deb`), Fedora 44 (`.rpm`) and
@@ -3842,13 +3897,19 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| — | **Bitmap subtitles** (PGS, VOBSUB — about a fifth of the embedded streams). No WebVTT without OCR; they are not listed rather than listed and blank. Burn-in covers them and costs `-c:v copy`, which is what the eight-slot sizing assumes never happens |
| — | Delegation (§3.4) |
| — | Tier 3 roster attestation (§3.3) |
-| — | Android client |
+| — | **Android: phone behaviour and release** — the back button driving the page, recovery from a network handover, keeping a download alive with the screen off, lock-screen media controls, and a release key (§11.3) |
| — | **Federation between two hubs.** The protocol is written and switched off in the code (§7.6); what is not built is one run between two machines |
### 15.3 Open, and why each is where it is
| Item | Status |
|---|---|
+| **The desktop cast relay drops whole segments** | `cast-relay.js` drops a fragment once 8 MB wait for a receiver, and a fragment is a segment, 5 to 10 MB at a film's bitrate: the picture freezes for its length. Its backlog is bounded in fragments only. The Android relay spools a receiver's lead to disk and bounds its backlog in bytes (§11.4); the desktop has neither |
+| **The desktop cast relay takes the first chunk for the whole header** | The node's first chunk can be the ftyp alone, the moov in the next; the receiver then gives up. Fixed in the Android relay (§11.4), not in `cast-relay.js` |
+| **A cast restart may pull far ahead** | Seen once on an emulator with a synthetic film: after the restart's reinit the node reported `duration=None`, and the page pulled most of the film at network speed. Not reproduced on a real film; the suspicion is a read-ahead budget computed without a duration |
+| **"Copy stream URL" after picking a receiver casts to that receiver** | The player keeps the last device chosen, so the copy-only path reconnects it instead of only starting the relay |
+| **The home page says "No groups yet" when the hub cannot be reached** | An unreachable hub reads as an account with no groups, rather than as an error |
+| **`meshbay-node init` says "Settings → Link Node"** | The control is on the Profile page, as QUICKSTART says |
| **C4** for accounts with browser access | Closed against operators by the pepper; **open against an active hub**, which holds the pepper and can fetch a bundle with a token it mints — the adversary T3 already concedes for browsers (§3.7) |
| **A desktop that never held an identity cannot open its recovery copy** | The application opens a node's passphrase copy, not the recovery copy: after a reset, an identity it never held is recovered from a browser (§3.6). And an identity the application mints gets a recovery copy only when the recovery key is entered on its Profile page with browser access on |
| **T3** for browser users | **Accepted permanently.** Removed for native clients, and that removal's value depends on reproducible builds |
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index d631804..ad51982 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -392,9 +392,10 @@ resume.
### Casting to a TV
-**Desktop application only.** A film playing in the application can be sent to a
-cast-capable TV or dongle on the same network: *Cast to device* in the player,
-pick one from the list. Devices appear as they answer, usually within a couple
+**The desktop and Android applications.** A film playing in the application can
+be sent to a cast-capable TV or dongle on the same network: *Cast to device* in
+the player, pick one from the list. On a phone, the film goes on playing silently
+on the phone while the TV shows it, and the screen can be turned off. Devices appear as they answer, usually within a couple
of seconds; the search carries on for a few more, for a TV that is still
waking up.
@@ -967,12 +968,16 @@ Better to know now than to go looking for it:
is nothing to check a download against and no updates through your
distribution. Until that ships, take them from the download page and from
nowhere else.
-- **There is no Android client.** A phone browser works.
+- **The Android application is not released yet.** It works — groups, chat,
+ films, downloads, casting — but is built and installed by hand and signed
+ with a development key, so there is no store page and no update channel. The
+ back button and the lock screen do not drive it yet. A phone browser works
+ too.
- **A node cannot be hosted on Android**, and is not planned to be.
- **Hubs do not talk to each other yet.** Everyone in a group needs an account
on the same hub.
-- **Casting reaches Chromecast devices** from the desktop application. Support
- for other TV protocols is designed but not built.
+- **Casting reaches Chromecast devices** from the desktop and Android
+ applications. Support for other TV protocols is designed but not built.
- **Some subtitle tracks cannot be shown** — the ones stored as images rather
than text, roughly one embedded track in five. Displaying them would need
text recognition.