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.os.SystemClock import android.util.Log import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.WindowInsets import android.webkit.ConsoleMessage import android.webkit.PermissionRequest import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.widget.FrameLayout import androidx.webkit.ScriptHandler import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewClientCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import org.json.JSONObject import org.meshbay.client.bridge.Bridge import org.meshbay.client.bridge.Channels import org.meshbay.client.bridge.KeyChannels import org.meshbay.client.cast.CastChannels import org.meshbay.client.cast.CastService import org.meshbay.client.hub.HubClient import org.meshbay.client.keys.SecretStore import org.meshbay.client.save.SaveSinks import org.meshbay.client.shell.Pickers import org.meshbay.client.shell.ShellWebView import org.meshbay.client.shell.EngineCheck import org.meshbay.client.shell.NativeText import org.meshbay.client.shell.UiAssets import java.util.concurrent.CountDownLatch /** * The shell: one WebView showing the packaged interface, and the bridge. * * What this file must never do: load anything into the WebView that is not the * package (the hub never becomes the document origin — T3), or hand the page a * way to the hub other than the bridge. */ class MainActivity : Activity() { private lateinit var root: FrameLayout private lateinit var web: ShellWebView private lateinit var cast: CastChannels private lateinit var hub: HubClient private lateinit var channels: Channels private val pickers = Pickers(this) private val text = NativeText { code -> try { assets.open("ui/locales/$code.js").bufferedReader().use { it.readText() } } catch (e: java.io.IOException) { null } } private var shim: ScriptHandler? = null private var casting = false private var playing = false 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) if (savedInstanceState == null && Build.VERSION.SDK_INT >= 31) holdSplash() // A WebView too old for the page's crypto would fail at the first // handshake; say so before loading anything. EngineCheck.problem(this)?.let { setContentView(EngineCheck.screen(this, it)); return } hub = HubClient(getSharedPreferences("shell", Context.MODE_PRIVATE)) web = ShellWebView(this) root.addView(web, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) configure(web) val keys = KeyChannels(SecretStore(this), confirm = ::confirmNatively, declined = { text.get("native.declined", channels.locale) }) val saves = SaveSinks(this, getSharedPreferences("downloads", Context.MODE_PRIVATE), pickers, startActivity = { intent -> runOnUiThread { startActivity(intent) } }) // A process killed mid-download left unfinished files; nothing else will. Thread { saves.cleanUpAfterAKilledProcess() }.start() cast = CastChannels(this, onCasting = { on -> runOnUiThread { casting = on; keepAlive() } }, tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } }) channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } }, hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves, cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } }) WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels)) cast.control.warmUp() installShim() web.loadUrl(UiAssets.START) } private fun configure(web: WebView) { WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) web.settings.apply { javaScriptEnabled = true domStorageEnabled = true // IndexedDB and localStorage: session, resume positions allowFileAccess = false allowContentAccess = false // The page is our own bundled UI. With `true`, WebView wants play() inside the tap // handler itself, and the music player only calls it after fetching the track. mediaPlaybackRequiresUserGesture = false 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) } // : the system picker; the page reads what it is // given through the File objects the WebView makes of the URIs. // The callback is always answered, or the next chooser never opens. override fun onShowFileChooser(view: WebView, callback: android.webkit.ValueCallback>, params: FileChooserParams): Boolean { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*") .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE) Thread { val result = pickers.run(intent) // A single pick in multiple mode can come back with an // empty clipData and the file in `data`: take whichever // carries it, or the page receives a selection of nothing. val uris = result?.let { r -> val clip = r.clipData?.takeIf { it.itemCount > 0 } clip?.let { c -> (0 until c.itemCount).map { c.getItemAt(it).uri } } ?: listOfNotNull(r.data) }?.takeIf { it.isNotEmpty() }?.toTypedArray() runOnUiThread { callback.onReceiveValue(uris) } }.start() return true } override fun onConsoleMessage(m: ConsoleMessage): Boolean { if (BuildConfig.DEBUG) Log.i("MeshBayPage", "${m.messageLevel()} ${m.message()} @${m.sourceId()}:${m.lineNumber()}") return true } } } /** * The shim, with the hub address in it: the page reads that synchronously * while its modules load. Changing the hub replaces the shim and reloads — * a page left running would go on talking to the old hub with no sign of it. */ private fun installShim() { shim?.remove() val source = assets.open("bridge/meshbay-bridge.js").bufferedReader().use { it.readText() } val binary = WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER) val prelude = "const HUB_BASE = ${JSONObject.quote(hub.base)};\nconst BINARY = $binary;\n" + "const CAST = ${cast.control.available()};\n" shim = WebViewCompat.addDocumentStartJavaScript(web, "(function(){$prelude$source\n})();", setOf(UiAssets.ORIGIN)) } private fun reloadForHub() { installShim() web.loadUrl(UiAssets.START) } /** * A confirmation this process draws (main.js confirmNatively). Called from * a bridge worker, never the UI thread, which it waits on. The keyboard is * handed back to the page afterwards: after a dialog the document can stay * unfocused and every keystroke go nowhere (CLAUDE.md, ask.js). */ private fun confirmNatively(key: String): Boolean { val done = CountDownLatch(1) var accepted = false runOnUiThread { android.app.AlertDialog.Builder(this) .setMessage(text.get(key, channels.locale)) .setPositiveButton(text.get("dialog.ok", channels.locale)) { _, _ -> accepted = true } .setNegativeButton(text.get("dialog.cancel", channels.locale), null) .setOnDismissListener { web.requestFocus(); done.countDown() } .show() } done.await() return accepted } @Deprecated("Activity results for the system pickers; the platform API, kept for minSdk 26.") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (!pickers.deliver(requestCode, resultCode, data)) super.onActivityResult(requestCode, resultCode, data) } /** * A cast, or music playing here, keeps the process, the Wi-Fi and the page * alive with the screen off (spike S-2a, scenario F): the foreground service * holds the first two, the WebView reported visible holds the third. */ private fun keepAlive() { val on = casting || playing web.keepVisible = on val service = Intent(this, CastService::class.java) if (!on) { stopService(service); return } service.putExtra(CastService.EXTRA_TEXT, if (casting) "Casting on this Wi-Fi" else text.get("music.queue_title", channels.locale)) // Refused from the background on Android 12+; the page is then simply // frozen with the screen off, as it was before this existed. try { startForegroundService(service) } catch (e: IllegalStateException) { Log.w(Bridge.TAG, "keep-alive refused: $e") } } 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") } } /** * Android 12+ shows the launcher icon until the first frame is drawn, which * on a warm process is a flash. Holding that frame keeps it up long enough * to be seen; the WebView loads underneath in the meantime. */ private fun holdSplash() { val until = SystemClock.uptimeMillis() + SPLASH_MS root.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { override fun onPreDraw(): Boolean { if (SystemClock.uptimeMillis() < until) return false root.viewTreeObserver.removeOnPreDrawListener(this) return true } }) } /** Edge-to-edge is enforced from Android 15: keep the page clear of the bars and the keyboard. */ private fun applyInsets(view: View) { view.setOnApplyWindowInsetsListener { v, insets -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val bars = if (fullscreen != null) android.graphics.Insets.NONE else insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime() or WindowInsets.Type.displayCutout()) v.setPadding(bars.left, bars.top, bars.right, bars.bottom) } else { @Suppress("DEPRECATION") v.setPadding(insets.systemWindowInsetLeft, insets.systemWindowInsetTop, insets.systemWindowInsetRight, insets.systemWindowInsetBottom) } insets } } private fun setFullscreenBars(on: Boolean) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val c = window.insetsController ?: return if (on) { c.hide(WindowInsets.Type.systemBars()) c.systemBarsBehavior = android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } else c.show(WindowInsets.Type.systemBars()) } root.requestApplyInsets() } @Deprecated("Back is handed to the page in phase 4; until then it leaves fullscreen or backgrounds the app.") override fun onBackPressed() { if (fullscreen != null) { fullscreenCallback?.onCustomViewHidden(); return } if (web.canGoBack()) { web.goBack(); return } // Never finish(): that would tear down every connection and transfer. moveTaskToBack(true) } override fun onDestroy() { if (::cast.isInitialized && cast.relay.active) cast.relay.stop() if (casting || playing) { casting = false; playing = false; keepAlive() } if (::web.isInitialized) { root.removeView(web); web.destroy() } super.onDestroy() } companion object { private const val SPLASH_MS = 500L } }