aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md55
-rw-r--r--docs/MESHBAY_DESIGN.md260
-rw-r--r--docs/USERGUIDE.md20
-rw-r--r--packages/meshbay-android/.gitignore6
-rw-r--r--packages/meshbay-android/README.md64
-rw-r--r--packages/meshbay-android/app/build.gradle.kts88
-rw-r--r--packages/meshbay-android/app/src/main/AndroidManifest.xml43
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js206
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt288
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt78
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt104
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt117
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Refused.kt4
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/BoxAccumulator.kt87
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt115
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt370
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastRelay.kt446
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastService.kt72
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/hub/HubClient.kt130
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt47
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt106
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt266
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt120
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt183
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveNames.kt74
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveSinks.kt238
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/EngineCheck.kt57
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt48
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/Pickers.kt40
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt25
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt93
-rw-r--r--packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml7
-rw-r--r--packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml7
-rw-r--r--packages/meshbay-android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.pngbin0 -> 17157 bytes
-rw-r--r--packages/meshbay-android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.pngbin0 -> 8754 bytes
-rw-r--r--packages/meshbay-android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.pngbin0 -> 28251 bytes
-rw-r--r--packages/meshbay-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.pngbin0 -> 55758 bytes
-rw-r--r--packages/meshbay-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.pngbin0 -> 87228 bytes
-rw-r--r--packages/meshbay-android/app/src/main/res/values/colors.xml5
-rw-r--r--packages/meshbay-android/app/src/main/res/values/themes.xml6
-rw-r--r--packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml11
-rw-r--r--packages/meshbay-android/app/src/main/res/xml/network_security_config.xml11
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt229
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt39
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt30
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakeSecrets.kt11
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt43
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyChannelsTest.kt81
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyringVectorsTest.kt151
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/NativeTextTest.kt34
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SaveNamesTest.kt45
-rw-r--r--packages/meshbay-android/build.gradle.kts1
-rw-r--r--packages/meshbay-android/gradle.properties2
-rw-r--r--packages/meshbay-android/gradle/wrapper/gradle-wrapper.jarbin0 -> 47623 bytes
-rw-r--r--packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties10
-rwxr-xr-xpackages/meshbay-android/gradlew248
-rw-r--r--packages/meshbay-android/gradlew.bat112
-rw-r--r--packages/meshbay-android/settings.gradle.kts9
-rw-r--r--packages/meshbay-client/package.json2
-rw-r--r--packages/meshbay-client/src/cast-chromecast.js49
-rw-r--r--packages/meshbay-client/src/main.js4
-rw-r--r--packages/meshbay-client/src/preload.js2
-rw-r--r--packages/meshbay-common/pyproject.toml2
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py2
-rw-r--r--packages/meshbay-hub/pyproject.toml2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/__init__.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css201
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js220
-rw-r--r--packages/meshbay-hub/tests/harness/window_leak.mjs7
-rw-r--r--packages/meshbay-hub/tests/test_android_cast.py133
-rw-r--r--packages/meshbay-hub/tests/test_android_downloads.py90
-rw-r--r--packages/meshbay-hub/tests/test_android_keys.py74
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py223
-rw-r--r--packages/meshbay-hub/tests/test_cast_discovery.py44
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py53
-rw-r--r--packages/meshbay-hub/tests/test_keyring_vectors.py142
-rw-r--r--packages/meshbay-hub/tests/test_video_audio_track.py4
-rw-r--r--packages/meshbay-hub/tests/test_video_seek.py65
-rw-r--r--packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js232
-rw-r--r--packages/meshbay-hub/tests/vectors/keyring.json342
-rw-r--r--packages/meshbay-node/pyproject.toml2
-rw-r--r--packages/meshbay-node/src/meshbay_node/__init__.py2
-rw-r--r--packages/meshbay-node/tests/test_attach_from_the_hub.py4
-rw-r--r--packages/meshbay-node/tests/test_node_status.py17
-rwxr-xr-xpackaging/deb/meshbay-hub/DEBIAN/prerm24
-rwxr-xr-xpackaging/deb/meshbay-node/DEBIAN/prerm36
-rw-r--r--packaging/rpm/meshbay-hub.spec6
-rw-r--r--packaging/rpm/meshbay-node.spec19
103 files changed, 6940 insertions, 105 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 189dbc5..525c5c3 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -17,7 +17,7 @@
> it. §13 is the register of those labels.
>
> Wire versions at the time of writing: **MNP 6.0** (oldest peer accepted 4.0),
-> **MHP 0.1**, packages **0.17.0**. The normative source for the wire format is
+> **MHP 0.1**, packages **0.18.0**. The normative source for the wire format is
> `MESHBAY_NODE_PROTOCOL.md`; this document states the design the protocol
> serves, not its byte layout.
@@ -134,76 +134,67 @@ paths, no filenames**.
---
-## 2. Trust model
+## 2. Who you trust
-### 2.1 Adversaries
+MeshBay lets you share things with the people you choose, and keeps everyone else
+out. In plain terms, here is who can see your group — and the one caveat worth
+knowing.
-Every claim below is written against one of these, and they are the only ones the
-document uses:
+### 2.1 Inside your group
-| Adversary | What they can do |
-|---|---|
-| **Passive hub** | Read everything the hub legitimately stores and relays |
-| **Active hub** | Also lie: forge tokens, invent accounts, substitute values it publishes, ship modified client code to a browser |
-| **Malicious node operator** | Read and alter everything on their own machine, including the plaintext files they host |
-| **Malicious group member** | Everything a member may do, plus anything the protocol fails to refuse |
-| **Network attacker** | Observe and tamper with traffic between any two parties |
-| **Local attacker** | Reach loopback services and files on a client or node machine |
-| **Registered hub user with no membership** | Reach every hub endpoint that does not check membership |
-| **Federated peer hub** | Push directory rows and revocations over MHP |
+A group lives on a node, run by its operator. Everyone in the group — the operator
+and the members — shares its files, its index and its chat. That is what a group
+*is*: the people you decided to let in, the same as a shared folder or a team
+workspace. They are the people you trust, and they are the only ones who can read
+what you put there.
-### 2.2 Security claims
+Two things follow from that by design — they are the shape of a shared space, not
+gaps in it:
-| Claim | Passive hub | Active hub | Malicious node operator | Malicious member | Network attacker |
-|---|---|---|---|---|---|
-| Data never transits the hub | ✅ | ✅ | — | — | ✅ |
-| File content is unreadable | ✅ | ❌ **T3** (browser) · ✅ native | ❌ by design — the operator hosts the files | ❌ members share the group key | ✅ |
-| The file index is unreadable | ✅ | ❌ T3 · ✅ native | ❌ | ❌ | ✅ |
-| Chat content is unreadable | ✅ | ❌ T3 · ✅ native | ❌ — the operator is a member | ❌ | ✅ |
-| Chat is unreadable **from a copy of the node's storage that lacks its unlock key** — not from a whole disk by default (§4.5) | ✅ | ✅ | — the operator holds the unlock key | ✅ | ✅ |
-| Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ |
-| The node cannot be impersonated | ✅ | ✅ | — | ✅ | ✅ |
-| Client code integrity | ❌ **T3, accepted** (browser) · ✅ ships in the package (native) | ❌ T3 · ⚠️ native: **detectable, not prevented** | ✅ | ✅ | ✅ |
-| The hub cannot obtain the group key | ✅ | ✅ **except** in an open-join group, where it can join legitimately (§7.3), and for an invitation the inviter asked the hub to mail, whose code it then holds (§3.4) | — | — | ✅ |
-| Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ |
-| Devices cannot be added by the hub | ✅ | ✅ — the hub holds no user key and cannot countersign | ⚠️ a node adds a device only to itself, where it already reads everything | ✅ | ✅ |
-| Chat senders are authenticated to each other | ✅ | ✅ | ⚠️ only for accounts the reader has already seen (§3.3) | ✅ | ✅ |
-| Keypair bundles (**C4**) | ✅ it holds the pepper and no bundle | ⚠️ it can fetch a bundle with a token it mints and holds the pepper: an offline passphrase search, as T3 already concedes for browsers · none to fetch for an account without browser access | ✅ no offline search: the bundle does not open without the hub's pepper — only sign-in attempts, bounded and audited · none on disk for an account without browser access | — | — |
-| Deleting your account erases you | ✅ hub-side | ✅ hub-side | ❌ files, pinned identity and bundle stay on the node (§7.7) | — | — |
-| Your identity keys stay yours | ✅ | ⚠️ as the row above | ✅ the bundle they hold does not open without the pepper, and a leaked bundle key opens **that node's** bundle only | ✅ | ✅ |
+- Your operator hosts your files, so they can change what they host. A node's
+ content is the operator's to keep and to serve (§7).
+- Leaving a group removes you on the hub, but copies already on a node stay with
+ its operator (§7.7) — as with anything you have handed to someone in person.
-### 2.3 What the project must not claim
+### 2.2 Everyone else sees nothing
-Three sentences are forbidden, each for a deliberate reason:
+Outside your group nobody can read what you share — not the hub that connects you,
+not anyone watching the network, not the operator of any *other* group:
-- **"Everything is encrypted and unreadable by other parties, even the hub."**
- A hub that ships the code can lift keys from the page regardless of protocol
- design (**T3**). That is an artifact-level attack, not a silent directory lie,
- and it is removed for native clients — not for browsers.
-- **"A native client makes the hub untrusted."** It converts an undetectable,
- per-request, per-user attack into a persistent artifact that can be hashed and
- compared. That value is realised by reproducible builds and published hashes,
- not by the packaging format. A build signed with a key the hub operator holds
- *relocates* trust; it does not remove it.
-- **"C4 is closed."** It is closed for an account whose identities the desktop
- application keeps (browser access off, §3.7): nothing of them is on any node.
- For an account with browser access the bundle is still on each node, sealed
- under the passphrase **and** a pepper the hub holds — no operator can search it
- offline, but an active hub can, and that is T3's adversary already. **An account
- is only as strong as its weakest client.**
+- Your content never passes through the hub in the clear. The hub introduces nodes
+ to one another and relays sealed traffic; it does not hold what you share (§5).
+- The hub holds no key of yours. It cannot read your group, add a device to your
+ account, or act as you — and it cannot hand your group's key to anyone, save
+ where you ask it to (an open-join group, or an invitation you asked it to mail;
+ §7.3, §3.4).
+- Each node carries its own identity keys, so a key that somehow leaked would open
+ that one node and no other (§3), and a copy of a node's storage without its
+ unlock key reveals none of its chat (§4.5).
-"End-to-end" here describes **client ↔ node**, never client ↔ client. Members and
-the operator read everything in their group; that is what a group is.
+### 2.3 Your operator hosts you, but cannot become you
-### 2.4 One boundary worth naming
+Keeping your content is the deal you made with your operator. Turning that into
+*being* you is the line they cannot cross. The identity bundle stored on their
+node opens only with your passphrase and a secret the hub releases to no one but a
+session that has already proved the passphrase or a device key; the only attempts
+left to an operator are ordinary sign-ins, which the hub counts and locks out. And
+reading what *they* host never reaches what *other* operators host (§3.2).
-An operator hosts your content by design. They should not be able to become
-*you*. The bundle on their disk does not let them try: it opens only with the
-passphrase and a pepper the hub hands to nobody but a session that proved the
-passphrase or a device key, so the only guesses left to them are sign-ins, which
-the hub counts and locks out. And were a bundle key to leak all the same, it
-opens **the bundle on that node** and no other. Reading what they host is by
-design; reading what *other* operators host is not, and does not follow (§3.2).
+### 2.4 The one honest caveat: the browser
+
+Used in a web browser, MeshBay is a page the hub sends you on each visit — so a hub
+that had been taken over could send an altered page and lift your keys from it.
+This is true of **every** web application; most simply never say so, and no
+protocol can prevent it, because the attack is in the code itself rather than in
+the messages. So we never claim your content is unreadable *even by the hub* when
+you use a browser.
+
+The desktop and mobile apps close this. Their code is installed once, from a
+package anyone can rebuild from source and check against a published hash, so a
+tampered build is caught instead of silently trusted. For the strongest assurance,
+use the app. This is the project's single standing limit for browser use — tracked
+as **T3** — and the full, adversary-by-adversary analysis behind every statement
+in this section is in §13.9 for readers who want it.
---
@@ -1964,7 +1955,7 @@ an operator-signed op.
Hub minimisation was considered and **deferred, and may be dropped** (decision D4).
The hub keeps serving the web UI and remains in the trusted path by choice. That is
a legitimate product call; what follows from it is carried deliberately rather than
-by accident (§2.3).
+by accident (§2.4).
**Stores:** accounts (username, encrypted email, status, role), the group registry
and membership, IP logs (one year, legal retention), node registrations, refresh
@@ -3352,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
@@ -3366,6 +3396,32 @@ 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.
+
+**While a receiver plays the film, the player is its remote.** Phone and
+television start apart and drift, so a scrubber on the local playhead lied about
+where the film was and a seek from it landed off by the gap. The page polls
+`cast:status`, whose `chromecast` carries the receiver's `playerState` and
+`position` (stream seconds, zero at the relay's start; the page adds the
+stream's start), and shows that over the local picture with play/pause
+(`cast:chromecast:pause`/`play`), ±30 s and a scrubber. A seek is the ordinary
+seek: it restarts the relay and reloads the receiver there, and until that
+lands the target is shown, not the old stream's position. Pausing the receiver
+pauses the local element too, which otherwise goes on fetching for nobody. The
+copy-URL cast has no receiver to read and keeps the ordinary player.
+
**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 =
@@ -3553,7 +3609,7 @@ Two structural recommendations from that review stand as rules:
|---|---|
| **T1** | **The password split.** The hub never sees a passphrase; it holds a verifier for a client-derived `auth_key`. The passphrase floor can therefore only be enforced client-side (§3.1) |
| **T2** | The hub was the key directory. **Closed** by admission redesign, not by safety numbers: the invite path reads no directory at all (§3.4). Reclassified as **H3** |
-| **T3** | **The hub serves the SPA. Accepted permanently for browser users.** It is the only remaining way an active hub reads content, it is an artifact-level attack rather than a silent lie, and it does not exist for a native client — whose value is realised by reproducible builds, not by packaging (§2.3, §8.2) |
+| **T3** | **The hub serves the SPA. Accepted permanently for browser users.** It is the only remaining way an active hub reads content, it is an artifact-level attack rather than a silent lie, and it does not exist for a native client — whose value is realised by reproducible builds, not by packaging (§13.9, §8.2) |
### 13.5b Availability between members (`AV`)
@@ -3686,6 +3742,72 @@ had already been asked.
| **O13** | **Hub identity pinning.** The client points at a hub by URL and nothing pins that hub's identity. Bounded, because a substituted hub can neither read content nor ship the code to a native client — worth doing all the same |
| **V1–V13**, **P1–P5** | Per-application open items: wording of a disabled-service state, whether artwork reuses the chunk path, cache TTL, multi-track surfacing, HEIC/RAW support, a fuller EXIF panel, lightbox preloading, album-boundary behaviour, cover selection |
+### 13.9 Formal adversary model
+
+§2 states the trust model for a human reader. This restates the same ground
+formally, for auditors and implementers: the adversaries every claim is written
+against, the claim-by-adversary matrix, and the over-claims the project refuses to
+make. Nothing here is new — it is §2 with the proofs shown.
+
+"End-to-end" throughout describes **client ↔ node**, never client ↔ client. The
+operator and the members read everything in their group; that is what a group is.
+
+#### Adversaries
+
+Every claim is written against one of these, and they are the only ones the
+document uses:
+
+| Adversary | What they can do |
+|---|---|
+| **Passive hub** | Read everything the hub legitimately stores and relays |
+| **Active hub** | Also lie: forge tokens, invent accounts, substitute values it publishes, ship modified client code to a browser |
+| **Malicious node operator** | Read and alter everything on their own machine, including the plaintext files they host |
+| **Malicious group member** | Everything a member may do, plus anything the protocol fails to refuse |
+| **Network attacker** | Observe and tamper with traffic between any two parties |
+| **Local attacker** | Reach loopback services and files on a client or node machine |
+| **Registered hub user with no membership** | Reach every hub endpoint that does not check membership |
+| **Federated peer hub** | Push directory rows and revocations over MHP |
+
+#### Claim matrix
+
+| Claim | Passive hub | Active hub | Malicious node operator | Malicious member | Network attacker |
+|---|---|---|---|---|---|
+| Data never transits the hub | ✅ | ✅ | — | — | ✅ |
+| File content is unreadable | ✅ | ❌ **T3** (browser) · ✅ native | ❌ by design — the operator hosts the files | ❌ members share the group key | ✅ |
+| The file index is unreadable | ✅ | ❌ T3 · ✅ native | ❌ | ❌ | ✅ |
+| Chat content is unreadable | ✅ | ❌ T3 · ✅ native | ❌ — the operator is a member | ❌ | ✅ |
+| Chat is unreadable **from a copy of the node's storage that lacks its unlock key** — not from a whole disk by default (§4.5) | ✅ | ✅ | — the operator holds the unlock key | ✅ | ✅ |
+| Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ |
+| The node cannot be impersonated | ✅ | ✅ | — | ✅ | ✅ |
+| Client code integrity | ❌ **T3, accepted** (browser) · ✅ ships in the package (native) | ❌ T3 · ⚠️ native: **publicly verifiable, not prevented** | ✅ | ✅ | ✅ |
+| The hub cannot obtain the group key | ✅ | ✅ **except** in an open-join group, where it can join legitimately (§7.3), and for an invitation the inviter asked the hub to mail, whose code it then holds (§3.4) | — | — | ✅ |
+| Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ |
+| Devices cannot be added by the hub | ✅ | ✅ — the hub holds no user key and cannot countersign | ⚠️ a node adds a device only to itself, where it already reads everything | ✅ | ✅ |
+| Chat senders are authenticated to each other | ✅ | ✅ | ⚠️ only for accounts the reader has already seen (§3.3) | ✅ | ✅ |
+| Keypair bundles (**C4**) | ✅ it holds the pepper and no bundle | ⚠️ it can fetch a bundle with a token it mints and holds the pepper: an offline passphrase search, as T3 already concedes for browsers · none to fetch for an account without browser access | ✅ no offline search: the bundle does not open without the hub's pepper — only sign-in attempts, bounded and audited · none on disk for an account without browser access | — | — |
+| Deleting your account erases you | ✅ hub-side | ✅ hub-side | ❌ files, pinned identity and bundle stay on the node (§7.7) | — | — |
+| Your identity keys stay yours | ✅ | ⚠️ as the row above | ✅ the bundle they hold does not open without the pepper, and a leaked bundle key opens **that node's** bundle only | ✅ | ✅ |
+
+#### Over-claims the project refuses to make
+
+Three sentences are forbidden, each for a deliberate reason:
+
+- **"Everything is encrypted and unreadable by other parties, even the hub."**
+ A hub that ships the code can lift keys from the page regardless of protocol
+ design (**T3**). That is an artifact-level attack, not a silent directory lie,
+ and it is removed for native clients — not for browsers.
+- **"A native client makes the hub untrusted."** It converts an undetectable,
+ per-request, per-user attack into a persistent artifact that can be hashed and
+ compared. That value is realised by reproducible builds and published hashes,
+ not by the packaging format. A build signed with a key the hub operator holds
+ *relocates* trust; it does not remove it.
+- **"C4 is closed."** It is closed for an account whose identities the desktop
+ application keeps (browser access off, §3.7): nothing of them is on any node.
+ For an account with browser access the bundle is still on each node, sealed
+ under the passphrase **and** a pepper the hub holds — no operator can search it
+ offline, but an active hub can, and that is T3's adversary already. **An account
+ is only as strong as its weakest client.**
+
---
## 14. Decisions that are not revisited
@@ -3757,7 +3879,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
@@ -3785,13 +3909,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 |
@@ -3830,7 +3960,7 @@ superseded document kept under `docs/`, a note somebody holds elsewhere.
| Cited as | Read |
|---|---|
-| `draft-v5 §2`, `draft-v6 §4` — security claims | §2.2 |
+| `draft-v5 §2`, `draft-v6 §4` — security claims | §13.9 |
| `draft-v5 §3` — transport, NAT traversal | §5.1 |
| `draft-v5 §4`, §4.1–4.4 — handshake, transcript, channel binding, mutual auth | §5.2 |
| `draft-v5 §5.1`, `draft-v6 §2.3`, `§2.4b` — privileged operations, key activation, authorship | §5.4 |
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index d631804..1790475 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -392,9 +392,13 @@ 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. While a
+TV plays the film, the player becomes its remote: the position shown is the
+TV's, with play/pause, 30-second jumps and a slider to go anywhere in the film.
+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 +971,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.
diff --git a/packages/meshbay-android/.gitignore b/packages/meshbay-android/.gitignore
new file mode 100644
index 0000000..8a50ec5
--- /dev/null
+++ b/packages/meshbay-android/.gitignore
@@ -0,0 +1,6 @@
+# Generated — the interface is copied from meshbay-hub/static at build time
+# and never committed (docs/MESHBAY_DESIGN.md §8.3).
+build/
+.gradle/
+local.properties
+.kotlin/
diff --git a/packages/meshbay-android/README.md b/packages/meshbay-android/README.md
new file mode 100644
index 0000000..85e2406
--- /dev/null
+++ b/packages/meshbay-android/README.md
@@ -0,0 +1,64 @@
+# MeshBay — Android client
+
+A client, not a host: no node runs on a phone (`docs/MESHBAY_DESIGN.md` §11.3).
+
+The shell is a system WebView showing the interface **from the package** —
+`meshbay-hub/src/meshbay_hub/static/` copied at build time into
+`build/generated/`, never committed (§8.3) — with a bridge
+(`app/src/main/assets/bridge/meshbay-bridge.js`) that offers the page the same
+`window.meshbay` as the desktop preload, wherever it offers anything at all.
+Hub calls leave from native code, to the signed-in hub only. The device key,
+the bundle key and every node identity are held natively under an Android
+Keystore key; the page is told public keys and handed signatures, asked for by
+kind — never bytes. `meshbay-hub/tests/vectors/keyring.json` holds that keyring
+to the desktop's and to the specification.
+
+Downloads are written to disk as they arrive — into the folder chosen in
+Settings (a Storage Access Framework tree, as `<name>.part` until complete) or
+the system Downloads collection (a pending entry until complete) — and the
+page holds an opaque id, never a URI. Uploads come through the system picker.
+
+Casting: the page pushes the decrypted stream to a local HTTP relay (a port of
+the desktop's `cast-relay.js`, bound to the Wi-Fi address only); receivers are
+found and driven through the platform cast SDK with the default media receiver.
+While a cast runs, a media-playback foreground service holds the CPU and the
+Wi-Fi, and the WebView is kept reported visible — without that, Chromium
+freezes the page 60 s after the screen goes off. Where play services are
+absent, the page is offered no cast at all.
+
+```bash
+# needs JDK 17+ and an Android SDK (ANDROID_HOME, or sdk.dir in local.properties)
+./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk
+./gradlew testDebugUnitTest # JVM unit tests
+```
+
+The security contract is also pinned from the Python suite by reading this
+source: `packages/meshbay-hub/tests/test_android_shell.py`.
+
+Not built yet: phone-specific behaviour (back button, network handover,
+keeping a download alive with the screen off), signed releases.
+
+## Icon
+
+The desktop client's icon, `meshbay-client/build/icon-square.png`, placed in
+the adaptive icon's safe zone (72 dp of the 108 dp layer, which is what every
+launcher mask leaves visible) over its own edge colour, so no mask crops the
+M. The five `mipmap-*/ic_launcher_foreground.png` are generated from it:
+
+```python
+from PIL import Image, ImageDraw, ImageFilter
+src = Image.open("../meshbay-client/build/icon-square.png").convert("RGBA")
+for name, L in [("mdpi", 108), ("hdpi", 162), ("xhdpi", 216), ("xxhdpi", 324), ("xxxhdpi", 432)]:
+ S = L * 72 // 108; b = max(2, S // 25)
+ img = src.resize((S, S), Image.LANCZOS)
+ mask = Image.new("L", (S, S), 0)
+ ImageDraw.Draw(mask).rectangle([b, b, S - b - 1, S - b - 1], fill=255)
+ img.putalpha(mask.filter(ImageFilter.GaussianBlur(b)))
+ layer = Image.new("RGBA", (L, L), (0, 0, 0, 0))
+ layer.paste(img, ((L - S) // 2, (L - S) // 2), img)
+ layer.save(f"app/src/main/res/mipmap-{name}/ic_launcher_foreground.png", optimize=True)
+```
+
+No monochrome layer: a themed icon keeps only the layer's alpha, and this one
+would be a filled square.
+
diff --git a/packages/meshbay-android/app/build.gradle.kts b/packages/meshbay-android/app/build.gradle.kts
new file mode 100644
index 0000000..3f29ac0
--- /dev/null
+++ b/packages/meshbay-android/app/build.gradle.kts
@@ -0,0 +1,88 @@
+import groovy.json.JsonSlurper
+
+plugins { id("com.android.application") }
+
+// One version for every package (CLAUDE.md): read from the desktop client's
+// package.json rather than written a second time here.
+val packageVersion = (JsonSlurper().parse(rootDir.resolve("../meshbay-client/package.json"))
+ as Map<*, *>)["version"] as String
+val versionParts = packageVersion.split(".").map { it.toInt() }
+
+android {
+ namespace = "org.meshbay.client"
+ compileSdk = 37
+ defaultConfig {
+ applicationId = "org.meshbay.client"
+ minSdk = 26
+ targetSdk = 36
+ versionName = packageVersion
+ versionCode = versionParts[0] * 10000 + versionParts[1] * 100 + versionParts[2]
+ }
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ // A stand-in until the release key exists (Stage D12): the debug
+ // key, so a release build installs over a debug one and back
+ // without losing the account. Not a key to publish anything with.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ buildFeatures { buildConfig = true }
+ testOptions { unitTests.isReturnDefaultValues = false }
+}
+
+dependencies {
+ implementation("androidx.webkit:webkit:1.17.1")
+ implementation("com.squareup.okhttp3:okhttp:5.5.0")
+ // Ed25519, X25519, HKDF and Argon2id, identical on the JVM and every
+ // Android version: the platform has no Argon2 and its Ed25519 is recent.
+ implementation("org.bouncycastle:bcprov-jdk18on:1.86")
+ // Casting: discovery through MediaRouter, control through the cast sender
+ // SDK and the default media receiver. Needs the vendor's play services at
+ // run time; where they are absent the page is offered no cast at all.
+ implementation("com.google.android.gms:play-services-cast-framework:22.3.1")
+ implementation("androidx.mediarouter:mediarouter:1.8.1")
+ testImplementation("junit:junit:4.13.2")
+ // Android's org.json is a stub on the JVM; the unit tests need the real one.
+ testImplementation("org.json:json:20260814")
+}
+
+/**
+ * The interface, copied from its single source at build time (§8.3).
+ *
+ * `meshbay-hub/src/meshbay_hub/static/` is the interface for the web, the
+ * desktop application and this one. The copy lands in build/ and is never
+ * committed, so it cannot fork. The page itself is the desktop application's
+ * `scripts/index.html` — the hub builds its own with a /a/<hash>/ prefix that
+ * would point back at the hub — so an application loading from its package
+ * has one page, not two.
+ */
+abstract class SyncUi : DefaultTask() {
+ @get:InputDirectory abstract val staticDir: DirectoryProperty
+ @get:InputFile abstract val indexHtml: RegularFileProperty
+ @get:OutputDirectory abstract val outputDir: DirectoryProperty
+
+ @TaskAction
+ fun copy() {
+ val ui = outputDir.get().asFile.resolve("ui")
+ outputDir.get().asFile.deleteRecursively()
+ staticDir.get().asFile.copyRecursively(ui)
+ indexHtml.get().asFile.copyTo(ui.resolve("index.html"), overwrite = true)
+ }
+}
+
+val syncUi = tasks.register<SyncUi>("syncUi") {
+ staticDir.set(rootDir.resolve("../meshbay-hub/src/meshbay_hub/static"))
+ indexHtml.set(rootDir.resolve("../meshbay-client/scripts/index.html"))
+ outputDir.set(layout.buildDirectory.dir("generated/ui-assets"))
+}
+
+androidComponents {
+ onVariants { variant ->
+ variant.sources.assets?.addGeneratedSourceDirectory(syncUi, SyncUi::outputDir)
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..2eae3ea
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+ <!-- Casting: the relay's foreground service, and the locks that keep the
+ CPU and the Wi-Fi up while the screen is off. -->
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
+ <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
+
+ <!-- No backup of any kind: the keys are wrapped by a Keystore key that a
+ restore cannot bring with it, so a backed-up store is one that silently
+ fails to open on the next device. -->
+ <application
+ android:label="MeshBay"
+ android:icon="@mipmap/ic_launcher"
+ android:roundIcon="@mipmap/ic_launcher_round"
+ android:allowBackup="false"
+ android:fullBackupContent="false"
+ android:dataExtractionRules="@xml/data_extraction_rules"
+ android:networkSecurityConfig="@xml/network_security_config"
+ android:theme="@style/Shell">
+ <activity
+ android:name=".MainActivity"
+ android:exported="true"
+ android:launchMode="singleTask"
+ android:windowSoftInputMode="adjustResize"
+ android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|keyboard|keyboardHidden|density|navigation">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+ <service
+ android:name=".cast.CastService"
+ android:exported="false"
+ android:foregroundServiceType="mediaPlayback" />
+ <meta-data
+ android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
+ android:value="org.meshbay.client.cast.CastOptionsProvider" />
+ </application>
+</manifest>
diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
new file mode 100644
index 0000000..4755531
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
@@ -0,0 +1,206 @@
+/**
+ * The bridge, and the whole of it — the Android counterpart of
+ * meshbay-client/src/preload.js, with the same shape wherever it offers
+ * something at all.
+ *
+ * Injected at document start into documents of the packaged origin, before any
+ * page script. It takes the native port the listener injected, hides the
+ * global, and exposes `window.meshbay` frozen. There is no context isolation
+ * on Android: page script runs in the same world, so what this buys is that
+ * nothing can reach the raw port by name, not that this file is out of reach.
+ * The confinement that matters is native — the listener answers the packaged
+ * origin's top-level document only, and checks every argument.
+ *
+ * What the desktop offers and this build does not is ABSENT, not a function
+ * that refuses: `platform.js` decides what to show from whether an object
+ * exists (`platform.node.available`, `platform.folder.available`, …).
+ *
+ * `HUB_BASE`, `BINARY` and `CAST` are prepended by the shell when it injects
+ * this file: the interface asks for the hub while its modules load, before
+ * anything can await; BINARY says whether the WebView carries ArrayBuffer
+ * messages; CAST whether this device can cast at all.
+ */
+(function () {
+ 'use strict';
+ const port = window.meshbayNative;
+ try { delete window.meshbayNative; } catch (e) { /* already gone */ }
+ // A same-origin child frame gets the port too; it gets no bridge, and native
+ // refuses whatever it sends anyway.
+ if (!port || window.top !== window) return;
+
+ const pending = new Map();
+ let seq = 0;
+ port.onmessage = (event) => {
+ let reply;
+ try { reply = JSON.parse(event.data); } catch (e) { return; }
+ const waiter = pending.get(reply.id);
+ if (!waiter) return;
+ pending.delete(reply.id);
+ if (reply.ok) waiter.resolve(reply.value);
+ else waiter.reject(new Error(reply.error));
+ };
+ const call = (channel, ...args) => new Promise((resolve, reject) => {
+ const id = ++seq;
+ pending.set(id, { resolve, reject });
+ port.postMessage(JSON.stringify({ id, ch: channel, args }));
+ });
+
+ // A write is a binary message: "MBB1" | id | channel | 0 | handle | bytes,
+ // big-endian — one copy, no JSON, no base64 (spike S-3: 136 MB/s awaited
+ // per 48 KB chunk). Without ArrayBuffer messages, base64 over JSON.
+ const SAVE_WRITE = 1;
+ const CAST_PUSH = 2;
+ const bytesOf = (chunk) => (chunk instanceof Uint8Array ? chunk
+ : ArrayBuffer.isView(chunk) ? new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)
+ : new Uint8Array(chunk));
+ const base64Of = (bytes) => {
+ let s = '';
+ for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
+ return btoa(s);
+ };
+ const sendBinary = (channel, handle, bytes) => new Promise((resolve, reject) => {
+ const id = ++seq;
+ pending.set(id, { resolve, reject });
+ const frame = new ArrayBuffer(16 + bytes.length);
+ const head = new DataView(frame);
+ head.setUint32(0, 0x4d424231); // "MBB1"
+ head.setUint32(4, id);
+ head.setUint16(8, channel);
+ head.setUint32(12, handle);
+ new Uint8Array(frame, 16).set(bytes);
+ port.postMessage(frame);
+ });
+ const writeChunk = (handle, chunk) => {
+ const bytes = bytesOf(chunk);
+ return BINARY ? sendBinary(SAVE_WRITE, handle, bytes) : call('save:write', handle, base64Of(bytes));
+ };
+ const pushSegment = (chunk) => {
+ const bytes = bytesOf(chunk);
+ return BINARY ? sendBinary(CAST_PUSH, 0, bytes) : call('cast:push', base64Of(bytes));
+ };
+
+ const meshbay = {
+ hubBase: () => HUB_BASE,
+ setHubBase: (base) => call('hub:set', base),
+
+ capabilities: {
+ nodeAdmin: false, // no node runs on a phone (§11.3)
+ localFolders: false,
+ nativeSave: true,
+ lanCast: CAST, // false where the vendor's play services are absent
+ tray: false,
+ },
+
+ setLocale: (code) => call('ui:locale', code),
+
+ // The page's origin is refused by the hub's absent CORS, and is not a
+ // credential anyway: native goes, to the signed-in hub only.
+ fetch: (url, init) => call('hub:fetch', url, init),
+
+ resolveStun: (urls) => call('ice:resolve-stun', urls),
+
+ // The device's hub key: generated, held and used natively. The page asks
+ // for a signature and never sees a key — it parses hostile input.
+ device: {
+ ensure: () => call('device:ensure'),
+ publicKey: () => call('device:public'),
+ sign: (username) => call('device:sign', username),
+ forget: () => call('device:forget'),
+ },
+
+ // The bundle key and the identity on every node, held natively: the page
+ // is told public keys and handed signatures and agreements. A signature is
+ // asked for by kind and fields, never by bytes.
+ keys: {
+ available: () => call('keys:available'),
+ deriveSession: (o) => call('keys:derive-session', o),
+ commitPending: (u) => call('keys:commit-pending', u),
+ dropPending: (u) => call('keys:drop-pending', u),
+ hasSession: (u) => call('keys:has-session', u),
+ forgetSession: (u) => call('keys:forget-session', u),
+ identity: (u, n) => call('keys:identity', u, n),
+ openBundle: (u, n, o) => call('keys:open-bundle', u, n, o),
+ mint: (u, n) => call('keys:mint', u, n),
+ sealBundle: (u, n, o) => call('keys:seal-bundle', u, n, o),
+ sealRecovery: (u, n, m, name) => call('keys:seal-recovery', u, n, m, name),
+ markSealed: (u, n, fp) => call('keys:mark-sealed', u, n, fp),
+ fingerprint: (u) => call('keys:fingerprint', u),
+ sign: (u, n, kind, fields) => call('keys:sign', u, n, kind, fields),
+ shared: (u, n, peer) => call('keys:shared', u, n, peer),
+ playlistKey: (u) => call('keys:playlist-key', u),
+ browserAccess: (u) => call('keys:browser-access', u),
+ setBrowserAccess: (u, on) => call('keys:set-browser-access', u, on),
+ createdHere: (u) => call('keys:created-here', u),
+ },
+
+ // Whether the OS protects what is stored. The store itself is not
+ // reachable from here.
+ secrets: {
+ backend: () => call('secrets:backend'),
+ },
+
+ // LAN cast relay: the page feeds it decrypted segments, a receiver on the
+ // same Wi-Fi plays from the URL. Present only where casting can work —
+ // `platform.cast.available` is whether this object exists.
+ ...(CAST ? { cast: {
+ start: (opts) => {
+ const o = Object.assign({}, opts || {});
+ if (o.initSegment) o.initSegment = base64Of(bytesOf(o.initSegment));
+ return call('cast:start', o);
+ },
+ push: (data) => pushSegment(data),
+ stop: () => call('cast:stop'),
+ subtitle: (sub) => call('cast:subtitle', sub),
+ finish: () => call('cast:finish'),
+ status: () => call('cast:status'),
+ scan: () => call('cast:scan'),
+ devices: () => call('cast:devices'),
+ chromecastConnect: (opts) => call('cast:chromecast:connect', opts),
+ chromecastReload: (opts) => call('cast:chromecast:reload', opts),
+ chromecastDisconnect: () => call('cast:chromecast:disconnect'),
+ chromecastPause: () => call('cast:chromecast:pause'),
+ chromecastPlay: () => call('cast:chromecast:play'),
+ } } : {}),
+
+ // Where downloads go, chosen once. A display name comes back, never a URI.
+ folder: {
+ choose: () => call('folder:choose'),
+ get: () => call('folder:get'),
+ forget: () => call('folder:forget'),
+ },
+
+ // A sink that writes to disk as chunks arrive, never a buffer handed over
+ // at the end. The page holds an id. `open` exists only where the target
+ // says the file may be opened — a type that runs nothing.
+ saveFile: async (suggestedName, opts) => {
+ const handle = await call('save:begin', suggestedName, opts);
+ if (!handle) return null;
+ const sink = {
+ name: handle.name,
+ write: (chunk) => writeChunk(handle.id, chunk),
+ close: () => call('save:end', handle.id),
+ abort: () => call('save:abort', handle.id),
+ };
+ if (handle.openable) sink.open = () => call('save:open', handle.id);
+ return sink;
+ },
+ };
+
+ const freeze = (o) => {
+ Object.freeze(o);
+ for (const v of Object.values(o)) if (v && typeof v === 'object' && !Object.isFrozen(v)) freeze(v);
+ return o;
+ };
+ Object.defineProperty(window, 'meshbay', {
+ value: freeze(meshbay), writable: false, configurable: false, enumerable: false,
+ });
+
+ // The WebView exposes File System Access and cannot back it with anything a
+ // person can see. Left in place, `downloads.SUPPORTED` reads true and a
+ // download could take a path that fails — or reach the blob floor silently.
+ for (const name of ['showDirectoryPicker', 'showSaveFilePicker', 'showOpenFilePicker']) {
+ try {
+ Object.defineProperty(window, name, { value: undefined, writable: false, configurable: false });
+ } catch (e) { /* not definable: leave it, native save comes first anyway */ }
+ }
+})();
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
new file mode 100644
index 0000000..a781350
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
@@ -0,0 +1,288 @@
+package org.meshbay.client
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowInsets
+import android.webkit.ConsoleMessage
+import android.webkit.PermissionRequest
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import android.widget.FrameLayout
+import androidx.webkit.ScriptHandler
+import androidx.webkit.WebViewAssetLoader
+import androidx.webkit.WebViewClientCompat
+import androidx.webkit.WebViewCompat
+import androidx.webkit.WebViewFeature
+import org.json.JSONObject
+import org.meshbay.client.bridge.Bridge
+import org.meshbay.client.bridge.Channels
+import org.meshbay.client.bridge.KeyChannels
+import org.meshbay.client.cast.CastChannels
+import org.meshbay.client.cast.CastService
+import org.meshbay.client.hub.HubClient
+import org.meshbay.client.keys.SecretStore
+import org.meshbay.client.save.SaveSinks
+import org.meshbay.client.shell.Pickers
+import org.meshbay.client.shell.ShellWebView
+import org.meshbay.client.shell.EngineCheck
+import org.meshbay.client.shell.NativeText
+import org.meshbay.client.shell.UiAssets
+import java.util.concurrent.CountDownLatch
+
+/**
+ * The shell: one WebView showing the packaged interface, and the bridge.
+ *
+ * What this file must never do: load anything into the WebView that is not the
+ * package (the hub never becomes the document origin — T3), or hand the page a
+ * way to the hub other than the bridge.
+ */
+class MainActivity : Activity() {
+ private lateinit var root: FrameLayout
+ private lateinit var web: ShellWebView
+ private lateinit var cast: CastChannels
+ private lateinit var hub: HubClient
+ private lateinit var channels: Channels
+ private val pickers = Pickers(this)
+ private val text = NativeText { code ->
+ try { assets.open("ui/locales/$code.js").bufferedReader().use { it.readText() } } catch (e: java.io.IOException) { null }
+ }
+ private var shim: ScriptHandler? = null
+ private var fullscreen: View? = null
+ private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ root = FrameLayout(this)
+ setContentView(root)
+ applyInsets(root)
+
+ // A WebView too old for the page's crypto would fail at the first
+ // handshake; say so before loading anything.
+ EngineCheck.problem(this)?.let { setContentView(EngineCheck.screen(this, it)); return }
+
+ hub = HubClient(getSharedPreferences("shell", Context.MODE_PRIVATE))
+ web = ShellWebView(this)
+ root.addView(web, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
+ configure(web)
+
+ val keys = KeyChannels(SecretStore(this), confirm = ::confirmNatively,
+ declined = { text.get("native.declined", channels.locale) })
+ val saves = SaveSinks(this, getSharedPreferences("downloads", Context.MODE_PRIVATE), pickers,
+ startActivity = { intent -> runOnUiThread { startActivity(intent) } })
+ // A process killed mid-download left unfinished files; nothing else will.
+ Thread { saves.cleanUpAfterAKilledProcess() }.start()
+ cast = CastChannels(this, onCasting = { on -> runOnUiThread { casting(on) } },
+ tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } })
+ channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } },
+ hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves,
+ cast = cast)
+ WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels))
+ cast.control.warmUp()
+ installShim()
+ web.loadUrl(UiAssets.START)
+ }
+
+ private fun configure(web: WebView) {
+ WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
+ web.settings.apply {
+ javaScriptEnabled = true
+ domStorageEnabled = true // IndexedDB and localStorage: session, resume positions
+ allowFileAccess = false
+ allowContentAccess = false
+ mediaPlaybackRequiresUserGesture = true
+ setSupportMultipleWindows(false)
+ mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW
+ }
+ android.webkit.CookieManager.getInstance().setAcceptThirdPartyCookies(web, false)
+
+ val loader = WebViewAssetLoader.Builder()
+ .setDomain(UiAssets.HOST)
+ .addPathHandler(UiAssets.PREFIX, UiAssets(this))
+ .build()
+ web.webViewClient = object : WebViewClientCompat() {
+ override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
+ val url = request.url
+ if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused()
+ // reCAPTCHA (sign-up) and nothing else goes to the network from
+ // the page; the policy says the same, this is the second wall.
+ if (UiAssets.isRecaptcha(url.host) && url.scheme == "https") return null
+ if (url.scheme == "blob" || url.scheme == "data") return null
+ return refused()
+ }
+
+ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
+ val url = request.url
+ if (url.host == UiAssets.HOST) return false
+ // The hub must never become the document origin. A link out
+ // opens in the person's browser, not in a window holding keys.
+ if (request.isForMainFrame && (url.scheme == "https" || url.scheme == "http")) openExternally(url)
+ return !(UiAssets.isRecaptcha(url.host) && !request.isForMainFrame)
+ }
+ }
+ web.webChromeClient = object : WebChromeClient() {
+ // Grant by enumeration: nothing. Camera, microphone, MIDI and
+ // whatever Chromium adds next arrive refused.
+ override fun onPermissionRequest(request: PermissionRequest) = request.deny()
+
+ // Without this, a video's requestFullscreen() never settles — a
+ // refusal that never rejects (CLAUDE.md). Measured in the spike.
+ override fun onShowCustomView(view: View, callback: CustomViewCallback) {
+ fullscreen?.let { root.removeView(it) }
+ fullscreen = view
+ fullscreenCallback = callback
+ root.addView(view, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
+ web.visibility = View.INVISIBLE
+ setFullscreenBars(true)
+ }
+
+ override fun onHideCustomView() {
+ fullscreen?.let { root.removeView(it) }
+ fullscreen = null
+ fullscreenCallback = null
+ web.visibility = View.VISIBLE
+ setFullscreenBars(false)
+ }
+
+ // <input type=file>: the system picker; the page reads what it is
+ // given through the File objects the WebView makes of the URIs.
+ // The callback is always answered, or the next chooser never opens.
+ override fun onShowFileChooser(view: WebView, callback: android.webkit.ValueCallback<Array<Uri>>,
+ params: FileChooserParams): Boolean {
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*")
+ .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE)
+ Thread {
+ val result = pickers.run(intent)
+ // A single pick in multiple mode can come back with an
+ // empty clipData and the file in `data`: take whichever
+ // carries it, or the page receives a selection of nothing.
+ val uris = result?.let { r ->
+ val clip = r.clipData?.takeIf { it.itemCount > 0 }
+ clip?.let { c -> (0 until c.itemCount).map { c.getItemAt(it).uri } } ?: listOfNotNull(r.data)
+ }?.takeIf { it.isNotEmpty() }?.toTypedArray()
+ runOnUiThread { callback.onReceiveValue(uris) }
+ }.start()
+ return true
+ }
+
+ override fun onConsoleMessage(m: ConsoleMessage): Boolean {
+ if (BuildConfig.DEBUG) Log.i("MeshBayPage", "${m.messageLevel()} ${m.message()} @${m.sourceId()}:${m.lineNumber()}")
+ return true
+ }
+ }
+ }
+
+ /**
+ * The shim, with the hub address in it: the page reads that synchronously
+ * while its modules load. Changing the hub replaces the shim and reloads —
+ * a page left running would go on talking to the old hub with no sign of it.
+ */
+ private fun installShim() {
+ shim?.remove()
+ val source = assets.open("bridge/meshbay-bridge.js").bufferedReader().use { it.readText() }
+ val binary = WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER)
+ val prelude = "const HUB_BASE = ${JSONObject.quote(hub.base)};\nconst BINARY = $binary;\n" +
+ "const CAST = ${cast.control.available()};\n"
+ shim = WebViewCompat.addDocumentStartJavaScript(web, "(function(){$prelude$source\n})();", setOf(UiAssets.ORIGIN))
+ }
+
+ private fun reloadForHub() {
+ installShim()
+ web.loadUrl(UiAssets.START)
+ }
+
+ /**
+ * A confirmation this process draws (main.js confirmNatively). Called from
+ * a bridge worker, never the UI thread, which it waits on. The keyboard is
+ * handed back to the page afterwards: after a dialog the document can stay
+ * unfocused and every keystroke go nowhere (CLAUDE.md, ask.js).
+ */
+ private fun confirmNatively(key: String): Boolean {
+ val done = CountDownLatch(1)
+ var accepted = false
+ runOnUiThread {
+ android.app.AlertDialog.Builder(this)
+ .setMessage(text.get(key, channels.locale))
+ .setPositiveButton(text.get("dialog.ok", channels.locale)) { _, _ -> accepted = true }
+ .setNegativeButton(text.get("dialog.cancel", channels.locale), null)
+ .setOnDismissListener { web.requestFocus(); done.countDown() }
+ .show()
+ }
+ done.await()
+ return accepted
+ }
+
+ @Deprecated("Activity results for the system pickers; the platform API, kept for minSdk 26.")
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ if (!pickers.deliver(requestCode, resultCode, data)) super.onActivityResult(requestCode, resultCode, data)
+ }
+
+ /**
+ * A cast keeps the process, the Wi-Fi and the page alive with the screen
+ * off (spike S-2a, scenario F): the foreground service holds the first two,
+ * the WebView reported visible holds the third.
+ */
+ private fun casting(on: Boolean) {
+ web.keepVisible = on
+ val service = Intent(this, CastService::class.java)
+ if (on) startForegroundService(service) else stopService(service)
+ }
+
+ private fun hasAsset(path: String) = try { assets.open(path).close(); true } catch (e: java.io.IOException) { false }
+
+ private fun refused() = WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), "".byteInputStream())
+
+ private fun openExternally(url: Uri) {
+ try { startActivity(Intent(Intent.ACTION_VIEW, url).addCategory(Intent.CATEGORY_BROWSABLE)) }
+ catch (e: android.content.ActivityNotFoundException) { Log.w(Bridge.TAG, "no browser for $url") }
+ }
+
+ /** Edge-to-edge is enforced from Android 15: keep the page clear of the bars and the keyboard. */
+ private fun applyInsets(view: View) {
+ view.setOnApplyWindowInsetsListener { v, insets ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val bars = if (fullscreen != null) android.graphics.Insets.NONE
+ else insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime() or WindowInsets.Type.displayCutout())
+ v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
+ } else {
+ @Suppress("DEPRECATION")
+ v.setPadding(insets.systemWindowInsetLeft, insets.systemWindowInsetTop,
+ insets.systemWindowInsetRight, insets.systemWindowInsetBottom)
+ }
+ insets
+ }
+ }
+
+ private fun setFullscreenBars(on: Boolean) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val c = window.insetsController ?: return
+ if (on) {
+ c.hide(WindowInsets.Type.systemBars())
+ c.systemBarsBehavior = android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ } else c.show(WindowInsets.Type.systemBars())
+ }
+ root.requestApplyInsets()
+ }
+
+ @Deprecated("Back is handed to the page in phase 4; until then it leaves fullscreen or backgrounds the app.")
+ override fun onBackPressed() {
+ if (fullscreen != null) { fullscreenCallback?.onCustomViewHidden(); return }
+ if (web.canGoBack()) { web.goBack(); return }
+ // Never finish(): that would tear down every connection and transfer.
+ moveTaskToBack(true)
+ }
+
+ override fun onDestroy() {
+ if (::cast.isInitialized && cast.relay.active) { cast.relay.stop(); casting(false) }
+ if (::web.isInitialized) { root.removeView(web); web.destroy() }
+ super.onDestroy()
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt
new file mode 100644
index 0000000..50552fa
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt
@@ -0,0 +1,78 @@
+package org.meshbay.client.bridge
+
+import android.net.Uri
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.webkit.WebView
+import androidx.webkit.JavaScriptReplyProxy
+import androidx.webkit.WebMessageCompat
+import androidx.webkit.WebViewCompat
+import org.json.JSONArray
+import org.json.JSONObject
+import org.meshbay.client.save.BinaryFrame
+import org.meshbay.client.shell.UiAssets
+import java.util.concurrent.Executors
+
+/**
+ * Everything the interface may ask of the application, and the only way in.
+ *
+ * `addWebMessageListener` injects `meshbayNative` only into documents of the
+ * packaged origin; the shim (assets/bridge/meshbay-bridge.js) takes it at
+ * document start and hides it. But a same-origin child frame gets one too — the
+ * spike measured it — so what actually confines the bridge is the check here:
+ * **the packaged origin's top-level document, and nothing else** (main.js
+ * `fromOurPage`). The page parses decrypted content from nodes, which is
+ * attacker-controlled input, so every argument is checked again in Channels.
+ */
+class Bridge(private val channels: Channels) : WebViewCompat.WebMessageListener {
+
+ private val main = Handler(Looper.getMainLooper())
+ // Hub calls and key operations block; none may run on the UI thread.
+ private val work = Executors.newCachedThreadPool()
+ private val serial = Executors.newSingleThreadExecutor()
+
+ override fun onPostMessage(view: WebView, message: WebMessageCompat, sourceOrigin: Uri,
+ isMainFrame: Boolean, replyProxy: JavaScriptReplyProxy) {
+ if (!isMainFrame || sourceOrigin.toString() != UiAssets.ORIGIN) {
+ Log.w(TAG, "refused a message from $sourceOrigin (main frame: $isMainFrame)")
+ val id = if (message.type == WebMessageCompat.TYPE_STRING)
+ try { JSONObject(message.data ?: "").optLong("id", -1) } catch (e: Exception) { -1 } else -1
+ replyProxy.postMessage(error(id, "Refused: not the MeshBay interface"))
+ return
+ }
+ if (message.type == WebMessageCompat.TYPE_ARRAY_BUFFER) {
+ val frame = BinaryFrame.parse(message.arrayBuffer) ?: return
+ dispatch(frame.id, replyProxy, "binary ${frame.channel}", channels.ordered(frame)) { channels.binary(frame) }
+ return
+ }
+ val request = try { JSONObject(message.data ?: return) } catch (e: Exception) { return }
+ val id = request.optLong("id", -1)
+ val channel = request.optString("ch")
+ val args = request.optJSONArray("args") ?: JSONArray()
+ dispatch(id, replyProxy, channel, channels.ordered(channel)) { channels.call(channel, args) }
+ }
+
+ private fun dispatch(id: Long, replyProxy: JavaScriptReplyProxy, channel: String, ordered: Boolean,
+ call: () -> Any?) {
+ (if (ordered) serial else work).execute {
+ val reply = try {
+ JSONObject().put("id", id).put("ok", true).put("value", call() ?: JSONObject.NULL).toString()
+ } catch (e: Refused) {
+ error(id, e.message ?: "Refused")
+ } catch (e: Exception) {
+ Log.w(TAG, "$channel failed", e)
+ error(id, e.message ?: e.javaClass.simpleName)
+ }
+ main.post { replyProxy.postMessage(reply) }
+ }
+ }
+
+ private fun error(id: Long, message: String) =
+ JSONObject().put("id", id).put("ok", false).put("error", message).toString()
+
+ companion object {
+ const val TAG = "MeshBay"
+ const val PORT = "meshbayNative"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
new file mode 100644
index 0000000..6992cdb
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
@@ -0,0 +1,104 @@
+package org.meshbay.client.bridge
+
+import org.json.JSONArray
+import org.meshbay.client.cast.CastChannels
+import org.meshbay.client.hub.HubClient
+import org.meshbay.client.save.BinaryFrame
+import org.meshbay.client.save.SaveSinks
+import java.net.Inet4Address
+import java.net.InetAddress
+
+/**
+ * The enumerated channels, and nothing else (main.js `registerBridge`).
+ *
+ * A channel that takes a path, a URL to anywhere, or bytes to sign from the
+ * page is the shape to avoid. What the desktop offers and a phone does not —
+ * the local node, shared folders, the tray — is not here at all, and the shim
+ * does not offer it either: `platform.js` decides what to show from whether a
+ * bridge object exists, so an object that only refused would put screens on
+ * the page that fail when used.
+ */
+class Channels(
+ private val hub: HubClient,
+ private val onHubChanged: () -> Unit,
+ private val hasCatalogue: (String) -> Boolean,
+ private val keys: KeyChannels? = null,
+ private val saves: SaveSinks? = null,
+ private val cast: CastChannels? = null,
+) {
+ @Volatile var locale = "en"
+ private set
+
+ fun call(channel: String, args: JSONArray): Any? = when (channel) {
+ "hub:set" -> hub.setBase(args.optString(0, "")).also { onHubChanged() }
+ "hub:fetch" -> hub.fetch(args.optString(0, ""), args.optJSONObject(1))
+ "ice:resolve-stun" -> resolveStun(args.optJSONArray(0) ?: JSONArray())
+ "ui:locale" -> setLocale(args.optString(0, ""))
+
+ // Where downloads go, chosen once; a display name, never a URI.
+ "folder:choose" -> saves().chooseFolder()
+ "folder:get" -> saves().getFolder()
+ "folder:forget" -> saves().forgetFolder()
+ // A sink the page refers to by an opaque id.
+ "save:begin" -> saves().begin(args.optString(0, ""), args.optJSONObject(1)?.optBoolean("auto", false) ?: false)
+ "save:write" -> { // the base64 path, for a WebView without ArrayBuffer messages
+ val bytes = java.util.Base64.getDecoder().decode(args.optString(1, ""))
+ saves().write(args.optLong(0, -1), bytes, 0, bytes.size)
+ }
+ "save:end" -> saves().end(args.optLong(0, -1))
+ "save:abort" -> saves().abort(args.optLong(0, -1))
+ "save:open" -> saves().open(args.optLong(0, -1))
+ else -> when {
+ keys != null && keys.handles(channel) -> keys.call(channel, args)
+ cast != null && cast.handles(channel) -> cast.call(channel, args)
+ else -> throw Refused("Refused: no such channel")
+ }
+ }
+
+ private fun saves() = saves ?: throw Refused("Refused: no such channel")
+
+ /** A binary message: one write, its bytes left where the message put them. */
+ fun binary(frame: BinaryFrame): Any? = when (frame.channel) {
+ BinaryFrame.SAVE_WRITE -> saves().write(frame.handle, frame.bytes, frame.offset, frame.length)
+ BinaryFrame.CAST_PUSH -> (cast ?: throw Refused("Refused: no such channel")).push(frame.bytes, frame.offset, frame.length)
+ else -> throw Refused("Refused: no such channel")
+ }
+
+ /**
+ * Calls whose order is part of their meaning: a relay start, the segments
+ * pushed after it, a stop. The page does not await each push, so on a pool
+ * they could overtake one another; these run on one thread, in arrival order.
+ */
+ fun ordered(channel: String) = cast?.ordered(channel) == true
+ fun ordered(frame: BinaryFrame) = frame.channel == BinaryFrame.CAST_PUSH
+
+ private fun setLocale(code: String): String {
+ // A code, never text, and only one the package has a catalogue for.
+ if (LOCALE.matches(code) && hasCatalogue(code)) locale = code
+ return locale
+ }
+
+ companion object {
+ private val LOCALE = Regex("^[a-z]{2}(-[A-Z]{2})?$")
+ private val STUN = Regex("^(stuns?):(\\[?[^\\]]+\\]?|[^:]+):(\\d+)$")
+
+ /**
+ * `stun:host:port` → `stun:ip:port`. Chromium's socket manager fails
+ * STUN hostnames outright behind some resolvers, and the page cannot do
+ * DNS. Unresolvable entries are dropped, literals pass through.
+ */
+ fun resolveStun(urls: JSONArray, lookup: (String) -> Array<InetAddress> = InetAddress::getAllByName): JSONArray {
+ val out = JSONArray()
+ for (i in 0 until urls.length()) {
+ val u = urls.optString(i)
+ val m = STUN.matchEntire(u)
+ if (m == null) { out.put(u); continue }
+ val (scheme, host, port) = m.destructured
+ if (Regex("^[\\d.]+$").matches(host) || host.contains(':')) { out.put(u); continue }
+ val ip = try { lookup(host).firstOrNull { it is Inet4Address }?.hostAddress } catch (e: Exception) { null }
+ if (ip != null) out.put("$scheme:$ip:$port")
+ }
+ return out
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt
new file mode 100644
index 0000000..f11b98c
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt
@@ -0,0 +1,117 @@
+package org.meshbay.client.bridge
+
+import org.json.JSONArray
+import org.json.JSONObject
+import org.meshbay.client.keys.DeviceKey
+import org.meshbay.client.keys.Keyring
+import org.meshbay.client.keys.SecretStore
+import org.meshbay.client.keys.Secrets
+
+/**
+ * The device key, the account's bundle key and its identity on every node —
+ * held here, never in the page (§8.2, §14.1 #20). The page is answered with
+ * public keys, signatures and agreements; it names what it signs by kind and
+ * fields, never by bytes (Transcripts). Arguments are checked as main.js does:
+ * ids are ids, keys are keys.
+ *
+ * `confirm` is a dialog this process draws, worded from the interface's own
+ * catalogues: what widens what leaves this device is never answered by the
+ * page.
+ */
+class KeyChannels(
+ private val secrets: Secrets,
+ private val confirm: (String) -> Boolean,
+ private val declined: () -> String,
+) {
+ private val device = DeviceKey(secrets)
+ val keyring = Keyring(
+ load = {
+ val raw = secrets.read().optString(SecretStore.KEYRING_SLOT, "")
+ if (raw.isEmpty()) null else try { JSONObject(raw) } catch (e: Exception) { null }
+ },
+ save = { state -> secrets.update { it.put(SecretStore.KEYRING_SLOT, state.toString()) } },
+ )
+
+ // Only where the OS protects what is stored: an identity kept here and lost
+ // at the next start would leave a node pinning a key nobody holds, so
+ // without key storage the page keeps its keys the way a browser does.
+ private fun available() = secrets.backend() != "unavailable"
+ private fun needKeys() { if (!available()) throw Refused("No OS key storage") }
+
+ fun handles(channel: String) = channel.startsWith("keys:") || channel.startsWith("device:") ||
+ channel == "secrets:backend"
+
+ fun call(channel: String, a: JSONArray): Any? = when (channel) {
+ "secrets:backend" -> secrets.backend()
+
+ "device:ensure" -> device.ensure()
+ "device:public" -> device.publicKey()
+ "device:sign" -> device.sign(a.optString(0, ""))
+ "device:forget" -> device.forget()
+
+ "keys:available" -> available()
+ "keys:derive-session" -> {
+ needKeys()
+ val o = a.optJSONObject(0) ?: JSONObject()
+ keyring.deriveSession(
+ password = o.optString("password", ""), username = o.optString("username", ""),
+ userId = uid(o.opt("userId")), pepperB64 = o.optString("pepperB64", ""),
+ pepperVersion = o.optInt("pepperVersion", 1).takeIf { it != 0 } ?: 1,
+ pendingChange = o.optBoolean("pending", false))
+ }
+ "keys:commit-pending" -> keyring.commitPending(uid(a.opt(0)))
+ "keys:drop-pending" -> keyring.dropPending(uid(a.opt(0)))
+ "keys:has-session" -> available() && keyring.hasSession(uid(a.opt(0)))
+ "keys:forget-session" -> keyring.forgetSession(uid(a.opt(0)))
+ "keys:identity" -> keyring.identity(uid(a.opt(0)), npk(a.opt(1)))?.let {
+ JSONObject().put("pkEdB64", it.pkEdB64).put("pkXB64", it.pkXB64).put("sealedWith", it.sealedWith ?: JSONObject.NULL)
+ }
+ "keys:open-bundle" -> pub(keyring.openBundle(uid(a.opt(0)), npk(a.opt(1)),
+ bundleEnc = a.optJSONObject(2)?.optString("bundleEnc", "") ?: ""))
+ "keys:mint" -> { needKeys(); pub(keyring.mint(uid(a.opt(0)), npk(a.opt(1)))) }
+ "keys:seal-bundle" -> keyring.sealBundle(uid(a.opt(0)), npk(a.opt(1)),
+ usePending = a.optJSONObject(2)?.optBoolean("pending", false) ?: false).let {
+ JSONObject().put("bundle", it.bundle).put("fingerprint", it.fingerprint)
+ }
+ "keys:seal-recovery" -> keyring.sealRecovery(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, ""), a.optString(3, ""))
+ "keys:mark-sealed" -> keyring.markSealed(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, ""))
+ "keys:fingerprint" -> keyring.currentFingerprint(uid(a.opt(0)))
+ // By kind and fields: the page never names the bytes.
+ "keys:sign" -> keyring.signAs(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, ""), a.optJSONObject(3) ?: JSONObject())
+ "keys:shared" -> keyring.shared(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, ""))
+ "keys:playlist-key" -> keyring.playlistKey(uid(a.opt(0)))
+ "keys:browser-access" -> keyring.browserAccess(uid(a.opt(0)))
+ // Turning it on leaves this account's identities on every node, sealed
+ // for a browser: the person decides that here, in a dialog the page
+ // cannot answer. Turning it off only narrows.
+ "keys:set-browser-access" -> {
+ val id = uid(a.opt(0))
+ val on = a.optBoolean(1, false)
+ if (on && !keyring.browserAccess(id) && !confirm("native.browser_access_confirm")) throw Refused(declined())
+ keyring.setBrowserAccess(id, on)
+ }
+ // An account created on this device starts without browser access.
+ // Only ever narrows, so the page may say it.
+ "keys:created-here" -> keyring.setBrowserAccess(uid(a.opt(0)), false)
+ else -> throw Refused("Refused: no such channel")
+ }
+
+ private fun pub(p: Keyring.Pub) = JSONObject().put("pkEdB64", p.pkEdB64).put("pkXB64", p.pkXB64)
+
+ companion object {
+ private val UID = Regex("^[0-9a-f-]{36}$", RegexOption.IGNORE_CASE)
+ private val NPK = Regex("^[A-Za-z0-9+/=]{1,100}$")
+
+ fun uid(v: Any?): String {
+ val s = if (v == null || v == JSONObject.NULL) "" else v.toString()
+ if (!UID.matches(s)) throw Refused("Refused: not an account id")
+ return s
+ }
+
+ fun npk(v: Any?): String {
+ val s = if (v == null || v == JSONObject.NULL) "" else v.toString()
+ if (!NPK.matches(s)) throw Refused("Refused: not a node's key")
+ return s
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Refused.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Refused.kt
new file mode 100644
index 0000000..6f550a5
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Refused.kt
@@ -0,0 +1,4 @@
+package org.meshbay.client.bridge
+
+/** A refusal whose message is written for a person; it reaches the page as is. */
+class Refused(message: String) : Exception(message)
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/BoxAccumulator.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/BoxAccumulator.kt
new file mode 100644
index 0000000..f56d58a
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/BoxAccumulator.kt
@@ -0,0 +1,87 @@
+package org.meshbay.client.cast
+
+import android.util.Log
+import java.io.ByteArrayOutputStream
+
+/**
+ * Re-frames the page's byte stream into whole moof+mdat fragments, which is
+ * what a receiver needs. A port of cast-relay.js's BoxAccumulator: the chunks
+ * the page pushes are arbitrary slices of the fMP4 stream, not box-aligned.
+ */
+class BoxAccumulator {
+ private var buf = ByteArray(0)
+ private var synced = false
+ private var preambleSeen = false
+
+ /**
+ * Called once, with every byte before the first moof: the stream's header
+ * (ftyp, moov), however many pushes it came in. The node's first chunk can
+ * hold the 28-byte ftyp alone, with the moov in the next one (measured).
+ */
+ var onPreamble: ((ByteArray) -> Unit)? = null
+
+ fun push(data: ByteArray, offset: Int = 0, length: Int = data.size - offset): List<ByteArray> {
+ buf = buf + data.copyOfRange(offset, offset + length)
+ val fragments = ArrayList<ByteArray>()
+
+ if (!synced) {
+ val idx = findMoof()
+ if (idx == -1) return fragments
+ if (!preambleSeen) { preambleSeen = true; onPreamble?.invoke(buf.copyOfRange(0, idx)) }
+ buf = buf.copyOfRange(idx, buf.size)
+ synced = true
+ }
+
+ while (buf.size >= 8) {
+ val size = u32(buf, 0)
+ val type = u32(buf, 4)
+
+ if (size < 8) {
+ // The byte stream stopped being framed fMP4. It recovers by
+ // rescanning, and this line is the only trace that the picture
+ // on the television is missing a piece.
+ warn("box sync lost: invalid size $size, rescanning")
+ synced = false
+ val idx = findMoof()
+ if (idx == -1) return fragments
+ buf = buf.copyOfRange(idx, buf.size)
+ synced = true
+ continue
+ }
+
+ if (type == MOOF) {
+ if (buf.size < size + 8) break
+ val mdat = u32(buf, size.toInt())
+ val pair = size + mdat
+ if (buf.size < pair) break
+ fragments.add(buf.copyOfRange(0, pair.toInt()))
+ buf = buf.copyOfRange(pair.toInt(), buf.size)
+ } else {
+ if (buf.size < size) break
+ buf = buf.copyOfRange(size.toInt(), buf.size)
+ }
+ }
+ return fragments
+ }
+
+ fun reset() { buf = ByteArray(0); synced = false; preambleSeen = false }
+
+ private fun findMoof(): Int {
+ for (i in 0..buf.size - 8) {
+ if (u32(buf, i + 4) == MOOF) {
+ val size = u32(buf, i)
+ if (size >= 8 && size < 1_000_000) return i
+ }
+ }
+ return -1
+ }
+
+ companion object {
+ const val MOOF = 0x6d6f6f66L
+ var warn: (String) -> Unit = { try { Log.w("MeshBay", "[cast-relay] $it") } catch (e: RuntimeException) { System.err.println(it) } }
+
+ fun u32(b: ByteArray, at: Int): Long =
+ ((b[at].toLong() and 0xff) shl 24) or ((b[at + 1].toLong() and 0xff) shl 16) or
+ ((b[at + 2].toLong() and 0xff) shl 8) or (b[at + 3].toLong() and 0xff)
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt
new file mode 100644
index 0000000..74a086b
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt
@@ -0,0 +1,115 @@
+package org.meshbay.client.cast
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import org.json.JSONArray
+import org.json.JSONObject
+import org.meshbay.client.bridge.Refused
+import java.net.Inet4Address
+import java.net.InetAddress
+import java.util.Base64
+
+/**
+ * main.js `cast:*`: the relay, the receiver, and what keeps both alive.
+ *
+ * `onCasting(true)` is called once the relay is up (start the foreground
+ * service, keep the WebView visible), `onCasting(false)` once it is down.
+ */
+class CastChannels(
+ private val context: Context,
+ private val onCasting: (Boolean) -> Unit,
+ private val tell: (String) -> Unit = {},
+) {
+ val control = CastControl(context)
+ val relay = CastRelay(::lanAddress, java.io.File(context.cacheDir, "cast-relay"))
+
+ fun handles(channel: String) = channel.startsWith("cast:")
+
+ /** What must reach the relay in the order the page sent it: start, pushes, subtitle, finish, stop. */
+ fun ordered(channel: String) = channel in setOf("cast:start", "cast:push", "cast:subtitle", "cast:finish", "cast:stop")
+
+ fun call(channel: String, a: JSONArray): Any? = when (channel) {
+ "cast:start" -> {
+ val o = a.optJSONObject(0) ?: JSONObject()
+ val init = o.optString("initSegment", "").takeIf { it.isNotEmpty() }?.let { Base64.getDecoder().decode(it) }
+ val started = relay.start(init, o.optJSONObject("subtitle"))
+ onCasting(true)
+ started
+ }
+ "cast:subtitle" -> relay.setSubtitle(a.optJSONObject(0))
+ "cast:push" -> { // the base64 path, for a WebView without ArrayBuffer messages
+ relay.push(Base64.getDecoder().decode(a.optString(0, ""))); true
+ }
+ "cast:stop" -> { relay.stop(); onCasting(false); true }
+ "cast:finish" -> { relay.finish(); true }
+ "cast:status" -> JSONObject().put("active", relay.active).put("url", relay.url ?: JSONObject.NULL)
+ .put("subtitle", relay.subtitleInfo() ?: JSONObject.NULL).put("chromecast", control.status())
+
+ "cast:scan" -> { control.startScan(); true }
+ "cast:devices" -> control.devices()
+ "cast:chromecast:connect" -> {
+ val o = a.optJSONObject(0) ?: JSONObject()
+ reported { control.connect(o.optString("deviceId", ""), relayUrl(o), subtitleOf(o)) }
+ }
+ "cast:chromecast:reload" -> {
+ val o = a.optJSONObject(0) ?: JSONObject()
+ reported { control.reload(relayUrl(o), subtitleOf(o)) }
+ }
+ "cast:chromecast:pause" -> control.pause()
+ "cast:chromecast:play" -> control.play()
+ "cast:chromecast:disconnect" -> control.disconnect()
+ else -> throw Refused("Refused: no such channel")
+ }
+
+ /**
+ * A failed cast says why, on the screen. The player catches the error and
+ * stops the relay without a word, which leaves the television on the
+ * receiver's idle screen and nobody knowing whether it ever reached the
+ * phone — so the receiver's reason and that fact are shown here.
+ */
+ private fun <T> reported(block: () -> T): T = try { block() } catch (e: Exception) {
+ val reached = if (relay.streamRequests > 0) "the television did reach this phone"
+ else "the television never reached this phone at ${relay.url?.substringBefore("/stream") ?: "?"}"
+ tell("Cast failed: ${e.message} — $reached")
+ throw e
+ }
+
+ fun push(bytes: ByteArray, offset: Int, length: Int): Boolean { relay.push(bytes, offset, length); return true }
+
+ /**
+ * The receiver is only ever pointed at this relay. A URL the page names is
+ * accepted when it is the relay's own, and refused otherwise — a page
+ * cannot use this application to make a television fetch anything else.
+ */
+ private fun relayUrl(o: JSONObject): String {
+ val asked = o.optString("mediaUrl", "")
+ val ours = relay.url ?: throw Refused("The cast relay is not running")
+ if (asked != ours) throw Refused("Refused: not this relay's stream")
+ return ours
+ }
+
+ /** As main.js: no subtitle named means the relay's current one. */
+ private fun subtitleOf(o: JSONObject): JSONObject? =
+ if (o.has("subtitle") && o.get("subtitle") != JSONObject.NULL) o.optJSONObject("subtitle") else relay.subtitleInfo()
+
+ /**
+ * The Wi-Fi (or Ethernet) address, never the mobile network's: a TV cannot
+ * be reached there. Nor a VPN's: a VPN network carries the transports of
+ * the network under it, Wi-Fi included, and its address is a tunnel the
+ * television cannot route to.
+ */
+ private fun lanAddress(): InetAddress? {
+ val cm = context.getSystemService(ConnectivityManager::class.java)
+ for (network in cm.allNetworks) {
+ val caps = cm.getNetworkCapabilities(network) ?: continue
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) continue
+ if (!caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
+ !caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) continue
+ val addr = cm.getLinkProperties(network)?.linkAddresses?.map { it.address }
+ ?.firstOrNull { it is Inet4Address && !it.isLoopbackAddress }
+ if (addr != null) return addr
+ }
+ return null
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt
new file mode 100644
index 0000000..f574c89
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt
@@ -0,0 +1,370 @@
+package org.meshbay.client.cast
+
+import android.content.Context
+import android.graphics.Color
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import androidx.mediarouter.media.MediaRouteSelector
+import androidx.mediarouter.media.MediaRouter
+import com.google.android.gms.cast.CastMediaControlIntent
+import com.google.android.gms.cast.MediaInfo
+import com.google.android.gms.cast.MediaLoadRequestData
+import com.google.android.gms.cast.MediaStatus
+import com.google.android.gms.cast.MediaTrack
+import com.google.android.gms.cast.TextTrackStyle
+import com.google.android.gms.cast.framework.CastContext
+import com.google.android.gms.cast.framework.CastOptions
+import com.google.android.gms.cast.framework.CastSession
+import com.google.android.gms.cast.framework.OptionsProvider
+import com.google.android.gms.cast.framework.SessionManagerListener
+import com.google.android.gms.common.ConnectionResult
+import com.google.android.gms.common.GoogleApiAvailability
+import org.json.JSONArray
+import org.json.JSONObject
+import org.meshbay.client.bridge.Refused
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/**
+ * Discovery and control of a receiver — what cast-chromecast.js does with mDNS
+ * and the cast protocol client, done here with the platform's cast sender SDK
+ * and MediaRouter. The page keeps its own picker: it polls `devices()` while a
+ * scan runs and receivers are listed as they answer.
+ *
+ * The default media receiver: no receiver registration. Needs the vendor's
+ * play services; where they are absent `available()` is false and the page
+ * offers no cast button rather than one that fails.
+ */
+class CastControl(private val context: Context) {
+ private val main = Handler(Looper.getMainLooper())
+ private val devices = ConcurrentHashMap<String, String>()
+ @Volatile private var scanning = false
+ @Volatile private var deviceName: String? = null
+ private var router: MediaRouter? = null
+ private val selector by lazy {
+ MediaRouteSelector.Builder()
+ .addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
+ .build()
+ }
+
+ private val routes = object : MediaRouter.Callback() {
+ override fun onRouteAdded(r: MediaRouter, route: MediaRouter.RouteInfo) = note(route)
+ override fun onRouteChanged(r: MediaRouter, route: MediaRouter.RouteInfo) = note(route)
+ override fun onRouteRemoved(r: MediaRouter, route: MediaRouter.RouteInfo) { devices.remove(route.id) }
+ }
+
+ private fun note(route: MediaRouter.RouteInfo) {
+ if (!route.isDefault && route.isEnabled && route.matchesSelector(selector)) devices[route.id] = route.name
+ }
+
+ fun available(): Boolean = try {
+ GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS
+ } catch (e: Exception) { false }
+
+ private fun need() { if (!available()) throw Refused("Casting is not available on this device") }
+
+ /**
+ * Create the cast context at launch, as the SDK asks. Created only at the
+ * first connect, it was born in the same moment the route was selected,
+ * and the session started without the listener hearing of it: on a fresh
+ * start the first cast timed out, every time (measured).
+ */
+ fun warmUp() {
+ if (!available()) return
+ try { onMain { CastContext.getSharedInstance(context) } } catch (e: Exception) { Log.w(TAG, "cast context", e) }
+ }
+
+ private fun <T> onMain(timeoutS: Long = 10, block: () -> T): T {
+ if (Looper.myLooper() == Looper.getMainLooper()) return block()
+ val done = CountDownLatch(1)
+ var value: Result<T>? = null
+ main.post { value = runCatching(block); done.countDown() }
+ if (!done.await(timeoutS, TimeUnit.SECONDS)) throw IllegalStateException("the cast service did not answer")
+ return value!!.getOrThrow()
+ }
+
+ /** Start a scan and return at once; `devices()` reads what it has found so far. */
+ fun startScan() {
+ need()
+ onMain {
+ val r = router ?: MediaRouter.getInstance(context).also { router = it }
+ devices.clear()
+ r.removeCallback(routes)
+ r.addCallback(selector, routes, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN)
+ r.routes.forEach(::note)
+ scanning = true
+ main.removeCallbacksAndMessages(SCAN_TOKEN)
+ main.postAtTime({
+ // Keep listening for changes, stop the active (radio-costly) scan.
+ r.addCallback(selector, routes, 0)
+ scanning = false
+ }, SCAN_TOKEN, android.os.SystemClock.uptimeMillis() + SCAN_DURATION_MS)
+ }
+ }
+
+ fun devices(): JSONObject {
+ val list = JSONArray()
+ for ((id, name) in devices) list.put(JSONObject().put("id", id).put("name", name))
+ return JSONObject().put("devices", list).put("scanning", scanning)
+ }
+
+ /** Connect, launch the default receiver, load; answers once the receiver has. */
+ fun connect(deviceId: String, mediaUrl: String, subtitle: JSONObject?): JSONObject {
+ need()
+ val session = onMain {
+ val cast = CastContext.getSharedInstance(context)
+ val r = router ?: MediaRouter.getInstance(context).also { router = it }
+ val route = r.routes.firstOrNull { it.id == deviceId } ?: throw Refused("Unknown device: $deviceId")
+ cast.sessionManager.currentCastSession?.takeIf { it.isConnected }?.let { return@onMain it }
+ val started = CountDownLatch(1)
+ var result: CastSession? = null
+ val listener = object : SessionManagerListener<CastSession> {
+ override fun onSessionStarted(s: CastSession, id: String) { result = s; started.countDown() }
+ override fun onSessionStartFailed(s: CastSession, error: Int) {
+ Log.w(TAG, "session start failed: error=$error")
+ started.countDown()
+ }
+ override fun onSessionStarting(s: CastSession) {}
+ override fun onSessionEnding(s: CastSession) {}
+ override fun onSessionEnded(s: CastSession, error: Int) {}
+ override fun onSessionResuming(s: CastSession, id: String) {}
+ override fun onSessionResumed(s: CastSession, wasSuspended: Boolean) { result = s; started.countDown() }
+ override fun onSessionResumeFailed(s: CastSession, error: Int) {}
+ override fun onSessionSuspended(s: CastSession, reason: Int) {}
+ }
+ cast.sessionManager.addSessionManagerListener(listener, CastSession::class.java)
+ Log.i(TAG, "connect: selecting route '${route.name}'")
+ r.selectRoute(route)
+ deviceName = route.name
+ Pending(started, { result }, { cast.sessionManager.removeSessionManagerListener(listener, CastSession::class.java) })
+ }.let { s ->
+ when (s) {
+ is CastSession -> s
+ is Pending -> try {
+ // The listener, or the session manager itself: a started
+ // session the listener missed is still a started session.
+ val deadline = System.currentTimeMillis() + 15_000
+ var found: CastSession? = null
+ while (found == null && System.currentTimeMillis() < deadline) {
+ if (s.done.await(300, TimeUnit.MILLISECONDS)) {
+ found = s.session() ?: throw IllegalStateException("The receiver refused the connection")
+ } else {
+ found = onMain {
+ CastContext.getSharedInstance(context).sessionManager.currentCastSession
+ ?.takeIf { it.isConnected }
+ }
+ if (found != null) Log.i(TAG, "session found connected without its callback")
+ }
+ }
+ found ?: throw IllegalStateException("Connection timeout")
+ } finally { main.post { s.cleanup() } }
+ else -> throw IllegalStateException()
+ }
+ }
+ // The cast framework's objects answer on the main thread only, the
+ // device's name included: read it there, never from this worker.
+ val name = deviceName ?: onMain { session.castDevice?.friendlyName }
+ Log.i(TAG, "session up on '$name', loading the relay stream")
+ val state = load(session, mediaUrl, subtitle)
+ watch(session)
+ return JSONObject().put("deviceName", name ?: JSONObject.NULL).put("playerState", state)
+ }
+
+ private class Pending(val done: CountDownLatch, val session: () -> CastSession?, val cleanup: () -> Unit)
+
+ /**
+ * What the receiver does for the whole film, not only at the start: a
+ * freeze on the television is a BUFFERING the phone never hears about
+ * otherwise. Logged with the position, on the main thread the SDK wants.
+ */
+ private var watched: com.google.android.gms.cast.framework.media.RemoteMediaClient? = null
+ private val watcher = object : com.google.android.gms.cast.framework.media.RemoteMediaClient.Callback() {
+ private var last = -1
+ override fun onStatusUpdated() {
+ val c = watched ?: return
+ val state = c.playerState
+ if (state == last) return
+ last = state
+ Log.i(TAG, "receiver state ${stateName(state)} at ${c.approximateStreamPosition / 1000.0}s" +
+ (if (state == MediaStatus.PLAYER_STATE_IDLE) " (idle: ${idleName(c.idleReason)})" else ""))
+ }
+ }
+
+ private fun watch(session: CastSession) = onMain {
+ val c = session.remoteMediaClient ?: return@onMain
+ if (watched === c) return@onMain
+ watched?.unregisterCallback(watcher)
+ c.registerCallback(watcher)
+ watched = c
+ }
+
+ fun reload(mediaUrl: String, subtitle: JSONObject?): JSONObject {
+ need()
+ val session = onMain { CastContext.getSharedInstance(context).sessionManager.currentCastSession }
+ ?: throw Refused("Not connected")
+ val state = load(session, mediaUrl, subtitle)
+ watch(session)
+ return JSONObject().put("playerState", state)
+ }
+
+ private fun load(session: CastSession, mediaUrl: String, subtitle: JSONObject?): String {
+ val subUrl = subtitle?.optString("url", "")?.takeIf { it.isNotEmpty() }
+ val info = MediaInfo.Builder(mediaUrl)
+ .setContentType("video/mp4")
+ // LIVE: the relay has no beginning to seek back to — the film's own
+ // timeline lives on this side, and a seek restarts the relay.
+ .setStreamType(MediaInfo.STREAM_TYPE_LIVE)
+ .apply {
+ if (subUrl != null) {
+ setMediaTracks(listOf(MediaTrack.Builder(TEXT_TRACK_ID, MediaTrack.TYPE_TEXT)
+ .setContentId(subUrl).setContentType("text/vtt")
+ .setSubtype(MediaTrack.SUBTYPE_SUBTITLES)
+ .setName(subtitle.optString("label", "").ifEmpty { "Subtitles" })
+ .setLanguage(subtitle.optString("language", "").ifEmpty { "und" })
+ .build()))
+ // White on nothing is unreadable over a bright scene; an outline costs no bandwidth.
+ setTextTrackStyle(TextTrackStyle().apply {
+ backgroundColor = Color.TRANSPARENT
+ foregroundColor = Color.WHITE
+ edgeType = TextTrackStyle.EDGE_TYPE_OUTLINE
+ edgeColor = Color.BLACK
+ fontScale = 1.0f
+ fontGenericFamily = TextTrackStyle.FONT_FAMILY_SANS_SERIF
+ })
+ }
+ }.build()
+ val request = MediaLoadRequestData.Builder().setMediaInfo(info).setAutoplay(true)
+ .apply { if (subUrl != null) setActiveTrackIds(longArrayOf(TEXT_TRACK_ID)) }.build()
+ val loaded = CountDownLatch(1)
+ var ok = false
+ var reason = ""
+ onMain {
+ val client = session.remoteMediaClient ?: throw IllegalStateException("The receiver has no media channel")
+ client.load(request).setResultCallback { r ->
+ ok = r.status.isSuccess
+ reason = "code ${r.status.statusCode}" + (r.status.statusMessage?.let { ", $it" } ?: "")
+ // The receiver's own reason, which is the only one there is:
+ // the page is told "refused" and nothing else.
+ Log.i(TAG, "load result: ok=$ok code=${r.status.statusCode} " +
+ "message=${r.status.statusMessage} state=${stateName(client.playerState)} " +
+ "idleReason=${client.idleReason} subtitles=${subUrl != null}")
+ loaded.countDown()
+ }
+ }
+ if (!loaded.await(20, TimeUnit.SECONDS)) {
+ Log.w(TAG, "load: no answer from the receiver in 20 s")
+ throw IllegalStateException("The receiver did not load the stream")
+ }
+ if (!ok) throw IllegalStateException("The receiver refused the stream ($reason)")
+ // A load the receiver accepted is not a film on the screen: it answers
+ // IDLE, then fetches the stream, and only then plays — or gives up.
+ // So the answer waits for what the receiver actually did (measured
+ // against this receiver: OK/IDLE, then BUFFERING, then PLAYING).
+ val deadline = System.currentTimeMillis() + PLAY_WAIT_MS
+ while (System.currentTimeMillis() < deadline) {
+ val (state, idle) = onMain {
+ val c = session.remoteMediaClient
+ (c?.playerState ?: MediaStatus.PLAYER_STATE_UNKNOWN) to (c?.idleReason ?: MediaStatus.IDLE_REASON_NONE)
+ }
+ if (state == MediaStatus.PLAYER_STATE_PLAYING || state == MediaStatus.PLAYER_STATE_BUFFERING ||
+ state == MediaStatus.PLAYER_STATE_PAUSED) {
+ Log.i(TAG, "receiver is ${stateName(state)}")
+ return stateName(state)
+ }
+ if (state == MediaStatus.PLAYER_STATE_IDLE && idle != MediaStatus.IDLE_REASON_NONE) {
+ Log.w(TAG, "receiver gave up: idle reason ${idleName(idle)}")
+ throw IllegalStateException("The receiver gave up on the stream (${idleName(idle)})")
+ }
+ Thread.sleep(300)
+ }
+ Log.w(TAG, "receiver still not playing after ${PLAY_WAIT_MS / 1000} s")
+ return onMain { stateName(session.remoteMediaClient?.playerState ?: MediaStatus.PLAYER_STATE_UNKNOWN) }
+ }
+
+ /** The player as a remote: pause and play the receiver, answered once it has. */
+ fun pause(): JSONObject = command { it.pause() }
+ fun play(): JSONObject = command { it.play() }
+
+ private fun command(send: (com.google.android.gms.cast.framework.media.RemoteMediaClient) ->
+ com.google.android.gms.common.api.PendingResult<com.google.android.gms.cast.framework.media.RemoteMediaClient.MediaChannelResult>): JSONObject {
+ need()
+ val done = CountDownLatch(1)
+ var ok = false
+ onMain {
+ val c = CastContext.getSharedInstance(context).sessionManager.currentCastSession?.remoteMediaClient
+ ?: throw Refused("Not connected")
+ send(c).setResultCallback { r -> ok = r.status.isSuccess; done.countDown() }
+ }
+ if (!done.await(10, TimeUnit.SECONDS)) throw IllegalStateException("The receiver did not answer")
+ if (!ok) throw IllegalStateException("The receiver refused the command")
+ return JSONObject().put("playerState", onMain {
+ stateName(CastContext.getSharedInstance(context).sessionManager.currentCastSession?.remoteMediaClient?.playerState
+ ?: MediaStatus.PLAYER_STATE_UNKNOWN)
+ })
+ }
+
+ fun disconnect(): Boolean {
+ if (!available()) return true
+ onMain { CastContext.getSharedInstance(context).sessionManager.endCurrentSession(true) }
+ deviceName = null
+ return true
+ }
+
+ fun status(): JSONObject = try {
+ if (!available()) JSONObject().put("connected", false).put("deviceName", JSONObject.NULL)
+ else onMain {
+ val s = CastContext.getSharedInstance(context).sessionManager.currentCastSession
+ val on = s != null && s.isConnected
+ val c = if (on) s!!.remoteMediaClient else null
+ JSONObject().put("connected", on).put("deviceName", if (on) (deviceName ?: s!!.castDevice?.friendlyName) else JSONObject.NULL)
+ // Where the television is, on the stream's timeline (zero at
+ // the relay's start): the page adds that start. Not the local
+ // playhead, which started earlier and drifts.
+ .put("playerState", c?.let { stateName(it.playerState) } ?: JSONObject.NULL)
+ .put("idleReason", c?.takeIf { it.playerState == MediaStatus.PLAYER_STATE_IDLE }
+ ?.let { idleName(it.idleReason) } ?: JSONObject.NULL)
+ .put("position", c?.let { it.approximateStreamPosition / 1000.0 } ?: JSONObject.NULL)
+ }
+ } catch (e: Exception) {
+ Log.w("MeshBay", "cast status", e)
+ JSONObject().put("connected", false).put("deviceName", JSONObject.NULL)
+ }
+
+ companion object {
+ const val SCAN_DURATION_MS = 6000L
+ private const val TAG = "MeshBayCast"
+ // The one track the receiver is ever told about; changing subtitles
+ // reloads with a new URL under this same id.
+ const val TEXT_TRACK_ID = 1L
+ private val SCAN_TOKEN = Any()
+
+ private const val PLAY_WAIT_MS = 15000L
+
+ fun idleName(r: Int) = when (r) {
+ MediaStatus.IDLE_REASON_FINISHED -> "finished"
+ MediaStatus.IDLE_REASON_CANCELED -> "cancelled"
+ MediaStatus.IDLE_REASON_INTERRUPTED -> "interrupted"
+ MediaStatus.IDLE_REASON_ERROR -> "error"
+ else -> "reason $r"
+ }
+
+ fun stateName(s: Int) = when (s) {
+ MediaStatus.PLAYER_STATE_PLAYING -> "PLAYING"
+ MediaStatus.PLAYER_STATE_PAUSED -> "PAUSED"
+ MediaStatus.PLAYER_STATE_BUFFERING -> "BUFFERING"
+ MediaStatus.PLAYER_STATE_LOADING -> "LOADING"
+ MediaStatus.PLAYER_STATE_IDLE -> "IDLE"
+ else -> "UNKNOWN"
+ }
+ }
+}
+
+/** The cast framework asks the manifest for this: the default media receiver. */
+class CastOptionsProvider : OptionsProvider {
+ override fun getCastOptions(context: Context): CastOptions =
+ CastOptions.Builder().setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID).build()
+
+ override fun getAdditionalSessionProviders(context: Context) = null
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastRelay.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastRelay.kt
new file mode 100644
index 0000000..21328db
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastRelay.kt
@@ -0,0 +1,446 @@
+package org.meshbay.client.cast
+
+import android.util.Log
+import org.json.JSONObject
+import java.io.BufferedReader
+import java.io.File
+import java.io.InputStreamReader
+import java.io.RandomAccessFile
+import java.nio.ByteBuffer
+import java.nio.channels.FileChannel
+import java.io.OutputStream
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.ServerSocket
+import java.net.Socket
+import java.net.SocketException
+import java.security.SecureRandom
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicLong
+
+/**
+ * Local HTTP relay for LAN casting — a port of meshbay-client/src/cast-relay.js,
+ * whose comments are the specification.
+ *
+ * The page decrypts the fMP4 stream from the node and pushes it here; a
+ * receiver on the same Wi-Fi plays it from the URL. Same mitigations:
+ *
+ * - bound to the LAN interface (the caller supplies it), never 0.0.0.0;
+ * - fixed port range 19550–19553, open only while casting;
+ * - an unguessable token in the URL (32 hex chars);
+ * - CORS on both served paths, both behind the token;
+ * - `Cache-Control: no-store` on every response;
+ * - the server closed when playback stops — zero residual surface.
+ *
+ * What a receiver has not read yet waits in a file, not in memory. The page
+ * runs ahead of the television by its whole read-ahead — tens of megabytes in
+ * the first seconds — and a fragment is a segment, megabytes at a film's
+ * bitrate (5 to 10 MB measured). Held in memory under the desktop's 8 MB
+ * bound, fragments were dropped and the picture froze for their length; held
+ * in memory without a bound, the heap went. A spool file per receiver holds
+ * the lead at no cost to either, and is deleted when the receiver goes.
+ *
+ * `java.net` and `java.nio` only, so the JVM tests run the real thing.
+ */
+class CastRelay(private val lanAddress: () -> InetAddress?, private val spoolDir: File) {
+
+ /**
+ * One receiver: the fragments it has been given, appended to its spool
+ * file, and how far it has read. Positional reads and writes on one
+ * channel, so the pusher and the reader never share a file pointer.
+ */
+ private class Client(val socket: Socket, val out: OutputStream, val file: File) {
+ val channel: FileChannel = RandomAccessFile(file, "rw").channel
+ val lock = Object()
+ var written = 0L // guarded by lock
+ var read = 0L // guarded by lock
+ var ended = false // guarded by lock
+ @Volatile var closed = false
+ val sent = AtomicLong()
+ val dropped = AtomicLong()
+ @Volatile var lastReport = System.currentTimeMillis()
+
+ fun waiting() = synchronized(lock) { written - read }
+
+ fun append(data: ByteArray) {
+ var at = synchronized(lock) { written }
+ val buf = ByteBuffer.wrap(data)
+ while (buf.hasRemaining()) at += channel.write(buf, at)
+ synchronized(lock) { written = at; lock.notifyAll() }
+ }
+
+ fun end() = synchronized(lock) { ended = true; lock.notifyAll() }
+
+ fun close() {
+ closed = true
+ synchronized(lock) { lock.notifyAll() }
+ try { channel.close() } catch (e: Exception) {}
+ file.delete()
+ }
+ }
+
+ private val spoolSeq = AtomicLong()
+
+ private class Subtitle(val vtt: ByteArray, val language: String, val label: String)
+
+ @Volatile private var server: ServerSocket? = null
+ @Volatile private var port = 0
+ @Volatile private var token: String? = null
+ @Volatile private var host: String? = null
+ @Volatile private var initSegment: ByteArray? = null
+ private val headerLock = Object()
+ @Volatile private var headerReady = false
+ @Volatile private var subtitle: Subtitle? = null
+ @Volatile private var subtitleVersion = 0
+ /** How many times a receiver asked for the stream since the relay started. */
+ @Volatile var streamRequests = 0
+ private set
+ private val ring = ArrayDeque<ByteArray>()
+ private var ringBytes = 0L
+ private val clients = ConcurrentHashMap.newKeySet<Client>()
+ private var accum = BoxAccumulator()
+
+ val active get() = server != null
+ /** For the tests: what a receiver joining now would be sent before live fragments. */
+ fun backlogBytes() = synchronized(ring) { ringBytes }
+
+ val url get() = if (server == null) null else "http://${hostPart()}:$port/stream.mp4?t=$token"
+
+ private fun hostPart() = host?.let { if (it.contains(':')) "[$it]" else it }
+
+ /** Versioned: a receiver caches a side-loaded track by its address. */
+ val subtitleUrl get() =
+ if (server == null || subtitle == null) null
+ else "http://${hostPart()}:$port/subs.vtt?t=$token&v=$subtitleVersion"
+
+ fun subtitleInfo(): JSONObject? = subtitle?.let {
+ JSONObject().put("url", subtitleUrl).put("language", it.language).put("label", it.label)
+ }
+
+ /** Cues already on the stream's timeline — the page shifts them; the relay serves bytes. */
+ fun setSubtitle(sub: JSONObject?): JSONObject? {
+ val vtt = sub?.optString("vtt", "") ?: ""
+ subtitle = if (vtt.isEmpty()) null
+ else Subtitle(vtt.toByteArray(Charsets.UTF_8), sub!!.optString("language", ""), sub.optString("label", ""))
+ subtitleVersion++
+ return subtitleInfo()
+ }
+
+ @Synchronized
+ fun start(init: ByteArray?, sub: JSONObject?): JSONObject {
+ if (server != null) stop()
+ val address = lanAddress() ?: throw IllegalStateException("Casting needs this device on Wi-Fi")
+ token = randomToken()
+ streamRequests = 0
+ pushes = 0
+ fragments = 0
+ host = address.hostAddress
+ initSegment = init?.let(::headerOnly)
+ // Nothing to wait for without a first chunk: no header is coming.
+ headerReady = init == null
+ synchronized(ring) { ring.clear(); ringBytes = 0 }
+ clients.clear()
+ accum = BoxAccumulator().also { a -> a.onPreamble = ::preamble }
+ // What a killed process left behind.
+ spoolDir.mkdirs()
+ spoolDir.listFiles()?.forEach { it.delete() }
+ subtitle = null
+ setSubtitle(sub)
+
+ var bound: ServerSocket? = null
+ for (i in 0 until PORT_COUNT) {
+ val s = ServerSocket()
+ try {
+ // As Node's server does (and so the desktop relay): a port just
+ // closed sits in TIME_WAIT, and every seek restarts the relay —
+ // without this, four quick seeks used all four ports. It does
+ // not let two live listeners share a port.
+ s.reuseAddress = true
+ s.bind(InetSocketAddress(address, PORT_BASE + i))
+ bound = s; port = PORT_BASE + i
+ break
+ } catch (e: java.net.BindException) {
+ s.close()
+ }
+ }
+ server = bound ?: throw IllegalStateException("All cast relay ports are in use")
+ Thread({ acceptLoop(bound) }, "cast-relay-accept").apply { isDaemon = true }.start()
+ log("started on ${hostPart()}:$port, init ${init?.size ?: 0} bytes, header ${initSegment?.size ?: 0} bytes")
+ return JSONObject().put("url", url).put("port", port).put("token", token)
+ .put("subtitle", subtitleInfo() ?: JSONObject.NULL)
+ }
+
+ /**
+ * The header is everything the page pushed before the first moof. The
+ * `init` the page hands to start() is only its first chunk, which may be
+ * the ftyp without the moov: served as the header, the receiver got no
+ * track description and gave up — the "fails the first time" of a fresh
+ * start, every time. The pushed stream carries the whole of it; `init` is
+ * the fallback when nothing came before the first moof.
+ */
+ private fun preamble(bytes: ByteArray) {
+ val header = headerOnly(bytes)
+ synchronized(headerLock) {
+ if (header.size >= 8 && BoxAccumulator.u32(header, 4) == FTYP) initSegment = header
+ headerReady = true
+ headerLock.notifyAll()
+ }
+ log("header complete: ${initSegment?.size ?: 0} bytes")
+ }
+
+ /** A receiver that connects before the first moof waits for the whole header, not a piece of it. */
+ private fun awaitHeader(): ByteArray? {
+ val deadline = System.currentTimeMillis() + HEADER_WAIT_MS
+ synchronized(headerLock) {
+ while (!headerReady) {
+ val left = deadline - System.currentTimeMillis()
+ if (left <= 0) break
+ headerLock.wait(left)
+ }
+ }
+ return initSegment
+ }
+
+ @Volatile private var pushes = 0
+ @Volatile private var fragments = 0
+
+ fun push(data: ByteArray, offset: Int = 0, length: Int = data.size - offset) {
+ if (server == null) return
+ pushes++
+ if (pushes <= 3 || pushes % 100 == 0) log("push #$pushes: $length bytes, $fragments fragments so far")
+ for (frag in accum.push(data, offset, length)) {
+ fragments++
+ // The backlog and the clients under one lock: a receiver joining
+ // gets each fragment exactly once, from the backlog or from here.
+ synchronized(ring) {
+ ring.addLast(frag); ringBytes += frag.size
+ // Bounded in fragments, as the desktop is, and in bytes too: a
+ // fragment is a whole segment, megabytes at a film's bitrate,
+ // and 64 of them outgrew a phone's heap — the app died of it.
+ while (ring.size > RING_CAP || (ringBytes > RING_MAX_BYTES && ring.size > 1)) {
+ ringBytes -= ring.removeFirst().size
+ }
+ for (c in clients) {
+ if (c.waiting() > spoolLimit()) {
+ // Only with the disk bound reached: the picture on the
+ // receiver freezes until the next fragment it gets.
+ val n = c.dropped.incrementAndGet()
+ if (n <= 3 || n % 20 == 0L) {
+ log("DROPPED fragment for ${c.socket.inetAddress?.hostAddress}: ${frag.size} bytes, " +
+ "${c.waiting() / 1048576} MiB already waiting, $n dropped so far")
+ }
+ continue
+ }
+ try { c.append(frag) } catch (e: Exception) { log("spool write failed: ${e.message}") }
+ }
+ }
+ }
+ }
+
+ /** End of film: every client's response is ended, the server stays until stop. */
+ fun finish() {
+ for (c in clients) c.end()
+ }
+
+ /** Half the free space, at most 2 GiB: a receiver this far behind is not coming back. */
+ private fun spoolLimit(): Long = minOf(SPOOL_MAX_BYTES, spoolDir.usableSpace / 2)
+
+ @Synchronized
+ fun stop() {
+ for (c in clients) { c.close(); try { c.socket.close() } catch (e: Exception) {} }
+ clients.clear()
+ server?.let { try { it.close() } catch (e: Exception) {} }
+ server = null
+ port = 0; token = null; initSegment = null; subtitle = null
+ synchronized(ring) { ring.clear(); ringBytes = 0 }
+ accum.reset()
+ }
+
+ // ── HTTP ────────────────────────────────────────────────────────────────
+
+ private fun acceptLoop(s: ServerSocket) {
+ while (!s.isClosed) {
+ val socket = try { s.accept() } catch (e: SocketException) { return }
+ Thread({ serve(socket) }, "cast-relay-client").apply { isDaemon = true }.start()
+ }
+ }
+
+ private fun serve(socket: Socket) {
+ try {
+ socket.soTimeout = 15000
+ val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.ISO_8859_1))
+ val requestLine = reader.readLine() ?: return socket.close()
+ while (true) { val line = reader.readLine() ?: break; if (line.isEmpty()) break }
+ socket.soTimeout = 0
+ val parts = requestLine.split(' ')
+ val method = parts.getOrElse(0) { "" }
+ val target = parts.getOrElse(1) { "" }
+ val out = socket.getOutputStream()
+
+ if (method != "GET" && method != "OPTIONS") return respond(out, socket, 405, emptyMap())
+ // The preflight is answered before the token is examined: a
+ // receiver sends it without credentials, and refusing it would
+ // read on the receiver as a network failure, not a refusal.
+ if (method == "OPTIONS") return respond(out, socket, 204, CORS_HEADERS)
+
+ val path = target.substringBefore('?')
+ log("$method $path from ${socket.inetAddress?.hostAddress}")
+ val query = target.substringAfter('?', "").split('&').associate {
+ it.substringBefore('=') to it.substringAfter('=', "")
+ }
+ if (token == null || query["t"] != token) return respond(out, socket, 403, emptyMap())
+ when (path) {
+ "/subs.vtt" -> serveSubtitle(out, socket)
+ "/stream.mp4" -> { streamRequests++; serveStream(out, socket) }
+ else -> respond(out, socket, 404, emptyMap())
+ }
+ } catch (e: Exception) {
+ try { socket.close() } catch (x: Exception) {}
+ }
+ }
+
+ private fun serveSubtitle(out: OutputStream, socket: Socket) {
+ val sub = subtitle ?: return respond(out, socket, 404, CORS_HEADERS)
+ respond(out, socket, 200, CORS_HEADERS + mapOf(
+ "Content-Type" to "text/vtt; charset=utf-8",
+ "Content-Length" to sub.vtt.size.toString(),
+ "Cache-Control" to "no-store",
+ ), sub.vtt)
+ }
+
+ private fun serveStream(out: OutputStream, socket: Socket) {
+ // Same headers as the subtitle: a receiver given a side-loaded track
+ // reads the media through the same CORS-checked path.
+ head(out, 200, CORS_HEADERS + mapOf(
+ "Content-Type" to "video/mp4",
+ "Cache-Control" to "no-store",
+ "Accept-Ranges" to "none",
+ "Connection" to "keep-alive",
+ "Transfer-Encoding" to "chunked",
+ ))
+ awaitHeader()?.let { chunk(out, it) }
+ out.flush()
+ val client = Client(socket, out, File(spoolDir, "client-${spoolSeq.incrementAndGet()}.spool"))
+ val backlog = synchronized(ring) {
+ for (frag in ring) client.append(frag)
+ clients.add(client)
+ ring.size
+ }
+ log("client ${socket.inetAddress?.hostAddress} served init + $backlog fragments")
+ val block = ByteBuffer.allocate(BLOCK)
+ try {
+ while (!client.closed) {
+ val at = synchronized(client.lock) {
+ while (client.read == client.written && !client.ended && !client.closed) client.lock.wait()
+ if (client.read == client.written) -1L else client.read
+ }
+ if (at < 0) {
+ if (!client.closed) { out.write("0\r\n\r\n".toByteArray()); out.flush() }
+ break
+ }
+ block.clear()
+ val n = client.channel.read(block, at)
+ if (n <= 0) continue
+ val t0 = System.currentTimeMillis()
+ chunk(out, block.array(), n)
+ out.flush()
+ val took = System.currentTimeMillis() - t0
+ synchronized(client.lock) { client.read += n }
+ client.sent.addAndGet(n.toLong())
+ // A write that blocks is the receiver not reading (or the
+ // Wi-Fi not carrying): the one place a stall downstream shows.
+ if (took > 5000) log("slow write to ${socket.inetAddress?.hostAddress}: $n bytes took $took ms")
+ val now = System.currentTimeMillis()
+ if (now - client.lastReport >= 10_000) {
+ client.lastReport = now
+ log("client ${socket.inetAddress?.hostAddress}: sent ${client.sent.get() / 1048576} MiB, " +
+ "${client.waiting() / 1048576} MiB waiting in the spool, ${client.dropped.get()} dropped")
+ }
+ }
+ } catch (e: Exception) {
+ // The receiver went away; nothing to tell anyone.
+ } finally {
+ log("client ${socket.inetAddress?.hostAddress} gone")
+ synchronized(ring) { clients.remove(client) }
+ client.close()
+ try { socket.close() } catch (e: Exception) {}
+ }
+ }
+
+ companion object {
+ /**
+ * The init segment is what comes before the first moof, and only that.
+ *
+ * The page hands over the first chunk it decrypted, which is a slice of
+ * the stream and not a box: on a real film it was 65536 bytes — ftyp,
+ * moov, then the first moof and the start of its mdat. Served whole,
+ * the receiver read that partial fragment, then the same fragment again
+ * from the accumulator (the page pushes that chunk too), and the box
+ * structure was broken from the first fragment: the receiver gave up
+ * within three seconds, on the television's idle screen. Whether the
+ * first chunk carries film depends on when the node read ffmpeg's
+ * output, so the same film can work once and not the next time.
+ */
+ fun headerOnly(init: ByteArray): ByteArray {
+ var at = 0
+ while (at + 8 <= init.size) {
+ if (BoxAccumulator.u32(init, at + 4) == BoxAccumulator.MOOF) return init.copyOfRange(0, at)
+ val size = BoxAccumulator.u32(init, at)
+ if (size < 8) break
+ at += size.toInt()
+ }
+ return init
+ }
+
+ const val RING_CAP = 64
+ const val RING_MAX_BYTES = 32L * 1024 * 1024
+ const val SPOOL_MAX_BYTES = 2L * 1024 * 1024 * 1024
+ private const val HEADER_WAIT_MS = 10_000L
+ private const val FTYP = 0x66747970L
+ private const val BLOCK = 256 * 1024
+ const val PORT_BASE = 19550
+ const val PORT_COUNT = 4
+
+ // Never the URL: it carries the token.
+ private fun log(m: String) = try { Log.i("MeshBayCast", "[relay] $m") } catch (e: RuntimeException) { /* JVM tests */ }
+
+ // A receiver reads a side-loaded subtitle with XHR from its own
+ // origin, so the headers it sends are allowed by name — Range included.
+ val CORS_HEADERS = mapOf(
+ "Access-Control-Allow-Origin" to "*",
+ "Access-Control-Allow-Methods" to "GET, OPTIONS",
+ "Access-Control-Allow-Headers" to "Content-Type, Accept-Encoding, Range",
+ "Access-Control-Expose-Headers" to "Content-Length, Content-Range",
+ )
+
+ private val REASONS = mapOf(200 to "OK", 204 to "No Content", 403 to "Forbidden",
+ 404 to "Not Found", 405 to "Method Not Allowed")
+
+ private fun randomToken(): String {
+ val b = ByteArray(16).also { SecureRandom().nextBytes(it) }
+ return b.joinToString("") { "%02x".format(it) }
+ }
+
+ private fun head(out: OutputStream, status: Int, headers: Map<String, String>) {
+ val sb = StringBuilder("HTTP/1.1 $status ${REASONS[status] ?: ""}\r\n")
+ for ((k, v) in headers) sb.append(k).append(": ").append(v).append("\r\n")
+ sb.append("\r\n")
+ out.write(sb.toString().toByteArray(Charsets.ISO_8859_1))
+ }
+
+ private fun respond(out: OutputStream, socket: Socket, status: Int, headers: Map<String, String>,
+ body: ByteArray = ByteArray(0)) {
+ val h = if (headers.containsKey("Content-Length")) headers else headers + ("Content-Length" to body.size.toString())
+ head(out, status, h + ("Connection" to "close"))
+ out.write(body)
+ out.flush()
+ socket.close()
+ }
+
+ private fun chunk(out: OutputStream, data: ByteArray, length: Int = data.size) {
+ out.write("${Integer.toHexString(length)}\r\n".toByteArray())
+ out.write(data, 0, length)
+ out.write("\r\n".toByteArray())
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastService.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastService.kt
new file mode 100644
index 0000000..eaa471f
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastService.kt
@@ -0,0 +1,72 @@
+package org.meshbay.client.cast
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.net.wifi.WifiManager
+import android.os.Build
+import android.os.IBinder
+import android.os.PowerManager
+
+/**
+ * Keeps a cast alive with the screen off: a media-playback foreground service
+ * with a partial wake lock and a Wi-Fi lock, for as long as the relay runs.
+ *
+ * Not sufficient alone — measured (spike S-2a): Chromium freezes a hidden page
+ * 60 s after the screen goes off whatever the process importance, and the
+ * pipeline (WebRTC → decrypt → relay) lives in the page. The shell also keeps
+ * the WebView reported visible while casting (ShellWebView); the two together
+ * held a full-rate stream through screen-off and forced Doze.
+ */
+class CastService : Service() {
+ private var wake: PowerManager.WakeLock? = null
+ private var wifi: WifiManager.WifiLock? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val nm = getSystemService(NotificationManager::class.java)
+ nm.createNotificationChannel(NotificationChannel(CHANNEL, "Casting", NotificationManager.IMPORTANCE_LOW))
+ val open = PendingIntent.getActivity(this, 0,
+ packageManager.getLaunchIntentForPackage(packageName), PendingIntent.FLAG_IMMUTABLE)
+ val n = Notification.Builder(this, CHANNEL)
+ .setContentTitle(intent?.getStringExtra(EXTRA_TITLE) ?: "MeshBay")
+ .setContentText("Casting on this Wi-Fi")
+ .setSmallIcon(android.R.drawable.ic_media_play)
+ .setContentIntent(open)
+ .setOngoing(true)
+ .build()
+ if (Build.VERSION.SDK_INT >= 29) startForeground(ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
+ else startForeground(ID, n)
+ if (wake == null) {
+ wake = getSystemService(PowerManager::class.java)
+ .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "meshbay:cast").apply { acquire(MAX_HOLD_MS) }
+ @Suppress("DEPRECATION")
+ val mode = if (Build.VERSION.SDK_INT >= 29) WifiManager.WIFI_MODE_FULL_LOW_LATENCY else WifiManager.WIFI_MODE_FULL_HIGH_PERF
+ wifi = (applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager)
+ .createWifiLock(mode, "meshbay:cast").apply { acquire() }
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onDestroy() {
+ wake?.let { if (it.isHeld) it.release() }
+ wifi?.let { if (it.isHeld) it.release() }
+ wake = null; wifi = null
+ super.onDestroy()
+ }
+
+ companion object {
+ private const val CHANNEL = "cast"
+ private const val ID = 7
+ const val EXTRA_TITLE = "title"
+ // A bound on the wake lock, not on the cast: a process that is killed
+ // without stopping the service must not hold the CPU for ever.
+ private const val MAX_HOLD_MS = 6L * 3600 * 1000
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/hub/HubClient.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/hub/HubClient.kt
new file mode 100644
index 0000000..3b8517c
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/hub/HubClient.kt
@@ -0,0 +1,130 @@
+package org.meshbay.client.hub
+
+import android.content.SharedPreferences
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import org.meshbay.client.bridge.Refused
+import java.io.IOException
+import java.net.ConnectException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+import java.util.concurrent.TimeUnit
+import javax.net.ssl.SSLException
+
+/**
+ * Every call to the hub leaves from here, never from the page.
+ *
+ * Not a preference: the page's origin is `https://appassets.androidplatform.net`,
+ * which the hub's absent CORS refuses — and that posture is worth keeping, its
+ * API is reachable from no web origin at all. So the page asks and this goes,
+ * to the hub it is signed in to and nowhere else (main.js `hub:fetch`).
+ */
+class HubClient(private val prefs: SharedPreferences) {
+
+ private val http = OkHttpClient.Builder()
+ // The hub's longest call is signaling, which gives up at fifteen
+ // seconds; past this, no answer is still coming (HUB_FETCH_TIMEOUT_MS).
+ .callTimeout(FETCH_TIMEOUT_S, TimeUnit.SECONDS)
+ .followRedirects(false)
+ .build()
+
+ val base: String get() = prefs.getString(KEY_BASE, "") ?: ""
+
+ /** Check that the address answers as a hub before writing it down. */
+ fun setBase(raw: String): String {
+ val url = raw.trim().trimEnd('/')
+ // An empty address is not "no hub": main.js probes it like any other
+ // and it fails, so the first-run screen cannot be passed with nothing.
+ if (url.isEmpty()) throw Refused("Enter the address of a hub.")
+ if (!url.startsWith("https://") && !LOOPBACK_HTTP.containsMatchIn(url)) {
+ // http only to this device's loopback; anywhere else it would put
+ // the session token on the wire in clear.
+ throw Refused("The hub address must be https")
+ }
+ val probe = url.toHttpUrlOrNull()?.newBuilder()?.encodedPath("/v1/hub/version")?.build()
+ ?: throw Refused("$url is not an address")
+ val answer = try {
+ http.newBuilder().callTimeout(PROBE_TIMEOUT_S, TimeUnit.SECONDS).build()
+ .newCall(Request.Builder().url(probe).build()).execute().use { r ->
+ if (!r.isSuccessful) throw IOException("answered ${r.code}")
+ JSONObject(r.body.string())
+ }
+ } catch (e: Exception) {
+ throw Refused(describeUnreachable(url, e))
+ }
+ if (!answer.has("hub")) throw Refused(describeUnreachable(url, IOException("did not answer as a hub")))
+ prefs.edit().putString(KEY_BASE, url).apply()
+ return url
+ }
+
+ /** `{status, ok, headers, body}`, the shape main.js returns and platform.apiFetch reads. */
+ fun fetch(url: String, init: JSONObject?): JSONObject {
+ val target = url.toHttpUrlOrNull() ?: throw Refused("not an address")
+ val hub = base.toHttpUrlOrNull()
+ // The page may only reach the hub it is signed in to: a path it
+ // controls must not become a request to somewhere else.
+ if (hub == null || !sameOrigin(target, hub)) throw Refused("Refused: not this hub")
+
+ val method = (init?.optString("method").takeUnless { it.isNullOrEmpty() } ?: "GET").uppercase()
+ val builder = Request.Builder().url(target)
+ var contentType: String? = null
+ init?.optJSONObject("headers")?.let { h ->
+ for (name in h.keys()) {
+ val value = h.get(name).toString()
+ if (name.equals("content-type", ignoreCase = true)) contentType = value
+ builder.header(name, value)
+ }
+ }
+ val text = init?.opt("body")?.takeUnless { it == JSONObject.NULL }?.toString()
+ val body = when {
+ method == "GET" || method == "HEAD" -> null
+ else -> (text ?: "").toRequestBody(contentType?.toMediaTypeOrNull())
+ }
+ builder.method(method, body)
+
+ return try {
+ http.newCall(builder.build()).execute().use { r ->
+ val headers = JSONObject()
+ for (name in r.headers.names()) headers.put(name.lowercase(), r.headers.values(name).joinToString(", "))
+ JSONObject().put("status", r.code).put("ok", r.isSuccessful)
+ .put("headers", headers).put("body", r.body.string())
+ }
+ } catch (e: IOException) {
+ // OkHttp's call timeout is an InterruptedIOException("timeout"), a
+ // read timeout a SocketTimeoutException; both mean the same thing.
+ if (e is SocketTimeoutException || e.message?.contains("timeout", ignoreCase = true) == true) {
+ throw Refused("${originOf(hub)} accepted the connection but did not answer within ${FETCH_TIMEOUT_S}s.")
+ }
+ throw Refused(describeUnreachable(originOf(hub), e))
+ }
+ }
+
+ companion object {
+ private const val KEY_BASE = "hubBase"
+ const val FETCH_TIMEOUT_S = 30L
+ private const val PROBE_TIMEOUT_S = 10L
+ private val LOOPBACK_HTTP = Regex("^http://(localhost|127\\.)")
+
+ fun sameOrigin(a: HttpUrl, b: HttpUrl) = a.scheme == b.scheme && a.host == b.host && a.port == b.port
+
+ private fun originOf(u: HttpUrl): String {
+ val defaultPort = (u.scheme == "https" && u.port == 443) || (u.scheme == "http" && u.port == 80)
+ return "${u.scheme}://${u.host}" + if (defaultPort) "" else ":${u.port}"
+ }
+
+ /** Why the hub could not be reached, in words somebody can act on (main.js). */
+ fun describeUnreachable(url: String, e: Throwable): String = when {
+ url.startsWith("https:") && e is SSLException ->
+ "$url does not speak https. If this hub is on your own machine, it is probably http — try http:// instead."
+ e is ConnectException -> "Nothing is listening at $url. Is the hub running?"
+ e is UnknownHostException -> "$url could not be found. Check the address."
+ e is SocketTimeoutException || e.message?.contains("timeout", true) == true -> "$url did not answer in time."
+ else -> "Could not reach $url: ${e.message ?: e.javaClass.simpleName}"
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt
new file mode 100644
index 0000000..4cd840c
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt
@@ -0,0 +1,47 @@
+package org.meshbay.client.keys
+
+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
+import org.bouncycastle.crypto.signers.Ed25519Signer
+import org.json.JSONObject
+import java.security.SecureRandom
+
+/**
+ * The device's key for signing in to the hub (E3): generated, held and used
+ * here, never handed to the page, which asks for a signature over
+ * `meshbay:user_auth:<username>:<ts>` — the bytes `POST /v1/users/auth`
+ * verifies. Not a per-node identity: nothing here correlates a person across
+ * operators (main.js `device:*`).
+ */
+class DeviceKey(private val store: Secrets, private val now: () -> Long = { System.currentTimeMillis() / 1000 }) {
+
+ private fun current(): Ed25519PrivateKeyParameters? {
+ val stored = store.read().optString(SecretStore.DEVICE_KEY, "")
+ return if (stored.isEmpty()) null else Kdf.edFromPkcs8(Kdf.unb64(stored))
+ }
+
+ private fun publicOf(k: Ed25519PrivateKeyParameters) = Kdf.b64(k.generatePublicKey().encoded)
+
+ fun ensure(): String {
+ current()?.let { return publicOf(it) }
+ val k = Ed25519PrivateKeyParameters(SecureRandom())
+ store.update { it.put(SecretStore.DEVICE_KEY, Kdf.b64(Kdf.edToPkcs8(k))) }
+ return publicOf(k)
+ }
+
+ fun publicKey(): String? = current()?.let { publicOf(it) }
+
+ fun sign(username: String): JSONObject? {
+ val k = current() ?: return null
+ val ts = now()
+ // The username is inside the signature, so one collected for another
+ // account is not usable.
+ val message = "meshbay:user_auth:$username:$ts".toByteArray(Charsets.UTF_8)
+ val s = Ed25519Signer().apply { init(true, k); update(message, 0, message.size) }
+ return JSONObject().put("timestamp", ts).put("signature", Kdf.b64(s.generateSignature()))
+ }
+
+ fun forget(): Boolean {
+ store.update { it.remove(SecretStore.DEVICE_KEY) }
+ return true
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt
new file mode 100644
index 0000000..30f094e
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt
@@ -0,0 +1,106 @@
+package org.meshbay.client.keys
+
+import org.bouncycastle.crypto.digests.SHA256Digest
+import org.bouncycastle.crypto.generators.Argon2BytesGenerator
+import org.bouncycastle.crypto.generators.HKDFBytesGenerator
+import org.bouncycastle.crypto.params.Argon2Parameters
+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
+import org.bouncycastle.crypto.params.HKDFParameters
+import org.bouncycastle.crypto.params.X25519PrivateKeyParameters
+import java.util.Base64
+import javax.crypto.Cipher
+import javax.crypto.spec.GCMParameterSpec
+import javax.crypto.spec.SecretKeySpec
+
+/**
+ * The primitives keyring.js takes from node:crypto and the vendored Argon2,
+ * with the same numbers. keyderive.js (the page), keyring.js (desktop) and this
+ * are one format: a mismatch looks like an account nobody can open, not like
+ * an error. meshbay-hub/tests/vectors/keyring.json holds them together.
+ */
+object Kdf {
+ // keyderive.js: the same numbers, or no bundle opens across the clients.
+ const val ARGON2_MEMORY_KIB = 131072
+ const val ARGON2_PASSES = 3
+ const val ARGON2_PARALLELISM = 1
+ const val ARGON2_TAG = 32
+
+ private val ED_PKCS8_PREFIX = hex("302e020100300506032b657004220420")
+ private val X_PKCS8_PREFIX = hex("302e020100300506032b656e04220420")
+
+ // One derivation at a time: 128 MiB each, on a phone. (Two concurrent
+ // lanes=4 derivations deadlock inside OpenSSL on the hub — CLAUDE.md; not
+ // this library, but there is no reason to find out.)
+ @Synchronized
+ fun argon2id(password: String, salt: ByteArray): ByteArray {
+ val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
+ .withVersion(Argon2Parameters.ARGON2_VERSION_13)
+ .withIterations(ARGON2_PASSES)
+ .withMemoryAsKB(ARGON2_MEMORY_KIB)
+ .withParallelism(ARGON2_PARALLELISM)
+ .withSalt(salt)
+ .build()
+ val gen = Argon2BytesGenerator()
+ gen.init(params)
+ val out = ByteArray(ARGON2_TAG)
+ gen.generateBytes(password.toByteArray(Charsets.UTF_8), out)
+ return out
+ }
+
+ /** node:crypto hkdfSync('sha256', ikm, <empty salt>, info, 32). */
+ fun hkdf(ikm: ByteArray, info: String): ByteArray {
+ val gen = HKDFBytesGenerator(SHA256Digest())
+ gen.init(HKDFParameters(ikm, null, info.toByteArray(Charsets.UTF_8)))
+ val out = ByteArray(32)
+ gen.generateBytes(out, 0, 32)
+ return out
+ }
+
+ fun sha256(data: ByteArray): ByteArray {
+ val d = SHA256Digest()
+ d.update(data, 0, data.size)
+ val out = ByteArray(32)
+ d.doFinal(out, 0)
+ return out
+ }
+
+ /** AES-256-GCM, 16-byte tag appended — the layout node:crypto's getAuthTag gives. */
+ fun gcmSeal(key: ByteArray, nonce: ByteArray, plain: ByteArray, aad: ByteArray?): ByteArray {
+ val c = Cipher.getInstance("AES/GCM/NoPadding")
+ c.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))
+ if (aad != null) c.updateAAD(aad)
+ return c.doFinal(plain)
+ }
+
+ fun gcmOpen(key: ByteArray, nonce: ByteArray, ctAndTag: ByteArray, aad: ByteArray?): ByteArray {
+ val c = Cipher.getInstance("AES/GCM/NoPadding")
+ c.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))
+ if (aad != null) c.updateAAD(aad)
+ return c.doFinal(ctAndTag)
+ }
+
+ // Keys are stored as Node exports them: PKCS#8 DER, RFC 8410, 48 bytes, no
+ // public key attached. Written out by hand because a library's own PKCS#8
+ // encoder may add the optional public key, and the stored format is one.
+ fun edToPkcs8(k: Ed25519PrivateKeyParameters) = ED_PKCS8_PREFIX + k.encoded
+ fun xToPkcs8(k: X25519PrivateKeyParameters) = X_PKCS8_PREFIX + k.encoded
+
+ fun edFromPkcs8(der: ByteArray): Ed25519PrivateKeyParameters {
+ require(der.size == 48 && der.copyOfRange(0, 16).contentEquals(ED_PKCS8_PREFIX)) {
+ "not an Ed25519 PKCS#8 key"
+ }
+ return Ed25519PrivateKeyParameters(der, 16)
+ }
+
+ fun xFromPkcs8(der: ByteArray): X25519PrivateKeyParameters {
+ require(der.size == 48 && der.copyOfRange(0, 16).contentEquals(X_PKCS8_PREFIX)) {
+ "not an X25519 PKCS#8 key"
+ }
+ return X25519PrivateKeyParameters(der, 16)
+ }
+
+ fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)
+ fun unb64(s: String?): ByteArray = Base64.getDecoder().decode(s ?: "")
+ fun hex(s: String): ByteArray = ByteArray(s.length / 2) { s.substring(2 * it, 2 * it + 2).toInt(16).toByte() }
+ fun toHex(b: ByteArray): String = b.joinToString("") { "%02x".format(it) }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt
new file mode 100644
index 0000000..fa8ff71
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt
@@ -0,0 +1,266 @@
+package org.meshbay.client.keys
+
+import org.bouncycastle.crypto.agreement.X25519Agreement
+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
+import org.bouncycastle.crypto.params.X25519PrivateKeyParameters
+import org.bouncycastle.crypto.params.X25519PublicKeyParameters
+import org.bouncycastle.crypto.signers.Ed25519Signer
+import org.json.JSONObject
+import java.security.SecureRandom
+import org.meshbay.client.keys.Kdf.b64
+import org.meshbay.client.keys.Kdf.hkdf
+import org.meshbay.client.keys.Kdf.unb64
+
+/**
+ * The account's keys on Android: the bundle master key `M` and the identity on
+ * every node, held here and never handed to the page. A port of
+ * meshbay-client/src/keyring.js — same state shape (masters, identities,
+ * access), same formats, same refusals — so the two read side by side.
+ *
+ * Storage is injected (load/save of one JSON object), as on desktop; the app
+ * keeps it in SecretStore (Keystore-wrapped). `random` is injected so the
+ * vectors can pin a nonce.
+ */
+class Keyring(
+ private val load: () -> JSONObject?,
+ private val save: (JSONObject) -> Unit,
+ private val transcripts: Transcripts = Transcripts(),
+ private val random: (Int) -> ByteArray = { n -> ByteArray(n).also { SecureRandom().nextBytes(it) } },
+) {
+ class Pub(val pkEdB64: String, val pkXB64: String, val sealedWith: String? = null)
+ class Sealed(val bundle: String, val fingerprint: String)
+ class FormatRetired : IllegalStateException("bundle_format_retired")
+
+ private class Master(val m: ByteArray, val v: Int)
+ private class Identity(val ed: String, val x: String)
+
+ // In memory: the key of a passphrase change not yet accepted by the hub.
+ private val pending = HashMap<String, Master>()
+
+ private fun state(): JSONObject {
+ val s = load() ?: JSONObject()
+ for (k in listOf("masters", "identities", "access")) if (!s.has(k)) s.put(k, JSONObject())
+ return s
+ }
+
+ private fun master(userId: String, usePending: Boolean = false): Master {
+ if (usePending) pending[userId]?.let { return it }
+ val m = state().getJSONObject("masters").optJSONObject(userId)
+ ?: throw IllegalStateException("no bundle key in this session")
+ return Master(unb64(m.getString("m")), m.getInt("v"))
+ }
+
+ private fun fingerprint(m: ByteArray) = Kdf.toHex(Kdf.sha256(m)).substring(0, 16)
+
+ private fun stored(userId: String, nodePk: String): Identity {
+ val id = state().getJSONObject("identities").optJSONObject(userId)?.optJSONObject(nodePk)
+ ?: throw IllegalStateException("no identity for this node")
+ return Identity(id.getString("ed"), id.getString("x"))
+ }
+
+ private fun publicOf(id: Identity) = Pub(
+ b64(Kdf.edFromPkcs8(unb64(id.ed)).generatePublicKey().encoded),
+ b64(Kdf.xFromPkcs8(unb64(id.x)).generatePublicKey().encoded),
+ )
+
+ private fun accessOn(userId: String) = state().getJSONObject("access").opt(userId) != false
+ private fun needAccess(userId: String) {
+ if (!accessOn(userId)) throw IllegalStateException("Refused: browser access is off for this account")
+ }
+
+ private fun keep(userId: String, nodePk: String, put: (JSONObject) -> Unit) {
+ val s = state()
+ val ids = s.getJSONObject("identities")
+ val forUser = ids.optJSONObject(userId) ?: JSONObject().also { ids.put(userId, it) }
+ val entry = forUser.optJSONObject(nodePk) ?: JSONObject().also { forUser.put(nodePk, it) }
+ put(entry)
+ save(s)
+ }
+
+ private fun aad(userId: String, nodePk: String) =
+ "meshbay:bundle:v3|$userId|$nodePk".toByteArray(Charsets.UTF_8)
+
+ // Exactly JSON.stringify({ skEd, skX }): base64 needs no escaping, and the
+ // sealed bytes are then comparable with the desktop's in tests.
+ private fun plaintext(id: Identity) = "{\"skEd\":\"${id.ed}\",\"skX\":\"${id.x}\"}"
+
+ private fun seal(id: Identity, key: ByteArray, userId: String, nodePk: String, pepperVersion: Int): String {
+ val nonce = random(12)
+ val ct = Kdf.gcmSeal(key, nonce, plaintext(id).toByteArray(Charsets.UTF_8), aad(userId, nodePk))
+ return b64(MAGIC + byteArrayOf((pepperVersion and 0xff).toByte()) + nonce + ct)
+ }
+
+ private fun parse(plain: ByteArray): Identity {
+ val o = JSONObject(String(plain, Charsets.UTF_8))
+ return Identity(o.getString("skEd"), o.getString("skX"))
+ }
+
+ private fun open(bundleB64: String, key: ByteArray, userId: String, nodePk: String): Identity {
+ val raw = unb64(bundleB64)
+ if (raw.size < 4 || !raw.copyOfRange(0, 4).contentEquals(MAGIC)) throw FormatRetired()
+ return parse(Kdf.gcmOpen(key, raw.copyOfRange(5, 17), raw.copyOfRange(17, raw.size), aad(userId, nodePk)))
+ }
+
+ /** TRANSITIONAL — MBK2: "MBK2" ‖ nonce ‖ AES-GCM under the Argon2 key, no AAD. */
+ private fun openLegacy(bundleB64: String, key: ByteArray): Identity {
+ val raw = unb64(bundleB64)
+ return parse(Kdf.gcmOpen(key, raw.copyOfRange(4, 16), raw.copyOfRange(16, raw.size), null))
+ }
+
+ private fun fromMnemonic(mnemonic: String): ByteArray {
+ val clean = mnemonic.replace(Regex("[^A-Za-z2-7]"), "").uppercase()
+ var bits = 0
+ var value = 0
+ val out = ArrayList<Byte>()
+ for (ch in clean) {
+ value = (value shl 5) or B32.indexOf(ch)
+ bits += 5
+ if (bits >= 8) { out.add(((value ushr (bits - 8)) and 0xff).toByte()); bits -= 8 }
+ }
+ if (out.size < 32) throw IllegalArgumentException("recovery key too short")
+ return out.subList(0, 32).toByteArray()
+ }
+
+ // ── The API, in keyring.js order ────────────────────────────────────────
+
+ fun hasSession(userId: String) = state().getJSONObject("masters").has(userId)
+
+ /** `M` from the passphrase and the pepper — one Argon2 run, as in the page. */
+ fun deriveSession(password: String, username: String, userId: String, pepperB64: String?,
+ pepperVersion: Int?, pendingChange: Boolean = false): Boolean {
+ if (userId.isEmpty() || pepperB64.isNullOrEmpty()) {
+ throw IllegalStateException("the hub did not provide the bundle pepper")
+ }
+ val salt = Kdf.sha256("meshbay:bundle:v2:$username".toByteArray(Charsets.UTF_8)).copyOfRange(0, 16)
+ val a = Kdf.argon2id(password, salt)
+ val m = hkdf(a + unb64(pepperB64), "meshbay:bundle-master:v3|$userId")
+ val v = if (pepperVersion == null || pepperVersion == 0) 1 else pepperVersion
+ if (pendingChange) { pending[userId] = Master(m, v); return true }
+ val s = state()
+ // `legacy` (TRANSITIONAL): the Argon2 key MBK2 bundles were sealed under.
+ s.getJSONObject("masters").put(userId, JSONObject().put("m", b64(m)).put("v", v).put("legacy", b64(a)))
+ save(s)
+ return true
+ }
+
+ fun commitPending(userId: String): Boolean {
+ val p = pending[userId] ?: return false
+ val s = state()
+ val legacy = s.getJSONObject("masters").optJSONObject(userId)?.optString("legacy", "")
+ val entry = JSONObject().put("m", b64(p.m)).put("v", p.v)
+ if (!legacy.isNullOrEmpty()) entry.put("legacy", legacy)
+ s.getJSONObject("masters").put(userId, entry)
+ save(s)
+ pending.remove(userId)
+ return true
+ }
+
+ fun dropPending(userId: String) = pending.remove(userId) != null
+
+ /** Sign-out: `M` goes. The identities stay — they are this device's. */
+ fun forgetSession(userId: String): Boolean {
+ val s = state()
+ s.getJSONObject("masters").remove(userId)
+ save(s)
+ pending.remove(userId)
+ return true
+ }
+
+ fun identity(userId: String, nodePk: String): Pub? {
+ val id = state().getJSONObject("identities").optJSONObject(userId)?.optJSONObject(nodePk) ?: return null
+ val pub = publicOf(Identity(id.getString("ed"), id.getString("x")))
+ val sw = id.opt("sealedWith")
+ return Pub(pub.pkEdB64, pub.pkXB64, if (sw is String) sw else null)
+ }
+
+ fun openBundle(userId: String, nodePk: String, bundleEnc: String, recoveryEnc: String? = null,
+ recoveryMnemonic: String? = null, username: String? = null): Pub {
+ val raw = unb64(bundleEnc)
+ if (raw.size >= 4 && raw.copyOfRange(0, 4).contentEquals(LEGACY_MAGIC)) {
+ val legacy = state().getJSONObject("masters").optJSONObject(userId)?.optString("legacy", "")
+ if (legacy.isNullOrEmpty()) throw IllegalStateException("no_legacy_key")
+ val id = openLegacy(bundleEnc, unb64(legacy))
+ keepIdentity(userId, nodePk, id)
+ return publicOf(id)
+ }
+ val m = master(userId).m
+ val id = try {
+ open(bundleEnc, hkdf(m, "meshbay:bundle:v3|node|$nodePk"), userId, nodePk)
+ } catch (e: Exception) {
+ if (e is FormatRetired || recoveryEnc.isNullOrEmpty() || recoveryMnemonic.isNullOrEmpty()) throw e
+ val rk = hkdf(fromMnemonic(recoveryMnemonic), "meshbay:recovery:v1:${username ?: ""}")
+ open(recoveryEnc, rk, userId, nodePk)
+ }
+ keepIdentity(userId, nodePk, id)
+ return publicOf(id)
+ }
+
+ private fun keepIdentity(userId: String, nodePk: String, id: Identity) =
+ keep(userId, nodePk) { it.put("ed", id.ed).put("x", id.x).put("sealedWith", JSONObject.NULL) }
+
+ fun mint(userId: String, nodePk: String): Pub {
+ val rnd = SecureRandom()
+ val id = Identity(b64(Kdf.edToPkcs8(Ed25519PrivateKeyParameters(rnd))),
+ b64(Kdf.xToPkcs8(X25519PrivateKeyParameters(rnd))))
+ keepIdentity(userId, nodePk, id)
+ return publicOf(id)
+ }
+
+ /** The identity sealed for its node, under `M` (or the pending one). */
+ fun sealBundle(userId: String, nodePk: String, usePending: Boolean = false): Sealed {
+ needAccess(userId)
+ val m = master(userId, usePending)
+ val bundle = seal(stored(userId, nodePk), hkdf(m.m, "meshbay:bundle:v3|node|$nodePk"), userId, nodePk, m.v)
+ return Sealed(bundle, fingerprint(m.m))
+ }
+
+ /** The recovery copy: sealed under the recovery key, owing nothing to `M`. */
+ fun sealRecovery(userId: String, nodePk: String, mnemonic: String, username: String): String {
+ needAccess(userId)
+ val rk = hkdf(fromMnemonic(mnemonic), "meshbay:recovery:v1:$username")
+ return seal(stored(userId, nodePk), rk, userId, nodePk, 0)
+ }
+
+ fun markSealed(userId: String, nodePk: String, fp: String?): Boolean {
+ keep(userId, nodePk) { it.put("sealedWith", if (fp.isNullOrEmpty()) JSONObject.NULL else fp) }
+ return true
+ }
+
+ fun currentFingerprint(userId: String) = fingerprint(master(userId).m)
+
+ /** Sign what `kind` names, built from `fields` (Transcripts). */
+ fun signAs(userId: String, nodePk: String, kind: String, fields: JSONObject?): String {
+ val id = stored(userId, nodePk)
+ val pub = publicOf(id)
+ val transcript = transcripts.forKind(kind, fields, Transcripts.Ctx(userId, nodePk, pub.pkEdB64, pub.pkXB64))
+ val signer = Ed25519Signer()
+ signer.init(true, Kdf.edFromPkcs8(unb64(id.ed)))
+ signer.update(transcript, 0, transcript.size)
+ return b64(signer.generateSignature())
+ }
+
+ fun shared(userId: String, nodePk: String, peerPkB64: String): String {
+ val agreement = X25519Agreement()
+ agreement.init(Kdf.xFromPkcs8(unb64(stored(userId, nodePk).x)))
+ val out = ByteArray(32)
+ agreement.calculateAgreement(X25519PublicKeyParameters(unb64(peerPkB64), 0), out, 0)
+ return b64(out)
+ }
+
+ fun playlistKey(userId: String) = b64(hkdf(master(userId).m, "meshbay:playlists:v2"))
+
+ fun browserAccess(userId: String) = accessOn(userId)
+ fun setBrowserAccess(userId: String, on: Boolean): Boolean {
+ val s = state()
+ s.getJSONObject("access").put(userId, on)
+ save(s)
+ return on
+ }
+
+ companion object {
+ private val MAGIC = "MBK3".toByteArray()
+ // TRANSITIONAL — the format before MBK3, read once to be replaced.
+ private val LEGACY_MAGIC = "MBK2".toByteArray()
+ private const val B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt
new file mode 100644
index 0000000..5a2c1bb
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt
@@ -0,0 +1,120 @@
+package org.meshbay.client.keys
+
+import android.content.Context
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.security.keystore.StrongBoxUnavailableException
+import android.util.Log
+import org.json.JSONObject
+import java.io.File
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+/**
+ * The application's secrets, at rest: the desktop's safeStorage blob, here
+ * wrapped by an AES-256-GCM key that lives in Android Keystore and never leaves
+ * it (in StrongBox where the device has one).
+ *
+ * One file, `files/secrets.bin` = nonce ‖ ciphertext ‖ tag over the JSON of
+ * every slot (`device_key`, `keyring` — main.js's slots), replaced atomically.
+ * Nothing in it is reachable from the page: it holds the device's hub key.
+ *
+ * Honest without protection, as on desktop: if the Keystore cannot be used,
+ * `backend()` says `unavailable` and nothing is stored — the page then keeps
+ * its keys the way a browser does, rather than this downgrading silently.
+ */
+/** What the keys need of their storage; SecretStore on a device, a map in tests. */
+interface Secrets {
+ fun backend(): String
+ fun read(): JSONObject
+ fun update(fn: (JSONObject) -> Unit)
+}
+
+class SecretStore(private val context: Context) : Secrets {
+ private val file get() = File(context.filesDir, "secrets.bin")
+ @Volatile private var strongBox = false
+
+ private fun key(): SecretKey? = try {
+ val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
+ (ks.getKey(ALIAS, null) as SecretKey?) ?: generate()
+ } catch (e: Exception) {
+ Log.w(TAG, "Keystore unusable", e)
+ null
+ }
+
+ private fun generate(): SecretKey {
+ fun spec(strong: Boolean) = KeyGenParameterSpec.Builder(ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(256)
+ // Usable without a prompt: the app answers the hub on its own, as
+ // the desktop keychain does once the session is unlocked.
+ .setUserAuthenticationRequired(false)
+ .setRandomizedEncryptionRequired(true)
+ .apply { if (strong) setIsStrongBoxBacked(true) }
+ .build()
+ val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
+ return try {
+ gen.init(spec(true)); gen.generateKey().also { strongBox = true }
+ } catch (e: StrongBoxUnavailableException) {
+ gen.init(spec(false)); gen.generateKey()
+ }
+ }
+
+ override fun backend(): String {
+ if (key() == null) return "unavailable"
+ return if (strongBox || isStrongBox()) "android_strongbox" else "android_keystore"
+ }
+
+ private fun isStrongBox(): Boolean = try {
+ val k = key() ?: return false
+ val info = javax.crypto.SecretKeyFactory.getInstance(k.algorithm, "AndroidKeyStore")
+ .getKeySpec(k, android.security.keystore.KeyInfo::class.java) as android.security.keystore.KeyInfo
+ if (android.os.Build.VERSION.SDK_INT >= 31) info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX else false
+ } catch (e: Exception) { false }
+
+ @Synchronized
+ override fun read(): JSONObject {
+ val k = key() ?: return JSONObject()
+ val raw = try { file.readBytes() } catch (e: java.io.FileNotFoundException) { return JSONObject() }
+ return try {
+ val c = Cipher.getInstance("AES/GCM/NoPadding")
+ c.init(Cipher.DECRYPT_MODE, k, GCMParameterSpec(128, raw, 0, 12))
+ JSONObject(String(c.doFinal(raw, 12, raw.size - 12), Charsets.UTF_8))
+ } catch (e: Exception) {
+ // A store that does not open is reported, never overwritten: what
+ // is in it is a device key and identities nodes have pinned.
+ throw IllegalStateException("the key store does not open", e)
+ }
+ }
+
+ @Synchronized
+ fun write(all: JSONObject) {
+ val k = key() ?: throw IllegalStateException("No OS key storage")
+ val c = Cipher.getInstance("AES/GCM/NoPadding")
+ c.init(Cipher.ENCRYPT_MODE, k)
+ val sealed = c.iv + c.doFinal(all.toString().toByteArray(Charsets.UTF_8))
+ val tmp = File(context.filesDir, "secrets.bin.tmp")
+ tmp.writeBytes(sealed)
+ if (!tmp.renameTo(file)) throw IllegalStateException("could not replace the key store")
+ }
+
+ /** One slot, read and replaced under the same lock. */
+ @Synchronized
+ override fun update(fn: (JSONObject) -> Unit) {
+ val all = read()
+ fn(all)
+ write(all)
+ }
+
+ companion object {
+ private const val TAG = "MeshBay"
+ private const val ALIAS = "meshbay.secrets.v1"
+ const val DEVICE_KEY = "device_key"
+ const val KEYRING_SLOT = "keyring"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt
new file mode 100644
index 0000000..4d183f7
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt
@@ -0,0 +1,183 @@
+package org.meshbay.client.keys
+
+import org.json.JSONObject
+import java.io.ByteArrayOutputStream
+import java.util.Base64
+import kotlin.math.abs
+
+/**
+ * What a node identity signs, built here from named fields — never bytes the
+ * page chose. A port of meshbay-client/src/transcripts.js, byte for byte; the
+ * shapes it checks and the refusals it gives are the same, and
+ * meshbay-hub/tests/vectors/keyring.json is what holds them (and
+ * meshbay_common) together.
+ *
+ * `now` is injected so the vectors can pin the clock; production passes the
+ * system clock.
+ */
+class Transcripts(private val now: () -> Double = { System.currentTimeMillis() / 1000.0 }) {
+
+ class Refused(what: String) : IllegalArgumentException("Refused: $what")
+
+ data class Ctx(val userId: String, val nodePk: String, val pkEdB64: String, val pkXB64: String)
+
+ private fun refuse(what: String): Nothing = throw Refused(what)
+
+ private fun enc(s: String) = s.toByteArray(Charsets.UTF_8)
+
+ private fun lenPrefixed(prefix: String, parts: List<ByteArray>): ByteArray {
+ val out = ByteArrayOutputStream()
+ out.write(prefix.toByteArray(Charsets.UTF_8))
+ for (p in parts) {
+ out.write(byteArrayOf((p.size ushr 24).toByte(), (p.size ushr 16).toByte(),
+ (p.size ushr 8).toByte(), p.size.toByte()))
+ out.write(p)
+ }
+ return out.toByteArray()
+ }
+
+ // JavaScript's String(v ?? '') over a value that came through JSON.
+ private fun jsString(v: Any?): String = when (v) {
+ null, JSONObject.NULL -> ""
+ is String -> v
+ is Boolean -> v.toString()
+ is Int, is Long -> v.toString()
+ is Number -> {
+ val d = v.toDouble()
+ if (d == Math.floor(d) && !d.isInfinite() && abs(d) < 1e21) d.toLong().toString() else d.toString()
+ }
+ else -> v.toString()
+ }
+
+ // JavaScript's Number(v), for the values a timestamp or an epoch can arrive as.
+ private fun jsNumber(v: Any?): Double = when (v) {
+ null, JSONObject.NULL -> if (v == null) Double.NaN else 0.0
+ is Number -> v.toDouble()
+ is Boolean -> if (v) 1.0 else 0.0
+ is String -> v.trim().let { if (it.isEmpty()) 0.0 else it.toDoubleOrNull() ?: Double.NaN }
+ else -> Double.NaN
+ }
+
+ private fun isInteger(d: Double) = !d.isNaN() && !d.isInfinite() && d == Math.floor(d)
+
+ private val base64Shape = Regex("^[A-Za-z0-9+/]*={0,2}$")
+
+ private fun bytes(v: Any?, what: String, min: Int = 1, max: Int = 64): ByteArray {
+ val s = jsString(v)
+ if (!base64Shape.matches(s)) refuse("$what is not base64")
+ // Node's decoder is lenient where Java's throws (a lone trailing
+ // character). Both outcomes are a refusal: Node's yields a short
+ // buffer that the length check below refuses.
+ val b = try { Base64.getDecoder().decode(s) } catch (e: IllegalArgumentException) {
+ refuse("$what has the wrong length")
+ }
+ if (b.size < min || b.size > max) refuse("$what has the wrong length")
+ return b
+ }
+
+ private fun key32(v: Any?, what: String): String {
+ bytes(v, what, 32, 32)
+ return jsString(v)
+ }
+
+ private fun text(v: Any?, what: String, max: Int = 256): String {
+ val s = jsString(v)
+ if (s.length > max) refuse("$what is too long") // UTF-16 units, as JS counts
+ return s
+ }
+
+ private val groupShape = Regex("^[A-Za-z0-9_-]{1,64}$")
+ private fun groupId(v: Any?): String {
+ val s = jsString(v)
+ if (s.isNotEmpty() && !groupShape.matches(s)) refuse("not a group id")
+ return s
+ }
+
+ private fun timestamp(v: Any?): String {
+ val n = jsNumber(v)
+ if (!isInteger(n) || abs(n - now()) > TS_SLACK_S) refuse("the timestamp is not now")
+ return jsString(n.toLong())
+ }
+
+ fun forKind(kind: String, f: JSONObject?, ctx: Ctx): ByteArray {
+ val fields = f ?: JSONObject()
+ fun field(name: String): Any? = if (fields.has(name)) fields.get(name) else null
+ fun sameNode(): String {
+ if (jsString(field("nodePk")) != ctx.nodePk) refuse("another node")
+ return ctx.nodePk
+ }
+ fun sameUser(): String {
+ if (jsString(field("userId")) != ctx.userId) refuse("another account")
+ return ctx.userId
+ }
+ fun nonceNode() = bytes(field("nonceNode"), "the node nonce", 16, 64)
+
+ return when (kind) {
+ "join" -> lenPrefixed(PREFIX_JOIN, listOf(
+ enc(sameNode()), enc(groupId(field("groupId"))), enc(sameUser()),
+ enc(ctx.pkEdB64), enc(ctx.pkXB64), nonceNode(), enc(timestamp(field("ts")))))
+ "device_hello" -> lenPrefixed(PREFIX_DEVICE_HELLO, listOf(
+ enc(sameNode()), enc(groupId(field("groupId"))), enc(sameUser()),
+ enc(ctx.pkEdB64), nonceNode(), enc(timestamp(field("ts")))))
+ "device_request" -> {
+ val codeHash = jsString(field("codeHash"))
+ if (!Regex("^[0-9a-f]{64}$").matches(codeHash)) refuse("not a request hash")
+ lenPrefixed(PREFIX_DEVICE_REQUEST, listOf(
+ enc(sameNode()), enc(sameUser()), enc(ctx.pkEdB64), enc(ctx.pkXB64),
+ enc(codeHash), nonceNode(), enc(timestamp(field("ts")))))
+ }
+ "device_add" -> lenPrefixed(PREFIX_DEVICE_ADD, listOf(
+ enc(sameNode()), enc(sameUser()), enc(key32(field("pkEd"), "the device key")),
+ enc(key32(field("pkX"), "the device key")), nonceNode(), enc(timestamp(field("ts")))))
+ "device_revoke" -> lenPrefixed(PREFIX_DEVICE_REVOKE, listOf(
+ enc(sameNode()), enc(sameUser()), enc(key32(field("pkEd"), "the device key")),
+ nonceNode(), enc(timestamp(field("ts")))))
+ "chat" -> {
+ val epoch = jsNumber(field("epoch"))
+ if (!isInteger(epoch) || epoch < 0) refuse("not an epoch")
+ lenPrefixed(PREFIX_CHAT, listOf(
+ enc(groupId(field("groupId"))), enc(jsString(epoch.toLong())),
+ Base64.getDecoder().decode(ctx.pkEdB64),
+ bytes(field("nonce"), "the message nonce", 12, 24),
+ bytes(field("ct"), "the message", 1, 8 * 1024 * 1024)))
+ }
+ "admin" -> {
+ val op = jsString(field("op"))
+ if (op !in ADMIN_OPS) refuse("not an operation")
+ lenPrefixed(PREFIX_ADMIN, listOf(
+ enc(op), enc(sameNode()), enc(groupId(field("groupId"))),
+ enc(text(field("subject"), "the subject", 16384)),
+ bytes(field("nonce"), "the challenge nonce", 16, 64),
+ enc(timestamp(field("ts")))))
+ }
+ else -> refuse("nothing is signed as \"${kind.take(32)}\"")
+ }
+ }
+
+ companion object {
+ // The node's clock and ours: a signature for a moment far from now is one to keep for later.
+ const val TS_SLACK_S = 600
+
+ // The signed operations this application asks a node to perform
+ // (meshbay_common/adminop.py) — transcripts.js's list, held equal by
+ // test_android_keys.py. A list, not a pattern: what widens a node's
+ // sharing (root_add, root_update, group_attach — gone from MNP 6.0) is
+ // never signed here, so a script in the page cannot drive an older node
+ // into it either.
+ val ADMIN_OPS = setOf(
+ "file_delete", "dir_delete", "invite_create", "invite_link_create",
+ "invite_cancel", "member_revoke", "apps_enabled", "set_scan_settings",
+ "tmdb_config", "tmdb_enabled", "tmdb_override", "tmdb_rematch",
+ "musicbrainz_enabled", "root_remove", "root_eject", "root_plug",
+ "app_directories", "chat_directory", "chat_link_preview", "search_listed",
+ "chat_epoch",
+ )
+ const val PREFIX_JOIN = "meshbay:join:v1"
+ const val PREFIX_DEVICE_REQUEST = "meshbay:device_req:v1"
+ const val PREFIX_DEVICE_ADD = "meshbay:device_add:v1"
+ const val PREFIX_DEVICE_REVOKE = "meshbay:device_revoke:v1"
+ const val PREFIX_DEVICE_HELLO = "meshbay:device_hello:v1"
+ const val PREFIX_CHAT = "meshbay:chat:v1"
+ const val PREFIX_ADMIN = "meshbay:admin:v1"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveNames.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveNames.kt
new file mode 100644
index 0000000..2414730
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveNames.kt
@@ -0,0 +1,74 @@
+package org.meshbay.client.save
+
+/** The pure part of saving: names, types and the binary frame. JVM-tested. */
+object SaveNames {
+ private val BIDI = Regex("[\\u061c\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]")
+
+ /**
+ * main.js `save:begin`: the basename only, bidirectional controls replaced
+ * — "invoice‮fdp.exe" would otherwise be listed as "invoiceexe.pdf".
+ */
+ fun sanitize(suggested: String?): String {
+ val base = (suggested ?: "").replace('\\', '/').substringAfterLast('/')
+ .replace(BIDI, "_").replace(Regex("[\\u0000-\\u001f]"), "_").trim()
+ return if (base.isEmpty() || base == "." || base == "..") "download" else base
+ }
+
+ /**
+ * downloads.js `OPENABLE`, entry for entry (test_android_downloads.py):
+ * what may be handed to another app to open, under a type chosen from the
+ * name — never one guessed from the bytes.
+ */
+ val OPENABLE = mapOf(
+ "pdf" to "application/pdf",
+ "png" to "image/png", "jpg" to "image/jpeg", "jpeg" to "image/jpeg", "gif" to "image/gif",
+ "webp" to "image/webp", "avif" to "image/avif", "bmp" to "image/bmp",
+ "mp3" to "audio/mpeg", "m4a" to "audio/mp4", "aac" to "audio/aac", "ogg" to "audio/ogg",
+ "oga" to "audio/ogg", "opus" to "audio/ogg", "flac" to "audio/flac", "wav" to "audio/wav",
+ "mp4" to "video/mp4", "m4v" to "video/mp4", "webm" to "video/webm", "ogv" to "video/ogg",
+ "mov" to "video/quicktime",
+ "txt" to "text/plain", "log" to "text/plain", "md" to "text/plain", "csv" to "text/plain",
+ )
+
+ fun extension(name: String): String? = Regex("\\.([A-Za-z0-9]+)$").find(name)?.groupValues?.get(1)?.lowercase()
+
+ fun openableType(name: String): String? = extension(name)?.let { OPENABLE[it] }
+
+ /** The type a file is created under: openable ones by name, the rest opaque. */
+ fun storedType(name: String): String = openableType(name) ?: "application/octet-stream"
+
+ /** "name (n).ext", as main.js `freeName` — never an overwrite. */
+ fun numbered(name: String, n: Int): String {
+ val ext = extension(name)?.let { ".$it" } ?: ""
+ val stem = if (ext.isEmpty()) name else name.dropLast(ext.length)
+ return "$stem ($n)$ext"
+ }
+}
+
+/**
+ * A binary bridge message: one write, no JSON, no base64.
+ *
+ * "MBB1" | u32 request id | u16 channel | u16 reserved | u32 handle | bytes
+ *
+ * all big-endian. The reply is an ordinary JSON reply carrying the id.
+ */
+class BinaryFrame(val id: Long, val channel: Int, val handle: Long, val bytes: ByteArray, val offset: Int) {
+ val length get() = bytes.size - offset
+
+ companion object {
+ const val HEADER = 16
+ const val SAVE_WRITE = 1
+ const val CAST_PUSH = 2
+ private val MAGIC = byteArrayOf('M'.code.toByte(), 'B'.code.toByte(), 'B'.code.toByte(), '1'.code.toByte())
+
+ private fun u32(b: ByteArray, at: Int) =
+ ((b[at].toLong() and 0xff) shl 24) or ((b[at + 1].toLong() and 0xff) shl 16) or
+ ((b[at + 2].toLong() and 0xff) shl 8) or (b[at + 3].toLong() and 0xff)
+
+ fun parse(b: ByteArray): BinaryFrame? {
+ if (b.size < HEADER || !(0 until 4).all { b[it] == MAGIC[it] }) return null
+ val channel = ((b[8].toInt() and 0xff) shl 8) or (b[9].toInt() and 0xff)
+ return BinaryFrame(u32(b, 4), channel, u32(b, 12), b, HEADER)
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveSinks.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveSinks.kt
new file mode 100644
index 0000000..2da7ec7
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveSinks.kt
@@ -0,0 +1,238 @@
+package org.meshbay.client.save
+
+import android.content.ContentResolver
+import android.content.ContentValues
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.net.Uri
+import android.os.Build
+import android.provider.DocumentsContract
+import android.provider.MediaStore
+import android.util.Log
+import org.json.JSONObject
+import org.meshbay.client.bridge.Refused
+import org.meshbay.client.shell.Pickers
+import java.io.OutputStream
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicLong
+
+/**
+ * Downloads, written to disk as they arrive — never collected in the page and
+ * handed over at the end (§8.5; main.js `save:*`).
+ *
+ * The page never names a path or a URI: it asks, is told a display name, and
+ * holds an opaque id. Where a file lands:
+ *
+ * - **automatic**, a folder chosen in Settings → that folder (a Storage Access
+ * Framework tree), as `<name>.part`, renamed on completion;
+ * - **automatic**, no folder chosen → the system's Downloads collection, as a
+ * pending entry that only becomes visible when complete — the Android form
+ * of `.part`;
+ * - **asked**, or a chosen folder that has gone → the system save dialog.
+ * A folder that was chosen and has since gone is never silently replaced
+ * by Downloads: somebody who picked a card wants to know it is not there.
+ *
+ * An unfinished file never carries the final name where that can be avoided,
+ * an aborted one is deleted, and one left by a killed process is deleted at the
+ * next start — Android gives no reliable quit hook, so `before-quit`'s cleanup
+ * happens there.
+ */
+class SaveSinks(
+ private val context: Context,
+ private val prefs: SharedPreferences,
+ private val pickers: Pickers,
+ private val startActivity: (Intent) -> Unit,
+) {
+ private enum class Kind { TREE_PART, MEDIASTORE, PICKED }
+
+ private class Sink(val uri: Uri, val out: OutputStream, val kind: Kind, val finalName: String, val tree: Uri?)
+
+ private val resolver: ContentResolver get() = context.contentResolver
+ private val sinks = ConcurrentHashMap<Long, Sink>()
+ private val completed = ConcurrentHashMap<Long, Pair<Uri, String>>()
+ private val ids = AtomicLong()
+
+ // ── Where downloads go ──────────────────────────────────────────────────
+
+ private val configuredTree: Uri? get() = prefs.getString(KEY_TREE, null)?.let(Uri::parse)
+
+ /** The chosen folder, if it is still there and still ours to write. */
+ private fun usableTree(): Uri? {
+ val tree = configuredTree ?: return null
+ val held = resolver.persistedUriPermissions.any { it.uri == tree && it.isWritePermission }
+ return if (held && displayName(treeDocument(tree)) != null) tree else null
+ }
+
+ private fun treeDocument(tree: Uri) =
+ DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree))
+
+ private fun displayName(uri: Uri): String? = try {
+ resolver.query(uri, arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), null, null, null)?.use {
+ if (it.moveToFirst()) it.getString(0) else null
+ }
+ } catch (e: Exception) { null }
+
+ fun chooseFolder(): String? {
+ val result = pickers.run(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)) ?: return null
+ val tree = result.data ?: return null
+ resolver.takePersistableUriPermission(tree,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ configuredTree?.takeIf { it != tree }?.let { release(it) }
+ prefs.edit().putString(KEY_TREE, tree.toString()).apply()
+ return displayName(treeDocument(tree)) ?: "folder"
+ }
+
+ /** `{name, isDefault}`, which the Settings row renders; a name, never a URI. */
+ fun getFolder(): JSONObject {
+ val tree = usableTree()
+ val name = tree?.let { displayName(treeDocument(it)) }
+ return JSONObject().put("name", name ?: DEFAULT_NAME).put("isDefault", name == null)
+ }
+
+ fun forgetFolder(): Boolean {
+ configuredTree?.let { release(it) }
+ prefs.edit().remove(KEY_TREE).apply()
+ return true
+ }
+
+ private fun release(tree: Uri) {
+ try {
+ resolver.releasePersistableUriPermission(tree,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ } catch (e: SecurityException) { /* already gone */ }
+ }
+
+ // ── Writing ─────────────────────────────────────────────────────────────
+
+ fun begin(suggestedName: String?, auto: Boolean): JSONObject? {
+ val wanted = SaveNames.sanitize(suggestedName)
+ val type = SaveNames.storedType(wanted)
+ val tree = usableTree()
+ var target: Pair<Uri, Kind>? = null
+
+ if (auto && !(configuredTree != null && tree == null)) {
+ target = try {
+ when {
+ tree != null -> DocumentsContract.createDocument(resolver, treeDocument(tree),
+ "application/octet-stream", "$wanted.part")?.let { it to Kind.TREE_PART }
+ Build.VERSION.SDK_INT >= 29 -> resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI,
+ ContentValues().apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, wanted)
+ put(MediaStore.MediaColumns.MIME_TYPE, type)
+ put(MediaStore.MediaColumns.IS_PENDING, 1)
+ })?.let { it to Kind.MEDIASTORE }
+ else -> null
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "automatic save target failed", e); null
+ }
+ }
+ if (target == null) {
+ val ask = Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE)
+ .setType(type).putExtra(Intent.EXTRA_TITLE, wanted)
+ tree?.let { ask.putExtra(DocumentsContract.EXTRA_INITIAL_URI, treeDocument(it)) }
+ val uri = pickers.run(ask)?.data ?: return null // dismissed: not an error
+ target = uri to Kind.PICKED
+ }
+
+ val (uri, kind) = target
+ val out = resolver.openOutputStream(uri, "wt") ?: throw Refused("Could not write the file")
+ val id = ids.incrementAndGet()
+ val shown = if (kind == Kind.TREE_PART) wanted else (displayName(uri) ?: wanted)
+ sinks[id] = Sink(uri, out, kind, wanted, tree)
+ rememberPending(uri, true)
+ return JSONObject().put("id", id).put("name", shown)
+ .put("openable", SaveNames.openableType(shown) != null)
+ }
+
+ /** Returns once the bytes are written: the await is the backpressure. */
+ fun write(id: Long, bytes: ByteArray, offset: Int, length: Int): Boolean {
+ val sink = sinks[id] ?: throw Refused("No such download")
+ synchronized(sink) { sink.out.write(bytes, offset, length) }
+ return true
+ }
+
+ fun end(id: Long): Boolean {
+ val sink = sinks.remove(id) ?: return false
+ synchronized(sink) { sink.out.flush(); sink.out.close() }
+ // Publishing the file is what makes it complete — only after the stream
+ // has flushed, or the final name would be on a short file.
+ val published: Uri = try {
+ when (sink.kind) {
+ Kind.MEDIASTORE -> {
+ resolver.update(sink.uri, ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, null, null)
+ sink.uri
+ }
+ Kind.TREE_PART -> renameFree(sink.uri, sink.finalName)
+ Kind.PICKED -> sink.uri
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "could not finalise a download", e)
+ return false
+ }
+ rememberPending(sink.uri, false)
+ completed[id] = published to (displayName(published) ?: sink.finalName)
+ return true
+ }
+
+ private fun renameFree(uri: Uri, name: String): Uri {
+ var candidate = name
+ for (n in 2 until 1000) {
+ try {
+ return DocumentsContract.renameDocument(resolver, uri, candidate) ?: uri
+ } catch (e: Exception) {
+ // Most providers refuse a name that exists; try the next one.
+ candidate = SaveNames.numbered(name, n)
+ }
+ }
+ throw IllegalStateException("No free name for $name")
+ }
+
+ fun abort(id: Long): Boolean {
+ val sink = sinks.remove(id) ?: return false
+ synchronized(sink) { try { sink.out.close() } catch (e: Exception) { /* already closed */ } }
+ // A cancelled download leaves nothing: a truncated file looks like a
+ // complete one to whoever opens it next.
+ delete(sink.uri)
+ rememberPending(sink.uri, false)
+ return true
+ }
+
+ /** Hand a finished file to the app that opens its type — only a type that runs nothing. */
+ fun open(id: Long): Boolean {
+ val (uri, name) = completed[id] ?: return false
+ val type = SaveNames.openableType(name) ?: throw Refused("This kind of file is not opened from here")
+ startActivity(Intent(Intent.ACTION_VIEW).setDataAndType(uri, type)
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK))
+ return true
+ }
+
+ // ── What a killed process left behind ───────────────────────────────────
+
+ private fun rememberPending(uri: Uri, add: Boolean) = synchronized(prefs) {
+ val set = HashSet(prefs.getStringSet(KEY_PENDING, emptySet()) ?: emptySet())
+ if (add) set.add(uri.toString()) else set.remove(uri.toString())
+ prefs.edit().putStringSet(KEY_PENDING, set).commit()
+ }
+
+ fun cleanUpAfterAKilledProcess() {
+ val pending = prefs.getStringSet(KEY_PENDING, emptySet()) ?: emptySet()
+ for (u in pending) delete(Uri.parse(u))
+ prefs.edit().remove(KEY_PENDING).apply()
+ }
+
+ private fun delete(uri: Uri) {
+ try {
+ if (DocumentsContract.isDocumentUri(context, uri)) DocumentsContract.deleteDocument(resolver, uri)
+ else resolver.delete(uri, null, null)
+ } catch (e: Exception) { Log.w(TAG, "could not remove an unfinished download", e) }
+ }
+
+ companion object {
+ private const val TAG = "MeshBay"
+ private const val KEY_TREE = "downloadTree"
+ private const val KEY_PENDING = "pendingDownloads"
+ const val DEFAULT_NAME = "Downloads"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/EngineCheck.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/EngineCheck.kt
new file mode 100644
index 0000000..6382182
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/EngineCheck.kt
@@ -0,0 +1,57 @@
+package org.meshbay.client.shell
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.view.Gravity
+import android.view.View
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.webkit.WebViewCompat
+import androidx.webkit.WebViewFeature
+
+/**
+ * The engine floor, checked before the page is loaded (design O6: verified,
+ * not assumed).
+ *
+ * The WebView updates through the store independently of Android, so the floor
+ * is a Chromium version, not an API level. What binds it: Ed25519 and X25519 in
+ * WebCrypto (the handshake, chat and the group envelope) — Chromium 137 — and
+ * the two androidx.webkit features the bridge is built on. Measured present on
+ * WebView 145 (spike S-1); the floor itself still has to be confirmed on the
+ * oldest real device to be supported.
+ */
+object EngineCheck {
+ const val MIN_CHROMIUM = 137
+
+ fun problem(context: Context): String? {
+ if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) ||
+ !WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
+ return "This device's Android System WebView is too old for MeshBay."
+ }
+ val version = WebViewCompat.getCurrentWebViewPackage(context)?.versionName ?: return null
+ val major = version.substringBefore('.').toIntOrNull() ?: return null
+ return if (major < MIN_CHROMIUM) {
+ "MeshBay needs Android System WebView $MIN_CHROMIUM or newer; this device has $version."
+ } else null
+ }
+
+ fun screen(context: Context, problem: String): View = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER
+ setPadding(48, 48, 48, 48)
+ addView(TextView(context).apply { text = problem; textSize = 18f; gravity = Gravity.CENTER })
+ addView(Button(context).apply {
+ text = "Update Android System WebView"
+ setOnClickListener {
+ val id = "com.google.android.webview"
+ try { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$id"))) }
+ catch (e: ActivityNotFoundException) {
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$id")))
+ }
+ }
+ })
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt
new file mode 100644
index 0000000..ae23e46
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt
@@ -0,0 +1,48 @@
+package org.meshbay.client.shell
+
+/**
+ * A sentence from the interface's own catalogues, for the few things the
+ * application draws itself (main.js `nativeText`). The page chooses the
+ * language and nothing else: a confirmation it worded would be one it could
+ * answer for itself.
+ *
+ * The catalogues are `export default { 'key': '…', … }` with single-quoted
+ * strings; a plural entry is an object, of which `other` is taken.
+ */
+class NativeText(private val readCatalogue: (String) -> String?) {
+
+ fun get(key: String, locale: String, params: Map<String, String> = emptyMap()): String {
+ var text = pick(readCatalogue(locale), key) ?: pick(readCatalogue("en"), key) ?: key
+ // split/join, as i18n.js: a folder name may contain `$&`.
+ for ((k, v) in params) text = text.split("{$k}").joinToString(v)
+ return text
+ }
+
+ companion object {
+ fun pick(source: String?, key: String): String? {
+ source ?: return null
+ val k = Regex.escape(key)
+ Regex("""(?m)^\s*'$k'\s*:\s*'((?:[^'\\]|\\.)*)'""").find(source)?.let { return unescape(it.groupValues[1]) }
+ Regex("""(?ms)^\s*'$k'\s*:\s*\{.*?\bother\s*:\s*'((?:[^'\\]|\\.)*)'""").find(source)?.let { return unescape(it.groupValues[1]) }
+ return null
+ }
+
+ fun unescape(s: String): String {
+ val out = StringBuilder()
+ var i = 0
+ while (i < s.length) {
+ val c = s[i]
+ if (c == '\\' && i + 1 < s.length) {
+ val n = s[i + 1]
+ when (n) {
+ 'n' -> out.append('\n'); 't' -> out.append('\t')
+ 'u' -> if (i + 5 < s.length) { out.append(s.substring(i + 2, i + 6).toInt(16).toChar()); i += 4 }
+ else -> out.append(n)
+ }
+ i += 2
+ } else { out.append(c); i++ }
+ }
+ return out.toString()
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/Pickers.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/Pickers.kt
new file mode 100644
index 0000000..f54ef87
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/Pickers.kt
@@ -0,0 +1,40 @@
+package org.meshbay.client.shell
+
+import android.app.Activity
+import android.content.Intent
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * A system picker (folder, save-as, open) started from a bridge worker and
+ * waited on there — the bridge never blocks the UI thread, and the page gets
+ * its answer as the reply to the call that asked.
+ */
+class Pickers(private val activity: Activity) {
+ private class Waiting { val done = CountDownLatch(1); @Volatile var result: Intent? = null }
+
+ private val waiting = ConcurrentHashMap<Int, Waiting>()
+ private val codes = AtomicInteger(4000)
+
+ /** The result intent, or null when the person dismissed the picker. */
+ fun run(intent: Intent): Intent? {
+ val code = codes.incrementAndGet()
+ val w = Waiting()
+ waiting[code] = w
+ activity.runOnUiThread {
+ try { activity.startActivityForResult(intent, code) }
+ catch (e: android.content.ActivityNotFoundException) { waiting.remove(code); w.done.countDown() }
+ }
+ w.done.await()
+ return w.result
+ }
+
+ /** From Activity.onActivityResult; true when the code was one of ours. */
+ fun deliver(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
+ val w = waiting.remove(requestCode) ?: return false
+ w.result = if (resultCode == Activity.RESULT_OK) data ?: Intent() else null
+ w.done.countDown()
+ return true
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt
new file mode 100644
index 0000000..92a186f
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt
@@ -0,0 +1,25 @@
+package org.meshbay.client.shell
+
+import android.content.Context
+import android.view.View
+import android.webkit.WebView
+
+/**
+ * While `keepVisible` is set, the WebView is told its window stayed visible
+ * when the screen turns off. Chromium then never marks the page hidden, and
+ * its freeze of hidden pages — exactly 60 s after hiding, measured (spike
+ * S-2a C) — never starts. Set only while a cast runs: a page that is never
+ * hidden is never throttled, which is the battery cost the freeze exists to
+ * avoid.
+ */
+class ShellWebView(context: Context) : WebView(context) {
+ var keepVisible = false
+
+ override fun onWindowVisibilityChanged(visibility: Int) {
+ super.onWindowVisibilityChanged(if (keepVisible) View.VISIBLE else visibility)
+ }
+
+ override fun onVisibilityChanged(changedView: View, visibility: Int) {
+ super.onVisibilityChanged(changedView, if (keepVisible) View.VISIBLE else visibility)
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt
new file mode 100644
index 0000000..2300668
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt
@@ -0,0 +1,93 @@
+package org.meshbay.client.shell
+
+import android.content.Context
+import android.webkit.WebResourceResponse
+import androidx.webkit.WebViewAssetLoader
+import java.io.File
+
+/**
+ * Serves the packaged interface, and nothing else.
+ *
+ * The page's origin is `https://appassets.androidplatform.net` — a secure
+ * context, without which `crypto.subtle` does not exist — and every file comes
+ * out of the APK's `assets/ui/`, copied from the hub's static directory at build
+ * time (§8.3). The hub never becomes the document origin: that is the whole
+ * reason the application exists (T3).
+ *
+ * The stock asset handler sets no headers, so this one exists for three: the
+ * policy, sent as a header because a <meta> policy drops `frame-ancestors`;
+ * `nosniff`; and `no-store`, since every file is already local.
+ */
+class UiAssets(private val context: Context) : WebViewAssetLoader.PathHandler {
+
+ override fun handle(path: String): WebResourceResponse? {
+ // Asset paths are not a filesystem, but a `..` that reached
+ // AssetManager would still be a path the page chose; refuse it.
+ if (path.split('/').any { it == ".." || it == "." } || path.startsWith("/")) return notFound()
+ val asset = "ui/" + path.ifEmpty { "index.html" }
+ val stream = try { context.assets.open(asset) } catch (e: java.io.IOException) { return notFound() }
+ val headers = mapOf(
+ "Content-Security-Policy" to CSP,
+ "X-Content-Type-Options" to "nosniff",
+ "Cache-Control" to "no-store",
+ )
+ val type = contentType(asset)
+ val charset = if (type.startsWith("text/") || type == "application/json") "utf-8" else null
+ return WebResourceResponse(type, charset, 200, "OK", headers, stream)
+ }
+
+ private fun notFound() =
+ WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), "".byteInputStream())
+
+ companion object {
+ const val HOST = "appassets.androidplatform.net"
+ const val ORIGIN = "https://$HOST"
+ const val PREFIX = "/ui/"
+ const val START = "$ORIGIN${PREFIX}index.html"
+
+ // reCAPTCHA gates sign-up here as it does in a browser and on the
+ // desktop; these two hosts and no others.
+ private const val RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"
+
+ /**
+ * meshbay-client/src/main.js's CSP, directive for directive
+ * (test_android_shell.py holds them together). `'wasm-unsafe-eval'` is
+ * the Argon2 that opens bundles: without it nobody reaches their keys.
+ */
+ val CSP = listOf(
+ "default-src 'none'",
+ "script-src 'self' 'wasm-unsafe-eval' $RECAPTCHA_SRC",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: blob: $RECAPTCHA_SRC",
+ "media-src 'self' blob:",
+ "font-src 'self'",
+ "connect-src 'self' $RECAPTCHA_SRC",
+ "worker-src 'self'",
+ "object-src blob:",
+ "frame-src blob: $RECAPTCHA_SRC",
+ "frame-ancestors 'none'",
+ "base-uri 'none'",
+ "form-action 'none'",
+ ).joinToString("; ")
+
+ fun isRecaptcha(host: String?) = host == "www.google.com" || host == "www.gstatic.com"
+
+ fun contentType(name: String): String = when (File(name).extension.lowercase()) {
+ "html" -> "text/html"
+ "js", "mjs" -> "text/javascript"
+ "css" -> "text/css"
+ "json" -> "application/json"
+ "wasm" -> "application/wasm"
+ "svg" -> "image/svg+xml"
+ "png" -> "image/png"
+ "jpg", "jpeg" -> "image/jpeg"
+ "ico" -> "image/x-icon"
+ "webp" -> "image/webp"
+ "woff2" -> "font/woff2"
+ "woff" -> "font/woff"
+ "txt" -> "text/plain"
+ "xml" -> "application/xml"
+ else -> "application/octet-stream"
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..50c1c99
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- The desktop client's icon (meshbay-client/build/icon-square.png), placed
+ in the adaptive icon's safe zone so no launcher mask crops the M. -->
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@color/ic_launcher_background" />
+ <foreground android:drawable="@mipmap/ic_launcher_foreground" />
+</adaptive-icon>
diff --git a/packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..50c1c99
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- The desktop client's icon (meshbay-client/build/icon-square.png), placed
+ in the adaptive icon's safe zone so no launcher mask crops the M. -->
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@color/ic_launcher_background" />
+ <foreground android:drawable="@mipmap/ic_launcher_foreground" />
+</adaptive-icon>
diff --git a/packages/meshbay-android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/packages/meshbay-android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..5b29801
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/meshbay-android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/packages/meshbay-android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..4e211fb
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/meshbay-android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/packages/meshbay-android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..d86c80b
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/meshbay-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/meshbay-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..2fdac24
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/meshbay-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/packages/meshbay-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..6cc1a12
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/meshbay-android/app/src/main/res/values/colors.xml b/packages/meshbay-android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..515e694
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- The edge of icon-square.png, so the safe-zone image meets a seamless field. -->
+ <color name="ic_launcher_background">#010822</color>
+</resources>
diff --git a/packages/meshbay-android/app/src/main/res/values/themes.xml b/packages/meshbay-android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..a7df056
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/values/themes.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <style name="Shell" parent="@android:style/Theme.DeviceDefault.NoActionBar">
+ <item name="android:windowBackground">@android:color/black</item>
+ </style>
+</resources>
diff --git a/packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml b/packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..f0abf11
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<data-extraction-rules>
+ <cloud-backup>
+ <exclude domain="root" /><exclude domain="file" /><exclude domain="database" />
+ <exclude domain="sharedpref" /><exclude domain="external" />
+ </cloud-backup>
+ <device-transfer>
+ <exclude domain="root" /><exclude domain="file" /><exclude domain="database" />
+ <exclude domain="sharedpref" /><exclude domain="external" />
+ </device-transfer>
+</data-extraction-rules>
diff --git a/packages/meshbay-android/app/src/main/res/xml/network_security_config.xml b/packages/meshbay-android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..8bf3e82
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Plain http only to a hub on this device's own loopback — the desktop rule
+ ("http only to localhost or 127.*"). Anywhere else a session token would
+ cross the network in clear. -->
+<network-security-config>
+ <base-config cleartextTrafficPermitted="false" />
+ <domain-config cleartextTrafficPermitted="true">
+ <domain includeSubdomains="false">localhost</domain>
+ <domain includeSubdomains="false">127.0.0.1</domain>
+ </domain-config>
+</network-security-config>
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt
new file mode 100644
index 0000000..5d3125f
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt
@@ -0,0 +1,229 @@
+package org.meshbay.client
+
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshbay.client.cast.BoxAccumulator
+import org.meshbay.client.cast.CastRelay
+import java.io.ByteArrayOutputStream
+import java.io.DataInputStream
+import java.net.InetAddress
+import java.net.Socket
+
+/** The real relay on loopback, read with a raw socket: what a receiver sees. */
+class CastRelayTest {
+ private val spool = java.nio.file.Files.createTempDirectory("relay-spool").toFile()
+ private val relay = CastRelay({ InetAddress.getByName("127.0.0.1") }, spool)
+
+ @After fun stop() { relay.stop(); spool.deleteRecursively() }
+
+ private class Reply(val status: Int, val headers: Map<String, String>, val body: ByteArray)
+
+ private fun get(url: String, method: String = "GET", readBytes: Int = -1): Reply {
+ val u = java.net.URI(url)
+ Socket(u.host, u.port).use { s ->
+ s.soTimeout = 5000
+ s.getOutputStream().write("$method ${u.rawPath}${u.rawQuery?.let { "?$it" } ?: ""} HTTP/1.1\r\nHost: x\r\n\r\n".toByteArray())
+ val input = DataInputStream(s.getInputStream())
+ val headLines = ArrayList<String>()
+ val line = StringBuilder()
+ while (true) {
+ val c = input.read()
+ if (c == -1) break
+ if (c == '\n'.code) { val l = line.toString().trimEnd('\r'); if (l.isEmpty()) break; headLines.add(l); line.clear() }
+ else line.append(c.toChar())
+ }
+ val status = headLines[0].split(' ')[1].toInt()
+ val headers = headLines.drop(1).associate { it.substringBefore(':').lowercase() to it.substringAfter(':').trim() }
+ val body = ByteArrayOutputStream()
+ if (headers["transfer-encoding"] == "chunked") {
+ while (readBytes < 0 || body.size() < readBytes) {
+ val sizeLine = StringBuilder()
+ while (true) { val c = input.read(); if (c == -1 || c == '\n'.code) break; sizeLine.append(c.toChar()) }
+ val size = sizeLine.toString().trim().toIntOrNull(16) ?: break
+ if (size == 0) break
+ val buf = ByteArray(size); input.readFully(buf); body.write(buf); input.read(); input.read()
+ }
+ } else {
+ val n = headers["content-length"]?.toInt() ?: 0
+ val buf = ByteArray(n); input.readFully(buf); body.write(buf)
+ }
+ return Reply(status, headers, body.toByteArray())
+ }
+ }
+
+ private fun box(type: String, payload: Int): ByteArray {
+ val size = 8 + payload
+ return byteArrayOf((size ushr 24).toByte(), (size ushr 16).toByte(), (size ushr 8).toByte(), size.toByte()) +
+ type.toByteArray() + ByteArray(payload) { (it % 251).toByte() }
+ }
+
+ private fun fragment(n: Int) = box("moof", 16 + n) + box("mdat", 1000 + n)
+
+ @Test fun `fragments are re-framed from arbitrary slices`() {
+ val acc = BoxAccumulator()
+ val stream = box("ftyp", 12) + fragment(1) + fragment(2) + fragment(3)
+ val out = ArrayList<ByteArray>()
+ var i = 0
+ while (i < stream.size) { val n = minOf(37, stream.size - i); out += acc.push(stream, i, n); i += n }
+ assertEquals(3, out.size)
+ assertArrayEquals(fragment(2), out[1])
+ }
+
+ @Test fun `a lost frame is recovered by rescanning for the next moof`() {
+ val acc = BoxAccumulator()
+ BoxAccumulator.warn = {}
+ val out = acc.push(fragment(1) + byteArrayOf(0, 0, 0, 1, 1, 2, 3, 4) + fragment(2))
+ assertEquals(2, out.size)
+ }
+
+ @Test fun `the stream is init then the backlog then what follows`() {
+ val init = box("ftyp", 20) + box("moov", 50)
+ val started = relay.start(init, null)
+ relay.push(fragment(1)); relay.push(fragment(2))
+ val reply = get(started.getString("url"), readBytes = init.size + fragment(1).size + fragment(2).size)
+ assertEquals(200, reply.status)
+ assertEquals("video/mp4", reply.headers["content-type"])
+ assertEquals("no-store", reply.headers["cache-control"])
+ assertArrayEquals(init + fragment(1) + fragment(2), reply.body)
+ }
+
+ @Test fun `a first chunk that carries film is served as its header only`() {
+ // As the page delivers it from a real film: a 64 KB slice holding the
+ // header, the first moof and the start of its mdat — pushed as well.
+ val header = box("ftyp", 20) + box("moov", 1200)
+ val stream = header + fragment(1) + fragment(2)
+ val firstChunk = stream.copyOfRange(0, header.size + 300)
+ val started = relay.start(firstChunk, null)
+ var at = 0
+ while (at < stream.size) { val n = minOf(300, stream.size - at); relay.push(stream, at, n); at += n }
+ val reply = get(started.getString("url"), readBytes = stream.size)
+ assertArrayEquals("header, then each fragment once", stream, reply.body)
+ }
+
+ @Test fun `a header split across chunks is served whole`() {
+ // Measured on a fresh start: the first chunk was the 28-byte ftyp
+ // alone, the moov came in the next push. Served as the header, the
+ // receiver had no moov and gave up — the first cast failed every time.
+ val ftyp = box("ftyp", 20)
+ val moov = box("moov", 2124)
+ val started = relay.start(ftyp, null)
+ val url = started.getString("url")
+ // The page pushes the first chunk too, then the rest.
+ relay.push(ftyp); relay.push(moov); relay.push(fragment(1)); relay.push(fragment(2))
+ val reply = get(url, readBytes = ftyp.size + moov.size + fragment(1).size + fragment(2).size)
+ assertArrayEquals(ftyp + moov + fragment(1) + fragment(2), reply.body)
+ }
+
+ @Test fun `a receiver early for the header waits for all of it`() {
+ val ftyp = box("ftyp", 20)
+ val moov = box("moov", 2124)
+ val url = relay.start(ftyp, null).getString("url")
+ relay.push(ftyp)
+ val late = Thread { Thread.sleep(400); relay.push(moov); relay.push(fragment(1)) }.apply { start() }
+ val reply = get(url, readBytes = ftyp.size + moov.size + fragment(1).size)
+ late.join()
+ assertArrayEquals(ftyp + moov + fragment(1), reply.body)
+ }
+
+ @Test fun `the token is required and unguessable`() {
+ val url = relay.start(null, null).getString("url")
+ val token = url.substringAfter("t=")
+ assertTrue(Regex("^[0-9a-f]{32}$").matches(token))
+ assertEquals(403, get(url.replace(token, "0".repeat(32))).status)
+ assertEquals(403, get(url.substringBefore("?")).status)
+ assertEquals(405, get(url, method = "POST").status)
+ assertEquals(404, get(url.replace("/stream.mp4", "/other")).status)
+ }
+
+ @Test fun `the subtitle is webvtt behind the token, readable cross-origin, re-addressed when it changes`() {
+ val r = relay.start(null, JSONObject().put("vtt", "WEBVTT\n\n00:00.000 --> 00:01.000\nhi\n").put("language", "fr").put("label", "Français"))
+ val sub = r.getJSONObject("subtitle")
+ val reply = get(sub.getString("url"))
+ assertEquals(200, reply.status)
+ assertEquals("text/vtt; charset=utf-8", reply.headers["content-type"])
+ assertEquals("*", reply.headers["access-control-allow-origin"])
+ assertTrue(String(reply.body).startsWith("WEBVTT"))
+ assertEquals(403, get(sub.getString("url").replace(Regex("t=[0-9a-f]+"), "t=x")).status)
+
+ val second = relay.setSubtitle(JSONObject().put("vtt", "WEBVTT\n"))!!
+ assertNotEquals(sub.getString("url"), second.getString("url"))
+ assertNull(relay.setSubtitle(null))
+ assertEquals(404, get(second.getString("url")).status)
+ assertEquals(200, get(r.getString("url"), readBytes = 0).status)
+ }
+
+ @Test fun `the preflight is answered before the token is checked`() {
+ val url = relay.start(null, null).getString("url")
+ val reply = get(url.substringBefore("?"), method = "OPTIONS")
+ assertEquals(204, reply.status)
+ assertEquals("GET, OPTIONS", reply.headers["access-control-allow-methods"])
+ assertTrue(reply.headers["access-control-allow-headers"]!!.contains("Range"))
+ }
+
+ @Test fun `the stream carries the same CORS headers as its subtitle`() {
+ val url = relay.start(null, null).getString("url")
+ val reply = get(url, readBytes = 0)
+ for ((k, v) in CastRelay.CORS_HEADERS) assertEquals(k, v, reply.headers[k.lowercase()])
+ }
+
+ @Test fun `the backlog is bounded in bytes, not only in fragments`() {
+ relay.start(null, null)
+ // 40 fragments of 4 MB: under the fragment cap, far over a phone's heap.
+ val big = box("moof", 16) + box("mdat", 4 * 1024 * 1024)
+ repeat(40) { relay.push(big) }
+ assertTrue("backlog ${relay.backlogBytes()}", relay.backlogBytes() <= CastRelay.RING_MAX_BYTES)
+ assertTrue(relay.backlogBytes() >= CastRelay.RING_MAX_BYTES - big.size)
+ }
+
+ @Test fun `seek after seek, the relay restarts on its ports`() {
+ // A seek restarts the relay, and a receiver was connected each time:
+ // the ports it closed sit in TIME_WAIT.
+ repeat(8) {
+ val url = relay.start(null, null).getString("url")
+ relay.push(fragment(it))
+ get(url, readBytes = fragment(it).size)
+ relay.stop()
+ }
+ }
+
+ @Test fun `a receiver far behind loses nothing, and its spool goes with it`() {
+ // Fragments of 4 MB, ten of them pushed while the receiver has read
+ // none: far past the 8 MB the desktop drops at, which froze the TV.
+ val url = relay.start(null, null).getString("url")
+ val u = java.net.URI(url)
+ Socket(u.host, u.port).use { s ->
+ s.getOutputStream().write("GET ${u.rawPath}?${u.rawQuery} HTTP/1.1\r\nHost: x\r\n\r\n".toByteArray())
+ Thread.sleep(300)
+ val big = (0 until 10).map { box("moof", 16 + it) + box("mdat", 4 * 1024 * 1024 + it) }
+ big.forEach { relay.push(it) }
+ assertEquals("one spool file for the one receiver", 1, spool.listFiles()!!.size)
+ val input = DataInputStream(s.getInputStream())
+ while (true) { val l = StringBuilder(); while (true) { val c = input.read(); if (c == '\n'.code) break; l.append(c.toChar()) }; if (l.toString().trim().isEmpty()) break }
+ val body = ByteArrayOutputStream()
+ val want = big.sumOf { it.size }
+ while (body.size() < want) {
+ val size = StringBuilder().also { sb -> while (true) { val c = input.read(); if (c == '\n'.code) break; sb.append(c.toChar()) } }.toString().trim().toInt(16)
+ val buf = ByteArray(size); input.readFully(buf); body.write(buf); input.read(); input.read()
+ }
+ assertArrayEquals("every fragment, in order, none dropped", big.reduce { a, b -> a + b }, body.toByteArray())
+ }
+ Thread.sleep(300)
+ relay.stop()
+ assertEquals("the spool is deleted with the receiver", 0, spool.listFiles()!!.size)
+ }
+
+ @Test fun `stopping closes the port`() {
+ val url = relay.start(null, null).getString("url")
+ relay.stop()
+ val u = java.net.URI(url)
+ val refused = try { Socket(u.host, u.port).close(); false } catch (e: java.net.ConnectException) { true }
+ assertTrue(refused)
+ assertNull(relay.url)
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt
new file mode 100644
index 0000000..adc6366
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt
@@ -0,0 +1,39 @@
+package org.meshbay.client
+
+import org.json.JSONArray
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.meshbay.client.bridge.Channels
+import org.meshbay.client.bridge.Refused
+import org.meshbay.client.hub.HubClient
+import java.net.InetAddress
+import java.net.UnknownHostException
+
+class ChannelsTest {
+ private val channels = Channels(HubClient(FakePrefs()), onHubChanged = {}, hasCatalogue = { it == "fr" || it == "pt-BR" })
+
+ @Test fun `a channel that is not enumerated is refused`() {
+ for (ch in listOf("node:op", "root:choose", "window:minimize-to-tray", "keys:sign", "", "hub:fetch2")) {
+ assertThrows(ch, Refused::class.java) { channels.call(ch, JSONArray()) }
+ }
+ }
+
+ @Test fun `the locale is a code with a catalogue, never text`() {
+ assertEquals("fr", channels.call("ui:locale", JSONArray().put("fr")))
+ assertEquals("fr", channels.call("ui:locale", JSONArray().put("de"))) // no catalogue
+ assertEquals("fr", channels.call("ui:locale", JSONArray().put("../en"))) // not a code
+ assertEquals("pt-BR", channels.call("ui:locale", JSONArray().put("pt-BR")))
+ }
+
+ @Test fun `stun hostnames are resolved, literals kept, failures dropped`() {
+ val lookup: (String) -> Array<InetAddress> = { host ->
+ if (host == "stun.example") arrayOf(InetAddress.getByAddress(host, byteArrayOf(192.toByte(), 0, 2, 7)))
+ else throw UnknownHostException(host)
+ }
+ val out = Channels.resolveStun(JSONArray(listOf("stun:stun.example:3478", "stun:198.51.100.1:3478",
+ "stun:[2001:db8::1]:3478", "stun:gone.example:3478", "turn:x")), lookup)
+ assertEquals(listOf("stun:192.0.2.7:3478", "stun:198.51.100.1:3478", "stun:[2001:db8::1]:3478", "turn:x"),
+ (0 until out.length()).map { out.getString(it) })
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt
new file mode 100644
index 0000000..33d1c0f
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt
@@ -0,0 +1,30 @@
+package org.meshbay.client
+
+import android.content.SharedPreferences
+
+/** SharedPreferences is an interface; a map is enough for the JVM tests. */
+class FakePrefs : SharedPreferences {
+ val map = HashMap<String, Any?>()
+ override fun getAll(): MutableMap<String, *> = map
+ override fun getString(key: String, defValue: String?) = map[key] as String? ?: defValue
+ override fun getStringSet(key: String, defValues: MutableSet<String>?) = defValues
+ override fun getInt(key: String, defValue: Int) = map[key] as Int? ?: defValue
+ override fun getLong(key: String, defValue: Long) = map[key] as Long? ?: defValue
+ override fun getFloat(key: String, defValue: Float) = map[key] as Float? ?: defValue
+ override fun getBoolean(key: String, defValue: Boolean) = map[key] as Boolean? ?: defValue
+ override fun contains(key: String) = map.containsKey(key)
+ override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {}
+ override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {}
+ override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor {
+ override fun putString(k: String, v: String?) = apply { map[k] = v }
+ override fun putStringSet(k: String, v: MutableSet<String>?) = apply { map[k] = v }
+ override fun putInt(k: String, v: Int) = apply { map[k] = v }
+ override fun putLong(k: String, v: Long) = apply { map[k] = v }
+ override fun putFloat(k: String, v: Float) = apply { map[k] = v }
+ override fun putBoolean(k: String, v: Boolean) = apply { map[k] = v }
+ override fun remove(k: String) = apply { map.remove(k) }
+ override fun clear() = apply { map.clear() }
+ override fun commit() = true
+ override fun apply() {}
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakeSecrets.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakeSecrets.kt
new file mode 100644
index 0000000..86537bd
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakeSecrets.kt
@@ -0,0 +1,11 @@
+package org.meshbay.client
+
+import org.json.JSONObject
+import org.meshbay.client.keys.Secrets
+
+class FakeSecrets(var backendName: String = "android_keystore") : Secrets {
+ var all = JSONObject()
+ override fun backend() = backendName
+ override fun read() = JSONObject(all.toString())
+ override fun update(fn: (JSONObject) -> Unit) { val a = read(); fn(a); all = a }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt
new file mode 100644
index 0000000..8211013
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt
@@ -0,0 +1,43 @@
+package org.meshbay.client
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshbay.client.bridge.Refused
+import org.meshbay.client.hub.HubClient
+
+class HubClientTest {
+ private fun hub(base: String = "") = HubClient(FakePrefs().apply { map["hubBase"] = base })
+
+ @Test fun `an empty address is refused, not stored as no hub`() {
+ val e = assertThrows(Refused::class.java) { hub().setBase(" ") }
+ assertEquals("Enter the address of a hub.", e.message)
+ }
+
+ @Test fun `plain http is refused except to loopback`() {
+ for (url in listOf("http://example.org", "http://10.0.2.2:8770", "ftp://x", "http://192.168.1.2")) {
+ val e = assertThrows(Refused::class.java) { hub().setBase(url) }
+ assertEquals(url, "The hub address must be https", e.message)
+ }
+ }
+
+ @Test fun `the page reaches the signed-in hub and nowhere else`() {
+ val h = hub("https://hub.example")
+ for (url in listOf("https://other.example/v1/x", "http://hub.example/v1/x",
+ "https://hub.example:8443/v1/x", "https://hub.example.evil/v1/x", "not a url")) {
+ assertThrows(url, Refused::class.java) { h.fetch(url, JSONObject()) }
+ }
+ }
+
+ @Test fun `nothing is fetched before a hub is set`() {
+ assertThrows(Refused::class.java) { hub("").fetch("https://hub.example/v1/x", null) }
+ }
+
+ @Test fun `unreachable hubs are described in words somebody can act on`() {
+ assertTrue(HubClient.describeUnreachable("https://h", java.net.ConnectException()).startsWith("Nothing is listening at https://h"))
+ assertTrue(HubClient.describeUnreachable("https://h", java.net.UnknownHostException()).contains("could not be found"))
+ assertTrue(HubClient.describeUnreachable("https://h", javax.net.ssl.SSLHandshakeException("x")).contains("does not speak https"))
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyChannelsTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyChannelsTest.kt
new file mode 100644
index 0000000..c4333db
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyChannelsTest.kt
@@ -0,0 +1,81 @@
+package org.meshbay.client
+
+import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
+import org.bouncycastle.crypto.signers.Ed25519Signer
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshbay.client.bridge.KeyChannels
+import org.meshbay.client.bridge.Refused
+import org.meshbay.client.keys.Kdf
+
+class KeyChannelsTest {
+ private val user = "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0"
+ private val node = Kdf.b64(ByteArray(32) { 0x11 })
+ private var asked = 0
+ private var answer = false
+ private val secrets = FakeSecrets()
+ private val keys = KeyChannels(secrets, confirm = { asked++; answer }, declined = { "Cancelled" })
+
+ private fun call(ch: String, vararg args: Any?) = keys.call(ch, JSONArray(args.toList()))
+
+ @Test fun `ids and node keys are checked before anything is done`() {
+ for (bad in listOf("", "../x", "not-an-id", "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0x", null)) {
+ assertThrows("$bad", Refused::class.java) { call("keys:fingerprint", bad) }
+ }
+ for (bad in listOf("", "a/b c", "x".repeat(101), null)) {
+ assertThrows("$bad", Refused::class.java) { call("keys:identity", user, bad) }
+ }
+ }
+
+ @Test fun `the device key signs the bytes the hub verifies and never leaves`() {
+ val pub = call("device:ensure") as String
+ assertEquals(pub, call("device:ensure")) // once, then the same key
+ val s = call("device:sign", "alice") as JSONObject
+ val msg = "meshbay:user_auth:alice:${s.getLong("timestamp")}".toByteArray()
+ val v = Ed25519Signer().apply { init(false, Ed25519PublicKeyParameters(Kdf.unb64(pub), 0)); update(msg, 0, msg.size) }
+ assertTrue(v.verifySignature(Kdf.unb64(s.getString("signature"))))
+ call("device:forget")
+ assertNull(call("device:public"))
+ }
+
+ @Test fun `browser access is widened only by the person, natively`() {
+ call("keys:created-here", user)
+ assertEquals(false, call("keys:browser-access", user))
+ answer = false
+ val e = assertThrows(Refused::class.java) { call("keys:set-browser-access", user, true) }
+ assertEquals("Cancelled", e.message)
+ assertEquals(1, asked)
+ assertEquals(false, call("keys:browser-access", user))
+ answer = true
+ assertEquals(true, call("keys:set-browser-access", user, true))
+ // Narrowing asks nobody.
+ assertEquals(false, call("keys:set-browser-access", user, false))
+ assertEquals(2, asked)
+ }
+
+ @Test fun `without OS key storage nothing is minted or derived`() {
+ val none = KeyChannels(FakeSecrets("unavailable"), confirm = { true }, declined = { "" })
+ assertEquals(false, none.call("keys:available", JSONArray()))
+ assertThrows(Refused::class.java) { none.call("keys:mint", JSONArray(listOf(user, node))) }
+ assertEquals(false, none.call("keys:has-session", JSONArray(listOf(user))))
+ }
+
+ @Test fun `a minted identity answers with public keys only`() {
+ val r = call("keys:mint", user, node) as JSONObject
+ assertEquals(setOf("pkEdB64", "pkXB64"), r.keys().asSequence().toSet())
+ val id = call("keys:identity", user, node) as JSONObject
+ assertEquals(r.getString("pkEdB64"), id.getString("pkEdB64"))
+ assertEquals(JSONObject.NULL, id.get("sealedWith"))
+ }
+
+ @Test fun `channels outside the list are refused`() {
+ assertThrows(Refused::class.java) { call("keys:export") }
+ assertFalse(keys.handles("hub:fetch"))
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyringVectorsTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyringVectorsTest.kt
new file mode 100644
index 0000000..63edbde
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyringVectorsTest.kt
@@ -0,0 +1,151 @@
+package org.meshbay.client
+
+import org.json.JSONObject
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshbay.client.keys.Kdf
+import org.meshbay.client.keys.Keyring
+import org.meshbay.client.keys.Transcripts
+import java.io.File
+
+/**
+ * The keyring and the transcripts against meshbay-hub/tests/vectors/keyring.json,
+ * which the desktop keyring writes and the specification reproduces
+ * (test_keyring_vectors.py). Every deterministic field byte for byte, every
+ * refusal with its message: a bundle this sealed is one the page and the
+ * desktop open, and the reverse.
+ */
+class KeyringVectorsTest {
+ private var passed = 0
+ private val failures = ArrayList<String>()
+ private fun check(label: String, ok: Boolean, detail: () -> String = { "" }) {
+ if (ok) passed++ else failures.add("$label ${detail()}")
+ }
+ private fun <T> eq(label: String, got: T, want: T) = check(label, got == want) { "got=$got want=$want" }
+
+ @Test fun `every vector is reproduced`() {
+
+ val v = JSONObject(VECTORS.readText())
+ val input = v.getJSONObject("input")
+ val kdf = v.getJSONObject("kdf")
+ val bundles = v.getJSONObject("bundles")
+ val now = input.getLong("now").toDouble()
+ val userId = input.getString("userId")
+ val nodePk = input.getString("nodePk")
+ val username = input.getString("username")
+
+ // The store, as SecretStore would hold it.
+ var store = JSONObject()
+ var nonces: ArrayDeque<ByteArray> = ArrayDeque()
+ fun ring() = Keyring(
+ load = { JSONObject(store.toString()) },
+ save = { store = JSONObject(it.toString()) },
+ transcripts = Transcripts { now },
+ random = { n -> nonces.removeFirstOrNull() ?: ByteArray(n).also { java.security.SecureRandom().nextBytes(it) } },
+ )
+ val ring = ring()
+
+ // 1. KDF chain.
+ val salt = Kdf.sha256("meshbay:bundle:v2:$username".toByteArray()).copyOfRange(0, 16)
+ eq("salt", Kdf.toHex(salt), kdf.getString("salt_hex"))
+ ring.deriveSession(input.getString("password"), username, userId,
+ input.getString("pepperB64"), input.getInt("pepperVersion"))
+ val masters = store.getJSONObject("masters").getJSONObject(userId)
+ eq("argon2id", Kdf.toHex(Kdf.unb64(masters.getString("legacy"))), kdf.getString("argon2_hex"))
+ eq("M", Kdf.toHex(Kdf.unb64(masters.getString("m"))), kdf.getString("master_hex"))
+ eq("pepper version", masters.getInt("v"), input.getInt("pepperVersion"))
+ eq("fingerprint", ring.currentFingerprint(userId), kdf.getString("master_fingerprint"))
+ eq("node key", Kdf.toHex(Kdf.hkdf(Kdf.unb64(masters.getString("m")), "meshbay:bundle:v3|node|$nodePk")),
+ kdf.getString("node_key_hex"))
+ eq("playlist key", ring.playlistKey(userId), kdf.getString("playlist_key_b64"))
+
+ // 2. Identity: placed in the store as keyring.js would leave it.
+ val ident = v.getJSONObject("identity")
+ store.getJSONObject("identities").put(userId, JSONObject().put(nodePk,
+ JSONObject().put("ed", ident.getString("ed_pkcs8_b64")).put("x", ident.getString("x_pkcs8_b64"))
+ .put("sealedWith", JSONObject.NULL)))
+ val pub = ring.identity(userId, nodePk)!!
+ eq("pkEd", pub.pkEdB64, ident.getJSONObject("public").getString("pkEdB64"))
+ eq("pkX", pub.pkXB64, ident.getJSONObject("public").getString("pkXB64"))
+
+ // 3. Bundles: sealed byte for byte, and opened.
+ val fixedNonce = Kdf.hex(input.getString("fixedNonceHex"))
+ nonces.addLast(fixedNonce)
+ val sealed = ring.sealBundle(userId, nodePk)
+ eq("bundle sealed with a fixed nonce", sealed.bundle, bundles.getString("fixed_nonce_bundle_b64"))
+ eq("bundle fingerprint", sealed.fingerprint, bundles.getString("fixed_nonce_fingerprint"))
+ nonces.addLast(fixedNonce)
+ eq("recovery bundle", ring.sealRecovery(userId, nodePk, input.getString("mnemonic"), username),
+ bundles.getString("recovery_fixed_nonce_b64"))
+
+ fun opensTo(label: String, block: (Keyring) -> Keyring.Pub) {
+ val saved = store
+ store = JSONObject().put("masters", JSONObject().put(userId, masters)).put("identities", JSONObject())
+ .put("access", JSONObject())
+ try {
+ val p = block(ring())
+ check(label, p.pkEdB64 == pub.pkEdB64 && p.pkXB64 == pub.pkXB64) { "opened to another identity" }
+ check("$label leaves the identity unsealed", ring().identity(userId, nodePk)?.sealedWith == null)
+ } catch (e: Exception) {
+ check(label, false) { e.toString() }
+ } finally { store = saved }
+ }
+ opensTo("desktop bundle (fixed nonce) opens") { it.openBundle(userId, nodePk, bundles.getString("fixed_nonce_bundle_b64")) }
+ opensTo("MBK2 legacy bundle opens") { it.openBundle(userId, nodePk, bundles.getString("legacy_mbk2_b64")) }
+ val bogus = Kdf.b64("MBK3".toByteArray() + ByteArray(30) { 1 })
+ opensTo("recovery copy opens through the fallback") {
+ it.openBundle(userId, nodePk, bogus, bundles.getString("recovery_fixed_nonce_b64"),
+ input.getString("mnemonic"), username)
+ }
+ // A bundle Kotlin seals (random nonce) must open — round trip.
+ val ours = ring.sealBundle(userId, nodePk).bundle
+ opensTo("a bundle sealed here opens here") { it.openBundle(userId, nodePk, ours) }
+ // A retired format is refused, never tried against the recovery key.
+ try {
+ ring.openBundle(userId, nodePk, Kdf.b64("MBK1xxxxxxxxxxxxxxxxxxxxxxxxxxxxx".toByteArray()))
+ check("retired format refused", false)
+ } catch (e: Keyring.FormatRetired) { check("retired format refused", true) }
+
+ // Browser access off: nothing sealed.
+ ring.setBrowserAccess(userId, false)
+ try { ring.sealBundle(userId, nodePk); check("no bundle while browser access is off", false) }
+ catch (e: IllegalStateException) { check("no bundle while browser access is off", e.message!!.startsWith("Refused")) }
+ ring.setBrowserAccess(userId, true)
+
+ // 4. Agreement.
+ val ag = v.getJSONObject("agreement")
+ eq("X25519 agreement", ring.shared(userId, nodePk, ag.getString("peer_x_pub_b64")), ag.getString("shared_b64"))
+
+ // 5. Transcripts and signatures.
+ val tr = Transcripts { now }
+ val ctx = Transcripts.Ctx(userId, nodePk, pub.pkEdB64, pub.pkXB64)
+ val kinds = v.getJSONObject("transcripts")
+ for (kind in kinds.keys()) {
+ val t = kinds.getJSONObject(kind)
+ eq("transcript $kind", Kdf.toHex(tr.forKind(kind, t.getJSONObject("fields"), ctx)), t.getString("transcript_hex"))
+ eq("signature $kind", ring.signAs(userId, nodePk, kind, t.getJSONObject("fields")), t.getString("signature_b64"))
+ }
+ val refusals = v.getJSONArray("refusals")
+ for (i in 0 until refusals.length()) {
+ val r = refusals.getJSONObject(i)
+ try {
+ tr.forKind(r.getString("kind"), r.getJSONObject("fields"), ctx)
+ check("refusal '${r.getString("label")}'", false) { "was signed" }
+ } catch (e: Transcripts.Refused) {
+ eq("refusal '${r.getString("label")}' message", e.message, r.getString("error"))
+ }
+ }
+
+ // Sign-out drops M and keeps the identities.
+ ring.forgetSession(userId)
+ check("sign-out drops M", !ring.hasSession(userId))
+ check("sign-out keeps the identity", ring.identity(userId, nodePk) != null)
+ assertTrue("vector failures:\n" + failures.joinToString("\n"), failures.isEmpty())
+ assertTrue("too few checks ran: $passed", passed >= 55)
+ }
+
+ companion object {
+ // Unit tests run with the module directory as working directory.
+ val VECTORS = File("../../meshbay-hub/tests/vectors/keyring.json")
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/NativeTextTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/NativeTextTest.kt
new file mode 100644
index 0000000..9e0bc7e
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/NativeTextTest.kt
@@ -0,0 +1,34 @@
+package org.meshbay.client
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+import org.meshbay.client.shell.NativeText
+import java.io.File
+
+/** The application's own dialogs, worded from the real catalogues in every language. */
+class NativeTextTest {
+ private val locales = File("../../meshbay-hub/src/meshbay_hub/static/locales")
+ private val text = NativeText { code -> File(locales, "$code.js").takeIf { it.exists() }?.readText() }
+
+ @Test fun `every catalogue words the native dialogs`() {
+ val codes = locales.listFiles()!!.map { it.nameWithoutExtension }
+ assertEquals(10, codes.size)
+ for (code in codes) for (key in listOf("native.browser_access_confirm", "native.declined", "dialog.ok", "dialog.cancel")) {
+ assertNotEquals("$code $key", key, text.get(key, code))
+ }
+ }
+
+ @Test fun `escapes are read as the page reads them`() {
+ assertEquals("Annulé — rien n'a été modifié.", text.get("native.declined", "fr"))
+ }
+
+ @Test fun `an unknown language falls back to English, an unknown key to itself`() {
+ assertEquals("Cancel", text.get("dialog.cancel", "xx"))
+ assertEquals("no.such.key", text.get("no.such.key", "fr"))
+ }
+
+ @Test fun `plural entries give their other form and parameters are filled`() {
+ assertEquals("Use at least 12 characters", text.get("register.err_min_len", "en", mapOf("n" to "12")))
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SaveNamesTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SaveNamesTest.kt
new file mode 100644
index 0000000..d3b8450
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SaveNamesTest.kt
@@ -0,0 +1,45 @@
+package org.meshbay.client
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.meshbay.client.save.BinaryFrame
+import org.meshbay.client.save.SaveNames
+
+class SaveNamesTest {
+ @Test fun `a suggested name is a basename with no bidirectional tricks`() {
+ assertEquals("invoice_fdp.exe", SaveNames.sanitize("invoice\u202efdp.exe"))
+ assertEquals("passwd", SaveNames.sanitize("../../etc/passwd"))
+ assertEquals("x.txt", SaveNames.sanitize("C:\\Users\\x.txt"))
+ assertEquals("download", SaveNames.sanitize(""))
+ assertEquals("download", SaveNames.sanitize(".."))
+ assertEquals("a_b", SaveNames.sanitize("a\u0000b"))
+ }
+
+ @Test fun `only what runs nothing is opened, under a type from the name`() {
+ assertEquals("application/pdf", SaveNames.openableType("Report.PDF"))
+ assertEquals("video/mp4", SaveNames.openableType("clip.mp4"))
+ for (n in listOf("page.html", "image.svg", "run.apk", "script.js", "noext", "x.pdf.exe")) {
+ assertNull(n, SaveNames.openableType(n))
+ }
+ assertEquals("application/octet-stream", SaveNames.storedType("page.html"))
+ }
+
+ @Test fun `a taken name becomes name (n), never an overwrite`() {
+ assertEquals("film (2).mkv", SaveNames.numbered("film.mkv", 2))
+ assertEquals("README (3)", SaveNames.numbered("README", 3))
+ }
+
+ @Test fun `a binary frame is read as the shim writes it`() {
+ val payload = byteArrayOf(1, 2, 3, 4, 5)
+ val b = byteArrayOf(0x4d, 0x42, 0x42, 0x31, 0, 0, 1, 2, 0, 1, 0, 0, 0, 0, 0, 7) + payload
+ val f = BinaryFrame.parse(b)!!
+ assertEquals(258L, f.id)
+ assertEquals(BinaryFrame.SAVE_WRITE, f.channel)
+ assertEquals(7L, f.handle)
+ assertArrayEquals(payload, f.bytes.copyOfRange(f.offset, f.bytes.size))
+ assertNull(BinaryFrame.parse(byteArrayOf(0x4d, 0x42, 0x42, 0x32) + ByteArray(12)))
+ assertNull(BinaryFrame.parse(ByteArray(10)))
+ }
+}
diff --git a/packages/meshbay-android/build.gradle.kts b/packages/meshbay-android/build.gradle.kts
new file mode 100644
index 0000000..a4370dd
--- /dev/null
+++ b/packages/meshbay-android/build.gradle.kts
@@ -0,0 +1 @@
+plugins { id("com.android.application") version "9.4.1" apply false }
diff --git a/packages/meshbay-android/gradle.properties b/packages/meshbay-android/gradle.properties
new file mode 100644
index 0000000..660848f
--- /dev/null
+++ b/packages/meshbay-android/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx2g
+android.useAndroidX=true
diff --git a/packages/meshbay-android/gradle/wrapper/gradle-wrapper.jar b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..5097068
--- /dev/null
+++ b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..9f3a241
--- /dev/null
+++ b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,10 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
diff --git a/packages/meshbay-android/gradlew b/packages/meshbay-android/gradlew
new file mode 100755
index 0000000..249efbb
--- /dev/null
+++ b/packages/meshbay-android/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/packages/meshbay-android/gradlew.bat b/packages/meshbay-android/gradlew.bat
new file mode 100644
index 0000000..3185a43
--- /dev/null
+++ b/packages/meshbay-android/gradlew.bat
@@ -0,0 +1,112 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+@rem Catch executions from older scripts and ensure they exit cleanly.
+@rem This can be removed once we can be reasonably confident that few people
+@rem will be migrating directly to this new wrapper.
+goto afterSafetyNet
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+goto exitWithErrorLevel
+:afterSafetyNet
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+1>&2 echo.
+1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+1>&2 echo.
+1>&2 echo Please set the JAVA_HOME variable in your environment to match the
+1>&2 echo location of your Java installation.
+
+"%COMSPEC%" /c exit 1
+goto exitWithErrorLevel
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+1>&2 echo.
+1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+1>&2 echo.
+1>&2 echo Please set the JAVA_HOME variable in your environment to match the
+1>&2 echo location of your Java installation.
+
+"%COMSPEC%" /c exit 1
+goto exitWithErrorLevel
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
+
+@rem This label must not be changed. We rely on old scripts being able to jump to this point.
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/packages/meshbay-android/settings.gradle.kts b/packages/meshbay-android/settings.gradle.kts
new file mode 100644
index 0000000..cead413
--- /dev/null
+++ b/packages/meshbay-android/settings.gradle.kts
@@ -0,0 +1,9 @@
+pluginManagement {
+ repositories { google(); mavenCentral(); gradlePluginPortal() }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories { google(); mavenCentral() }
+}
+rootProject.name = "meshbay-android"
+include(":app")
diff --git a/packages/meshbay-client/package.json b/packages/meshbay-client/package.json
index 80c021f..8a1fec7 100644
--- a/packages/meshbay-client/package.json
+++ b/packages/meshbay-client/package.json
@@ -1,6 +1,6 @@
{
"name": "meshbay-client",
- "version": "0.17.0",
+ "version": "0.18.0",
"description": "MeshBay desktop client — the interface ships with the application, not from the hub",
"license": "AGPL-3.0-or-later",
"author": "MeshBay Team <team@meshbay.org>",
diff --git a/packages/meshbay-client/src/cast-chromecast.js b/packages/meshbay-client/src/cast-chromecast.js
index 7fc8079..55deb5b 100644
--- a/packages/meshbay-client/src/cast-chromecast.js
+++ b/packages/meshbay-client/src/cast-chromecast.js
@@ -174,6 +174,7 @@ class CastChromecast {
player.on('status', (status) => {
debug(`[cast-chromecast] player status: ${status.playerState}`);
+ this._noteStatus(status);
});
debug(`[cast-chromecast] loading with ${subtitle?.url ? 'subtitles' : 'no subtitles'}`);
@@ -185,6 +186,7 @@ class CastChromecast {
});
});
+ this._noteStatus(status);
debug(`[cast-chromecast] loaded on "${device.name}", state: ${status.playerState}`);
return { deviceName: device.name, playerState: status.playerState };
}
@@ -200,6 +202,7 @@ class CastChromecast {
resolve(s);
});
});
+ this._noteStatus(status);
debug(`[cast-chromecast] reloaded, state: ${status.playerState}`);
return { playerState: status.playerState };
}
@@ -215,10 +218,55 @@ class CastChromecast {
this._cleanup();
}
+ /**
+ * What the receiver last said, kept so the page can show where the
+ * *television* is: its playhead is not the local one, which started earlier
+ * and drifts. `currentTime` is on the stream's timeline — zero at the point
+ * the relay started — and the page adds that start.
+ */
+ _noteStatus(status) {
+ if (!status) return;
+ this._lastStatus = {
+ playerState: status.playerState || null,
+ idleReason: status.idleReason || null,
+ position: Number(status.currentTime) || 0,
+ at: Date.now(),
+ };
+ }
+
+ /** The receiver's position now: extrapolated while it plays, as its own clock does. */
+ _position() {
+ const s = this._lastStatus;
+ if (!s) return null;
+ return s.playerState === 'PLAYING' ? s.position + (Date.now() - s.at) / 1000 : s.position;
+ }
+
+ async pause() {
+ if (!this._player) throw new Error('Not connected');
+ const status = await new Promise((resolve, reject) => {
+ this._player.pause((err, s) => (err ? reject(err) : resolve(s)));
+ });
+ this._noteStatus(status);
+ return { playerState: status && status.playerState };
+ }
+
+ async play() {
+ if (!this._player) throw new Error('Not connected');
+ const status = await new Promise((resolve, reject) => {
+ this._player.play((err, s) => (err ? reject(err) : resolve(s)));
+ });
+ this._noteStatus(status);
+ return { playerState: status && status.playerState };
+ }
+
getStatus() {
+ const s = this._lastStatus;
return {
connected: this._client !== null,
deviceName: this._connectedDevice?.name || null,
+ playerState: s ? s.playerState : null,
+ idleReason: s ? s.idleReason : null,
+ position: this._client !== null ? this._position() : null,
};
}
@@ -229,6 +277,7 @@ class CastChromecast {
this._client = null;
this._player = null;
this._connectedDevice = null;
+ this._lastStatus = null;
}
}
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 0ad7e71..4c8707c 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -2330,6 +2330,10 @@ function registerBridge() {
subtitle === undefined ? castRelay.subtitle : subtitle);
});
+ // The player becomes a remote while casting: these drive the receiver.
+ handle('cast:chromecast:pause', async () => castChromecast.pause());
+ handle('cast:chromecast:play', async () => castChromecast.play());
+
handle('cast:chromecast:disconnect', async () => {
await castChromecast.disconnect();
return true;
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
index 665ec1f..43cf317 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -180,6 +180,8 @@ contextBridge.exposeInMainWorld('meshbay', {
chromecastConnect: (opts) => ipcRenderer.invoke('cast:chromecast:connect', opts),
chromecastReload: (opts) => ipcRenderer.invoke('cast:chromecast:reload', opts),
chromecastDisconnect: () => ipcRenderer.invoke('cast:chromecast:disconnect'),
+ chromecastPause: () => ipcRenderer.invoke('cast:chromecast:pause'),
+ chromecastPlay: () => ipcRenderer.invoke('cast:chromecast:play'),
},
// A sink that writes to disk as chunks arrive, never a buffer handed over at
diff --git a/packages/meshbay-common/pyproject.toml b/packages/meshbay-common/pyproject.toml
index 188ccf6..f8035ba 100644
--- a/packages/meshbay-common/pyproject.toml
+++ b/packages/meshbay-common/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "meshbay-common"
-version = "0.17.0"
+version = "0.18.0"
description = "MeshBay shared cryptographic primitives and protocol types"
requires-python = ">=3.12"
dependencies = [
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index 2a58326..3ffb2b7 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -1,6 +1,6 @@
"""MeshBay common — shared crypto primitives and protocol types."""
-__version__ = "0.17.0"
+__version__ = "0.18.0"
# 0.2: added PING/PONG, and `before`/`has_more` on chat history. Both are
# additive — an 0.1 peer sends no `before` and gets the newest page, which is
# what it wanted — so this is a MINOR bump, not a MAJOR one.
diff --git a/packages/meshbay-hub/pyproject.toml b/packages/meshbay-hub/pyproject.toml
index 7f1154c..c927242 100644
--- a/packages/meshbay-hub/pyproject.toml
+++ b/packages/meshbay-hub/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "meshbay-hub"
-version = "0.17.0"
+version = "0.18.0"
description = "MeshBay Hub — identity authority and group registry server"
requires-python = ">=3.12"
dependencies = [
diff --git a/packages/meshbay-hub/src/meshbay_hub/__init__.py b/packages/meshbay-hub/src/meshbay_hub/__init__.py
index 4d3d6dd..cf6868d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/__init__.py
+++ b/packages/meshbay-hub/src/meshbay_hub/__init__.py
@@ -1,3 +1,3 @@
"""MeshBay Hub — identity authority and group registry."""
-__version__ = "0.17.0"
+__version__ = "0.18.0"
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index a1afe38..2a3efaf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -88,7 +88,7 @@ async def hub_pubkey():
# reads MBK1/MBK2. An older client would still write them, and every such
# bundle is one no browser of the account can open again.
MIN_CLIENT_VERSION = "0.17.0"
-RECOMMENDED_CLIENT_VERSION = "0.17.0"
+RECOMMENDED_CLIENT_VERSION = "0.18.0"
@router.get("/version")
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 72dd123..6f97ca5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -592,7 +592,10 @@ function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroup
const [setupDismissed, setSetupDismissed] = useState(false);
if (groups.length === 0) {
- if (platform.isNative && !setupDismissed) {
+ // Setting up a node needs one to administer: the desktop application, not
+ // any native shell. A phone has a bridge and no node, and what it needs
+ // with no groups is the invitations and the join link below.
+ if (platform.capabilities.nodeAdmin && !setupDismissed) {
return html`<${SetupWelcome}
onDismiss=${() => setSetupDismissed(true)} />`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
index aa45a25..2ff2bd4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
@@ -392,6 +392,10 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
throw err;
}
const chunkMsg = await inflight[nextRecv];
+ // Released as soon as it is read: a resolved promise left here holds its
+ // ciphertext, and the array holds every chunk of the file — so a download
+ // written straight to disk was also held whole in the page until it ended.
+ inflight[nextRecv] = undefined;
// One shape, and a refusal for anything else. There used to be two fallbacks
// below this: a base64 `ct_b64` chunk, which was the real wire format until
// the binary switch in Phase 9.15 and which no node has sent since, and a
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
index c80258c..4fa4357 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
@@ -73,6 +73,10 @@ const ICON_PATHS = {
'chevron-left': ['M14.5 6l-6 6 6 6'],
'chevron-right': ['M9.5 6l6 6-6 6'],
close: ['M6 6l12 12M18 6L6 18'],
+ // A circle nearly closed, its arrow at the top pointing the way it turns;
+ // the remote writes the seconds inside.
+ skipback: ['M12 4.5a8 8 0 1 1-6.93 4', 'M14.5 2l-2.5 2.5 2.5 2.5'],
+ skipfwd: ['M12 4.5a8 8 0 1 0 6.93 4', 'M9.5 2l2.5 2.5-2.5 2.5'],
chat: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'],
folder: ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z'],
'bell-off': ['M18 9a6 6 0 0 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index c78bb52..bdc4dd5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -390,6 +390,13 @@ export default {
'cast.copied': 'URL kopiert',
'cast.scanning': 'Suche nach Geräten…',
'cast.no_devices': 'Keine Geräte gefunden',
+ 'cast.casting_to': 'Wiedergabe auf',
+ 'cast.seek': 'Position im Film',
+ 'cast.waiting': 'Warten auf den Fernseher…',
+ 'cast.back30': '30 Sekunden zurück',
+ 'cast.fwd30': '30 Sekunden vor',
+ 'cast.play': 'Abspielen',
+ 'cast.pause': 'Pause',
// Settings
'settings.title': 'Einstellungen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index f5879b3..658f388 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -388,6 +388,13 @@ export default {
'cast.copied': 'URL copied',
'cast.scanning': 'Scanning for devices…',
'cast.no_devices': 'No devices found',
+ 'cast.casting_to': 'Casting to',
+ 'cast.seek': 'Position in the film',
+ 'cast.waiting': 'Waiting for the television…',
+ 'cast.back30': 'Back 30 seconds',
+ 'cast.fwd30': 'Forward 30 seconds',
+ 'cast.play': 'Play',
+ 'cast.pause': 'Pause',
// Settings
'settings.title': 'Settings',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index a35aa96..9e73017 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -388,6 +388,13 @@ export default {
'cast.copied': 'URL copiada',
'cast.scanning': 'Buscando dispositivos…',
'cast.no_devices': 'No se encontraron dispositivos',
+ 'cast.casting_to': 'Transmitiendo a',
+ 'cast.seek': 'Posición en la película',
+ 'cast.waiting': 'Esperando al televisor…',
+ 'cast.back30': 'Retroceder 30 segundos',
+ 'cast.fwd30': 'Avanzar 30 segundos',
+ 'cast.play': 'Reproducir',
+ 'cast.pause': 'Pausa',
// Settings
'settings.title': 'Ajustes',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 2e823cd..5d18d41 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -389,6 +389,13 @@ export default {
'cast.copied': 'URL copiée',
'cast.scanning': "Recherche d'appareils…",
'cast.no_devices': 'Aucun appareil trouvé',
+ 'cast.casting_to': 'Diffusion sur',
+ 'cast.seek': 'Position dans le film',
+ 'cast.waiting': 'En attente de la télévision…',
+ 'cast.back30': 'Reculer de 30 secondes',
+ 'cast.fwd30': 'Avancer de 30 secondes',
+ 'cast.play': 'Lecture',
+ 'cast.pause': 'Pause',
// Settings
'settings.title': 'Paramètres',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 84879e8..c4d2acc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -389,6 +389,13 @@ export default {
'cast.copied': 'URL copiato',
'cast.scanning': 'Ricerca dispositivi…',
'cast.no_devices': 'Nessun dispositivo trovato',
+ 'cast.casting_to': 'Trasmissione su',
+ 'cast.seek': 'Posizione nel film',
+ 'cast.waiting': 'In attesa del televisore…',
+ 'cast.back30': 'Indietro di 30 secondi',
+ 'cast.fwd30': 'Avanti di 30 secondi',
+ 'cast.play': 'Riproduci',
+ 'cast.pause': 'Pausa',
// Settings
'settings.title': 'Impostazioni',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 9167efe..fe52bfa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -386,6 +386,13 @@ export default {
'cast.copied': 'URLをコピーしました',
'cast.scanning': 'デバイスを検索中…',
'cast.no_devices': 'デバイスが見つかりません',
+ 'cast.casting_to': 'キャスト先',
+ 'cast.seek': '再生位置',
+ 'cast.waiting': 'テレビを待っています…',
+ 'cast.back30': '30秒戻る',
+ 'cast.fwd30': '30秒進む',
+ 'cast.play': '再生',
+ 'cast.pause': '一時停止',
// Settings
'settings.title': '設定',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 4845c03..90b71f0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -390,6 +390,13 @@ export default {
'cast.copied': 'URL gekopieerd',
'cast.scanning': 'Apparaten zoeken…',
'cast.no_devices': 'Geen apparaten gevonden',
+ 'cast.casting_to': 'Casten naar',
+ 'cast.seek': 'Positie in de film',
+ 'cast.waiting': 'Wachten op de televisie…',
+ 'cast.back30': '30 seconden terug',
+ 'cast.fwd30': '30 seconden vooruit',
+ 'cast.play': 'Afspelen',
+ 'cast.pause': 'Pauzeren',
// Settings
'settings.title': 'Instellingen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index e3f21d3..8c31cf3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -401,6 +401,13 @@ export default {
'cast.copied': 'URL skopiowany',
'cast.scanning': 'Wyszukiwanie urządzeń…',
'cast.no_devices': 'Nie znaleziono urządzeń',
+ 'cast.casting_to': 'Przesyłanie do',
+ 'cast.seek': 'Pozycja w filmie',
+ 'cast.waiting': 'Czekam na telewizor…',
+ 'cast.back30': '30 sekund wstecz',
+ 'cast.fwd30': '30 sekund do przodu',
+ 'cast.play': 'Odtwórz',
+ 'cast.pause': 'Wstrzymaj',
// Settings
'settings.title': 'Ustawienia',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 00510c6..2f951ee 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -390,6 +390,13 @@ export default {
'cast.copied': 'URL copiada',
'cast.scanning': 'Procurando dispositivos…',
'cast.no_devices': 'Nenhum dispositivo encontrado',
+ 'cast.casting_to': 'Transmitindo para',
+ 'cast.seek': 'Posição no filme',
+ 'cast.waiting': 'Aguardando a TV…',
+ 'cast.back30': 'Voltar 30 segundos',
+ 'cast.fwd30': 'Avançar 30 segundos',
+ 'cast.play': 'Reproduzir',
+ 'cast.pause': 'Pausar',
// Settings
'settings.title': 'Configurações',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 51a6572..ea02545 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -383,6 +383,13 @@ export default {
'cast.copied': '链接已复制',
'cast.scanning': '正在搜索设备…',
'cast.no_devices': '未找到设备',
+ 'cast.casting_to': '投放到',
+ 'cast.seek': '播放位置',
+ 'cast.waiting': '正在等待电视…',
+ 'cast.back30': '后退 30 秒',
+ 'cast.fwd30': '前进 30 秒',
+ 'cast.play': '播放',
+ 'cast.pause': '暂停',
// Settings
'settings.title': '设置',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 749f93e..c131495 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -497,6 +497,19 @@ export const cast = {
if (!bridge || !bridge.cast) return false;
return bridge.cast.chromecastDisconnect();
},
+ // Remote control of the receiver. Absent from an older bridge, so callers
+ // check `canControl` rather than catching a TypeError.
+ get canControl() {
+ return Boolean(bridge && bridge.cast && bridge.cast.chromecastPause && bridge.cast.chromecastPlay);
+ },
+ async chromecastPause() {
+ if (!this.canControl) return null;
+ return bridge.cast.chromecastPause();
+ },
+ async chromecastPlay() {
+ if (!this.canControl) return null;
+ return bridge.cast.chromecastPlay();
+ },
};
/**
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 23013a4..e1e152e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2084,6 +2084,207 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
outline: none;
}
+/* The player as a remote while a television plays the film. Opaque, over
+ the local picture, which keeps running underneath only to pace the
+ download; the container is given room for it on a portrait phone, where
+ the picture alone would be a strip. */
+.video-container-remote { min-height: min(72vh, 560px); }
+
+.video-remote {
+ --remote-accent: #23b1f0;
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 32px;
+ padding: 24px 20px;
+ border-radius: 12px;
+ background:
+ radial-gradient(120% 70% at 50% 0%, rgba(35, 177, 240, 0.16) 0%, rgba(35, 177, 240, 0) 60%),
+ #0a1224;
+ color: #e2e8f0;
+ overflow: hidden;
+}
+
+.video-remote-head {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ text-align: center;
+ max-width: 100%;
+}
+
+.video-remote-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 64px;
+ height: 64px;
+ margin-bottom: 8px;
+ border-radius: 50%;
+ background: rgba(35, 177, 240, 0.14);
+ box-shadow: 0 0 0 1px rgba(35, 177, 240, 0.35), 0 0 32px rgba(35, 177, 240, 0.25);
+ color: var(--remote-accent);
+ font-size: 30px;
+}
+
+.video-remote-label {
+ font-size: 0.75rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: #94a3b8;
+}
+
+.video-remote-device {
+ font-size: 1.35rem;
+ font-weight: 600;
+ color: #f8fafc;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.video-remote-state {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 1.2em;
+ font-size: 0.85rem;
+ color: #94a3b8;
+}
+
+.video-remote-track { width: min(100%, 560px); }
+
+.video-remote-times {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 8px;
+ font-size: 0.85rem;
+ font-variant-numeric: tabular-nums;
+ color: #94a3b8;
+}
+
+/* The track is drawn by hand so the part already played is filled: `--pct`
+ is set inline from the position. */
+.video-remote-seek {
+ --pct: 0%;
+ -webkit-appearance: none;
+ appearance: none;
+ display: block;
+ width: 100%;
+ height: 28px;
+ margin: 0;
+ background: transparent;
+ cursor: pointer;
+}
+.video-remote-seek:disabled { cursor: default; opacity: 0.5; }
+.video-remote-seek::-webkit-slider-runnable-track {
+ height: 6px;
+ border-radius: 3px;
+ background: linear-gradient(to right, var(--remote-accent) var(--pct), rgba(148, 163, 184, 0.25) var(--pct));
+}
+.video-remote-seek::-moz-range-track {
+ height: 6px;
+ border-radius: 3px;
+ background: linear-gradient(to right, var(--remote-accent) var(--pct), rgba(148, 163, 184, 0.25) var(--pct));
+}
+.video-remote-seek::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 18px;
+ height: 18px;
+ margin-top: -6px;
+ border: 0;
+ border-radius: 50%;
+ background: #ffffff;
+ box-shadow: 0 0 0 4px rgba(35, 177, 240, 0.35), 0 2px 6px rgba(0, 0, 0, 0.4);
+}
+.video-remote-seek::-moz-range-thumb {
+ width: 18px;
+ height: 18px;
+ border: 0;
+ border-radius: 50%;
+ background: #ffffff;
+ box-shadow: 0 0 0 4px rgba(35, 177, 240, 0.35), 0 2px 6px rgba(0, 0, 0, 0.4);
+}
+
+.video-remote-buttons {
+ display: flex;
+ align-items: center;
+ gap: 36px;
+}
+
+.video-remote-skip,
+.video-remote-main {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ border-radius: 50%;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ transition: transform 0.1s ease, background-color 0.15s ease;
+}
+.video-remote-skip:active:not(:disabled),
+.video-remote-main:active:not(:disabled) { transform: scale(0.92); }
+.video-remote-skip:disabled,
+.video-remote-main:disabled { opacity: 0.35; cursor: default; }
+
+.video-remote-skip {
+ width: 56px;
+ height: 56px;
+ background: transparent;
+ color: #e2e8f0;
+ font-size: 44px;
+}
+.video-remote-skip:hover:not(:disabled) { background: rgba(148, 163, 184, 0.12); }
+.video-remote-skip .icon { stroke-width: 1.4; }
+.video-remote-skip-n {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 50%;
+ transform: translateY(-38%);
+ font-size: 12px;
+ font-weight: 700;
+ text-align: center;
+ pointer-events: none;
+}
+
+.video-remote-main {
+ width: 76px;
+ height: 76px;
+ background: var(--remote-accent);
+ color: #04121f;
+ font-size: 34px;
+ box-shadow: 0 8px 24px rgba(35, 177, 240, 0.35);
+}
+.video-remote-main:hover:not(:disabled) { background: #4cc3f5; }
+.video-remote-main .icon { stroke-width: 2.6; }
+/* The triangle is a closed path: filled, it reads as play at a glance. */
+.video-remote-main.is-paused .icon path { fill: currentColor; }
+.video-remote-main.is-paused .icon { transform: translateX(2px); }
+
+.video-remote-stop {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 20px;
+ border: 1px solid rgba(148, 163, 184, 0.35);
+ border-radius: 999px;
+ background: transparent;
+ color: #cbd5e1;
+ font: inherit;
+ font-size: 0.9rem;
+ cursor: pointer;
+}
+.video-remote-stop:hover { border-color: #fca5a5; color: #fecaca; }
+
/* Where the film was picked up again. Sits over the picture rather than
pushing it down, and clears itself once the viewer is watching. */
.video-container { position: relative; }
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
index 2a4d2ea..2c424fb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -280,6 +280,64 @@ function formatClock(seconds) {
}
/**
+ * The player as a remote, while a television plays the film.
+ *
+ * Only what it is told: the receiver's position on the film's timeline (null
+ * until the receiver has answered), its state, and whether a seek is on its
+ * way. The scrubber reports a drag as it moves and a seek once let go.
+ */
+function CastRemote({ device, position, duration, playerState, moving,
+ onDrag, onSeek, onToggle, onStop }) {
+ const known = position !== null;
+ const paused = playerState === 'PAUSED';
+ const busy = !known || moving || playerState === 'BUFFERING' || playerState === 'LOADING';
+ const pct = known && duration > 0 ? Math.min(100, (position / duration) * 100) : 0;
+ return html`
+ <div class="video-remote">
+ <div class="video-remote-head">
+ <span class="video-remote-badge"><${Icon} name="cast" /></span>
+ <span class="video-remote-label">${t('cast.casting_to')}</span>
+ <span class="video-remote-device">${device}</span>
+ <span class="video-remote-state">
+ ${busy ? html`<span class="spinner"></span>${' '}${t('cast.waiting')}` : ''}
+ </span>
+ </div>
+ <div class="video-remote-track">
+ <input class="video-remote-seek" type="range" min="0" step="1"
+ style=${`--pct:${pct}%`}
+ max=${Math.max(1, Math.floor(duration))}
+ value=${Math.floor(position || 0)}
+ disabled=${!known || !(duration > 0)}
+ aria-label=${t('cast.seek')}
+ onInput=${(e) => onDrag(+e.target.value)}
+ onChange=${(e) => onSeek(+e.target.value)} />
+ <div class="video-remote-times">
+ <span>${known ? formatClock(position) : '–:––'}</span>
+ <span>${duration > 0 ? formatClock(duration) : '–:––'}</span>
+ </div>
+ </div>
+ <div class="video-remote-buttons">
+ <button class="video-remote-skip" disabled=${!known}
+ onClick=${() => onSeek(position - 30)}
+ title=${t('cast.back30')} aria-label=${t('cast.back30')}>
+ <${Icon} name="skipback" /><span class="video-remote-skip-n">30</span></button>
+ <button class="video-remote-main ${paused ? 'is-paused' : ''}" disabled=${!playerState}
+ onClick=${onToggle}
+ title=${paused ? t('cast.play') : t('cast.pause')}
+ aria-label=${paused ? t('cast.play') : t('cast.pause')}>
+ <${Icon} name=${paused ? 'play' : 'pause'} /></button>
+ <button class="video-remote-skip" disabled=${!known}
+ onClick=${() => onSeek(position + 30)}
+ title=${t('cast.fwd30')} aria-label=${t('cast.fwd30')}>
+ <${Icon} name="skipfwd" /><span class="video-remote-skip-n">30</span></button>
+ </div>
+ <button class="video-remote-stop" onClick=${onStop}>
+ <${Icon} name="close" /> ${t('cast.stop')}</button>
+ </div>
+ `;
+}
+
+/**
* Where *this account on this device* last left off in a given file.
*
* localStorage rather than the node: it needs no protocol, no storage anyone
@@ -438,6 +496,19 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
const castRestartPendingRef = useRef(false);
const castDeviceRef = useRef(null);
const castRestartGenRef = useRef(0);
+ // The receiver as last polled: { playerState, position } with the position
+ // on the film's timeline, or null before it has answered.
+ const [remote, setRemote] = useState(null);
+ // Where the viewer sent the television and it has not arrived yet. Shown in
+ // place of the receiver's position, which until the reload still belongs to
+ // the stream being abandoned and would make the scrubber jump back.
+ const [remoteTarget, setRemoteTarget] = useState(null);
+ const [remoteDrag, setRemoteDrag] = useState(null);
+ const remoteFilmPosRef = useRef(null);
+ // Segments of the *new* stream that arrive while its seek is still landing
+ // (`reinitAt`/`resumeAt` wait on the SourceBuffer). Null when not holding.
+ const heldRef = useRef(null);
+ const holdGenRef = useRef(0);
const landingPlayheadRef = useRef(false);
// The current Screen Wake Lock sentinel, if the browser granted one — see
// the effect below. Null on any platform/context that does not support it,
@@ -764,6 +835,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
ranges: describeRanges(),
});
awaitingInitRef.current = true;
+ heldRef.current = null; holdGenRef.current++;
// A seek supersedes a resume that had been asked for and not landed:
// this one empties the buffer, and taking the resume branch on its
// `stream_init` would leave the film we navigated away from in place.
@@ -798,6 +870,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
});
resumingRef.current = true;
awaitingInitRef.current = true;
+ heldRef.current = null; holdGenRef.current++;
seekTargetRef.current = null;
outstandingRef.current = STREAM_WINDOW;
console.log('[resume] request', +target.toFixed(1));
@@ -1064,7 +1137,23 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
castRestartPendingRef.current = true;
}
const land = resuming ? resumeAt : reinitAt;
- land(msg.start || 0).catch(() => {
+ // What follows this message on the ordered channel is the new
+ // stream, its header first. The landing waits on the SourceBuffer,
+ // and those segments used to arrive in that gap and be dropped as
+ // the old film's: the player kept its header from the start of the
+ // film and never noticed, but a cast relay restarted here got a
+ // stream beginning at a moof — no ftyp, no moov — and the receiver
+ // gave up on it within a second (measured on a phone). So they are
+ // held, and taken in order once the landing is done.
+ const hold = ++holdGenRef.current;
+ heldRef.current = [];
+ land(msg.start || 0).then(async () => {
+ while (holdGenRef.current === hold && heldRef.current && heldRef.current.length) {
+ await consumeSegment(heldRef.current.shift());
+ }
+ if (holdGenRef.current === hold) heldRef.current = null;
+ }).catch(() => {
+ if (holdGenRef.current === hold) heldRef.current = null;
setError(t('video.err_transport'));
setPhase('error');
});
@@ -1178,6 +1267,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
// for credit that cannot come. A race, which is why the same seek
// worked twice and hung on the third.
outstandingRef.current = Math.max(0, outstandingRef.current - 1);
+ // The new stream, arriving while its seek lands: kept, not dropped.
+ // Counted above already, so the replay must not count it again.
+ if (heldRef.current) {
+ if (!msg.file_id || msg.file_id === entry.id) heldRef.current.push(msg);
+ return;
+ }
if (awaitingInitRef.current) {
console.log('[seek] dropping segment (awaitingInit), outstanding:', outstandingRef.current);
}
@@ -1190,6 +1285,13 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
// would otherwise be decrypted against the wrong file — which fails,
// loudly, in the console, for something that is simply not ours.
if (msg.file_id && msg.file_id !== entry.id) return;
+ await consumeSegment(msg);
+ };
+
+ // Everything a segment goes through once it is known to belong to the
+ // stream being played: by arrival, or replayed after a landing.
+ const consumeSegment = async (msg) => {
+ if (cancelled) return;
try {
const plaintext = await window.MeshBayCrypto.decryptChunkBin(
gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct);
@@ -1220,6 +1322,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
} else {
navigator.clipboard.writeText(result.url).catch(() => {});
}
+ if (castRestartGenRef.current === gen) setRemoteTarget(null);
}).catch((err) => {
if (castRestartGenRef.current !== gen) return;
console.error('[cast] restart failed:', err);
@@ -1436,6 +1539,24 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
// The scan lives as long as the picker is open: closing it, or picking a
// receiver, stops the updates. Receivers are listed as they answer, and the
// spinner sits below them so one landing never moves the row being aimed at.
+ // While casting, the film plays on the receiver; the local element keeps
+ // playing — its playhead is what paces the stream the relay forwards — but
+ // silently. It went on with its sound, so a phone in the room doubled the
+ // television's audio, screen off included. What the viewer had set comes
+ // back when the cast ends.
+ const mutedBeforeCastRef = useRef(null);
+ useEffect(() => {
+ const v = videoRef.current;
+ if (!v) return;
+ if (castActive) {
+ if (mutedBeforeCastRef.current === null) mutedBeforeCastRef.current = v.muted;
+ v.muted = true;
+ } else if (mutedBeforeCastRef.current !== null) {
+ v.muted = mutedBeforeCastRef.current;
+ mutedBeforeCastRef.current = null;
+ }
+ }, [castActive]);
+
useEffect(() => {
if (!castPickerOpen) return undefined;
setCastDevices([]);
@@ -1602,6 +1723,75 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
};
}, []);
+ // ── Remote ────────────────────────────────────────────────────────────
+ //
+ // While a television plays the film, this player is its remote. The phone
+ // and the receiver do not play in step — they start apart and drift — so a
+ // scrubber on the local playhead lied about where the film was, and a seek
+ // from it landed off by that much. Everything here reads the receiver and
+ // tells it what to do; seeking is the ordinary seek, which restarts the
+ // relay and reloads the receiver at the new position.
+ const remoteMode = castActive && castDeviceName !== null && platform.cast.canControl;
+ useEffect(() => {
+ if (!remoteMode) {
+ setRemote(null); setRemoteTarget(null); setRemoteDrag(null);
+ remoteFilmPosRef.current = null;
+ return undefined;
+ }
+ let stopped = false;
+ const poll = async () => {
+ try {
+ const s = await platform.cast.status();
+ const c = s && s.chromecast;
+ if (stopped) return;
+ if (c && c.connected && c.position != null && !castRestartPendingRef.current) {
+ const position = streamStartRef.current + c.position;
+ remoteFilmPosRef.current = position;
+ setRemote({ playerState: c.playerState, position });
+ } else {
+ setRemote((r) => (r && c && c.connected ? { ...r, playerState: c.playerState } : r));
+ }
+ } catch { /* asked again on the next tick */ }
+ };
+ poll();
+ const id = setInterval(poll, 1000);
+ return () => { stopped = true; clearInterval(id); };
+ }, [remoteMode]);
+
+ const stopCast = async () => {
+ await platform.cast.chromecastDisconnect().catch(() => {});
+ await platform.cast.stop();
+ setCastActive(false); castActiveRef.current = false;
+ setCastUrl(null);
+ setCastDeviceName(null);
+ castDeviceRef.current = null;
+ };
+
+ const remoteShown = remoteDrag ?? remoteTarget ?? (remote ? remote.position : null);
+ const remoteSeek = (to) => {
+ const duration = durationRef.current;
+ const target = Math.max(0, duration > 0 ? Math.min(to, duration - 1) : to);
+ setRemoteTarget(target);
+ if (requestSeekRef.current) requestSeekRef.current(target);
+ };
+ const remoteToggle = async () => {
+ const v = videoRef.current;
+ try {
+ if (remote && remote.playerState === 'PAUSED') {
+ await platform.cast.chromecastPlay();
+ if (v) v.play().catch(() => {});
+ } else {
+ await platform.cast.chromecastPause();
+ // The local copy only paces the download: left running, it would go
+ // on fetching for a television that is not watching.
+ if (v) v.pause();
+ }
+ setRemote((r) => (r ? { ...r, playerState: r.playerState === 'PAUSED' ? 'PLAYING' : 'PAUSED' } : r));
+ } catch (err) {
+ console.warn('[cast] remote command failed:', err);
+ }
+ };
+
return html`
<div class="video-overlay video-player-overlay" onClick=${(e) => {
if (e.target.classList.contains('video-overlay')) onClose();
@@ -1630,7 +1820,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
setAudioTrack(track.i);
const v = videoRef.current;
const seek = requestSeekRef.current;
- if (v && seek) seek(v.currentTime);
+ // Where the television is, when one plays the film: the
+ // phone's playhead has drifted from it.
+ const at = remoteFilmPosRef.current ?? (v && v.currentTime);
+ if (at != null && seek) seek(at);
}}>
${track.i === audioTrack
? html`<${Icon} name="check" />`
@@ -1687,12 +1880,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
<button class="video-close ${castActive ? 'cast-active' : ''}"
onClick=${async () => {
if (castActive) {
- await platform.cast.chromecastDisconnect().catch(() => {});
- await platform.cast.stop();
- setCastActive(false); castActiveRef.current = false;
- setCastUrl(null);
- setCastDeviceName(null);
- castDeviceRef.current = null;
+ await stopCast();
} else if (castCodecRef.current && initSegmentRef.current) {
if (castPickerOpen) {
setCastPickerOpen(false);
@@ -1777,7 +1965,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
</div>
${(phase === 'streaming' || phase === 'loading') && html`
- <div class="video-container">
+ <div class="video-container ${remoteMode ? 'video-container-remote' : ''}">
<video ref=${videoRef} controls autoplay>
${subtitleUrl && html`
<track key=${subtitleUrl} kind="subtitles" src=${subtitleUrl}
@@ -1788,6 +1976,18 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
default />
`}
</video>
+ ${remoteMode && html`
+ <${CastRemote}
+ device=${castDeviceName}
+ position=${remoteShown}
+ duration=${durationRef.current}
+ playerState=${remote ? remote.playerState : null}
+ moving=${remoteTarget !== null}
+ onDrag=${setRemoteDrag}
+ onSeek=${(to) => { setRemoteDrag(null); remoteSeek(to); }}
+ onToggle=${remoteToggle}
+ onStop=${stopCast} />
+ `}
${phase === 'loading' && html`
<div class="video-loading">
<div class="video-loading-label">
@@ -1826,4 +2026,4 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
* live.
*/
-export { VideoPlayer };
+export { VideoPlayer, CastRemote };
diff --git a/packages/meshbay-hub/tests/harness/window_leak.mjs b/packages/meshbay-hub/tests/harness/window_leak.mjs
index d5185de..f05d8c0 100644
--- a/packages/meshbay-hub/tests/harness/window_leak.mjs
+++ b/packages/meshbay-hub/tests/harness/window_leak.mjs
@@ -41,20 +41,23 @@ let cancelled = false;
const pump = () => {};
const flushQueue = () => {};
const gekRef = { current: null };
+// Not holding: these are the abandoned stream's segments, before any stream_init.
+const heldRef = { current: null };
+const consumeSegment = async () => {};
const window_ = { MeshBayCrypto: { decryptChunkBin: async () => new Uint8Array(4) } };
const silentConsole = { log() {}, warn() {}, error() {} };
const handler = new Function(
'msg', 'cancelled', 'awaitingInitRef', 'outstandingRef', 'queueRef',
- 'entry', 'gekRef', 'pump', 'flushQueue', 'console', 'window',
+ 'entry', 'gekRef', 'pump', 'flushQueue', 'console', 'window', 'heldRef', 'consumeSegment',
`return (async () => {${source}})();`);
const deliver = (n) => Promise.all(
Array.from({ length: n }, (_, i) => handler(
{ file_id: entry.id, segment_index: i, nonce: 'n', ct: 'c' },
cancelled, awaitingInitRef, outstandingRef, queueRef, entry, gekRef,
- pump, flushQueue, silentConsole, window_)));
+ pump, flushQueue, silentConsole, window_, heldRef, consumeSegment)));
const run = async () => {
// The whole window arrives while reinitAt is still awaiting its updateends.
diff --git a/packages/meshbay-hub/tests/test_android_cast.py b/packages/meshbay-hub/tests/test_android_cast.py
new file mode 100644
index 0000000..a346d15
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_android_cast.py
@@ -0,0 +1,133 @@
+"""
+Casting in the Android application, pinned by reading the source.
+
+The relay is a port of meshbay-client/src/cast-relay.js and its mitigations are
+the same ones; the JVM tests (CastRelayTest) run it on loopback. What is held
+here is the wiring around it: the same bridge surface as the desktop, order
+kept where order is meaning, the receiver pointed at nothing but the relay, and
+what keeps a cast alive with the screen off switched on only while one runs.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+PACKAGES = Path(__file__).resolve().parents[2]
+MAIN = PACKAGES / "meshbay-android" / "app" / "src" / "main"
+SRC = MAIN / "kotlin" / "org" / "meshbay" / "client"
+CAST = SRC / "cast"
+SHIM = MAIN / "assets" / "bridge" / "meshbay-bridge.js"
+DESKTOP = PACKAGES / "meshbay-client" / "src"
+
+pytestmark = pytest.mark.skipif(not CAST.exists(), reason="android sources not present")
+
+
+def _read(path: Path) -> str:
+ return path.read_text(encoding="utf-8")
+
+
+def test_the_cast_channels_are_the_desktops():
+ def channels(text: str, call: str) -> set[str]:
+ return set(re.findall(rf"{call}\('(cast:[\w:-]+)'", text))
+
+ preload = channels(_read(DESKTOP / "preload.js"), r"ipcRenderer\.invoke")
+ shim = channels(_read(SHIM), "call")
+ kt = _read(CAST / "CastChannels.kt")
+ native = set(re.findall(r'^\s*"(cast:[\w:-]+)" ->', kt, flags=re.M))
+ assert preload and shim == preload, shim ^ preload
+ assert native == preload, native ^ preload
+
+
+def test_no_cast_object_where_casting_cannot_work():
+ """`platform.cast.available` is whether the object exists."""
+ shim = _read(SHIM)
+ assert "...(CAST ? { cast: {" in shim
+ assert "lanCast: CAST" in shim
+ assert "const CAST = ${cast.control.available()}" in _read(SRC / "MainActivity.kt")
+
+
+def test_the_relay_keeps_the_desktop_mitigations():
+ relay = _read(CAST / "CastRelay.kt")
+ desktop = _read(DESKTOP / "cast-relay.js")
+ for name in ("RING_CAP", "PORT_BASE", "PORT_COUNT"):
+ js = re.search(rf"const {name} = (\d+);", desktop).group(1)
+ assert re.search(rf"const val {name} = {js}\b", relay), name
+ assert "InetSocketAddress(address, PORT_BASE + i)" in relay, "bound to the LAN address"
+ # Code, not comments: the comment saying "never 0.0.0.0" is not a bind.
+ code = re.sub(r"/\*.*?\*/", "", relay, flags=re.S)
+ code = re.sub(r"(?m)//.*$", "", code)
+ assert "0.0.0.0" not in code
+ assert 'query["t"] != token' in relay
+ # The preflight before the token: a refusal there reads as a network error.
+ serve = relay.split("private fun serve(", 1)[1]
+ assert serve.index('method == "OPTIONS"') < serve.index('query["t"] != token')
+
+
+def test_what_a_receiver_has_not_read_waits_on_disk():
+ """The desktop drops a fragment once 8 MB wait for a receiver; a fragment
+ is 5 to 10 MB at a film's bitrate, so the television froze for its length
+ (measured: three dropped in the first fifteen seconds). Here the lead waits
+ in a spool file per receiver, deleted with it, and is dropped only past a
+ disk bound."""
+ relay = _read(CAST / "CastRelay.kt")
+ assert "BACKPRESSURE_HIGH" not in relay
+ assert "RandomAccessFile(file, \"rw\").channel" in relay
+ assert "c.waiting() > spoolLimit()" in relay
+ assert "SPOOL_MAX_BYTES = 2L * 1024 * 1024 * 1024" in relay
+ assert "file.delete()" in relay
+ assert 'java.io.File(context.cacheDir, "cast-relay")' in _read(CAST / "CastChannels.kt")
+
+
+def test_the_backlog_is_bounded_in_bytes():
+ """A fragment is a segment, megabytes at a film's bitrate: 64 of them killed
+ the application with an OutOfMemoryError on the emulator."""
+ relay = _read(CAST / "CastRelay.kt")
+ assert "RING_MAX_BYTES" in relay and "ringBytes > RING_MAX_BYTES" in relay
+
+
+def test_the_relay_is_on_wifi_never_the_mobile_network():
+ channels = _read(CAST / "CastChannels.kt")
+ lan = channels.split("private fun lanAddress()", 1)[1]
+ assert "TRANSPORT_WIFI" in lan and "TRANSPORT_ETHERNET" in lan
+ assert "TRANSPORT_CELLULAR" not in lan
+
+
+def test_the_receiver_is_pointed_at_the_relay_and_nothing_else():
+ channels = _read(CAST / "CastChannels.kt")
+ check = channels.split("private fun relayUrl(", 1)[1].split("\n }", 1)[0]
+ assert "asked != ours" in check and "throw Refused" in check
+ assert channels.count("relayUrl(o)") == 2, "connect and reload both go through the check"
+
+
+def test_relay_calls_keep_their_order():
+ """The page does not await each push; on a pool they could overtake."""
+ channels = _read(CAST / "CastChannels.kt")
+ assert '"cast:start", "cast:push", "cast:subtitle", "cast:finish", "cast:stop"' in channels
+ bridge = _read(SRC / "bridge" / "Bridge.kt")
+ assert "Executors.newSingleThreadExecutor()" in bridge
+ assert "(if (ordered) serial else work).execute" in bridge
+ assert "fun ordered(frame: BinaryFrame) = frame.channel == BinaryFrame.CAST_PUSH" in _read(
+ SRC / "bridge" / "Channels.kt")
+
+
+def test_screen_off_survival_is_switched_on_only_while_casting():
+ activity = _read(SRC / "MainActivity.kt")
+ casting = activity.split("private fun casting(on: Boolean)", 1)[1].split("\n }", 1)[0]
+ assert "web.keepVisible = on" in casting
+ assert "startForegroundService(service) else stopService(service)" in casting
+ assert activity.count("keepVisible =") == 1, "the page is kept visible from one place only"
+ view = _read(SRC / "shell" / "ShellWebView.kt")
+ assert "var keepVisible = false" in view
+ manifest = _read(MAIN / "AndroidManifest.xml")
+ assert 'android:name=".cast.CastService"' in manifest
+ assert 'android:foregroundServiceType="mediaPlayback"' in manifest
+
+
+def test_the_receiver_is_given_what_the_desktop_gives_it():
+ control = _read(CAST / "CastControl.kt")
+ assert "DEFAULT_MEDIA_RECEIVER_APPLICATION_ID" in control
+ assert "MediaInfo.STREAM_TYPE_LIVE" in control
+ assert "TEXT_TRACK_ID = 1L" in control
+ assert "SCAN_DURATION_MS = 6000L" in control
+ assert re.search(r"const SCAN_DURATION_MS = 6000;", _read(DESKTOP / "cast-chromecast.js"))
diff --git a/packages/meshbay-hub/tests/test_android_downloads.py b/packages/meshbay-hub/tests/test_android_downloads.py
new file mode 100644
index 0000000..96da9a4
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_android_downloads.py
@@ -0,0 +1,90 @@
+"""
+Downloads in the Android application, pinned by reading the source.
+
+The page's contract with a native save target is platform.js `nativeSave`: a
+sink with write/close/abort, an `open` only when the target can open the file,
+and nothing that names a path. The Android sink keeps it; what it may open is
+downloads.js `OPENABLE`, held equal here because a second copy of a list of
+safe types is how an `.html` gets opened one day.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+from spa_source import STATIC
+
+PACKAGES = Path(__file__).resolve().parents[2]
+MAIN = PACKAGES / "meshbay-android" / "app" / "src" / "main"
+SAVE = MAIN / "kotlin" / "org" / "meshbay" / "client" / "save"
+SHIM = MAIN / "assets" / "bridge" / "meshbay-bridge.js"
+
+pytestmark = pytest.mark.skipif(not SAVE.exists(), reason="android sources not present")
+
+
+def _read(path: Path) -> str:
+ return path.read_text(encoding="utf-8")
+
+
+def test_what_may_be_opened_is_the_pages_list():
+ js = _read(STATIC / "downloads.js").split("const OPENABLE = {", 1)[1].split("};", 1)[0]
+ kt = _read(SAVE / "SaveNames.kt").split("val OPENABLE = mapOf(", 1)[1].split("\n )", 1)[0]
+ page = {k: v.split(";")[0] for k, v in re.findall(r"(\w+): '([^']+)'", js)}
+ android = dict(re.findall(r'"(\w+)" to "([^"]+)"', kt))
+ assert page and android == page, set(android.items()) ^ set(page.items())
+
+
+def test_open_is_offered_only_where_the_target_says_so():
+ shim = _read(SHIM)
+ save = shim.split("saveFile: async", 1)[1].split("\n },", 1)[0]
+ assert "if (handle.openable) sink.open" in save
+ sinks = _read(SAVE / "SaveSinks.kt")
+ assert '.put("openable", SaveNames.openableType(shown) != null)' in sinks
+ opening = sinks.split("fun open(id: Long)", 1)[1].split("\n }", 1)[0]
+ assert "SaveNames.openableType(name) ?: throw Refused" in opening
+
+
+def test_the_page_is_told_names_never_uris():
+ sinks = _read(SAVE / "SaveSinks.kt")
+ for fn in ("fun chooseFolder", "fun getFolder", "fun begin"):
+ body = sinks.split(fn, 1)[1].split("\n fun ", 1)[0]
+ returned = re.findall(r'put\("(\w+)"', body)
+ assert not {"uri", "path", "tree"} & set(returned), (fn, returned)
+ assert "return displayName(treeDocument(tree))" in sinks
+
+
+def test_the_save_channels_are_the_desktops():
+ preload = _read(PACKAGES / "meshbay-client" / "src" / "preload.js")
+ shim = _read(SHIM)
+
+ def channels(text: str, call: str) -> set[str]:
+ return set(re.findall(rf"{call}\('((?:save|folder):[\w:-]+)'", text))
+
+ assert channels(shim, "call") == channels(preload, r"ipcRenderer\.invoke")
+ assert "nativeSave: true" in shim
+
+
+def test_an_unfinished_file_never_carries_the_final_name():
+ sinks = _read(SAVE / "SaveSinks.kt")
+ assert '"$wanted.part"' in sinks
+ assert "MediaStore.MediaColumns.IS_PENDING, 1" in sinks
+ assert "MediaStore.MediaColumns.IS_PENDING, 0" in sinks
+ abort = sinks.split("fun abort(id: Long)", 1)[1].split("\n }", 1)[0]
+ assert "delete(sink.uri)" in abort
+ assert "fun cleanUpAfterAKilledProcess" in sinks
+ assert "saves.cleanUpAfterAKilledProcess()" in _read(
+ MAIN / "kotlin" / "org" / "meshbay" / "client" / "MainActivity.kt")
+
+
+def test_a_chosen_folder_that_has_gone_is_not_silently_replaced():
+ begin = _read(SAVE / "SaveSinks.kt").split("fun begin(", 1)[1].split("\n fun ", 1)[0]
+ assert "auto && !(configuredTree != null && tree == null)" in begin
+
+
+def test_writes_are_binary_and_awaited():
+ shim = _read(SHIM)
+ assert "head.setUint32(0, 0x4d424231)" in shim
+ assert "port.postMessage(frame)" in shim
+ bridge = _read(MAIN / "kotlin" / "org" / "meshbay" / "client" / "bridge" / "Bridge.kt")
+ before = bridge.split("TYPE_ARRAY_BUFFER", 1)[0]
+ assert "!isMainFrame" in before, "a binary message must pass the same sender check"
diff --git a/packages/meshbay-hub/tests/test_android_keys.py b/packages/meshbay-hub/tests/test_android_keys.py
new file mode 100644
index 0000000..a00b909
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_android_keys.py
@@ -0,0 +1,74 @@
+"""
+The Android keyring against the desktop's, where the shared vectors cannot see.
+
+`tests/vectors/keyring.json` holds the bytes (and the Android unit tests read
+it). What a vector cannot hold is a *list*: which admin operations may be
+signed at all, and the Argon2 parameters a format change would move. Two
+implementations of a list drift silently — an operation the desktop stopped
+signing at MNP 6.0 and Android still signs is a script in the page driving an
+older node into widening its sharing. So both are read from source and
+compared.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+PACKAGES = Path(__file__).resolve().parents[2]
+MAIN = PACKAGES / "meshbay-android" / "app" / "src" / "main"
+KEYS = MAIN / "kotlin" / "org" / "meshbay" / "client" / "keys"
+CLIENT = PACKAGES / "meshbay-client" / "src"
+SHIM = MAIN / "assets" / "bridge" / "meshbay-bridge.js"
+
+pytestmark = pytest.mark.skipif(not KEYS.exists(), reason="android sources not present")
+
+
+def _read(path: Path) -> str:
+ return path.read_text(encoding="utf-8")
+
+
+def test_the_same_admin_operations_are_signed():
+ js = _read(CLIENT / "transcripts.js")
+ js = js.split("const ADMIN_OPS = new Set([", 1)[1].split("]", 1)[0]
+ kt = _read(KEYS / "Transcripts.kt").split("val ADMIN_OPS = setOf(", 1)[1].split(")", 1)[0]
+ desktop = set(re.findall(r"'([a-z_]+)'", js))
+ android = set(re.findall(r'"([a-z_]+)"', kt))
+ assert desktop and android == desktop, android ^ desktop
+ # The ones MNP 6.0 took away must not come back on either side.
+ assert not {"root_add", "root_update", "group_attach"} & android
+
+
+def test_the_argon2_parameters_are_the_desktops():
+ js = _read(CLIENT / "keyring.js")
+ m = re.search(r"memory: (\d+), passes: (\d+), parallelism: (\d+), tagLength: (\d+)", js)
+ kt = _read(KEYS / "Kdf.kt")
+ want = dict(zip(("MEMORY_KIB", "PASSES", "PARALLELISM", "TAG"), m.groups()))
+ for name, value in want.items():
+ assert re.search(rf"const val ARGON2_{name} = {value}\b", kt), name
+
+
+def test_the_shim_offers_the_desktops_key_surface():
+ preload = _read(CLIENT / "preload.js")
+ shim = _read(SHIM)
+
+ def channels(text: str, call: str) -> set[str]:
+ return set(re.findall(rf"{call}\('((?:keys|device|secrets):[\w:-]+)'", text))
+
+ assert channels(shim, "call") == channels(preload, r"ipcRenderer\.invoke")
+
+
+def test_signing_is_by_kind_and_no_private_key_is_returned():
+ channels = _read(KEYS.parent / "bridge" / "KeyChannels.kt")
+ assert '"keys:sign" -> keyring.signAs(' in channels
+ # No channel hands the page a stored key: the store's slots are never a
+ # return value, and identity/mint/open answer with public keys.
+ assert "secrets.read()" in channels # the keyring's load, and nothing else
+ assert channels.count("secrets.read()") == 1
+ assert '"pkEdB64"' in channels and '"skEd"' not in channels
+
+
+def test_widening_browser_access_is_confirmed_natively():
+ channels = _read(KEYS.parent / "bridge" / "KeyChannels.kt")
+ branch = channels.split('"keys:set-browser-access" ->', 1)[1].split('"keys:created-here"', 1)[0]
+ assert 'confirm("native.browser_access_confirm")' in branch
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py
new file mode 100644
index 0000000..e569bb7
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -0,0 +1,223 @@
+"""
+The Android shell's security contract, pinned by reading its source.
+
+The same treatment `test_desktop_shell.py` gives the Electron application, for
+the same reason: an emulator or a phone is what proves the shell runs, and the
+suite has neither. What this proves is that the properties the design depends
+on (docs/MESHBAY_DESIGN.md §8.2, as the Android plan restates them) are in the
+source, and it fails when one is removed. The JVM unit tests run too when an
+Android SDK is present.
+"""
+
+import os
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+PACKAGES = Path(__file__).resolve().parents[2]
+ANDROID = PACKAGES / "meshbay-android"
+APP = ANDROID / "app"
+SRC = APP / "src" / "main" / "kotlin" / "org" / "meshbay" / "client"
+SHIM = APP / "src" / "main" / "assets" / "bridge" / "meshbay-bridge.js"
+CLIENT = PACKAGES / "meshbay-client"
+
+pytestmark = pytest.mark.skipif(not ANDROID.exists(), reason="android sources not present")
+
+
+def _read(path: Path) -> str:
+ return path.read_text(encoding="utf-8")
+
+
+def _kotlin() -> str:
+ return "\n".join(_read(p) for p in sorted(SRC.rglob("*.kt")))
+
+
+def _strip_js_comments(source: str) -> str:
+ source = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
+ return re.sub(r"(?m)//.*$", "", source)
+
+
+# ── The page comes from the package ──────────────────────────────────────────
+
+def test_the_interface_is_copied_from_its_single_source_and_never_committed():
+ build = _read(APP / "build.gradle.kts")
+ assert '"../meshbay-hub/src/meshbay_hub/static"' in build
+ # The desktop application's page: the hub's carries a /a/<hash>/ prefix
+ # that would point back at the hub.
+ assert '"../meshbay-client/scripts/index.html"' in build
+ assert "deleteRecursively()" in build, "a stale file could survive a rebuild"
+ assert "addGeneratedSourceDirectory" in build
+ assert "build/" in _read(ANDROID / ".gitignore")
+ assert not (APP / "src" / "main" / "assets" / "ui").exists(), \
+ "a copy of the interface is in the source tree, which is how a fork begins"
+
+
+def test_the_webview_loads_the_package_and_nothing_else():
+ activity = _read(SRC / "MainActivity.kt")
+ loads = re.findall(r"\.loadUrl\(([^)]*)\)", activity)
+ assert loads and set(loads) == {"UiAssets.START"}, loads
+ assert "loadDataWithBaseURL" not in activity and "loadData(" not in activity
+ # The hub never becomes the document origin; a link out leaves the app.
+ assert "shouldOverrideUrlLoading" in activity and "openExternally" in activity
+
+
+def test_the_policy_is_the_desktop_policy_sent_as_a_header():
+ """One interface, one policy for the packaged builds — and the desktop's
+ is already held to the hub's by test_the_two_policies_stay_in_step."""
+ def directives(text: str, start: str, end: str) -> dict[str, str]:
+ body = text.split(start, 1)[1].split(end, 1)[0]
+ out = {}
+ for d in re.findall(r"[`\"]([a-z-]+(?: [^`\"]*)?)[`\"]", body):
+ d = d.replace("${RECAPTCHA_SRC}", "$RECAPTCHA_SRC")
+ out[d.split()[0]] = d
+ return out
+
+ desktop = directives(_read(CLIENT / "src" / "main.js"), "const CSP = [", "].join")
+ kotlin = _read(SRC / "shell" / "UiAssets.kt")
+ android = directives(kotlin, "val CSP = listOf(", ").joinToString")
+ assert desktop and android == desktop, set(android.items()) ^ set(desktop.items())
+
+ assets = _read(SRC / "shell" / "UiAssets.kt")
+ assert '"Content-Security-Policy" to CSP' in assets
+ recaptcha = 'RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"'
+ assert recaptcha in assets
+ markup = re.sub(r"<!--.*?-->", "", _read(CLIENT / "scripts" / "index.html"), flags=re.S)
+ assert "Content-Security-Policy" not in markup
+
+
+def test_the_asset_handler_cannot_be_walked_out_of():
+ assets = _read(SRC / "shell" / "UiAssets.kt")
+ assert '".."' in assets and 'val asset = "ui/"' in assets
+
+
+def test_plain_http_is_refused_except_to_loopback():
+ hub = _read(SRC / "hub" / "HubClient.kt")
+ assert "The hub address must be https" in hub
+ assert 'Regex("^http://(localhost|127\\\\.)")' in hub
+ config = _read(APP / "src" / "main" / "res" / "xml" / "network_security_config.xml")
+ assert '<base-config cleartextTrafficPermitted="false"' in config
+ allowed = re.findall(r"<domain[^>]*>([^<]+)</domain>", config)
+ assert set(allowed) == {"localhost", "127.0.0.1"}
+
+
+def test_the_page_reaches_the_signed_in_hub_only():
+ hub = _read(SRC / "hub" / "HubClient.kt")
+ assert "Refused: not this hub" in hub and "sameOrigin(target, hub)" in hub
+ assert ".followRedirects(false)" in hub, "a redirect would carry the token elsewhere"
+
+
+# ── The bridge ──────────────────────────────────────────────────────────────
+
+def test_no_javascript_interface_is_ever_added():
+ """addJavascriptInterface injects into every frame of every origin."""
+ for path in APP.rglob("*.kt"):
+ assert "addJavascriptInterface" not in _read(path), path
+
+
+def test_every_message_is_checked_for_our_top_level_document():
+ bridge = _read(SRC / "bridge" / "Bridge.kt")
+ check = bridge.split("override fun onPostMessage", 1)[1].split("work.execute", 1)[0]
+ assert "!isMainFrame" in check and "UiAssets.ORIGIN" in check
+ activity = _read(SRC / "MainActivity.kt")
+ assert "addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN)" in activity
+ assert re.search(r"addDocumentStartJavaScript\(web, .*setOf\(UiAssets\.ORIGIN\)\)", activity)
+
+
+def _shim_channels() -> set[str]:
+ return set(re.findall(r"call\('([\w:-]+)'", _strip_js_comments(_read(SHIM))))
+
+
+def test_the_shim_offers_desktop_channels_and_native_answers_each():
+ preload_js = _read(CLIENT / "src" / "preload.js")
+ preload = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", preload_js))
+ native = set()
+ for path in (SRC / "bridge" / "Channels.kt", SRC / "bridge" / "KeyChannels.kt",
+ SRC / "cast" / "CastChannels.kt"):
+ native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M))
+ shim = _shim_channels()
+ assert shim, "no channel found in the shim"
+ assert shim <= preload, f"channels the desktop does not have: {shim - preload}"
+ assert shim == native, f"shim and native disagree: {shim ^ native}"
+
+
+def test_what_a_phone_does_not_have_is_absent_not_refusing():
+ """platform.js decides what to show from whether an object exists
+ (`platform.node.available`, `platform.folder.available`, …): an object that
+ only refused would put screens on the page that fail when used."""
+ shim = _strip_js_comments(_read(SHIM))
+ exposed = shim.split("const meshbay = {", 1)[1].split("\n };", 1)[0]
+ for absent in ("node:", "rootPicker:", "minimizeToTray:", "setTrayLabels:"):
+ assert absent not in exposed, absent
+ assert "nodeAdmin: false" in exposed and "localFolders: false" in exposed
+
+
+def test_the_bridge_is_frozen_and_the_port_hidden():
+ shim = _strip_js_comments(_read(SHIM))
+ assert "delete window.meshbayNative" in shim
+ assert "window.top !== window" in shim
+ assert "writable: false, configurable: false" in shim
+ assert "Object.freeze" in shim
+
+
+def test_file_system_access_is_removed_from_the_page():
+ """The WebView exposes it (spike S-1) and cannot back it with anything."""
+ shim = _strip_js_comments(_read(SHIM))
+ for name in ("showDirectoryPicker", "showSaveFilePicker", "showOpenFilePicker"):
+ assert f"'{name}'" in shim, name
+
+
+def test_the_hub_address_is_injected_not_fetched():
+ """platform.hubBase() is called while modules load, before anything can await."""
+ assert "HUB_BASE" in _strip_js_comments(_read(SHIM))
+ assert "const HUB_BASE = ${JSONObject.quote(hub.base)}" in _read(SRC / "MainActivity.kt")
+
+
+def test_the_node_setup_welcome_needs_a_node():
+ """A phone has a bridge and no node: with no groups yet it must see the
+ invitations and the join link, not a node wizard it cannot finish."""
+ from spa_source import STATIC
+ app = _read(STATIC / "app.js")
+ home = app.split("function HomePage(", 1)[1].split("\n}\n", 1)[0]
+ gate = home.split("<${SetupWelcome}", 1)[0]
+ assert "platform.capabilities.nodeAdmin" in gate
+ assert "platform.isNative" not in gate
+
+
+# ── The window ──────────────────────────────────────────────────────────────
+
+def test_nothing_is_granted_and_video_may_go_fullscreen():
+ activity = _read(SRC / "MainActivity.kt")
+ assert "onPermissionRequest(request: PermissionRequest) = request.deny()" in activity
+ # Without a custom view, requestFullscreen() never settles (CLAUDE.md).
+ assert "override fun onShowCustomView" in activity
+ assert "override fun onHideCustomView" in activity
+
+
+def test_no_backup_carries_the_keys_away():
+ manifest = _read(APP / "src" / "main" / "AndroidManifest.xml")
+ assert 'android:allowBackup="false"' in manifest
+ assert 'android:dataExtractionRules="@xml/data_extraction_rules"' in manifest
+
+
+def test_the_gradle_distribution_is_pinned_by_checksum():
+ props = _read(ANDROID / "gradle" / "wrapper" / "gradle-wrapper.properties")
+ assert re.search(r"^distributionSha256Sum=[0-9a-f]{64}$", props, flags=re.M)
+
+
+def test_the_version_is_the_packages_version():
+ """All packages share one version (CLAUDE.md); this one reads it rather
+ than writing it a second time."""
+ build = _read(APP / "build.gradle.kts")
+ assert 'rootDir.resolve("../meshbay-client/package.json")' in build
+ assert not re.search(r'versionName = "\d', build)
+
+
+@pytest.mark.skipif(not os.environ.get("ANDROID_HOME") or shutil.which("java") is None,
+ reason="no Android SDK in the environment")
+def test_the_jvm_unit_tests_pass():
+ result = subprocess.run(["./gradlew", "--no-daemon", "-q", "testDebugUnitTest"],
+ cwd=ANDROID, capture_output=True, text=True, timeout=900)
+ assert result.returncode == 0, result.stdout[-3000:] + result.stderr[-3000:]
diff --git a/packages/meshbay-hub/tests/test_cast_discovery.py b/packages/meshbay-hub/tests/test_cast_discovery.py
index 9e9ce85..8d156ff 100644
--- a/packages/meshbay-hub/tests/test_cast_discovery.py
+++ b/packages/meshbay-hub/tests/test_cast_discovery.py
@@ -106,3 +106,47 @@ def test_a_new_scan_is_not_cut_short_by_the_one_it_replaced(run):
assert run["restartedStillScanning"] is True
assert run["restartClearedOldDevices"] is True
assert run["restartedEnds"] is True
+
+
+def test_the_local_player_is_silent_while_casting():
+ """The local element keeps playing while casting — its playhead paces the
+ stream the relay forwards — so it must not keep its sound: a phone in the
+ room doubled the television's audio, screen off included. Whatever the
+ viewer had set comes back when the cast ends."""
+ from spa_source import STATIC
+ player = (STATIC / "video-player.js").read_text(encoding="utf-8")
+ effect = player.split("const mutedBeforeCastRef = useRef(null);", 1)[1]
+ effect = effect.split("}, [castActive]);", 1)[0]
+ assert "v.muted = true;" in effect
+ assert "mutedBeforeCastRef.current = v.muted;" in effect
+ assert "v.muted = mutedBeforeCastRef.current;" in effect
+ assert "pause()" not in effect, "pausing would stop the stream the receiver is playing"
+
+
+def test_the_player_is_a_remote_while_casting():
+ """Phone and television do not play in step, so while a receiver plays the
+ film the scrubber shows the receiver's position (stream time plus where
+ the stream started) and every seek goes through the ordinary seek, which
+ restarts the relay and reloads the receiver there. The copy-URL cast has
+ no receiver to read and keeps the local player."""
+ from spa_source import STATIC
+ player = (STATIC / "video-player.js").read_text(encoding="utf-8")
+ remote = player.split("// ── Remote ──", 1)[1].split("return html`", 1)[0]
+ assert "castDeviceName !== null && platform.cast.canControl" in remote
+ assert "streamStartRef.current + c.position" in remote
+ # Until the restart lands, the receiver's position is the old stream's.
+ assert "!castRestartPendingRef.current" in remote
+ assert "requestSeekRef.current(target)" in remote
+ assert "platform.cast.chromecastPause()" in remote
+ assert "platform.cast.chromecastPlay()" in remote
+ # A language change restarts the stream where the television is.
+ assert "remoteFilmPosRef.current ?? (v && v.currentTime)" in player
+
+
+def test_every_locale_names_the_remote_controls():
+ from spa_source import STATIC
+ keys = ["cast.casting_to", "cast.seek", "cast.waiting", "cast.back30", "cast.fwd30", "cast.play", "cast.pause"]
+ for f in sorted((STATIC / "locales").glob("*.js")):
+ text = f.read_text(encoding="utf-8")
+ for k in keys:
+ assert f"'{k}':" in text, f"{f.name} lacks {k}"
diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py
index 3716f4c..7062d39 100644
--- a/packages/meshbay-hub/tests/test_downloads.py
+++ b/packages/meshbay-hub/tests/test_downloads.py
@@ -602,3 +602,56 @@ console.log(JSON.stringify(out));
f"resume at {out['resumeFrom']} — that gap is a hole in the file")
# The resumed run covers exactly the rest, and repeats nothing.
assert out["writtenAfterResume"] == list(range(out["resumeFrom"], 10)), out
+
+
+def test_a_written_chunk_is_not_held_until_the_download_ends(tmp_path):
+ """A file streamed to disk holds one pipeline window in the page, not the
+ whole file.
+
+ `pipelinedDownload` kept every chunk's resolved promise in `inflight` for
+ the length of the call, and each promise held its ciphertext — so a file
+ written straight to disk was also held whole in memory until the last
+ chunk landed. Measured in the Android application on a 2 GB download: the
+ page's JS heap tracked the bytes already written, 905 MB at 928 MB, and the
+ renderer reached 2.1 GB before dropping to 82 MB at the end. Every target
+ that writes as it goes (a granted folder, a service worker, the desktop's
+ native save) had the same cost. The real function runs here, with each
+ chunk message weakly referenced and the collector forced between writes.
+ """
+ src = (STATIC / "file-utils.js").read_text(encoding="utf-8")
+ fn = src[src.index("async function pipelinedDownload"):]
+ fn = fn[:fn.index("\n}\n") + 2]
+
+ script = tmp_path / "retention.mjs"
+ script.write_text("""
+const PIPELINE_WINDOW = 4;
+const TOTAL = 40;
+const refs = [];
+let worst = 0;
+const _fetchChunkResilient = async (transport, fileId, i) => {
+ const msg = { ct: new Uint8Array(64 * 1024), nonce: new Uint8Array(12) };
+ refs[i] = new WeakRef(msg);
+ return msg;
+};
+const _writeOrStall = async (w, bytes, index) => {
+ // A macrotask ends the job that keeps WeakRef targets alive; then collect.
+ await new Promise((r) => setTimeout(r, 0));
+ globalThis.gc();
+ let alive = 0;
+ for (let j = 0; j < index - PIPELINE_WINDOW; j++) if (refs[j] && refs[j].deref()) alive++;
+ worst = Math.max(worst, alive);
+};
+globalThis.window = { MeshBayCrypto: {
+ decryptChunkBin: async () => ({ byteLength: 64 * 1024 }),
+} };
+""" + fn + """
+await pipelinedDownload({}, 'k', 'file', TOTAL, () => {}, {}, { aborted: false }, '', 0);
+console.log(JSON.stringify({ worst }));
+""", encoding="utf-8")
+ proc = subprocess.run(["node", "--expose-gc", str(script)], capture_output=True,
+ text=True, encoding="utf-8", timeout=60)
+ assert proc.returncode == 0, proc.stderr
+ worst = json.loads(proc.stdout)["worst"]
+ assert worst == 0, (
+ f"{worst} chunks already written were still held when a later one was — "
+ "the download keeps the whole file in memory until it ends")
diff --git a/packages/meshbay-hub/tests/test_keyring_vectors.py b/packages/meshbay-hub/tests/test_keyring_vectors.py
new file mode 100644
index 0000000..3928965
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_keyring_vectors.py
@@ -0,0 +1,142 @@
+"""
+The keypair-bundle and transcript vectors (`tests/vectors/keyring.json`).
+
+One file that every implementation of the bundle format and of the signed
+transcripts has to reproduce: the page (`keyderive.js`), the desktop keyring
+(`keyring.js`, `transcripts.js`), the Python reference below, and the Android
+keyring, whose unit tests read the same file. Pairwise parity tests grow with
+the square of the implementations; a shared file grows by one consumer.
+
+Two things are checked here. The file is what the shipped desktop code writes,
+so it cannot drift from the code it describes. And the specification
+(`docs/MESHBAY_DESIGN.md` §3.7, written out with argon2-cffi and
+`cryptography`, sharing nothing with the generator) arrives at the same bytes.
+"""
+
+import base64
+import hashlib
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+VECTORS = Path(__file__).resolve().parent / "vectors"
+FILE = VECTORS / "keyring.json"
+GENERATOR = VECTORS / "gen_keyring_vectors.js"
+KEYRING = Path(__file__).resolve().parents[2] / "meshbay-client" / "src" / "keyring.js"
+
+try:
+ from argon2.low_level import Type, hash_secret_raw
+ HAVE_ARGON2 = True
+except ImportError:
+ HAVE_ARGON2 = False
+
+
+def _vectors() -> dict:
+ return json.loads(FILE.read_text(encoding="utf-8"))
+
+
+@pytest.mark.skipif(shutil.which("node") is None or not KEYRING.exists(),
+ reason="node or the desktop client sources are unavailable")
+def test_the_file_is_what_the_shipped_keyring_writes():
+ """Regenerated from keyring.js and transcripts.js, byte for byte. A change
+ to either that alters a bundle or a transcript fails here first — which is
+ the moment to decide whether it is a format change every client must make."""
+ out = subprocess.run(["node", str(GENERATOR)], capture_output=True, text=True,
+ timeout=120, check=True).stdout
+ assert json.loads(out) == _vectors(), (
+ "keyring.js or transcripts.js no longer produce tests/vectors/keyring.json; "
+ "if the format change is deliberate, regenerate it and update every client")
+
+
+def _hkdf(ikm: bytes, info: str) -> bytes:
+ from cryptography.hazmat.primitives import hashes
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+ return HKDF(hashes.SHA256(), 32, None, info.encode()).derive(ikm)
+
+
+@pytest.fixture(scope="module")
+def spec():
+ """The chain from the specification: passphrase → Argon2id → M → keys."""
+ if not HAVE_ARGON2:
+ pytest.skip("argon2-cffi is unavailable")
+ v = _vectors()
+ i, p = v["input"], v["kdf"]["argon2_params"]
+ salt = hashlib.sha256(f"meshbay:bundle:v2:{i['username']}".encode()).digest()[:16]
+ a = hash_secret_raw(i["password"].encode(), salt, time_cost=p["passes"],
+ memory_cost=p["memory"], parallelism=p["parallelism"],
+ hash_len=p["tagLength"], type=Type.ID)
+ m = _hkdf(a + base64.b64decode(i["pepperB64"]), f"meshbay:bundle-master:v3|{i['userId']}")
+ return {"v": v, "salt": salt, "a": a, "m": m,
+ "node_key": _hkdf(m, f"meshbay:bundle:v3|node|{i['nodePk']}"),
+ "recovery_key": _hkdf(bytes.fromhex(v["kdf"]["mnemonic_bytes_hex"]),
+ f"meshbay:recovery:v1:{i['username']}")}
+
+
+def test_the_specification_derives_the_same_keys(spec):
+ k = spec["v"]["kdf"]
+ assert spec["salt"].hex() == k["salt_hex"]
+ assert spec["a"].hex() == k["argon2_hex"]
+ assert spec["m"].hex() == k["master_hex"]
+ assert hashlib.sha256(spec["m"]).hexdigest()[:16] == k["master_fingerprint"]
+ assert spec["node_key"].hex() == k["node_key_hex"]
+ playlist = _hkdf(spec["m"], "meshbay:playlists:v2")
+ assert base64.b64encode(playlist).decode() == k["playlist_key_b64"]
+ assert spec["recovery_key"].hex() == k["recovery_key_hex"]
+
+
+def test_the_specification_seals_the_same_bundles(spec):
+ """MBK3 = magic ‖ pepper version ‖ nonce ‖ AES-GCM(JSON, aad). With the nonce
+ pinned, sealing is deterministic, so every client must write these bytes."""
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+ v = spec["v"]
+ i, b = v["input"], v["bundles"]
+ nonce = bytes.fromhex(i["fixedNonceHex"])
+ plain, aad = b["sealed_json_plaintext"].encode(), b["aad"].encode()
+
+ def seal(key: bytes, version: int) -> str:
+ return base64.b64encode(b"MBK3" + bytes([version]) + nonce
+ + AESGCM(key).encrypt(nonce, plain, aad)).decode()
+
+ assert seal(spec["node_key"], i["pepperVersion"]) == b["fixed_nonce_bundle_b64"]
+ assert seal(spec["recovery_key"], 0) == b["recovery_fixed_nonce_b64"]
+ raw = base64.b64decode(b["legacy_mbk2_b64"]) # TRANSITIONAL: MBK2, no AAD
+ assert AESGCM(spec["a"]).decrypt(raw[4:16], raw[16:], None) == plain
+
+
+def test_the_identity_signs_and_agrees_as_recorded():
+ from cryptography.hazmat.primitives import serialization
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+ from cryptography.hazmat.primitives.asymmetric.x25519 import (
+ X25519PrivateKey,
+ X25519PublicKey,
+ )
+ v = _vectors()
+ i = v["input"]
+ ed = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(i["edSeedHex"]))
+ x = X25519PrivateKey.from_private_bytes(bytes.fromhex(i["xSeedHex"]))
+
+ def raw(k) -> str:
+ return base64.b64encode(k.public_key().public_bytes(
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw)).decode()
+
+ assert raw(ed) == v["identity"]["public"]["pkEdB64"]
+ assert raw(x) == v["identity"]["public"]["pkXB64"]
+ peer = X25519PublicKey.from_public_bytes(bytes.fromhex(i["peerXPubHex"]))
+ assert base64.b64encode(x.exchange(peer)).decode() == v["agreement"]["shared_b64"]
+ for kind, t in v["transcripts"].items():
+ sig = base64.b64encode(ed.sign(bytes.fromhex(t["transcript_hex"]))).decode()
+ assert sig == t["signature_b64"], kind
+
+
+def test_every_signed_kind_and_a_refusal_for_each_check_is_recorded():
+ """A consumer that passes an empty list proves nothing; pin what is there."""
+ v = _vectors()
+ assert set(v["transcripts"]) == {"join", "device_hello", "device_request",
+ "device_add", "device_revoke", "chat", "admin"}
+ labels = {r["label"] for r in v["refusals"]}
+ assert {"another node", "another account", "stale timestamp", "unknown kind",
+ "an op that widens sharing (gone from MNP 6.0)"} <= labels
+ assert all(r["error"].startswith("Refused: ") for r in v["refusals"])
diff --git a/packages/meshbay-hub/tests/test_video_audio_track.py b/packages/meshbay-hub/tests/test_video_audio_track.py
index 82d6e18..6515798 100644
--- a/packages/meshbay-hub/tests/test_video_audio_track.py
+++ b/packages/meshbay-hub/tests/test_video_audio_track.py
@@ -203,8 +203,8 @@ def test_changing_track_restarts_the_stream_where_the_film_already_was(app):
assert "audioTrackRef.current = track.i" in handler
assert "requestSeekRef.current" in handler, \
"a track change must go through the seek path"
- assert "seek(v.currentTime)" in handler, \
- "a track change must resume where the film already was"
+ assert "remoteFilmPosRef.current ?? (v && v.currentTime)" in handler, \
+ "a track change must resume where the film already was (on the television, when casting)"
def test_the_player_believes_the_node_about_which_track_is_playing(app):
diff --git a/packages/meshbay-hub/tests/test_video_seek.py b/packages/meshbay-hub/tests/test_video_seek.py
index 3289eda..3bcdb71 100644
--- a/packages/meshbay-hub/tests/test_video_seek.py
+++ b/packages/meshbay-hub/tests/test_video_seek.py
@@ -320,3 +320,68 @@ def test_the_resume_strings_exist_everywhere(locale):
text = (STATIC / "locales" / f"{locale}.js").read_text(encoding="utf-8")
for key in ("video.resumed_at", "video.from_start"):
assert key in text, f"{locale} is missing {key}"
+
+
+def test_a_seeks_first_segments_are_held_while_it_lands_not_dropped(tmp_path):
+ """What follows a `stream_init` is the new stream, its header first.
+
+ The landing (`reinitAt`/`resumeAt`) waits on the SourceBuffer, and those
+ segments used to arrive in that gap and be dropped as the old film's. The
+ player never noticed — its SourceBuffer kept the header from the start of
+ the film — but a cast relay restarted at that landing was handed a stream
+ beginning at a moof, and the receiver gave up within a second. Measured on
+ a phone: two segments dropped one millisecond after `stream_init`, a relay
+ header of 0 bytes; with them held, a 2 KB header and the film on the TV.
+
+ The real handler and the real drain run here: held while landing, counted
+ once, another file's segment refused, and replayed in arrival order.
+ """
+ import json
+ import subprocess
+
+ if shutil.which("node") is None:
+ pytest.skip("node is not available")
+ app = APP.read_text(encoding="utf-8")
+ start = app.index("transport.onStreamData = async (msg) => {")
+ handler = app[app.index("{", start) + 1:app.index("\n };", start)]
+ drain_at = app.index("land(msg.start || 0).then(async () => {")
+ drain = app[app.index("{", drain_at) + 1:app.index("}).catch(", drain_at)]
+
+ script = tmp_path / "hold.mjs"
+ script.write_text(
+ f"const handlerSrc = {json.dumps(handler)}, drainSrc = {json.dumps(drain)};\n" + """
+const consumed = [];
+const env = {
+ cancelled: false, entry: { id: 'film' },
+ outstandingRef: { current: 8 }, awaitingInitRef: { current: true },
+ heldRef: { current: [] }, holdGenRef: { current: 1 }, hold: 1,
+ consumeSegment: async (m) => { consumed.push(m.segment_index); },
+ console: { log() {} },
+};
+const names = Object.keys(env);
+const handler = new Function(...names, 'msg', `return (async () => {${handlerSrc}})();`);
+const drain = new Function(...names, `return (async () => {${drainSrc}})();`);
+const call = (fn, msg) => fn(...names.map((k) => env[k]), msg);
+
+// Landing: the new stream arrives, plus one stray segment from another file.
+for (const [i, file] of [[0, 'film'], [1, 'film'], [2, 'other'], [3, 'film']]) {
+ await call(handler, { file_id: file, segment_index: i });
+}
+const held = env.heldRef.current.map((m) => m.segment_index);
+const consumedWhileLanding = consumed.length;
+const outstanding = env.outstandingRef.current;
+// The landing completes.
+env.awaitingInitRef.current = false;
+await call(drain);
+console.log(JSON.stringify({ held, consumedWhileLanding, outstanding, consumed,
+ heldAfter: env.heldRef.current }));
+""", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True,
+ encoding="utf-8", timeout=60)
+ assert proc.returncode == 0, proc.stderr
+ out = json.loads(proc.stdout)
+ assert out["held"] == [0, 1, 3], "the new stream's segments were dropped or mixed"
+ assert out["consumedWhileLanding"] == 0, "a segment was taken before the landing finished"
+ assert out["outstanding"] == 4, "each arrival is counted once, held or not"
+ assert out["consumed"] == [0, 1, 3], "the replay must keep arrival order, header first"
+ assert out["heldAfter"] is None, "holding must stop once the landing has drained"
diff --git a/packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js b/packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js
new file mode 100644
index 0000000..055070f
--- /dev/null
+++ b/packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js
@@ -0,0 +1,232 @@
+// Golden vectors for the keypair bundle and the transcripts, produced by the
+// code the desktop application ships: meshbay-client's keyring.js and
+// transcripts.js, with the vendored Argon2 the page also runs.
+//
+// node gen_keyring_vectors.js > keyring.json
+//
+// Every implementation of the bundle format and of the transcripts — the page,
+// the desktop keyring, the Python reference, the Android keyring — must
+// reproduce this file (test_keyring_vectors.py, and the Android unit tests).
+// It is regenerated deliberately, for a format change, never by a test. The
+// output is deterministic: nonces and the clock are pinned.
+
+'use strict';
+
+const path = require('node:path');
+const crypto = require('node:crypto');
+
+const REPO = path.resolve(__dirname, '..', '..', '..', '..');
+const CLIENT = path.join(REPO, 'packages/meshbay-client/src');
+const VENDOR = path.join(REPO, 'packages/meshbay-hub/src/meshbay_hub/static/vendor');
+
+const { createKeyring } = require(path.join(CLIENT, 'keyring.js'));
+const { transcriptFor } = require(path.join(CLIENT, 'transcripts.js'));
+const { wasmArgon2 } = require(path.join(CLIENT, 'argon2-wasm.js'));
+
+const b64 = (b) => Buffer.from(b).toString('base64');
+const hex = (b) => Buffer.from(b).toString('hex');
+const hkdf = (ikm, info) => Buffer.from(
+ crypto.hkdfSync('sha256', ikm, Buffer.alloc(0), Buffer.from(info), 32));
+
+// Fixed inputs. Invented values only.
+const NOW = 1790000000; // a fixed "now" for transcript timestamps
+const INPUT = {
+ username: 'vector-user',
+ userId: '0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0',
+ password: 'a passphrase used only for vectors',
+ pepperB64: b64(Buffer.alloc(32, 7)),
+ pepperVersion: 3,
+ nodePk: b64(Buffer.alloc(32, 0x11)),
+ otherNodePk: b64(Buffer.alloc(32, 0x22)),
+ groupId: 'grp_vector-01',
+ mnemonic: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
+ edSeedHex: '9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60',
+ xSeedHex: '77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a',
+ peerXPubHex: 'de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f',
+ fixedNonceHex: '000102030405060708090a0b',
+ legacyNonceHex: '0b0a09080706050403020100',
+ now: NOW,
+};
+
+const pkcs8 = (prefixHex, seedHex) =>
+ Buffer.concat([Buffer.from(prefixHex, 'hex'), Buffer.from(seedHex, 'hex')]);
+const ED_PKCS8 = pkcs8('302e020100300506032b657004220420', INPUT.edSeedHex);
+const X_PKCS8 = pkcs8('302e020100300506032b656e04220420', INPUT.xSeedHex);
+
+function withFixedRandom(bytesHex, fn) {
+ const real = crypto.randomBytes;
+ crypto.randomBytes = (n) => {
+ const b = Buffer.from(bytesHex, 'hex');
+ if (b.length !== n) throw new Error(`fixed random of ${b.length}, asked ${n}`);
+ return b;
+ };
+ try { return fn(); } finally { crypto.randomBytes = real; }
+}
+
+function withNow(seconds, fn) {
+ const real = Date.now;
+ Date.now = () => seconds * 1000;
+ try { return fn(); } finally { Date.now = real; }
+}
+
+(async () => {
+ const argon2 = wasmArgon2(VENDOR);
+ let store = {};
+ const ring = createKeyring({
+ argon2,
+ load: () => JSON.parse(JSON.stringify(store)),
+ save: (o) => { store = JSON.parse(JSON.stringify(o)); },
+ });
+
+ // 1. KDF chain.
+ const salt = crypto.createHash('sha256')
+ .update(`meshbay:bundle:v2:${INPUT.username}`).digest().subarray(0, 16);
+ const ARGON2 = { memory: 131072, passes: 3, parallelism: 1, tagLength: 32 };
+ await ring.deriveSession({
+ password: INPUT.password, username: INPUT.username, userId: INPUT.userId,
+ pepperB64: INPUT.pepperB64, pepperVersion: INPUT.pepperVersion,
+ });
+ const m = Buffer.from(store.masters[INPUT.userId].m, 'base64');
+ const a = Buffer.from(store.masters[INPUT.userId].legacy, 'base64');
+ const kdf = {
+ argon2_params: ARGON2,
+ salt_hex: hex(salt),
+ argon2_hex: hex(a),
+ master_hex: hex(m),
+ master_fingerprint: ring.currentFingerprint(INPUT.userId),
+ node_key_hex: hex(hkdf(m, `meshbay:bundle:v3|node|${INPUT.nodePk}`)),
+ playlist_key_b64: ring.playlistKey(INPUT.userId),
+ recovery_key_hex: null, // filled below
+ };
+
+ // 2. A fixed identity, placed in the store as mint() would leave it.
+ store.identities[INPUT.userId] = {
+ [INPUT.nodePk]: { ed: b64(ED_PKCS8), x: b64(X_PKCS8), sealedWith: null },
+ };
+ const identity = {
+ ed_pkcs8_b64: b64(ED_PKCS8),
+ x_pkcs8_b64: b64(X_PKCS8),
+ public: ring.identity(INPUT.userId, INPUT.nodePk),
+ };
+
+ // 3. Bundles.
+ const fixed = withFixedRandom(INPUT.fixedNonceHex,
+ () => ring.sealBundle(INPUT.userId, INPUT.nodePk));
+ const recovery = withFixedRandom(INPUT.fixedNonceHex,
+ () => ring.sealRecovery(INPUT.userId, INPUT.nodePk, INPUT.mnemonic, INPUT.username));
+
+ // The recovery key, re-derived the way keyring.js does (fromMnemonic + hkdf).
+ const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+ let bits = 0; let value = 0; const raw = [];
+ for (const ch of INPUT.mnemonic.replace(/[^A-Za-z2-7]/g, '').toUpperCase()) {
+ value = (value << 5) | B32.indexOf(ch); bits += 5;
+ if (bits >= 8) { raw.push((value >>> (bits - 8)) & 0xff); bits -= 8; }
+ }
+ kdf.mnemonic_bytes_hex = hex(Buffer.from(raw.slice(0, 32)));
+ kdf.recovery_key_hex = hex(hkdf(Buffer.from(raw.slice(0, 32)),
+ `meshbay:recovery:v1:${INPUT.username}`));
+
+ // MBK2 (TRANSITIONAL): "MBK2" ‖ nonce ‖ AES-GCM(JSON) under the Argon2 key, no AAD.
+ const legacyNonce = Buffer.from(INPUT.legacyNonceHex, 'hex');
+ const c = crypto.createCipheriv('aes-256-gcm', a, legacyNonce);
+ const plain = JSON.stringify({ skEd: b64(ED_PKCS8), skX: b64(X_PKCS8) });
+ const legacy = b64(Buffer.concat([
+ Buffer.from('MBK2'), legacyNonce, c.update(plain), c.final(), c.getAuthTag()]));
+
+ // Each must open through the shipped keyring, into a fresh store.
+ const check = async (label, bundleEnc, extra = {}) => {
+ let s2 = {};
+ const r2 = createKeyring({ argon2, load: () => JSON.parse(JSON.stringify(s2)),
+ save: (o) => { s2 = JSON.parse(JSON.stringify(o)); } });
+ await r2.deriveSession({ password: INPUT.password, username: INPUT.username,
+ userId: INPUT.userId, pepperB64: INPUT.pepperB64, pepperVersion: INPUT.pepperVersion });
+ const pub = r2.openBundle(INPUT.userId, INPUT.nodePk, { bundleEnc, ...extra });
+ if (pub.pkEdB64 !== identity.public.pkEdB64 || pub.pkXB64 !== identity.public.pkXB64) {
+ throw new Error(`${label} opened to another identity`);
+ }
+ return true;
+ };
+ await check('fixed', fixed.bundle);
+ await check('legacy', legacy);
+ // The recovery copy, through the fallback: a passphrase copy that does not open.
+ await check('recovery', b64(Buffer.concat([Buffer.from('MBK3'), Buffer.alloc(30, 1)])), {
+ recoveryEnc: recovery, recoveryMnemonic: INPUT.mnemonic, username: INPUT.username });
+
+ const bundles = {
+ sealed_json_plaintext: plain,
+ aad: `meshbay:bundle:v3|${INPUT.userId}|${INPUT.nodePk}`,
+ fixed_nonce_bundle_b64: fixed.bundle,
+ fixed_nonce_fingerprint: fixed.fingerprint,
+ recovery_fixed_nonce_b64: recovery,
+ legacy_mbk2_b64: legacy,
+ };
+
+ // 4. Agreement.
+ const agreement = {
+ peer_x_pub_b64: b64(Buffer.from(INPUT.peerXPubHex, 'hex')),
+ shared_b64: ring.shared(INPUT.userId, INPUT.nodePk, b64(Buffer.from(INPUT.peerXPubHex, 'hex'))),
+ };
+
+ // 5. Transcripts: every kind, then refusals.
+ const ctx = { userId: INPUT.userId, nodePk: INPUT.nodePk, ...identity.public };
+ delete ctx.sealedWith;
+ const nonceNode = b64(Buffer.alloc(32, 0x33));
+ const kinds = {
+ join: { nodePk: INPUT.nodePk, groupId: INPUT.groupId, userId: INPUT.userId, nonceNode, ts: NOW },
+ device_hello: { nodePk: INPUT.nodePk, groupId: INPUT.groupId, userId: INPUT.userId, nonceNode, ts: NOW - 30 },
+ device_request: { nodePk: INPUT.nodePk, userId: INPUT.userId,
+ codeHash: 'ab'.repeat(32), nonceNode, ts: NOW + 30 },
+ device_add: { nodePk: INPUT.nodePk, userId: INPUT.userId,
+ pkEd: b64(Buffer.alloc(32, 0x44)), pkX: b64(Buffer.alloc(32, 0x55)), nonceNode, ts: NOW },
+ device_revoke: { nodePk: INPUT.nodePk, userId: INPUT.userId,
+ pkEd: b64(Buffer.alloc(32, 0x44)), nonceNode, ts: NOW },
+ chat: { groupId: INPUT.groupId, epoch: 4, nonce: b64(Buffer.alloc(24, 0x66)),
+ ct: b64(Buffer.from('ciphertext of a chat line, opaque here')) },
+ admin: { op: 'apps_enabled', nodePk: INPUT.nodePk, groupId: INPUT.groupId,
+ subject: '{"apps":["chat","videos"],"note":"é ü 漢"}',
+ nonce: b64(Buffer.alloc(16, 0x77)), ts: NOW },
+ };
+ const transcripts = {};
+ for (const [kind, fields] of Object.entries(kinds)) {
+ const bytes = withNow(NOW, () => transcriptFor(kind, fields, ctx));
+ const sig = withNow(NOW, () => ring.signAs(INPUT.userId, INPUT.nodePk, kind, fields));
+ transcripts[kind] = { fields, transcript_hex: hex(bytes), signature_b64: sig };
+ }
+
+ const refusals = [];
+ const refuse = (label, kind, fields) => {
+ try {
+ withNow(NOW, () => transcriptFor(kind, fields, ctx));
+ throw new Error(`vector "${label}" was NOT refused by transcripts.js`);
+ } catch (e) {
+ if (/NOT refused/.test(e.message)) throw e;
+ refusals.push({ label, kind, fields, error: e.message });
+ }
+ };
+ refuse('another node', 'join', { ...kinds.join, nodePk: INPUT.otherNodePk });
+ refuse('another account', 'join', { ...kinds.join, userId: '00000000-0000-4000-8000-000000000000' });
+ refuse('stale timestamp', 'join', { ...kinds.join, ts: NOW - 601 });
+ refuse('future timestamp', 'device_hello', { ...kinds.device_hello, ts: NOW + 601 });
+ refuse('fractional timestamp', 'join', { ...kinds.join, ts: NOW + 0.5 });
+ refuse('bad group id', 'join', { ...kinds.join, groupId: 'a/b' });
+ refuse('short node nonce', 'join', { ...kinds.join, nonceNode: b64(Buffer.alloc(15)) });
+ refuse('not base64', 'join', { ...kinds.join, nonceNode: 'not*base64' });
+ refuse('bad request hash', 'device_request', { ...kinds.device_request, codeHash: 'AB'.repeat(32) });
+ refuse('device key wrong length', 'device_add', { ...kinds.device_add, pkEd: b64(Buffer.alloc(31)) });
+ refuse('negative epoch', 'chat', { ...kinds.chat, epoch: -1 });
+ refuse('chat nonce too short', 'chat', { ...kinds.chat, nonce: b64(Buffer.alloc(11)) });
+ refuse('bad op', 'admin', { ...kinds.admin, op: 'Drop-Table' });
+ refuse('an op that widens sharing (gone from MNP 6.0)', 'admin', { ...kinds.admin, op: 'root_add' });
+ refuse('a well-formed op not on the list', 'admin', { ...kinds.admin, op: 'set_app_setting' });
+ refuse('subject too long', 'admin', { ...kinds.admin, subject: 'x'.repeat(16385) });
+ refuse('unknown kind', 'sign_anything', { bytes: 'AAAA' });
+
+ const out = {
+ _about: 'Generated by gen_keyring_vectors.js from meshbay-client keyring.js and '
+ + 'transcripts.js with the vendored Argon2. Every implementation of the bundle '
+ + 'format and the transcripts must reproduce every field.',
+ input: INPUT,
+ kdf, identity, bundles, agreement, transcripts, refusals,
+ };
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
+})().catch((e) => { console.error(e); process.exit(1); });
diff --git a/packages/meshbay-hub/tests/vectors/keyring.json b/packages/meshbay-hub/tests/vectors/keyring.json
new file mode 100644
index 0000000..84ecff0
--- /dev/null
+++ b/packages/meshbay-hub/tests/vectors/keyring.json
@@ -0,0 +1,342 @@
+{
+ "_about": "Generated by gen_keyring_vectors.js from meshbay-client keyring.js and transcripts.js with the vendored Argon2. Every implementation of the bundle format and the transcripts must reproduce every field.",
+ "input": {
+ "username": "vector-user",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "password": "a passphrase used only for vectors",
+ "pepperB64": "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
+ "pepperVersion": 3,
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "otherNodePk": "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=",
+ "groupId": "grp_vector-01",
+ "mnemonic": "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",
+ "edSeedHex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
+ "xSeedHex": "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a",
+ "peerXPubHex": "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f",
+ "fixedNonceHex": "000102030405060708090a0b",
+ "legacyNonceHex": "0b0a09080706050403020100",
+ "now": 1790000000
+ },
+ "kdf": {
+ "argon2_params": {
+ "memory": 131072,
+ "passes": 3,
+ "parallelism": 1,
+ "tagLength": 32
+ },
+ "salt_hex": "2244e8b97822de2b9e210e22301700ff",
+ "argon2_hex": "95e8531f721421b13bba6e6585de932654d26e5428793f8734b4e7da74b14a7c",
+ "master_hex": "ecb972b6454304630cecffe115a9f679905ce98457c741f7d534f575322b1cef",
+ "master_fingerprint": "292fe17f616a1ef6",
+ "node_key_hex": "948aa161c470cd16e944cbc1dfc1a375a76a17761ad80014423d64cf2401bd2f",
+ "playlist_key_b64": "Gyd4KTER2IS4dHieFp9DkiHExSP3hjLT/SMXF0TBUno=",
+ "recovery_key_hex": "612b9cf5cc507ba1483555d7748dc7e20734374994baa092fca605df3600a8c3",
+ "mnemonic_bytes_hex": "00443214c74254b635cf84653a56d7c675be77df00443214c74254b635cf8465"
+ },
+ "identity": {
+ "ed_pkcs8_b64": "MC4CAQAwBQYDK2VwBCIEIJ1hsZ3v/VpguoRK9JLsLMREScVpezJpGXA7rAMcrn9g",
+ "x_pkcs8_b64": "MC4CAQAwBQYDK2VuBCIEIHcHbQpzGKV9PBbBclGyZkXfTC+H68CZKrF3+6UduSwq",
+ "public": {
+ "pkEdB64": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
+ "pkXB64": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=",
+ "sealedWith": null
+ }
+ },
+ "bundles": {
+ "sealed_json_plaintext": "{\"skEd\":\"MC4CAQAwBQYDK2VwBCIEIJ1hsZ3v/VpguoRK9JLsLMREScVpezJpGXA7rAMcrn9g\",\"skX\":\"MC4CAQAwBQYDK2VuBCIEIHcHbQpzGKV9PBbBclGyZkXfTC+H68CZKrF3+6UduSwq\"}",
+ "aad": "meshbay:bundle:v3|0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0|ERERERERERERERERERERERERERERERERERERERERERE=",
+ "fixed_nonce_bundle_b64": "TUJLMwMAAQIDBAUGBwgJCgu/0e64MEzT13AuLRE9rV3gw4cF1jPwKvIl8h0OsNnW1UsbNLdQcWg+SVVgNSOfR2SneoWBbieI1Gypb/9osJ7gkWHfOcvlMqa0AdjoS3ww/Tf0/hL/LLB5B04w1oujnfsvhCL6zaPZtjyPZ5PUc6Ks7AqXmJZtuqSN33qEjZoQH9xQKNb6LUVJrWTjSUl2NZ02Y1mIVOd6Y9Af421u/vn41FIxwg==",
+ "fixed_nonce_fingerprint": "292fe17f616a1ef6",
+ "recovery_fixed_nonce_b64": "TUJLMwAAAQIDBAUGBwgJCgvqVgf86f7WSRXeERiv5CqFqgsFVR5vXLcYOKVOWblJErRq4D3pWWM1UMSyWOEjv0Q88dukHmm/ncpvUV24rtrBwQ3a2o0O3Zu4QQxKispflVoCuYIakbwh8dSF5Qh7Pgdat2TpWO0/OekZpvXv6kUdiMFviB1qKSkzE0od+qgdh0Wokqb5cvD9Mxr5CQkrNlMVkGs+O73ITEIUkDlDHNNSr+2GMg==",
+ "legacy_mbk2_b64": "TUJLMgsKCQgHBgUEAwIBAOAz0yDaxedAe3ervmsyggv0fyDeHyTeMdfuBhO3mEHtfub86kZFyk6RG+SUuZHd2uReHxdA+6sZhexlTb32eK7Fg2MfsAhXlVdO6p0JS+LT2Dx4IV8KV7f8En1n5RE7ppy2QtMjqKzi56nzY0uihtNDmgnaQgas4anOMFJDzONfR6ek8f5DQWAKxDc/AKb8jf+DnhOY2F3J5NNzl4swIXe60FND"
+ },
+ "agreement": {
+ "peer_x_pub_b64": "3p7bfXt9wbTTW2HC7OQ1Nz+DQ8hbeGdNrfx+FG+IK08=",
+ "shared_b64": "Sl2dW6TOLeFyjjv0gDUPJeB+IclH0Z4zdvCbPB4WF0I="
+ },
+ "transcripts": {
+ "join": {
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "grp_vector-01",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=",
+ "ts": 1790000000
+ },
+ "transcript_hex": "6d6573686261793a6a6f696e3a76310000002c455245524552455245524552455245524552455245524552455245524552455245524552455245524552453d0000000d6772705f766563746f722d30310000002430663165326433632d346235612d343936382d383737362d6135623463336432653166300000002c3131715941594b7843726656532f3754795751484f6737686376506170694d6c727749616150634855526f3d0000002c6853447743596b777031523069333363744437335767322f4f67306d4f427230363653706a717162546d6f3d0000002033333333333333333333333333333333333333333333333333333333333333330000000a31373930303030303030",
+ "signature_b64": "PjmA9stj7pqTWdaHGgNQjiouiC3V6YktCa7ebXy7aZVUIeeoKT5nf7hqhkkNV0hUUvYZoWsu9rD14TwVZI6pBw=="
+ },
+ "device_hello": {
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "grp_vector-01",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=",
+ "ts": 1789999970
+ },
+ "transcript_hex": "6d6573686261793a6465766963655f68656c6c6f3a76310000002c455245524552455245524552455245524552455245524552455245524552455245524552455245524552453d0000000d6772705f766563746f722d30310000002430663165326433632d346235612d343936382d383737362d6135623463336432653166300000002c3131715941594b7843726656532f3754795751484f6737686376506170694d6c727749616150634855526f3d0000002033333333333333333333333333333333333333333333333333333333333333330000000a31373839393939393730",
+ "signature_b64": "IVOAHoLco3TvqaRdN4lvv8YGmE4PQu54njs23luu/j51vOdXmkbAVS9HYBrSmPhVPxc9UW5B/KY9vdzHYnMZBg=="
+ },
+ "device_request": {
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "codeHash": "abababababababababababababababababababababababababababababababab",
+ "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=",
+ "ts": 1790000030
+ },
+ "transcript_hex": "6d6573686261793a6465766963655f7265713a76310000002c455245524552455245524552455245524552455245524552455245524552455245524552455245524552453d0000002430663165326433632d346235612d343936382d383737362d6135623463336432653166300000002c3131715941594b7843726656532f3754795751484f6737686376506170694d6c727749616150634855526f3d0000002c6853447743596b777031523069333363744437335767322f4f67306d4f427230363653706a717162546d6f3d00000040616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261620000002033333333333333333333333333333333333333333333333333333333333333330000000a31373930303030303330",
+ "signature_b64": "wi1DvdWqKYSvrmweN8U8E/IGe5akrEO1nXClVjBoKsr2geksMrxnOrkAFSO2Ecf7js4hT2OxoYgVBzjiRdV0AA=="
+ },
+ "device_add": {
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "pkEd": "REREREREREREREREREREREREREREREREREREREREREQ=",
+ "pkX": "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=",
+ "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=",
+ "ts": 1790000000
+ },
+ "transcript_hex": "6d6573686261793a6465766963655f6164643a76310000002c455245524552455245524552455245524552455245524552455245524552455245524552455245524552453d0000002430663165326433632d346235612d343936382d383737362d6135623463336432653166300000002c524552455245524552455245524552455245524552455245524552455245524552455245524552455245513d0000002c565656565656565656565656565656565656565656565656565656565656565656565656565656565656553d0000002033333333333333333333333333333333333333333333333333333333333333330000000a31373930303030303030",
+ "signature_b64": "D+tRe/2fbscnA3wdhHsnEfUCu0U/JszfIhFvYC8lEy8AqiD7osn3GWotQIMGSO3FoQz62WJHZsAdUaelgQJUCg=="
+ },
+ "device_revoke": {
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "pkEd": "REREREREREREREREREREREREREREREREREREREREREQ=",
+ "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=",
+ "ts": 1790000000
+ },
+ "transcript_hex": "6d6573686261793a6465766963655f7265766f6b653a76310000002c455245524552455245524552455245524552455245524552455245524552455245524552455245524552453d0000002430663165326433632d346235612d343936382d383737362d6135623463336432653166300000002c524552455245524552455245524552455245524552455245524552455245524552455245524552455245513d0000002033333333333333333333333333333333333333333333333333333333333333330000000a31373930303030303030",
+ "signature_b64": "NmVrQU3Fb0rms+wYQI9TIdc7Ry0H/G9CLU5stNNnGe6/WU29bSU8V81FXk6ze5dOfi0ezz4YmWrem7cRAR5MBg=="
+ },
+ "chat": {
+ "fields": {
+ "groupId": "grp_vector-01",
+ "epoch": 4,
+ "nonce": "ZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZm",
+ "ct": "Y2lwaGVydGV4dCBvZiBhIGNoYXQgbGluZSwgb3BhcXVlIGhlcmU="
+ },
+ "transcript_hex": "6d6573686261793a636861743a76310000000d6772705f766563746f722d3031000000013400000020d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a000000186666666666666666666666666666666666666666666666660000002663697068657274657874206f6620612063686174206c696e652c206f70617175652068657265",
+ "signature_b64": "Ogv5d2lhr0ABJacfp0XEbUH7jO8lIplbCZLj1jL5bt8kHyPvKpRDruZkCg+vo9sOFWjMuAlIzQALuS6zE62JBA=="
+ },
+ "admin": {
+ "fields": {
+ "op": "apps_enabled",
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "grp_vector-01",
+ "subject": "{\"apps\":[\"chat\",\"videos\"],\"note\":\"é ü 漢\"}",
+ "nonce": "d3d3d3d3d3d3d3d3d3d3dw==",
+ "ts": 1790000000
+ },
+ "transcript_hex": "6d6573686261793a61646d696e3a76310000000c617070735f656e61626c65640000002c455245524552455245524552455245524552455245524552455245524552455245524552455245524552453d0000000d6772705f766563746f722d30310000002d7b2261707073223a5b2263686174222c22766964656f73225d2c226e6f7465223a22c3a920c3bc20e6bca2227d00000010777777777777777777777777777777770000000a31373930303030303030",
+ "signature_b64": "ocx6tu6SKu3U8mEQUWhNNIZieGDfYquLdtBfez0vqb2EkfFzPfD1yraP3OhlvZYTIZfnWfzJ1R9y/sRN6vZgAg=="
+ }
+ },
+ "refusals": [
+ {
+ "label": "another node",
+ "kind": "join",
+ "fields": {
+ "nodePk": "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=",
+ "groupId": "grp_vector-01",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=",
+ "ts": 1790000000
+ },
+ "error": "Refused: another node"
+ },
+ {
+ "label": "another account",
+ "kind": "join",
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "grp_vector-01",
+ "userId": "00000000-0000-4000-8000-000000000000",
+ "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=",
+ "ts": 1790000000
+ },
+ "error": "Refused: another account"
+ },
+ {
+ "label": "stale timestamp",
+ "kind": "join",
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "grp_vector-01",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=",
+ "ts": 1789999399
+ },
+ "error": "Refused: the timestamp is not now"
+ },
+ {
+ "label": "future timestamp",
+ "kind": "device_hello",
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "grp_vector-01",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=",
+ "ts": 1790000601
+ },
+ "error": "Refused: the timestamp is not now"
+ },
+ {
+ "label": "fractional timestamp",
+ "kind": "join",
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "grp_vector-01",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=",
+ "ts": 1790000000.5
+ },
+ "error": "Refused: the timestamp is not now"
+ },
+ {
+ "label": "bad group id",
+ "kind": "join",
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "a/b",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=",
+ "ts": 1790000000
+ },
+ "error": "Refused: not a group id"
+ },
+ {
+ "label": "short node nonce",
+ "kind": "join",
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "grp_vector-01",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "nonceNode": "AAAAAAAAAAAAAAAAAAAA",
+ "ts": 1790000000
+ },
+ "error": "Refused: the node nonce has the wrong length"
+ },
+ {
+ "label": "not base64",
+ "kind": "join",
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "grp_vector-01",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "nonceNode": "not*base64",
+ "ts": 1790000000
+ },
+ "error": "Refused: the node nonce is not base64"
+ },
+ {
+ "label": "bad request hash",
+ "kind": "device_request",
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "codeHash": "ABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABAB",
+ "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=",
+ "ts": 1790000030
+ },
+ "error": "Refused: not a request hash"
+ },
+ {
+ "label": "device key wrong length",
+ "kind": "device_add",
+ "fields": {
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
+ "pkEd": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
+ "pkX": "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=",
+ "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=",
+ "ts": 1790000000
+ },
+ "error": "Refused: the device key has the wrong length"
+ },
+ {
+ "label": "negative epoch",
+ "kind": "chat",
+ "fields": {
+ "groupId": "grp_vector-01",
+ "epoch": -1,
+ "nonce": "ZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZm",
+ "ct": "Y2lwaGVydGV4dCBvZiBhIGNoYXQgbGluZSwgb3BhcXVlIGhlcmU="
+ },
+ "error": "Refused: not an epoch"
+ },
+ {
+ "label": "chat nonce too short",
+ "kind": "chat",
+ "fields": {
+ "groupId": "grp_vector-01",
+ "epoch": 4,
+ "nonce": "AAAAAAAAAAAAAAA=",
+ "ct": "Y2lwaGVydGV4dCBvZiBhIGNoYXQgbGluZSwgb3BhcXVlIGhlcmU="
+ },
+ "error": "Refused: the message nonce has the wrong length"
+ },
+ {
+ "label": "bad op",
+ "kind": "admin",
+ "fields": {
+ "op": "Drop-Table",
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "grp_vector-01",
+ "subject": "{\"apps\":[\"chat\",\"videos\"],\"note\":\"é ü 漢\"}",
+ "nonce": "d3d3d3d3d3d3d3d3d3d3dw==",
+ "ts": 1790000000
+ },
+ "error": "Refused: not an operation"
+ },
+ {
+ "label": "an op that widens sharing (gone from MNP 6.0)",
+ "kind": "admin",
+ "fields": {
+ "op": "root_add",
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "grp_vector-01",
+ "subject": "{\"apps\":[\"chat\",\"videos\"],\"note\":\"é ü 漢\"}",
+ "nonce": "d3d3d3d3d3d3d3d3d3d3dw==",
+ "ts": 1790000000
+ },
+ "error": "Refused: not an operation"
+ },
+ {
+ "label": "a well-formed op not on the list",
+ "kind": "admin",
+ "fields": {
+ "op": "set_app_setting",
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "grp_vector-01",
+ "subject": "{\"apps\":[\"chat\",\"videos\"],\"note\":\"é ü 漢\"}",
+ "nonce": "d3d3d3d3d3d3d3d3d3d3dw==",
+ "ts": 1790000000
+ },
+ "error": "Refused: not an operation"
+ },
+ {
+ "label": "subject too long",
+ "kind": "admin",
+ "fields": {
+ "op": "apps_enabled",
+ "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=",
+ "groupId": "grp_vector-01",
+ "subject": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ "nonce": "d3d3d3d3d3d3d3d3d3d3dw==",
+ "ts": 1790000000
+ },
+ "error": "Refused: the subject is too long"
+ },
+ {
+ "label": "unknown kind",
+ "kind": "sign_anything",
+ "fields": {
+ "bytes": "AAAA"
+ },
+ "error": "Refused: nothing is signed as \"sign_anything\""
+ }
+ ]
+}
diff --git a/packages/meshbay-node/pyproject.toml b/packages/meshbay-node/pyproject.toml
index ce1f718..e505048 100644
--- a/packages/meshbay-node/pyproject.toml
+++ b/packages/meshbay-node/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "meshbay-node"
-version = "0.17.0"
+version = "0.18.0"
description = "MeshBay Node — local file host, streaming server, and group daemon"
requires-python = ">=3.12"
dependencies = [
diff --git a/packages/meshbay-node/src/meshbay_node/__init__.py b/packages/meshbay-node/src/meshbay_node/__init__.py
index c3c18ff..c58bc8a 100644
--- a/packages/meshbay-node/src/meshbay_node/__init__.py
+++ b/packages/meshbay-node/src/meshbay_node/__init__.py
@@ -1,3 +1,3 @@
"""MeshBay Node — local file host, streaming server, and group daemon."""
-__version__ = "0.17.0"
+__version__ = "0.18.0"
diff --git a/packages/meshbay-node/tests/test_attach_from_the_hub.py b/packages/meshbay-node/tests/test_attach_from_the_hub.py
index f4aaa25..acc9d18 100644
--- a/packages/meshbay-node/tests/test_attach_from_the_hub.py
+++ b/packages/meshbay-node/tests/test_attach_from_the_hub.py
@@ -10,6 +10,7 @@ group name chosen on the hub, a folder name — so none of it may end a TOML
string and write lines of its own.
"""
+import sys
import tomllib
from pathlib import Path
@@ -79,6 +80,9 @@ async def test_a_group_name_cannot_write_lines_into_node_toml(tmp_path):
assert hosted["node"]["ui_port"] == 18000
+@pytest.mark.skipif(sys.platform == "win32",
+ reason="Windows refuses a quote or a newline in a folder name, and a "
+ "path is written with `/`: there is no such folder to write")
async def test_a_folder_name_cannot_either(tmp_path):
state = _state(tmp_path, {"id": GID, "name": "Films"})
await ops.attach_group(state, "Films", str(tmp_path / "share"))
diff --git a/packages/meshbay-node/tests/test_node_status.py b/packages/meshbay-node/tests/test_node_status.py
index 075d11f..45a9710 100644
--- a/packages/meshbay-node/tests/test_node_status.py
+++ b/packages/meshbay-node/tests/test_node_status.py
@@ -192,7 +192,7 @@ async def test_add_root_creates_directory_and_returns_info(tmp_path):
conf = tmp_path / "node.toml"
conf.write_text(f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
- f' [[groups.roots]]\n path = "{shared}"\n', encoding="utf-8")
+ f' [[groups.roots]]\n path = "{shared.as_posix()}"\n', encoding="utf-8")
node_cfg = NodeConfig.__new__(NodeConfig)
node_cfg.groups = [cfg]
@@ -234,7 +234,7 @@ async def test_remove_root_requires_at_least_one_remaining(tmp_path):
conf = tmp_path / "node.toml"
conf.write_text(f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
- f' [[groups.roots]]\n path = "{shared}"\n', encoding="utf-8")
+ f' [[groups.roots]]\n path = "{shared.as_posix()}"\n', encoding="utf-8")
index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
roots = one_root(shared)
state = {
@@ -269,8 +269,9 @@ async def test_removing_a_writable_root_is_allowed(tmp_path):
conf = tmp_path / "node.toml"
conf.write_text(
f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
- f' [[groups.roots]]\n path = "{d1}"\n name = "incoming"\n writable = true\n\n'
- f' [[groups.roots]]\n path = "{d2}"\n name = "shared"\n', encoding="utf-8")
+ f' [[groups.roots]]\n path = "{d1.as_posix()}"\n name = "incoming"\n'
+ ' writable = true\n\n'
+ f' [[groups.roots]]\n path = "{d2.as_posix()}"\n name = "shared"\n', encoding="utf-8")
roots = RootSet.build([asdict(r) for r in cfg.roots])
index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
state = {
@@ -306,7 +307,7 @@ async def test_update_root_rewrites_the_flags_in_node_toml(tmp_path):
f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
f' [[groups.roots]]\n'
f' # the operator explained this one to themselves\n'
- f' path = "{d1}"\n name = "media"\n', encoding="utf-8")
+ f' path = "{d1.as_posix()}"\n name = "media"\n', encoding="utf-8")
roots = RootSet.build([asdict(r) for r in cfg.roots])
index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
state = {
@@ -355,7 +356,7 @@ async def test_update_root_replaces_a_legacy_upload_line(tmp_path):
conf = tmp_path / "node.toml"
conf.write_text(
f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
- f' [[groups.roots]]\n path = "{d1}"\n name = "media"\n'
+ f' [[groups.roots]]\n path = "{d1.as_posix()}"\n name = "media"\n'
f' upload = true\n', encoding="utf-8")
roots = RootSet.build([asdict(r) for r in cfg.roots])
index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
@@ -388,8 +389,8 @@ async def test_remove_root_succeeds_with_two_roots(tmp_path):
conf = tmp_path / "node.toml"
conf.write_text(
f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
- f' [[groups.roots]]\n path = "{d1}"\n name = "dir1"\n\n'
- f' [[groups.roots]]\n path = "{d2}"\n name = "dir2"\n', encoding="utf-8")
+ f' [[groups.roots]]\n path = "{d1.as_posix()}"\n name = "dir1"\n\n'
+ f' [[groups.roots]]\n path = "{d2.as_posix()}"\n name = "dir2"\n', encoding="utf-8")
roots = RootSet.build([asdict(r) for r in cfg.roots])
index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
state = {
diff --git a/packaging/deb/meshbay-hub/DEBIAN/prerm b/packaging/deb/meshbay-hub/DEBIAN/prerm
new file mode 100755
index 0000000..359e0a5
--- /dev/null
+++ b/packaging/deb/meshbay-hub/DEBIAN/prerm
@@ -0,0 +1,24 @@
+#!/bin/sh
+set -e
+
+# A hub left running once its package is gone serves from deleted code, and
+# keeps writing bytecode into the shared venv.
+case "$1" in
+ remove|deconfigure)
+ if [ -d /run/systemd/system ]; then
+ systemctl stop meshbay-hub.service 2>/dev/null || true
+ fi
+ ;;
+esac
+
+# The hub's code lives in meshbay-common's venv, and meshbay-common's own
+# cleanup runs only after this package is gone: the bytecode compiled next to
+# it is removed here, or dpkg cannot remove the directories it sits in.
+case "$1" in
+ remove|upgrade|deconfigure)
+ find /opt/meshbay-common/venv/lib/python3.*/site-packages/meshbay_hub \
+ -depth -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
+ ;;
+esac
+
+#DEBHELPER#
diff --git a/packaging/deb/meshbay-node/DEBIAN/prerm b/packaging/deb/meshbay-node/DEBIAN/prerm
new file mode 100755
index 0000000..6ccdead
--- /dev/null
+++ b/packaging/deb/meshbay-node/DEBIAN/prerm
@@ -0,0 +1,36 @@
+#!/bin/sh
+set -e
+
+# A node left running once its package is gone serves from deleted code, and
+# keeps writing bytecode into the shared venv. Every instance stops here: the
+# system ones, and the user service in each running user manager. Not on
+# upgrade: the postinst restarts the node onto new code.
+stop_user_nodes() {
+ for bus in /run/user/*/systemd/private; do
+ [ -S "$bus" ] || continue
+ uid=$(basename "$(dirname "$(dirname "$bus")")")
+ user=$(id -nu "$uid" 2>/dev/null) || continue
+ systemctl --user -M "$user@" stop meshbay-node.service 2>/dev/null || true
+ done
+}
+
+case "$1" in
+ remove|deconfigure)
+ if [ -d /run/systemd/system ]; then
+ systemctl stop 'meshbay-node@*.service' 2>/dev/null || true
+ stop_user_nodes
+ fi
+ ;;
+esac
+
+# The node's code lives in meshbay-common's venv, and meshbay-common's own
+# cleanup runs only after this package is gone: the bytecode compiled next to
+# it is removed here, or dpkg cannot remove the directories it sits in.
+case "$1" in
+ remove|upgrade|deconfigure)
+ find /opt/meshbay-common/venv/lib/python3.*/site-packages/meshbay_node \
+ -depth -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
+ ;;
+esac
+
+#DEBHELPER#
diff --git a/packaging/rpm/meshbay-hub.spec b/packaging/rpm/meshbay-hub.spec
index 90a4f08..ffd991a 100644
--- a/packaging/rpm/meshbay-hub.spec
+++ b/packaging/rpm/meshbay-hub.spec
@@ -40,6 +40,12 @@ install -d -o root -g meshbay -m 750 /etc/meshbay
%preun
%systemd_preun meshbay-hub.service
+# The hub's code lives in meshbay-common's venv, whose own cleanup runs only
+# after this package is gone: the bytecode compiled next to it is removed here.
+if [ "$1" -eq 0 ]; then
+ find /opt/meshbay-common/venv/lib/python3.*/site-packages/meshbay_hub \
+ -depth -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
+fi
%postun
%systemd_postun_with_restart meshbay-hub.service
diff --git a/packaging/rpm/meshbay-node.spec b/packaging/rpm/meshbay-node.spec
index 9b9c6bf..89f3a0a 100644
--- a/packaging/rpm/meshbay-node.spec
+++ b/packaging/rpm/meshbay-node.spec
@@ -70,8 +70,23 @@ if [ -d /run/systemd/system ]; then
fi
%preun
-if [ -d /run/systemd/system ]; then
- systemctl daemon-reload || true
+# A node left running once its package is gone serves from deleted code, and
+# keeps writing bytecode into the shared venv. Every instance stops on erase
+# ($1 = 0): the system ones, and the user service in each running user manager.
+if [ "$1" -eq 0 ] && [ -d /run/systemd/system ]; then
+ systemctl stop 'meshbay-node@*.service' 2>/dev/null || true
+ for bus in /run/user/*/systemd/private; do
+ [ -S "$bus" ] || continue
+ uid=$(basename "$(dirname "$(dirname "$bus")")")
+ user=$(id -nu "$uid" 2>/dev/null) || continue
+ systemctl --user -M "$user@" stop meshbay-node.service 2>/dev/null || true
+ done
+fi
+# The node's code lives in meshbay-common's venv, whose own cleanup runs only
+# after this package is gone: the bytecode compiled next to it is removed here.
+if [ "$1" -eq 0 ]; then
+ find /opt/meshbay-common/venv/lib/python3.*/site-packages/meshbay_node \
+ -depth -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
fi
%files