aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-android')
-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
55 files changed, 4607 insertions, 0 deletions
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")