aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-android/.gitignore6
-rw-r--r--packages/meshbay-android/README.md22
-rw-r--r--packages/meshbay-android/app/build.gradle.kts74
-rw-r--r--packages/meshbay-android/app/src/main/AndroidManifest.xml28
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js84
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt202
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt65
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt63
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Refused.kt4
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/hub/HubClient.kt130
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/EngineCheck.kt57
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt93
-rw-r--r--packages/meshbay-android/app/src/main/res/values/themes.xml6
-rw-r--r--packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml11
-rw-r--r--packages/meshbay-android/app/src/main/res/xml/network_security_config.xml11
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt39
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt30
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt43
-rw-r--r--packages/meshbay-android/build.gradle.kts1
-rw-r--r--packages/meshbay-android/gradle.properties2
-rw-r--r--packages/meshbay-android/gradle/wrapper/gradle-wrapper.jarbin0 -> 47623 bytes
-rw-r--r--packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties10
-rwxr-xr-xpackages/meshbay-android/gradlew248
-rw-r--r--packages/meshbay-android/gradlew.bat112
-rw-r--r--packages/meshbay-android/settings.gradle.kts9
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py210
26 files changed, 1560 insertions, 0 deletions
diff --git a/packages/meshbay-android/.gitignore b/packages/meshbay-android/.gitignore
new file mode 100644
index 0000000..8a50ec5
--- /dev/null
+++ b/packages/meshbay-android/.gitignore
@@ -0,0 +1,6 @@
+# Generated — the interface is copied from meshbay-hub/static at build time
+# and never committed (docs/MESHBAY_DESIGN.md §8.3).
+build/
+.gradle/
+local.properties
+.kotlin/
diff --git a/packages/meshbay-android/README.md b/packages/meshbay-android/README.md
new file mode 100644
index 0000000..8c357fe
--- /dev/null
+++ b/packages/meshbay-android/README.md
@@ -0,0 +1,22 @@
+# MeshBay — Android client
+
+A client, not a host: no node runs on a phone (`docs/MESHBAY_DESIGN.md` §11.3).
+
+The shell is a system WebView showing the interface **from the package** —
+`meshbay-hub/src/meshbay_hub/static/` copied at build time into
+`build/generated/`, never committed (§8.3) — with a bridge
+(`app/src/main/assets/bridge/meshbay-bridge.js`) that offers the page the same
+`window.meshbay` as the desktop preload, wherever it offers anything at all.
+Hub calls leave from native code, to the signed-in hub only.
+
+```bash
+# needs JDK 17+ and an Android SDK (ANDROID_HOME, or sdk.dir in local.properties)
+./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk
+./gradlew testDebugUnitTest # JVM unit tests
+```
+
+The security contract is also pinned from the Python suite by reading this
+source: `packages/meshbay-hub/tests/test_android_shell.py`.
+
+Not built yet: native keys, downloads to disk, casting, phone-specific
+behaviour (back button, network handover), signed releases.
diff --git a/packages/meshbay-android/app/build.gradle.kts b/packages/meshbay-android/app/build.gradle.kts
new file mode 100644
index 0000000..5364ec9
--- /dev/null
+++ b/packages/meshbay-android/app/build.gradle.kts
@@ -0,0 +1,74 @@
+import groovy.json.JsonSlurper
+
+plugins { id("com.android.application") }
+
+// One version for every package (CLAUDE.md): read from the desktop client's
+// package.json rather than written a second time here.
+val packageVersion = (JsonSlurper().parse(rootDir.resolve("../meshbay-client/package.json"))
+ as Map<*, *>)["version"] as String
+val versionParts = packageVersion.split(".").map { it.toInt() }
+
+android {
+ namespace = "org.meshbay.client"
+ compileSdk = 37
+ defaultConfig {
+ applicationId = "org.meshbay.client"
+ minSdk = 26
+ targetSdk = 36
+ versionName = packageVersion
+ versionCode = versionParts[0] * 10000 + versionParts[1] * 100 + versionParts[2]
+ }
+ buildTypes {
+ getByName("release") { isMinifyEnabled = false }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ buildFeatures { buildConfig = true }
+ testOptions { unitTests.isReturnDefaultValues = false }
+}
+
+dependencies {
+ implementation("androidx.webkit:webkit:1.17.1")
+ implementation("com.squareup.okhttp3:okhttp:5.5.0")
+ testImplementation("junit:junit:4.13.2")
+ // Android's org.json is a stub on the JVM; the unit tests need the real one.
+ testImplementation("org.json:json:20260814")
+}
+
+/**
+ * The interface, copied from its single source at build time (§8.3).
+ *
+ * `meshbay-hub/src/meshbay_hub/static/` is the interface for the web, the
+ * desktop application and this one. The copy lands in build/ and is never
+ * committed, so it cannot fork. The page itself is the desktop application's
+ * `scripts/index.html` — the hub builds its own with a /a/<hash>/ prefix that
+ * would point back at the hub — so an application loading from its package
+ * has one page, not two.
+ */
+abstract class SyncUi : DefaultTask() {
+ @get:InputDirectory abstract val staticDir: DirectoryProperty
+ @get:InputFile abstract val indexHtml: RegularFileProperty
+ @get:OutputDirectory abstract val outputDir: DirectoryProperty
+
+ @TaskAction
+ fun copy() {
+ val ui = outputDir.get().asFile.resolve("ui")
+ outputDir.get().asFile.deleteRecursively()
+ staticDir.get().asFile.copyRecursively(ui)
+ indexHtml.get().asFile.copyTo(ui.resolve("index.html"), overwrite = true)
+ }
+}
+
+val syncUi = tasks.register<SyncUi>("syncUi") {
+ staticDir.set(rootDir.resolve("../meshbay-hub/src/meshbay_hub/static"))
+ indexHtml.set(rootDir.resolve("../meshbay-client/scripts/index.html"))
+ outputDir.set(layout.buildDirectory.dir("generated/ui-assets"))
+}
+
+androidComponents {
+ onVariants { variant ->
+ variant.sources.assets?.addGeneratedSourceDirectory(syncUi, SyncUi::outputDir)
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..82b827e
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+<?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" />
+
+ <!-- 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: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>
+ </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..350e087
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
@@ -0,0 +1,84 @@
+/**
+ * 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` is prepended by the shell when it injects this file: the
+ * interface asks for it while its modules load, before anything can await.
+ */
+(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 }));
+ });
+
+ 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: false, // phase 2
+ lanCast: false, // phase 3
+ 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),
+ };
+
+ 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..f6f9740
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
@@ -0,0 +1,202 @@
+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.hub.HubClient
+import org.meshbay.client.shell.EngineCheck
+import org.meshbay.client.shell.UiAssets
+
+/**
+ * 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: WebView
+ private lateinit var hub: HubClient
+ 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 = WebView(this)
+ root.addView(web, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
+ configure(web)
+
+ val channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } },
+ hasCatalogue = { code -> hasAsset("ui/locales/$code.js") })
+ WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels))
+ 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)
+ }
+
+ 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 prelude = "const HUB_BASE = ${JSONObject.quote(hub.base)};\n"
+ shim = WebViewCompat.addDocumentStartJavaScript(web, "(function(){$prelude$source\n})();", setOf(UiAssets.ORIGIN))
+ }
+
+ private fun reloadForHub() {
+ installShim()
+ web.loadUrl(UiAssets.START)
+ }
+
+ 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 (::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..50f711c
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt
@@ -0,0 +1,65 @@
+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.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()
+
+ override fun onPostMessage(view: WebView, message: WebMessageCompat, sourceOrigin: Uri,
+ isMainFrame: Boolean, replyProxy: JavaScriptReplyProxy) {
+ val request = try { JSONObject(message.data ?: return) } catch (e: Exception) { return }
+ val id = request.optLong("id", -1)
+ if (!isMainFrame || sourceOrigin.toString() != UiAssets.ORIGIN) {
+ Log.w(TAG, "refused a message from $sourceOrigin (main frame: $isMainFrame)")
+ replyProxy.postMessage(error(id, "Refused: not the MeshBay interface"))
+ return
+ }
+ val channel = request.optString("ch")
+ val args = request.optJSONArray("args") ?: JSONArray()
+ work.execute {
+ val reply = try {
+ JSONObject().put("id", id).put("ok", true).put("value", channels.call(channel, args) ?: 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..81be25e
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
@@ -0,0 +1,63 @@
+package org.meshbay.client.bridge
+
+import org.json.JSONArray
+import org.meshbay.client.hub.HubClient
+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,
+) {
+ @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, ""))
+ else -> throw Refused("Refused: no such channel")
+ }
+
+ 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/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/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/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/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/values/themes.xml b/packages/meshbay-android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..a7df056
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/values/themes.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <style name="Shell" parent="@android:style/Theme.DeviceDefault.NoActionBar">
+ <item name="android:windowBackground">@android:color/black</item>
+ </style>
+</resources>
diff --git a/packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml b/packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..f0abf11
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<data-extraction-rules>
+ <cloud-backup>
+ <exclude domain="root" /><exclude domain="file" /><exclude domain="database" />
+ <exclude domain="sharedpref" /><exclude domain="external" />
+ </cloud-backup>
+ <device-transfer>
+ <exclude domain="root" /><exclude domain="file" /><exclude domain="database" />
+ <exclude domain="sharedpref" /><exclude domain="external" />
+ </device-transfer>
+</data-extraction-rules>
diff --git a/packages/meshbay-android/app/src/main/res/xml/network_security_config.xml b/packages/meshbay-android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..8bf3e82
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Plain http only to a hub on this device's own loopback — the desktop rule
+ ("http only to localhost or 127.*"). Anywhere else a session token would
+ cross the network in clear. -->
+<network-security-config>
+ <base-config cleartextTrafficPermitted="false" />
+ <domain-config cleartextTrafficPermitted="true">
+ <domain includeSubdomains="false">localhost</domain>
+ <domain includeSubdomains="false">127.0.0.1</domain>
+ </domain-config>
+</network-security-config>
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt
new file mode 100644
index 0000000..adc6366
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt
@@ -0,0 +1,39 @@
+package org.meshbay.client
+
+import org.json.JSONArray
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.meshbay.client.bridge.Channels
+import org.meshbay.client.bridge.Refused
+import org.meshbay.client.hub.HubClient
+import java.net.InetAddress
+import java.net.UnknownHostException
+
+class ChannelsTest {
+ private val channels = Channels(HubClient(FakePrefs()), onHubChanged = {}, hasCatalogue = { it == "fr" || it == "pt-BR" })
+
+ @Test fun `a channel that is not enumerated is refused`() {
+ for (ch in listOf("node:op", "root:choose", "window:minimize-to-tray", "keys:sign", "", "hub:fetch2")) {
+ assertThrows(ch, Refused::class.java) { channels.call(ch, JSONArray()) }
+ }
+ }
+
+ @Test fun `the locale is a code with a catalogue, never text`() {
+ assertEquals("fr", channels.call("ui:locale", JSONArray().put("fr")))
+ assertEquals("fr", channels.call("ui:locale", JSONArray().put("de"))) // no catalogue
+ assertEquals("fr", channels.call("ui:locale", JSONArray().put("../en"))) // not a code
+ assertEquals("pt-BR", channels.call("ui:locale", JSONArray().put("pt-BR")))
+ }
+
+ @Test fun `stun hostnames are resolved, literals kept, failures dropped`() {
+ val lookup: (String) -> Array<InetAddress> = { host ->
+ if (host == "stun.example") arrayOf(InetAddress.getByAddress(host, byteArrayOf(192.toByte(), 0, 2, 7)))
+ else throw UnknownHostException(host)
+ }
+ val out = Channels.resolveStun(JSONArray(listOf("stun:stun.example:3478", "stun:198.51.100.1:3478",
+ "stun:[2001:db8::1]:3478", "stun:gone.example:3478", "turn:x")), lookup)
+ assertEquals(listOf("stun:192.0.2.7:3478", "stun:198.51.100.1:3478", "stun:[2001:db8::1]:3478", "turn:x"),
+ (0 until out.length()).map { out.getString(it) })
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt
new file mode 100644
index 0000000..33d1c0f
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt
@@ -0,0 +1,30 @@
+package org.meshbay.client
+
+import android.content.SharedPreferences
+
+/** SharedPreferences is an interface; a map is enough for the JVM tests. */
+class FakePrefs : SharedPreferences {
+ val map = HashMap<String, Any?>()
+ override fun getAll(): MutableMap<String, *> = map
+ override fun getString(key: String, defValue: String?) = map[key] as String? ?: defValue
+ override fun getStringSet(key: String, defValues: MutableSet<String>?) = defValues
+ override fun getInt(key: String, defValue: Int) = map[key] as Int? ?: defValue
+ override fun getLong(key: String, defValue: Long) = map[key] as Long? ?: defValue
+ override fun getFloat(key: String, defValue: Float) = map[key] as Float? ?: defValue
+ override fun getBoolean(key: String, defValue: Boolean) = map[key] as Boolean? ?: defValue
+ override fun contains(key: String) = map.containsKey(key)
+ override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {}
+ override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {}
+ override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor {
+ override fun putString(k: String, v: String?) = apply { map[k] = v }
+ override fun putStringSet(k: String, v: MutableSet<String>?) = apply { map[k] = v }
+ override fun putInt(k: String, v: Int) = apply { map[k] = v }
+ override fun putLong(k: String, v: Long) = apply { map[k] = v }
+ override fun putFloat(k: String, v: Float) = apply { map[k] = v }
+ override fun putBoolean(k: String, v: Boolean) = apply { map[k] = v }
+ override fun remove(k: String) = apply { map.remove(k) }
+ override fun clear() = apply { map.clear() }
+ override fun commit() = true
+ override fun apply() {}
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt
new file mode 100644
index 0000000..8211013
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt
@@ -0,0 +1,43 @@
+package org.meshbay.client
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshbay.client.bridge.Refused
+import org.meshbay.client.hub.HubClient
+
+class HubClientTest {
+ private fun hub(base: String = "") = HubClient(FakePrefs().apply { map["hubBase"] = base })
+
+ @Test fun `an empty address is refused, not stored as no hub`() {
+ val e = assertThrows(Refused::class.java) { hub().setBase(" ") }
+ assertEquals("Enter the address of a hub.", e.message)
+ }
+
+ @Test fun `plain http is refused except to loopback`() {
+ for (url in listOf("http://example.org", "http://10.0.2.2:8770", "ftp://x", "http://192.168.1.2")) {
+ val e = assertThrows(Refused::class.java) { hub().setBase(url) }
+ assertEquals(url, "The hub address must be https", e.message)
+ }
+ }
+
+ @Test fun `the page reaches the signed-in hub and nowhere else`() {
+ val h = hub("https://hub.example")
+ for (url in listOf("https://other.example/v1/x", "http://hub.example/v1/x",
+ "https://hub.example:8443/v1/x", "https://hub.example.evil/v1/x", "not a url")) {
+ assertThrows(url, Refused::class.java) { h.fetch(url, JSONObject()) }
+ }
+ }
+
+ @Test fun `nothing is fetched before a hub is set`() {
+ assertThrows(Refused::class.java) { hub("").fetch("https://hub.example/v1/x", null) }
+ }
+
+ @Test fun `unreachable hubs are described in words somebody can act on`() {
+ assertTrue(HubClient.describeUnreachable("https://h", java.net.ConnectException()).startsWith("Nothing is listening at https://h"))
+ assertTrue(HubClient.describeUnreachable("https://h", java.net.UnknownHostException()).contains("could not be found"))
+ assertTrue(HubClient.describeUnreachable("https://h", javax.net.ssl.SSLHandshakeException("x")).contains("does not speak https"))
+ }
+}
diff --git a/packages/meshbay-android/build.gradle.kts b/packages/meshbay-android/build.gradle.kts
new file mode 100644
index 0000000..a4370dd
--- /dev/null
+++ b/packages/meshbay-android/build.gradle.kts
@@ -0,0 +1 @@
+plugins { id("com.android.application") version "9.4.1" apply false }
diff --git a/packages/meshbay-android/gradle.properties b/packages/meshbay-android/gradle.properties
new file mode 100644
index 0000000..660848f
--- /dev/null
+++ b/packages/meshbay-android/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx2g
+android.useAndroidX=true
diff --git a/packages/meshbay-android/gradle/wrapper/gradle-wrapper.jar b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..5097068
--- /dev/null
+++ b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..9f3a241
--- /dev/null
+++ b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,10 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
diff --git a/packages/meshbay-android/gradlew b/packages/meshbay-android/gradlew
new file mode 100755
index 0000000..249efbb
--- /dev/null
+++ b/packages/meshbay-android/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/packages/meshbay-android/gradlew.bat b/packages/meshbay-android/gradlew.bat
new file mode 100644
index 0000000..3185a43
--- /dev/null
+++ b/packages/meshbay-android/gradlew.bat
@@ -0,0 +1,112 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+@rem Catch executions from older scripts and ensure they exit cleanly.
+@rem This can be removed once we can be reasonably confident that few people
+@rem will be migrating directly to this new wrapper.
+goto afterSafetyNet
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+goto exitWithErrorLevel
+:afterSafetyNet
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+1>&2 echo.
+1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+1>&2 echo.
+1>&2 echo Please set the JAVA_HOME variable in your environment to match the
+1>&2 echo location of your Java installation.
+
+"%COMSPEC%" /c exit 1
+goto exitWithErrorLevel
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+1>&2 echo.
+1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+1>&2 echo.
+1>&2 echo Please set the JAVA_HOME variable in your environment to match the
+1>&2 echo location of your Java installation.
+
+"%COMSPEC%" /c exit 1
+goto exitWithErrorLevel
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
+
+@rem This label must not be changed. We rely on old scripts being able to jump to this point.
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/packages/meshbay-android/settings.gradle.kts b/packages/meshbay-android/settings.gradle.kts
new file mode 100644
index 0000000..cead413
--- /dev/null
+++ b/packages/meshbay-android/settings.gradle.kts
@@ -0,0 +1,9 @@
+pluginManagement {
+ repositories { google(); mavenCentral(); gradlePluginPortal() }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories { google(); mavenCentral() }
+}
+rootProject.name = "meshbay-android"
+include(":app")
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py
new file mode 100644
index 0000000..b2e0e4d
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -0,0 +1,210 @@
+"""
+The Android shell's security contract, pinned by reading its source.
+
+The same treatment `test_desktop_shell.py` gives the Electron application, for
+the same reason: an emulator or a phone is what proves the shell runs, and the
+suite has neither. What this proves is that the properties the design depends
+on (docs/MESHBAY_DESIGN.md §8.2, as the Android plan restates them) are in the
+source, and it fails when one is removed. The JVM unit tests run too when an
+Android SDK is present.
+"""
+
+import os
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+PACKAGES = Path(__file__).resolve().parents[2]
+ANDROID = PACKAGES / "meshbay-android"
+APP = ANDROID / "app"
+SRC = APP / "src" / "main" / "kotlin" / "org" / "meshbay" / "client"
+SHIM = APP / "src" / "main" / "assets" / "bridge" / "meshbay-bridge.js"
+CLIENT = PACKAGES / "meshbay-client"
+
+pytestmark = pytest.mark.skipif(not ANDROID.exists(), reason="android sources not present")
+
+
+def _read(path: Path) -> str:
+ return path.read_text(encoding="utf-8")
+
+
+def _kotlin() -> str:
+ return "\n".join(_read(p) for p in sorted(SRC.rglob("*.kt")))
+
+
+def _strip_js_comments(source: str) -> str:
+ source = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
+ return re.sub(r"(?m)//.*$", "", source)
+
+
+# ── The page comes from the package ──────────────────────────────────────────
+
+def test_the_interface_is_copied_from_its_single_source_and_never_committed():
+ build = _read(APP / "build.gradle.kts")
+ assert '"../meshbay-hub/src/meshbay_hub/static"' in build
+ # The desktop application's page: the hub's carries a /a/<hash>/ prefix
+ # that would point back at the hub.
+ assert '"../meshbay-client/scripts/index.html"' in build
+ assert "deleteRecursively()" in build, "a stale file could survive a rebuild"
+ assert "addGeneratedSourceDirectory" in build
+ assert "build/" in _read(ANDROID / ".gitignore")
+ assert not (APP / "src" / "main" / "assets" / "ui").exists(), \
+ "a copy of the interface is in the source tree, which is how a fork begins"
+
+
+def test_the_webview_loads_the_package_and_nothing_else():
+ activity = _read(SRC / "MainActivity.kt")
+ loads = re.findall(r"\.loadUrl\(([^)]*)\)", activity)
+ assert loads and set(loads) == {"UiAssets.START"}, loads
+ assert "loadDataWithBaseURL" not in activity and "loadData(" not in activity
+ # The hub never becomes the document origin; a link out leaves the app.
+ assert "shouldOverrideUrlLoading" in activity and "openExternally" in activity
+
+
+def test_the_policy_is_the_desktop_policy_sent_as_a_header():
+ """One interface, one policy for the packaged builds — and the desktop's
+ is already held to the hub's by test_the_two_policies_stay_in_step."""
+ def directives(text: str, start: str, end: str) -> dict[str, str]:
+ body = text.split(start, 1)[1].split(end, 1)[0]
+ out = {}
+ for d in re.findall(r"[`\"]([a-z-]+(?: [^`\"]*)?)[`\"]", body):
+ d = d.replace("${RECAPTCHA_SRC}", "$RECAPTCHA_SRC")
+ out[d.split()[0]] = d
+ return out
+
+ desktop = directives(_read(CLIENT / "src" / "main.js"), "const CSP = [", "].join")
+ kotlin = _read(SRC / "shell" / "UiAssets.kt")
+ android = directives(kotlin, "val CSP = listOf(", ").joinToString")
+ assert desktop and android == desktop, set(android.items()) ^ set(desktop.items())
+
+ assets = _read(SRC / "shell" / "UiAssets.kt")
+ assert '"Content-Security-Policy" to CSP' in assets
+ recaptcha = 'RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"'
+ assert recaptcha in assets
+ markup = re.sub(r"<!--.*?-->", "", _read(CLIENT / "scripts" / "index.html"), flags=re.S)
+ assert "Content-Security-Policy" not in markup
+
+
+def test_the_asset_handler_cannot_be_walked_out_of():
+ assets = _read(SRC / "shell" / "UiAssets.kt")
+ assert '".."' in assets and 'val asset = "ui/"' in assets
+
+
+def test_plain_http_is_refused_except_to_loopback():
+ hub = _read(SRC / "hub" / "HubClient.kt")
+ assert "The hub address must be https" in hub
+ assert 'Regex("^http://(localhost|127\\\\.)")' in hub
+ config = _read(APP / "src" / "main" / "res" / "xml" / "network_security_config.xml")
+ assert '<base-config cleartextTrafficPermitted="false"' in config
+ allowed = re.findall(r"<domain[^>]*>([^<]+)</domain>", config)
+ assert set(allowed) == {"localhost", "127.0.0.1"}
+
+
+def test_the_page_reaches_the_signed_in_hub_only():
+ hub = _read(SRC / "hub" / "HubClient.kt")
+ assert "Refused: not this hub" in hub and "sameOrigin(target, hub)" in hub
+ assert ".followRedirects(false)" in hub, "a redirect would carry the token elsewhere"
+
+
+# ── The bridge ──────────────────────────────────────────────────────────────
+
+def test_no_javascript_interface_is_ever_added():
+ """addJavascriptInterface injects into every frame of every origin."""
+ for path in APP.rglob("*.kt"):
+ assert "addJavascriptInterface" not in _read(path), path
+
+
+def test_every_message_is_checked_for_our_top_level_document():
+ bridge = _read(SRC / "bridge" / "Bridge.kt")
+ check = bridge.split("override fun onPostMessage", 1)[1].split("work.execute", 1)[0]
+ assert "!isMainFrame" in check and "UiAssets.ORIGIN" in check
+ activity = _read(SRC / "MainActivity.kt")
+ assert "addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN)" in activity
+ assert re.search(r"addDocumentStartJavaScript\(web, .*setOf\(UiAssets\.ORIGIN\)\)", activity)
+
+
+def _shim_channels() -> set[str]:
+ return set(re.findall(r"call\('([\w:-]+)'", _strip_js_comments(_read(SHIM))))
+
+
+def test_the_shim_offers_desktop_channels_and_native_answers_each():
+ preload_js = _read(CLIENT / "src" / "preload.js")
+ preload = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", preload_js))
+ channels_kt = _read(SRC / "bridge" / "Channels.kt")
+ native = set(re.findall(r'^\s*"([\w:-]+)" ->', channels_kt, flags=re.M))
+ shim = _shim_channels()
+ assert shim, "no channel found in the shim"
+ assert shim <= preload, f"channels the desktop does not have: {shim - preload}"
+ assert shim == native, f"shim and native disagree: {shim ^ native}"
+
+
+def test_what_a_phone_does_not_have_is_absent_not_refusing():
+ """platform.js decides what to show from whether an object exists
+ (`platform.node.available`, `platform.folder.available`, …): an object that
+ only refused would put screens on the page that fail when used."""
+ shim = _strip_js_comments(_read(SHIM))
+ exposed = shim.split("const meshbay = {", 1)[1].split("\n };", 1)[0]
+ for absent in ("node:", "rootPicker:", "minimizeToTray:", "setTrayLabels:"):
+ assert absent not in exposed, absent
+ assert "nodeAdmin: false" in exposed and "localFolders: false" in exposed
+
+
+def test_the_bridge_is_frozen_and_the_port_hidden():
+ shim = _strip_js_comments(_read(SHIM))
+ assert "delete window.meshbayNative" in shim
+ assert "window.top !== window" in shim
+ assert "writable: false, configurable: false" in shim
+ assert "Object.freeze" in shim
+
+
+def test_file_system_access_is_removed_from_the_page():
+ """The WebView exposes it (spike S-1) and cannot back it with anything."""
+ shim = _strip_js_comments(_read(SHIM))
+ for name in ("showDirectoryPicker", "showSaveFilePicker", "showOpenFilePicker"):
+ assert f"'{name}'" in shim, name
+
+
+def test_the_hub_address_is_injected_not_fetched():
+ """platform.hubBase() is called while modules load, before anything can await."""
+ assert "HUB_BASE" in _strip_js_comments(_read(SHIM))
+ assert "const HUB_BASE = ${JSONObject.quote(hub.base)}" in _read(SRC / "MainActivity.kt")
+
+
+# ── The window ──────────────────────────────────────────────────────────────
+
+def test_nothing_is_granted_and_video_may_go_fullscreen():
+ activity = _read(SRC / "MainActivity.kt")
+ assert "onPermissionRequest(request: PermissionRequest) = request.deny()" in activity
+ # Without a custom view, requestFullscreen() never settles (CLAUDE.md).
+ assert "override fun onShowCustomView" in activity
+ assert "override fun onHideCustomView" in activity
+
+
+def test_no_backup_carries_the_keys_away():
+ manifest = _read(APP / "src" / "main" / "AndroidManifest.xml")
+ assert 'android:allowBackup="false"' in manifest
+ assert 'android:dataExtractionRules="@xml/data_extraction_rules"' in manifest
+
+
+def test_the_gradle_distribution_is_pinned_by_checksum():
+ props = _read(ANDROID / "gradle" / "wrapper" / "gradle-wrapper.properties")
+ assert re.search(r"^distributionSha256Sum=[0-9a-f]{64}$", props, flags=re.M)
+
+
+def test_the_version_is_the_packages_version():
+ """All packages share one version (CLAUDE.md); this one reads it rather
+ than writing it a second time."""
+ build = _read(APP / "build.gradle.kts")
+ assert 'rootDir.resolve("../meshbay-client/package.json")' in build
+ assert not re.search(r'versionName = "\d', build)
+
+
+@pytest.mark.skipif(not os.environ.get("ANDROID_HOME") or shutil.which("java") is None,
+ reason="no Android SDK in the environment")
+def test_the_jvm_unit_tests_pass():
+ result = subprocess.run(["./gradlew", "--no-daemon", "-q", "testDebugUnitTest"],
+ cwd=ANDROID, capture_output=True, text=True, timeout=900)
+ assert result.returncode == 0, result.stdout[-3000:] + result.stderr[-3000:]