From 6ebfc86392a74dc0ae18f0d2703a3f91b8977d46 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 2 Oct 2026 12:14:01 +0200 Subject: 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 --- .../main/kotlin/org/meshbay/client/MainActivity.kt | 202 +++++++++++++++++++++ .../kotlin/org/meshbay/client/bridge/Bridge.kt | 65 +++++++ .../kotlin/org/meshbay/client/bridge/Channels.kt | 63 +++++++ .../kotlin/org/meshbay/client/bridge/Refused.kt | 4 + .../kotlin/org/meshbay/client/hub/HubClient.kt | 130 +++++++++++++ .../kotlin/org/meshbay/client/shell/EngineCheck.kt | 57 ++++++ .../kotlin/org/meshbay/client/shell/UiAssets.kt | 93 ++++++++++ 7 files changed, 614 insertions(+) create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Refused.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/hub/HubClient.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/EngineCheck.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt (limited to 'packages/meshbay-android/app/src/main/kotlin/org/meshbay') 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::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 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" + } + } +} -- cgit v1.2.3