diff options
Diffstat (limited to 'packages/meshbay-android/app/src/main')
36 files changed, 3404 insertions, 0 deletions
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 + * — "invoicefdp.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 Binary files differnew file mode 100644 index 0000000..5b29801 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 0000000..4e211fb --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 0000000..d86c80b --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 0000000..2fdac24 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 0000000..6cc1a12 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png 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> |