From 57758895bc7b0f7f21497194e81172f43d634852 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 3 Oct 2026 09:03:34 +0200 Subject: feat(android): casting through a LAN relay and the platform cast SDK A port of cast-relay.js (backlog also bounded in bytes), discovery and control with the default media receiver, relay calls kept in order, and a foreground service plus a WebView kept visible so a cast survives the screen going off. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-android/README.md | 12 +- packages/meshbay-android/app/build.gradle.kts | 5 + .../app/src/main/AndroidManifest.xml | 13 + .../app/src/main/assets/bridge/meshbay-bridge.js | 70 ++++-- .../main/kotlin/org/meshbay/client/MainActivity.kt | 27 +- .../kotlin/org/meshbay/client/bridge/Bridge.kt | 10 +- .../kotlin/org/meshbay/client/bridge/Channels.kt | 18 +- .../org/meshbay/client/cast/BoxAccumulator.kt | 78 ++++++ .../kotlin/org/meshbay/client/cast/CastChannels.kt | 93 +++++++ .../kotlin/org/meshbay/client/cast/CastControl.kt | 232 +++++++++++++++++ .../kotlin/org/meshbay/client/cast/CastRelay.kt | 278 +++++++++++++++++++++ .../kotlin/org/meshbay/client/cast/CastService.kt | 72 ++++++ .../kotlin/org/meshbay/client/save/SaveNames.kt | 1 + .../org/meshbay/client/shell/ShellWebView.kt | 25 ++ .../kotlin/org/meshbay/client/CastRelayTest.kt | 153 ++++++++++++ packages/meshbay-hub/tests/test_android_cast.py | 119 +++++++++ packages/meshbay-hub/tests/test_android_shell.py | 5 +- 17 files changed, 1176 insertions(+), 35 deletions(-) create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/BoxAccumulator.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastRelay.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastService.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt create mode 100644 packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt create mode 100644 packages/meshbay-hub/tests/test_android_cast.py (limited to 'packages') diff --git a/packages/meshbay-android/README.md b/packages/meshbay-android/README.md index 859cf00..68f6dad 100644 --- a/packages/meshbay-android/README.md +++ b/packages/meshbay-android/README.md @@ -18,6 +18,14 @@ Settings (a Storage Access Framework tree, as `.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 @@ -27,5 +35,5 @@ page holds an opaque id, never a URI. Uploads come through the system picker. 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: casting, phone-specific behaviour (back button, network -handover, keeping a transfer alive with the screen off), signed releases. +Not built yet: phone-specific behaviour (back button, network handover, +keeping a download alive with the screen off), signed releases. diff --git a/packages/meshbay-android/app/build.gradle.kts b/packages/meshbay-android/app/build.gradle.kts index 7f52abe..b26acf3 100644 --- a/packages/meshbay-android/app/build.gradle.kts +++ b/packages/meshbay-android/app/build.gradle.kts @@ -35,6 +35,11 @@ dependencies { // 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") diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml index 82b827e..d6f83ee 100644 --- a/packages/meshbay-android/app/src/main/AndroidManifest.xml +++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml @@ -2,6 +2,12 @@ + + + + + 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 `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-hub/tests/test_android_cast.py b/packages/meshbay-hub/tests/test_android_cast.py new file mode 100644 index 0000000..0ff383a --- /dev/null +++ b/packages/meshbay-hub/tests/test_android_cast.py @@ -0,0 +1,119 @@ +""" +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 "BACKPRESSURE_HIGH = 8L * 1024 * 1024" in relay + 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_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_shell.py b/packages/meshbay-hub/tests/test_android_shell.py index ef7355d..e569bb7 100644 --- a/packages/meshbay-hub/tests/test_android_shell.py +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -134,8 +134,9 @@ 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 name in ("Channels.kt", "KeyChannels.kt"): - native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(SRC / "bridge" / name), flags=re.M)) + 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}" -- cgit v1.2.3