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/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 ++++++++++++ 14 files changed, 1044 insertions(+), 31 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 (limited to 'packages/meshbay-android/app') 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) + } +} -- cgit v1.2.3