diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-03 09:03:34 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-03 14:24:54 +0200 |
| commit | 57758895bc7b0f7f21497194e81172f43d634852 (patch) | |
| tree | a4a3c71f9aafbc65199e140b7111ee04163fa295 /packages/meshbay-android | |
| parent | d8f5005ba2c9a249ce5ce8976d9c3754a3a7458e (diff) | |
| download | meshbay-57758895bc7b0f7f21497194e81172f43d634852.tar.gz | |
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-android')
15 files changed, 1054 insertions, 33 deletions
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 `<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 @@ -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 @@ <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 @@ -24,5 +30,12 @@ <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 index 5fb5bb6..0a2582a 100644 --- a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js +++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js @@ -15,9 +15,10 @@ * that refuses: `platform.js` decides what to show from whether an object * exists (`platform.node.available`, `platform.folder.available`, …). * - * `HUB_BASE` and `BINARY` are prepended by the shell when it injects this - * file: the interface asks for the hub while its modules load, before anything - * can await, and BINARY says whether the WebView carries ArrayBuffer messages. + * `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'; @@ -48,28 +49,34 @@ // 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); - if (!BINARY) { - let s = ''; - for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); - return call('save:write', handle, btoa(s)); - } - return 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, SAVE_WRITE); - head.setUint32(12, handle); - new Uint8Array(frame, 16).set(bytes); - port.postMessage(frame); - }); + 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 = { @@ -80,7 +87,7 @@ nodeAdmin: false, // no node runs on a phone (§11.3) localFolders: false, nativeSave: true, - lanCast: false, // phase 3 + lanCast: CAST, // false where the vendor's play services are absent tray: false, }, @@ -132,6 +139,27 @@ 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'), + } } : {}), + // Where downloads go, chosen once. A display name comes back, never a URI. folder: { choose: () => call('folder:choose'), 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 index bd096f7..41f5457 100644 --- 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 @@ -26,10 +26,13 @@ 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 @@ -44,7 +47,8 @@ import java.util.concurrent.CountDownLatch */ class MainActivity : Activity() { private lateinit var root: FrameLayout - private lateinit var web: WebView + 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) @@ -66,7 +70,7 @@ class MainActivity : Activity() { EngineCheck.problem(this)?.let { setContentView(EngineCheck.screen(this, it)); return } hub = HubClient(getSharedPreferences("shell", Context.MODE_PRIVATE)) - web = WebView(this) + web = ShellWebView(this) root.addView(web, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) configure(web) @@ -76,8 +80,10 @@ class MainActivity : Activity() { 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) } }) channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } }, - hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves) + hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves, + cast = cast) WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels)) installShim() web.loadUrl(UiAssets.START) @@ -181,7 +187,8 @@ class MainActivity : Activity() { 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" + 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)) } @@ -216,6 +223,17 @@ class MainActivity : Activity() { 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()) @@ -261,6 +279,7 @@ class MainActivity : Activity() { } 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 index 0946464..50552fa 100644 --- 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 @@ -30,6 +30,7 @@ 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) { @@ -42,18 +43,19 @@ class Bridge(private val channels: Channels) : WebViewCompat.WebMessageListener } if (message.type == WebMessageCompat.TYPE_ARRAY_BUFFER) { val frame = BinaryFrame.parse(message.arrayBuffer) ?: return - dispatch(frame.id, replyProxy, "binary ${frame.channel}") { channels.binary(frame) } + 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.call(channel, args) } + dispatch(id, replyProxy, channel, channels.ordered(channel)) { channels.call(channel, args) } } - private fun dispatch(id: Long, replyProxy: JavaScriptReplyProxy, channel: String, call: () -> Any?) { - work.execute { + 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) { 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 index 308dfa5..6992cdb 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -23,6 +24,7 @@ class Channels( 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 @@ -46,8 +48,11 @@ class Channels( "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 -> if (keys != null && keys.handles(channel)) keys.call(channel, args) - else throw Refused("Refused: no such channel") + 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") @@ -55,9 +60,18 @@ class Channels( /** 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 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..3aaf8d2 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/BoxAccumulator.kt @@ -0,0 +1,78 @@ +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 + + 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 + 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 } + + 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..0efae9d --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt @@ -0,0 +1,93 @@ +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, +) { + val control = CastControl(context) + val relay = CastRelay(::lanAddress) + + 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() + control.connect(o.optString("deviceId", ""), relayUrl(o), subtitleOf(o)) + } + "cast:chromecast:reload" -> { + val o = a.optJSONObject(0) ?: JSONObject() + control.reload(relayUrl(o), subtitleOf(o)) + } + "cast:chromecast:disconnect" -> control.disconnect() + else -> throw Refused("Refused: no such channel") + } + + 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. */ + 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_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..88e3a03 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt @@ -0,0 +1,232 @@ +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") } + + 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) { 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) + 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 { + if (!s.done.await(15, TimeUnit.SECONDS)) throw IllegalStateException("Connection timeout") + s.session() ?: throw IllegalStateException("The receiver refused the connection") + } finally { main.post { s.cleanup() } } + else -> throw IllegalStateException() + } + } + val state = load(session, mediaUrl, subtitle) + return JSONObject().put("deviceName", deviceName ?: session.castDevice?.friendlyName).put("playerState", state) + } + + private class Pending(val done: CountDownLatch, val session: () -> CastSession?, val cleanup: () -> Unit) + + fun reload(mediaUrl: String, subtitle: JSONObject?): JSONObject { + need() + val session = onMain { CastContext.getSharedInstance(context).sessionManager.currentCastSession } + ?: throw Refused("Not connected") + return JSONObject().put("playerState", load(session, mediaUrl, subtitle)) + } + + 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 + onMain { + val client = session.remoteMediaClient ?: throw IllegalStateException("The receiver has no media channel") + client.load(request).setResultCallback { r -> ok = r.status.isSuccess; loaded.countDown() } + } + if (!loaded.await(20, TimeUnit.SECONDS)) throw IllegalStateException("The receiver did not load the stream") + if (!ok) throw IllegalStateException("The receiver refused the stream") + return onMain { stateName(session.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 + JSONObject().put("connected", on).put("deviceName", if (on) (deviceName ?: s!!.castDevice?.friendlyName) else 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 + // 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() + + 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..5cd5f3e --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastRelay.kt @@ -0,0 +1,278 @@ +package org.meshbay.client.cast + +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +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.LinkedBlockingDeque +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. + * + * `java.net` only, so the JVM tests run the real thing. + */ +class CastRelay(private val lanAddress: () -> InetAddress?) { + + private class Client(val socket: Socket, val out: OutputStream) { + val queue = LinkedBlockingDeque<ByteArray>() + val queued = AtomicLong() + @Volatile var closed = false + } + + 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 + @Volatile private var subtitle: Subtitle? = null + @Volatile private var subtitleVersion = 0 + 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() + host = address.hostAddress + initSegment = init + synchronized(ring) { ring.clear(); ringBytes = 0 } + clients.clear() + accum = BoxAccumulator() + subtitle = null + setSubtitle(sub) + + var bound: ServerSocket? = null + for (i in 0 until PORT_COUNT) { + val s = ServerSocket() + try { + s.reuseAddress = false + 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() + return JSONObject().put("url", url).put("port", port).put("token", token) + .put("subtitle", subtitleInfo() ?: JSONObject.NULL) + } + + fun push(data: ByteArray, offset: Int = 0, length: Int = data.size - offset) { + if (server == null) return + for (frag in accum.push(data, offset, length)) { + 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.queued.get() > BACKPRESSURE_HIGH) continue // drop for a slow client + c.queued.addAndGet(frag.size.toLong()) + c.queue.offer(frag) + } + } + } + + /** End of film: every client's response is ended, the server stays until stop. */ + fun finish() { + for (c in clients) c.queue.offer(END) + } + + @Synchronized + fun stop() { + for (c in clients) { c.closed = true; c.queue.offer(END); 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('?') + 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" -> 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", + )) + val client = Client(socket, out) + initSegment?.let { chunk(out, it) } + val backlog = synchronized(ring) { ring.toList() } + for (frag in backlog) chunk(out, frag) + out.flush() + clients.add(client) + try { + while (!client.closed) { + val frag = client.queue.take() + if (frag === END) { out.write("0\r\n\r\n".toByteArray()); out.flush(); break } + client.queued.addAndGet(-frag.size.toLong()) + chunk(out, frag) + out.flush() + } + } catch (e: Exception) { + // The receiver went away; nothing to tell anyone. + } finally { + clients.remove(client) + try { socket.close() } catch (e: Exception) {} + } + } + + companion object { + const val RING_CAP = 64 + const val RING_MAX_BYTES = 32L * 1024 * 1024 + const val BACKPRESSURE_HIGH = 8L * 1024 * 1024 + const val PORT_BASE = 19550 + const val PORT_COUNT = 4 + private val END = ByteArray(0) + + // 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) { + out.write("${Integer.toHexString(data.size)}\r\n".toByteArray()) + out.write(data) + 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/save/SaveNames.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveNames.kt index eed096f..2414730 100644 --- 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 @@ -58,6 +58,7 @@ class BinaryFrame(val id: Long, val channel: Int, val handle: Long, val bytes: B 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) = 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/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..9fe1cfd --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt @@ -0,0 +1,153 @@ +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 relay = CastRelay { InetAddress.getByName("127.0.0.1") } + + @After fun stop() = relay.stop() + + 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 `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 `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) + } +} |