aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main/kotlin/org/meshbay
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-02 12:14:01 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-03 14:24:54 +0200
commit6ebfc86392a74dc0ae18f0d2703a3f91b8977d46 (patch)
tree99cf5d8b2939a9cf3af16fe4258186e9d78ed7ce /packages/meshbay-android/app/src/main/kotlin/org/meshbay
parent4e33fca55e48bbf6233e950355d31c603d88a6e6 (diff)
downloadmeshbay-6ebfc86392a74dc0ae18f0d2703a3f91b8977d46.tar.gz
feat(android): client shell with the interface from the package
WebView over the packaged UI (copied from hub/static at build time), the desktop CSP as a header, a bridge answering our top-level document only, hub calls from native to the signed-in hub. Keys stay in the page for now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-android/app/src/main/kotlin/org/meshbay')
-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
7 files changed, 614 insertions, 0 deletions
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"
+ }
+ }
+}