diff --git a/e2e/.yarnrc.yml b/e2e/.yarnrc.yml index 446307c..4f11c57 100644 --- a/e2e/.yarnrc.yml +++ b/e2e/.yarnrc.yml @@ -1,3 +1,5 @@ nodeLinker: node-modules enableScripts: true -npmMinimalAgeGate: 0 +npmPreapprovedPackages: + - "@wvb/*" + - "@wvb-playground/*" diff --git a/e2e/package.json b/e2e/package.json index fc6a3f2..3f4337d 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -9,8 +9,8 @@ "test": "vitest run" }, "dependencies": { - "@wvb-playground/testing": "^0.0.0", - "@wvb-playground/webview-hacker-news": "^0.0.0" + "@wvb-playground/testing": "0.0.1", + "@wvb-playground/webview-hacker-news": "0.0.0" }, "devDependencies": { "@types/node": "^25", diff --git a/e2e/yarn.lock b/e2e/yarn.lock index b3bf82a..f274181 100644 --- a/e2e/yarn.lock +++ b/e2e/yarn.lock @@ -966,6 +966,24 @@ __metadata: languageName: node linkType: hard +"@wvb-playground/testing@npm:0.0.1": + version: 0.0.1 + resolution: "@wvb-playground/testing@npm:0.0.1" + peerDependencies: + playwright-core: ^1.40.0 + selenium-webdriver: ^4.0.0 + webdriverio: ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + playwright-core: + optional: true + selenium-webdriver: + optional: true + webdriverio: + optional: true + checksum: 10c0/348f5199a5eea52b0af30c069d3d8687fb44abd3e420f856219d1911904b61715b1eb3d074c89d9fbc3826514ec72a8dcd6f15205574b77137c74137d9085759 + languageName: node + linkType: hard + "@wvb-playground/testing@npm:^0.0.0": version: 0.0.0 resolution: "@wvb-playground/testing@npm:0.0.0" @@ -984,7 +1002,7 @@ __metadata: languageName: node linkType: hard -"@wvb-playground/webview-hacker-news@npm:^0.0.0": +"@wvb-playground/webview-hacker-news@npm:0.0.0": version: 0.0.0 resolution: "@wvb-playground/webview-hacker-news@npm:0.0.0" dependencies: @@ -5539,8 +5557,8 @@ __metadata: resolution: "webview-bundle-android-e2e@workspace:." dependencies: "@types/node": "npm:^25" - "@wvb-playground/testing": "npm:^0.0.0" - "@wvb-playground/webview-hacker-news": "npm:^0.0.0" + "@wvb-playground/testing": "npm:0.0.1" + "@wvb-playground/webview-hacker-news": "npm:0.0.0" appium: "npm:^3.5.0" execa: "npm:^9.5.2" tinyglobby: "npm:^0.2.10" diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index 7aef0ec..c051f79 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -9,6 +9,8 @@ android { defaultConfig { minSdk = 24 + // Keep the @JavascriptInterface bridge methods in minified consumer apps. + consumerProguardFiles("consumer-rules.pro") } compileOptions { @@ -27,6 +29,12 @@ android { keepDebugSymbols.add("**/*.so") } } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { diff --git a/lib/consumer-rules.pro b/lib/consumer-rules.pro new file mode 100644 index 0000000..a9457ec --- /dev/null +++ b/lib/consumer-rules.pro @@ -0,0 +1,6 @@ +# The `wvbAndroid` invoke bridge is reached only from JavaScript via +# @JavascriptInterface, so R8 sees no Kotlin/Java caller and would otherwise be +# free to strip or rename the exposed method in a minified consumer app. +-keepclasseswithmembers,includedescriptorclasses class dev.wvb.** { + @android.webkit.JavascriptInterface ; +} diff --git a/lib/src/main/kotlin/dev/wvb/Bridge.kt b/lib/src/main/kotlin/dev/wvb/Bridge.kt new file mode 100644 index 0000000..f7d6e88 --- /dev/null +++ b/lib/src/main/kotlin/dev/wvb/Bridge.kt @@ -0,0 +1,164 @@ +package dev.wvb + +import android.webkit.WebView +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject + +/** + * The native end of the `@wvb/bridge` `invoke()` bridge: the `window.wvbAndroid` + * object the web side posts `{ name, params, success, error }` messages to. It + * dispatches to a registered [handler] and replies via the JS success/error + * callbacks. + */ +class Bridge internal constructor() : AutoCloseable { + // Concurrent: registered on the UI thread, read from the background dispatch. + private val handlers = ConcurrentHashMap Any?>() + + // Handlers run on this background scope regardless of the delivering thread; + // only the final evaluateJavascript reply hops to the UI thread. + private val scope = CoroutineScope(SupervisorJob()) + + @Volatile + private var webView: WebView? = null + + /** + * Registers a handler for the `invoke()` command [name], replacing any existing one. + * + * [block] receives the decoded `params` (an `org.json` value or `null`), may + * suspend, and returns a JSON-encodable value. Throwing rejects the `Promise` + * with `{ code?, message }`; throw a [BridgeError] to control the `code`. + */ + @Suppress("unused") + fun handler(name: String, block: suspend (params: Any?) -> Any?): Bridge { + handlers[name] = block + return this + } + + /** Adds a reusable group of commands. See [BridgeHandlers]. */ + fun add(handlers: BridgeHandlers): Bridge { + handlers.register(this) + return this + } + + internal fun attach(webView: WebView) { + this.webView = webView + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener( + webView, INTERFACE_NAME, setOf("*"), + ) { _, message, _, isMainFrame, _ -> + if (!isMainFrame) { + Log.bridge.error("invoke message dropped: not from the main frame") + } else { + message.data?.let { dispatch(it) } + } + } + } else { + Log.bridge.warning( + "bridge not attached: this WebView does not supports WEB_MESSAGE_LISTENER " + + "(needs a modern WebView / Chrome 88+)", + ) + } + } + + /** + * Cancels in-flight dispatches and detaches from the WebView. Returned from + * [WebViewBundle.install]; call it when the WebView is destroyed. + */ + override fun close() { + scope.cancel() + webView = null + } + + private fun dispatch(message: String) { + val json = runCatching { JSONObject(message) }.getOrNull() ?: run { + Log.bridge.error("invoke message dropped: malformed JSON") + return + } + val name = json.optString("name") + val success = json.optString("success") + val error = json.optString("error") + // No callback to reply through: the message is dropped and the web Promise + // hangs with no other signal, so log it loudly. + if (success.isEmpty() || error.isEmpty()) { + Log.bridge.error( + "invoke message dropped: missing success/error callback " + + "(command: ${name.ifEmpty { "" }})", + ) + return + } + val params = json.opt("params")?.takeUnless { it === JSONObject.NULL } + scope.launch { + val (callback, arg) = try { + val handler = handlers[name] + ?: throw BridgeError( + code = "handler_not_found", + message = "no invoke handler registered for \"$name\"", + ) + success to encodeValue(handler(params)) + } catch (failure: Throwable) { + error to encodeError(failure) + } + reply(name, callback, arg) + } + } + + private fun reply(name: String, callback: String, jsonArg: String) { + val webView = webView ?: return + val js = escapeForJs("$callback($jsonArg)") + webView.post { + runCatching { webView.evaluateJavascript(js, null) } + .onFailure { + // Usually benign (the page navigated), so a warning, not an error. + Log.bridge.warning("invoke reply eval failed (command: $name): ${it.message}") + } + } + } + + internal companion object { + const val INTERFACE_NAME = "wvbAndroid" + + // U+2028/U+2029 are valid in JSON strings but are JS line terminators that + // would break the `callback()` we evaluate, so escape them. + val LINE_SEPARATOR: String = Char(0x2028).toString() + val PARAGRAPH_SEPARATOR: String = Char(0x2029).toString() + + fun encodeValue(value: Any?): String = when (value) { + null, JSONObject.NULL -> "null" + is JSONObject, is JSONArray -> value.toString() + is String -> JSONObject.quote(value) + is Boolean -> value.toString() + is Double -> if (value.isFinite()) value.toString() else "null" + is Float -> if (value.isFinite()) value.toString() else "null" + is Number -> value.toString() + is Map<*, *> -> JSONObject(value).toString() + is Collection<*> -> JSONArray(value).toString() + is Array<*> -> JSONArray(value.asList()).toString() + else -> throw BridgeError( + code = "unencodable_result", + message = "invoke handler returned a value that is not JSON-encodable: ${value::class.java.name}", + ) + } + + fun encodeError(error: Throwable): String { + val json = JSONObject().put("message", error.message ?: error.toString()) + (error as? BridgeFailure)?.code?.let { json.put("code", it) } + return json.toString() + } + + fun escapeForJs(js: String): String = + js.replace(LINE_SEPARATOR, "\\u2028").replace(PARAGRAPH_SEPARATOR, "\\u2029") + } +} + +/** A reusable, self-contained group of `invoke()` commands, added via [Bridge.add]. */ +interface BridgeHandlers { + /** Registers this group's commands on [bridge]. */ + fun register(bridge: Bridge) +} diff --git a/lib/src/main/kotlin/dev/wvb/BridgeCodec.kt b/lib/src/main/kotlin/dev/wvb/BridgeCodec.kt new file mode 100644 index 0000000..dfcdef1 --- /dev/null +++ b/lib/src/main/kotlin/dev/wvb/BridgeCodec.kt @@ -0,0 +1,72 @@ +package dev.wvb + +import org.json.JSONObject + +/** Decodes `invoke()` params into typed values. */ +internal object BridgeCodec { + /** The params object, or an empty one when absent. */ + fun params(raw: Any?): JSONObject = raw as? JSONObject ?: JSONObject() +} + +/** A required string param; throws [BridgeError] (`invalid_params`) if missing. */ +internal fun JSONObject.requireString(key: String): String { + if (!has(key) || isNull(key)) { + throw BridgeError(code = "invalid_params", message = "expected string param \"$key\"") + } + return getString(key) +} + +/** An optional string param; `null` when absent or JSON `null`. */ +internal fun JSONObject.optionalString(key: String): String? = + if (!has(key) || isNull(key)) null else getString(key) + +// Response payloads. These mirror the JSON the web side consumes from the +// Electron bridge (`@wvb/node`): the source kind is keyed `type` on the wire +// though the FFI names it `kind`, and nullable fields are omitted (putOpt) to +// match the TS `?` optionals. + +private fun BundleSourceKind.bridgeValue(): String = when (this) { + BundleSourceKind.BUILTIN -> "builtin" + BundleSourceKind.REMOTE -> "remote" +} + +internal fun BundleManifestMetadata.toJson(): JSONObject = + JSONObject() + .putOpt("etag", etag) + .putOpt("integrity", integrity) + .putOpt("signature", signature) + .putOpt("lastModified", lastModified) + +internal fun BundleSourceVersion.toJson(): JSONObject = + JSONObject().put("type", kind.bridgeValue()).put("version", version) + +internal fun ListBundleItem.toJson(): JSONObject = + JSONObject() + .put("type", kind.bridgeValue()) + .put("name", name) + .put("version", version) + .put("current", current) + .put("metadata", metadata.toJson()) + +internal fun ListRemoteBundleInfo.toJson(): JSONObject = + JSONObject().put("name", name).put("version", version) + +internal fun RemoteBundleInfo.toJson(): JSONObject = + JSONObject() + .put("name", name) + .put("version", version) + .putOpt("etag", etag) + .putOpt("integrity", integrity) + .putOpt("signature", signature) + .putOpt("lastModified", lastModified) + +internal fun BundleUpdateInfo.toJson(): JSONObject = + JSONObject() + .put("name", name) + .put("version", version) + .put("isAvailable", isAvailable) + .putOpt("localVersion", localVersion) + .putOpt("etag", etag) + .putOpt("integrity", integrity) + .putOpt("signature", signature) + .putOpt("lastModified", lastModified) diff --git a/lib/src/main/kotlin/dev/wvb/BridgeError.kt b/lib/src/main/kotlin/dev/wvb/BridgeError.kt new file mode 100644 index 0000000..47ee044 --- /dev/null +++ b/lib/src/main/kotlin/dev/wvb/BridgeError.kt @@ -0,0 +1,17 @@ +package dev.wvb + +/** + * A throwable that maps to the web-facing `{ code?, message }` bridge error + * shape. Implement it to deliver a `code` alongside the `message`; other thrown + * errors are encoded with their message and no `code`. + */ +interface BridgeFailure { + val code: String? + val message: String +} + +/** The canonical `{ code?, message }` error thrown to reject an `invoke()` command. */ +class BridgeError( + override val code: String? = null, + override val message: String, +) : RuntimeException(message), BridgeFailure diff --git a/lib/src/main/kotlin/dev/wvb/Log.kt b/lib/src/main/kotlin/dev/wvb/Log.kt new file mode 100644 index 0000000..ce968c7 --- /dev/null +++ b/lib/src/main/kotlin/dev/wvb/Log.kt @@ -0,0 +1,43 @@ +package dev.wvb + +import android.util.Log as AndroidLog + +/** + * Severity of an SDK log event; mirrors Rust `tracing` levels so core events can + * forward into the same pipeline (see [CoreLog]). + */ +internal enum class LogLevel { TRACE, DEBUG, INFO, WARNING, ERROR } + +/** Unified logcat channels. Filter on the `webview-bundle.*` tags. */ +internal object Log { + val bridge: Channel = Channel("bridge") + val core: Channel = Channel("core") + + internal class Channel(category: String) { + private val tag = "${WebViewBundle.LOG_SUBSYSTEM}.$category" + + fun error(message: String) = log(LogLevel.ERROR, message) + + fun warning(message: String) = log(LogLevel.WARNING, message) + + fun log(level: LogLevel, message: String) { + when (level) { + LogLevel.TRACE, LogLevel.DEBUG -> AndroidLog.d(tag, message) + LogLevel.INFO -> AndroidLog.i(tag, message) + LogLevel.WARNING -> AndroidLog.w(tag, message) + LogLevel.ERROR -> AndroidLog.e(tag, message) + } + } + } +} + +/** + * Seam for forwarding the Rust core's `tracing` into the unified `core` channel. + * The level/route mapping is implemented; wiring it to an FFI log-subscriber + * callback is pending (the FFI exposes none yet). + */ +internal object CoreLog { + fun forward(level: LogLevel, target: String, message: String) { + Log.core.log(level, "[$target] $message") + } +} diff --git a/lib/src/main/kotlin/dev/wvb/WebViewBundle.kt b/lib/src/main/kotlin/dev/wvb/WebViewBundle.kt index e7fd253..682e47a 100644 --- a/lib/src/main/kotlin/dev/wvb/WebViewBundle.kt +++ b/lib/src/main/kotlin/dev/wvb/WebViewBundle.kt @@ -5,21 +5,20 @@ import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient +import dev.wvb.WebViewBundle.Companion.getInstance import kotlinx.coroutines.runBlocking /** Remote endpoint configuration for [WebViewBundleUpdaterConfig]. */ -public data class WebViewBundleRemoteConfig( +data class WebViewBundleRemoteConfig( /** The base URL of the remote server, e.g. `"https://bundles.example.com"`. */ val endpoint: String, ) /** - * Updater configuration for [WebViewBundleConfig]. - * - * When present, [WebViewBundle] builds a [Remote] from [remote] and an [Updater] - * wired to the source, both exposed on the instance. + * Updater configuration for [WebViewBundleConfig]. When present, [WebViewBundle] + * builds a [Remote] and [Updater] wired to the source, both exposed on the instance. */ -public data class WebViewBundleUpdaterConfig( +data class WebViewBundleUpdaterConfig( val remote: WebViewBundleRemoteConfig, /** Release channel (e.g. `"stable"`, `"beta"`). */ val channel: String? = null, @@ -28,27 +27,35 @@ public data class WebViewBundleUpdaterConfig( ) /** - * High-level configuration for [WebViewBundle]. + * Configuration for [WebViewBundle]. * - * @property protocols the protocols to register, evaluated in order (first whose - * matcher accepts the host serves the request). [WebViewBundleProtocol.bundle] - * matches every host, so register it last. See [WebViewBundleProtocol.bundle] - * and [WebViewBundleProtocol.local]. - * @property source bundle source options. Defaults to app-private directories and - * extracts builtin bundles from the APK `assets`. + * @property protocols protocols to register, evaluated in order (first whose + * matcher accepts the host wins). [WebViewBundleProtocol.bundle] matches every + * host, so register it last. + * @property source bundle source options; defaults to app-private directories. * @property updater when set, a [Remote] and [Updater] are created and exposed. - * @property onError invoked when a registered protocol fails to serve a request; - * called on the WebView's request thread. + * @property onError invoked on the WebView's request thread when a protocol fails + * to serve a request. */ -// Not a `data class`: the function-typed [onError] has reference identity, which -// would give generated equals/hashCode/copy surprising, unstable semantics. -public class WebViewBundleConfig( - public val protocols: List, - public val source: SourceOptions = SourceOptions(), - public val updater: WebViewBundleUpdaterConfig? = null, - public val onError: ((Throwable) -> Unit)? = null, +class WebViewBundleConfig( + val protocols: List, + val source: SourceOptions = SourceOptions(), + val updater: WebViewBundleUpdaterConfig? = null, + val onError: ((Throwable) -> Unit)? = null, ) +/** Configuration for [WebViewBundle.install]. */ +class InstallOptions { + /** A [WebViewClient] whose callbacks are preserved; the bundle-serving client wraps it. */ + var delegate: WebViewClient? = null + + /** Disable adding the `window.wvbAndroid` [Bridge] interface. */ + var disableBridge: Boolean = false + + /** Register native handlers for web-side `invoke()` calls; runs against a fresh [Bridge]. */ + var bridge: (Bridge.() -> Unit)? = null +} + /** A registered protocol paired with its FFI request handler. */ private class RegisteredProtocol( val protocol: WebViewBundleProtocol, @@ -57,50 +64,31 @@ private class RegisteredProtocol( ) /** - * Serves webview-bundle resources to an Android [WebView]. - * - * Android `WebView` only treats `https` origins as first-class, so — unlike the - * iOS integration's custom URL scheme — requests are intercepted by **host**: - * each [WebViewBundleProtocol] owns a set of `https` hosts, and a matching - * request is resolved from the bundle [source] (or proxied to a local server) - * instead of hitting the network. The bundle name is the first label of the - * request host, e.g. `https://app.wvb/index.html` -> bundle `app`. - * - * ```kotlin - * val wvb = WebViewBundle(context, WebViewBundleConfig( - * protocols = listOf(WebViewBundleProtocol.bundle()), - * )) - * wvb.install(webView) - * webView.loadUrl("https://app.wvb/") - * ``` - * - * Keep a reference for the lifetime of the web view; it owns the native handlers. - * Tear down in order — `webView.destroy()` first, then [close] — to release the - * underlying FFI resources. + * The primary class for webview-bundle API. */ -public class WebViewBundle private constructor( +class WebViewBundle private constructor( /** The bundle source requests are served from. */ - public val source: BundleSource, + val source: BundleSource, /** The remote endpoint, when an [WebViewBundleUpdaterConfig] was provided. */ - public val remote: Remote?, + val remote: Remote?, /** The updater, when an [WebViewBundleUpdaterConfig] was provided. */ - public val updater: Updater?, + val updater: Updater?, private val protocols: List, private val onError: ((Throwable) -> Unit)?, ) : AutoCloseable { /** * Resolves [request] against the registered protocols, or returns `null` when - * no protocol owns the request host (so the WebView loads it normally). + * no protocol owns the host (so the WebView loads it normally). * - * Intended to be called from [WebViewClient.shouldInterceptRequest], which the - * WebView invokes on a background thread; this blocks on the suspending FFI - * handler. A handler failure is reported to [onError] and surfaced as a `500` - * response rather than leaking the request to the network. + * Called from [WebViewClient.shouldInterceptRequest] on a background thread; a + * handler failure is reported to [onError] and respond http response with `500` status. */ - public fun handleRequest(request: WebResourceRequest): WebResourceResponse? { + fun handleRequest(request: WebResourceRequest): WebResourceResponse? { val url = request.url ?: return null - if (!url.scheme.equals("https", ignoreCase = true)) return null + if (!url.scheme.equals("http", ignoreCase = true) && + !url.scheme.equals("https", ignoreCase = true) + ) return null val rawHost = url.host ?: return null // Hosts are case-insensitive, but the bundle name / local lookup downstream // is not, so canonicalize the host (only) to lowercase before dispatch. @@ -120,11 +108,8 @@ public class WebViewBundle private constructor( val response = runBlocking { registered.handle(method, uri, headers) } response.toWebResourceResponse() } catch (error: Exception) { - // Only handler *exceptions* fail closed as a 500; fatal `Error`s (OOM, - // a missing native library, …) are left to propagate rather than be - // masked as an HTTP response. Build the fail-closed response first, then - // report: a throwing onError must never suppress it or escape onto the - // WebView thread. + // Only handler exceptions fail closed as a 500; fatal Errors (OOM, a + // missing native library, …) propagate. val response = errorWebResourceResponse(error) runCatching { onError?.invoke(error) } response @@ -133,68 +118,67 @@ public class WebViewBundle private constructor( /** * A [WebViewClient] that serves the registered bundles, optionally forwarding - * its other callbacks to [delegate]. Assign it to [WebView.setWebViewClient]. - * - * Low-level seam for callers that manage the [WebView] themselves; [install] - * is the high-level entry point. + * other callbacks to [delegate]. Low-level seam; [install] is the high-level entry. */ - public fun createWebViewClient(delegate: WebViewClient? = null): WebViewClient = + fun createWebViewClient(delegate: WebViewClient? = null): WebViewClient = WebViewBundleClient(this, delegate) /** - * Wires [webView] to serve the registered bundles. - * - * By default this applies the recommended WebView settings (JavaScript, DOM - * storage, mixed-content and file-access hardening), installs a bundle-serving - * [WebViewClient], and routes Service Worker requests through the bundle. - * Customize or opt out of any of these via the [configure] lambda — see - * [InstallOptions]: - * - * ```kotlin - * wvb.install(webView) // sensible defaults - * wvb.install(webView) { - * delegate = myWebViewClient - * webContentsDebuggingEnabled = BuildConfig.DEBUG - * installServiceWorker = false - * configureWebView = { it.settings.userAgentString = "MyApp" } - * } - * ``` + * Install webview bundle features to given [webView]. */ - public fun install(webView: WebView, configure: InstallOptions.() -> Unit = {}) { + fun install(webView: WebView, configure: InstallOptions.() -> Unit = {}): AutoCloseable { val options = InstallOptions().apply(configure) - options.applySettings(webView) webView.webViewClient = createWebViewClient(options.delegate) - if (options.installServiceWorker) { - installServiceWorkerClient() + val bridgeHandle = if (!options.disableBridge) { + Bridge().also { bridge -> + options.bridge?.invoke(bridge) + bridge.add(WebViewBundleBridge(this@WebViewBundle)) + bridge.attach(webView) + } + } else { + AutoCloseable {} } - options.configureWebView?.invoke(webView) + return bridgeHandle } /** - * Releases the native handlers and the source, remote, and updater handles. - * - * Call after `webView.destroy()` has returned, so the WebView can no longer - * dispatch [handleRequest]. Closing while a request is still in flight is safe - * — no use-after-free — but the racing request fails closed with a `500` - * rather than serving the resource, because its FFI handle is destroyed. + * Releases the source, remote, and updater handles and clears the process-wide + * instance so a later [getInstance] rebuilds it. Rarely needed (app shutdown / + * tests); does not touch per-WebView bridges. */ override fun close() { + synchronized(Companion) { if (instance === this) instance = null } protocols.forEach { runCatching { it.closeable.close() } } runCatching { updater?.close() } runCatching { remote?.close() } runCatching { source.close() } } - public companion object { + companion object { + /** The logcat tag prefix the SDK logs under; filter on `webview-bundle.*`. */ + const val LOG_SUBSYSTEM: String = "webview-bundle" + + @Volatile + private var instance: WebViewBundle? = null + /** - * Builds a [WebViewBundle] from a high-level [WebViewBundleConfig]. + * Returns the process-wide [WebViewBundle], building it from [config] on the + * first call. Single-instance per process: [config] is honored only on the + * first call; later calls return the existing instance and ignore it. * - * @throws Exception if the source cannot be built (e.g. builtin assets fail - * to extract) or a native handle cannot be created (e.g. an invalid remote - * endpoint); any handles already allocated are released before the - * exception propagates. + * @throws Exception if the source or a native handle cannot be built; any + * handles already allocated are released first. */ - public operator fun invoke(context: Context, config: WebViewBundleConfig): WebViewBundle { + fun getInstance(context: Context, config: WebViewBundleConfig): WebViewBundle = + instance ?: synchronized(this) { + instance ?: create(context.applicationContext, config).also { instance = it } + } + + /** Alias for [getInstance]; returns the shared instance, building it on first use. */ + operator fun invoke(context: Context, config: WebViewBundleConfig): WebViewBundle = + getInstance(context, config) + + private fun create(context: Context, config: WebViewBundleConfig): WebViewBundle { val source = makeBundleSource(context, config.source) var remote: Remote? = null @@ -203,7 +187,8 @@ public class WebViewBundle private constructor( try { config.updater?.let { updaterConfig -> val createdRemote = Remote(updaterConfig.remote.endpoint) - remote = createdRemote // assign before Updater(), so a throw there still closes it + remote = + createdRemote // assign before Updater(), so a throw there still closes it updater = Updater( source, createdRemote, @@ -221,10 +206,12 @@ public class WebViewBundle private constructor( val handler = BundleUrlHandler(source) RegisteredProtocol(protocol, handler::handle, handler) } + is WebViewBundleProtocol.Local -> { // Lowercase the host keys to match the case-insensitive // matcher and the lowercased request host. - val handler = LocalUrlHandler(protocol.hosts.mapKeys { it.key.lowercase() }) + val handler = + LocalUrlHandler(protocol.hosts.mapKeys { it.key.lowercase() }) RegisteredProtocol(protocol, handler::handle, handler) } } @@ -242,10 +229,10 @@ public class WebViewBundle private constructor( } } -/** Builds a [WebViewBundle] from a high-level [WebViewBundleConfig]. */ -public fun webViewBundle(context: Context, config: WebViewBundleConfig): WebViewBundle = - WebViewBundle(context, config) +/** Alias for [WebViewBundle.getInstance]; returns the shared instance, building it on first use. */ +fun webViewBundle(context: Context, config: WebViewBundleConfig): WebViewBundle = + getInstance(context, config) /** Short alias for [webViewBundle]. */ -public fun wvb(context: Context, config: WebViewBundleConfig): WebViewBundle = - WebViewBundle(context, config) +fun wvb(context: Context, config: WebViewBundleConfig): WebViewBundle = + getInstance(context, config) diff --git a/lib/src/main/kotlin/dev/wvb/WebViewBundleBridge.kt b/lib/src/main/kotlin/dev/wvb/WebViewBundleBridge.kt new file mode 100644 index 0000000..f6a1a51 --- /dev/null +++ b/lib/src/main/kotlin/dev/wvb/WebViewBundleBridge.kt @@ -0,0 +1,119 @@ +package dev.wvb + +import org.json.JSONArray + +/** + * Bridges to communicate with the WebView. + */ +internal class WebViewBundleBridge(private val wvb: WebViewBundle) : BridgeHandlers { + override fun register(bridge: Bridge) { + registerSource(bridge) + registerRemote(bridge) + registerUpdater(bridge) + } + + private fun registerSource(bridge: Bridge) { + val source = wvb.source + bridge.handler("sourceListBundles") { JSONArray(source.listBundles().map { it.toJson() }) } + bridge.handler("sourceLoadVersion") { params -> + source.loadVersion(BridgeCodec.params(params).requireString("bundleName"))?.toJson() + } + bridge.handler("sourceUpdateVersion") { params -> + val p = BridgeCodec.params(params) + source.updateVersion(p.requireString("bundleName"), p.requireString("version")) + null + } + bridge.handler("sourceResolveFilepath") { params -> + source.resolveFilepath(BridgeCodec.params(params).requireString("bundleName")) + } + bridge.handler("sourceGetBuiltinBundleFilepath") { params -> + val p = BridgeCodec.params(params) + source.getBuiltinBundleFilepath( + p.requireString("bundleName"), p.requireString("version") + ) + } + bridge.handler("sourceGetRemoteBundleFilepath") { params -> + val p = BridgeCodec.params(params) + source.getRemoteBundleFilepath( + p.requireString("bundleName"), p.requireString("version") + ) + } + bridge.handler("sourceLoadBuiltinMetadata") { params -> + val p = BridgeCodec.params(params) + source.loadBuiltinMetadata(p.requireString("bundleName"), p.requireString("version")) + ?.toJson() + } + bridge.handler("sourceLoadRemoteMetadata") { params -> + val p = BridgeCodec.params(params) + source.loadRemoteMetadata(p.requireString("bundleName"), p.requireString("version")) + ?.toJson() + } + bridge.handler("sourceUnloadDescriptor") { params -> + source.unloadDescriptor(BridgeCodec.params(params).requireString("bundleName")) + } + bridge.handler("sourceRemoveRemoteBundle") { params -> + val p = BridgeCodec.params(params) + source.removeRemoteBundle(p.requireString("bundleName"), p.requireString("version")) + } + bridge.handler("sourceRemoteRetainedVersions") { params -> + source.remoteRetainedVersions(BridgeCodec.params(params).requireString("bundleName")) + } + bridge.handler("sourcePruneRemoteBundles") { params -> + source.pruneRemoteBundles(BridgeCodec.params(params).requireString("bundleName")) + } + } + + private fun registerRemote(bridge: Bridge) { + bridge.handler("remoteListBundles") { params -> + val channel = BridgeCodec.params(params).optionalString("channel") + JSONArray(requireRemote().listBundles(channel).map { it.toJson() }) + } + bridge.handler("remoteGetInfo") { params -> + val p = BridgeCodec.params(params) + requireRemote().getInfo(p.requireString("bundleName"), p.optionalString("channel")) + .toJson() + } + bridge.handler("remoteDownload") { params -> + val p = BridgeCodec.params(params) + requireRemote().download( + p.requireString("bundleName"), p.optionalString("channel") + ).info.toJson() + } + bridge.handler("remoteDownloadVersion") { params -> + val p = BridgeCodec.params(params) + requireRemote().downloadVersion( + p.requireString("bundleName"), p.requireString("version") + ).info.toJson() + } + } + + private fun registerUpdater(bridge: Bridge) { + bridge.handler("updaterListRemotes") { + JSONArray( + requireUpdater().listRemotes().map { it.toJson() }) + } + bridge.handler("updaterGetUpdate") { params -> + requireUpdater().getUpdate(BridgeCodec.params(params).requireString("bundleName")) + .toJson() + } + bridge.handler("updaterDownload") { params -> + val p = BridgeCodec.params(params) + requireUpdater().downloadUpdate( + p.requireString("bundleName"), p.optionalString("version") + ).toJson() + } + bridge.handler("updaterInstall") { params -> + val p = BridgeCodec.params(params) + requireUpdater().install(p.requireString("bundleName"), p.requireString("version")) + null + } + } + + private fun requireRemote(): Remote = wvb.remote ?: throw BridgeError( + code = "remote_not_initialized", message = "remote is not initialized." + ) + + private fun requireUpdater(): Updater = wvb.updater ?: throw BridgeError( + code = "updater_not_initialized", message = "updater is not initialized." + ) +} diff --git a/lib/src/main/kotlin/dev/wvb/WebViewBundleClient.kt b/lib/src/main/kotlin/dev/wvb/WebViewBundleClient.kt index d325ca0..fccfde0 100644 --- a/lib/src/main/kotlin/dev/wvb/WebViewBundleClient.kt +++ b/lib/src/main/kotlin/dev/wvb/WebViewBundleClient.kt @@ -17,20 +17,6 @@ import androidx.annotation.RequiresApi /** * A [WebViewClient] that serves webview-bundle resources by intercepting requests * to the registered protocol hosts. - * - * Obtained via [WebViewBundle.install] or [WebViewBundle.createWebViewClient] — - * never constructed directly. Requests the bundle owns are served from - * [WebViewBundle.handleRequest]; unhandled requests and a fixed set of common - * callbacks are forwarded to the optional [delegate]: `shouldInterceptRequest`, - * `shouldOverrideUrlLoading`, `onPageStarted`, `onPageCommitVisible`, - * `onPageFinished`, `onLoadResource`, `doUpdateVisitedHistory`, `onReceivedError`, - * `onReceivedHttpError`, `onReceivedSslError`, `onReceivedHttpAuthRequest`, - * `onReceivedClientCertRequest`, and `onRenderProcessGone`. - * - * Any **other** [WebViewClient] override on the delegate is not invoked — the - * framework dispatches to this class's concrete methods. If you need one, implement - * your own [WebViewClient] and call [WebViewBundle.handleRequest] from - * `shouldInterceptRequest`. */ internal class WebViewBundleClient( private val owner: WebViewBundle, @@ -43,13 +29,6 @@ internal class WebViewBundleClient( ): WebResourceResponse? = owner.handleRequest(request) ?: delegate?.shouldInterceptRequest(view, request) - // --- delegation of the commonly-overridden callbacks --------------------- - // The framework dispatches to concrete overrides, so each forwarded callback - // must be spelled out. The bundle itself only needs shouldInterceptRequest; - // these simply preserve a wrapped client's behavior. The `delegate?.x() ?: - // super.x()` form runs the delegate when present (its non-null/Unit result - // suppresses super) and the default otherwise. - override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = delegate?.shouldOverrideUrlLoading(view, request) ?: super.shouldOverrideUrlLoading(view, request) @@ -80,7 +59,11 @@ internal class WebViewBundleClient( request: WebResourceRequest, error: WebResourceError, ) { - delegate?.onReceivedError(view, request, error) ?: super.onReceivedError(view, request, error) + delegate?.onReceivedError(view, request, error) ?: super.onReceivedError( + view, + request, + error + ) } override fun onReceivedHttpError( @@ -115,14 +98,7 @@ internal class WebViewBundleClient( ?: super.onReceivedClientCertRequest(view, request) } - // Boolean return: use an explicit null check, not elvis, so a delegate - // returning `false` (handled, don't kill the process) is honored. The - // framework only dispatches this on API 26+, so the API-26 calls are safe. @RequiresApi(Build.VERSION_CODES.O) override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean = - if (delegate != null) { - delegate.onRenderProcessGone(view, detail) - } else { - super.onRenderProcessGone(view, detail) - } + delegate?.onRenderProcessGone(view, detail) ?: super.onRenderProcessGone(view, detail) } diff --git a/lib/src/main/kotlin/dev/wvb/WebViewBundleProtocol.kt b/lib/src/main/kotlin/dev/wvb/WebViewBundleProtocol.kt index 0240118..eaa682b 100644 --- a/lib/src/main/kotlin/dev/wvb/WebViewBundleProtocol.kt +++ b/lib/src/main/kotlin/dev/wvb/WebViewBundleProtocol.kt @@ -1,39 +1,34 @@ package dev.wvb + /** * Configures which request hosts a [WebViewBundleProtocol.Bundle] **passes through * to the network** instead of serving from the bundle. - * - * A bundle protocol handles every `https` host by default — like the core, the - * bundle name is the first host label (`https://app.wvb/` -> bundle `app`). Use - * this builder to carve out external origins the page talks to (APIs, CDNs, - * analytics), so those reach the real network instead of being resolved to a - * (missing) bundle. Hosts are matched case-insensitively. - * - * Obtained via the `bundle { ... }` builder; never constructed directly. */ -public class BundlePassthrough internal constructor() { +class BundlePassthrough internal constructor() { private val exactHosts = mutableSetOf() private val domainSuffixes = mutableListOf() private val predicates = mutableListOf<(String) -> Boolean>() /** Pass requests to this exact host through to the network, e.g. `"example.com"`. */ - public fun passthrough(host: String) { + @Suppress("unused") + fun passthrough(host: String) { exactHosts += host.trim().lowercase() } /** - * Pass requests to any host under [domain] through to the network — `domain = - * "example.com"` matches `example.com` and `cdn.example.com`. + * Pass requests to any host under [domain] through to the network. */ - public fun passthroughDomain(domain: String) { + @Suppress("unused") + fun passthroughDomain(domain: String) { val normalized = domain.trim().trim('.').lowercase() require(normalized.isNotEmpty()) { "passthrough domain must not be empty" } domainSuffixes += normalized } /** Pass requests whose host satisfies [predicate] through to the network. */ - public fun passthrough(predicate: (host: String) -> Boolean) { + @Suppress("unused") + fun passthrough(predicate: (host: String) -> Boolean) { predicates += predicate } @@ -46,25 +41,9 @@ public class BundlePassthrough internal constructor() { } /** - * Binds `https` hosts served inside a `WebView` to a webview-bundle request - * handler. - * - * Android `WebView` only treats `https` origins as first-class, so — unlike iOS's - * custom URL scheme — a protocol is selected by the request **host**. A matching - * request is resolved from the bundle source (for [Bundle]) or proxied to a local - * server (for [Local]); everything else falls through to the network. The bundle - * name is the first label of the request host, e.g. `https://app.wvb/index.html` - * -> bundle `app`, path `/index.html`. Hosts are matched case-insensitively. - * - * Protocols are evaluated in registration order (first whose matcher accepts the - * host serves it). [bundle] matches **every** host by default, so register it - * **last**, after any [local] or otherwise-scoped protocols, or it will shadow - * them. - * - * Use the [WebViewBundleProtocol.bundle] and [WebViewBundleProtocol.local] - * factories to construct instances. + * Binds `https` hosts served inside a `WebView` to a webview-bundle request handler. */ -public sealed class WebViewBundleProtocol { +sealed class WebViewBundleProtocol { /** Returns `true` if this protocol should serve a request to [host]. */ internal abstract fun matches(host: String): Boolean @@ -74,7 +53,7 @@ public sealed class WebViewBundleProtocol { * Handles every `https` host (bundle name = first host label) except those the * optional [passthrough] sends to the network. */ - public class Bundle internal constructor( + class Bundle internal constructor( private val passthrough: BundlePassthrough?, ) : WebViewBundleProtocol() { override fun matches(host: String): Boolean = @@ -87,27 +66,23 @@ public sealed class WebViewBundleProtocol { * [hosts] maps a full request host to a local base URL, e.g. * `mapOf("app.wvb" to "http://10.0.2.2:3000")`. The key is matched against the * entire request host (case-insensitive). - * - * On the Android emulator the host machine's `localhost` is reachable at - * `10.0.2.2`, and cleartext `http` to it requires a network security config. */ - public class Local internal constructor( + class Local internal constructor( internal val hosts: Map, ) : WebViewBundleProtocol() { private val keys = hosts.keys.map { it.lowercase() }.toSet() override fun matches(host: String): Boolean = host.lowercase() in keys } - public companion object { + companion object { /** - * A [Bundle] protocol that serves **every** `https` host from the bundle - * source (bundle name = first host label). Register it last, as it matches - * all hosts. + * A [Bundle] protocol that serves data from the bundle source. */ - public fun bundle(): Bundle = Bundle(null) + @Suppress("unUsed") + fun bundle(): Bundle = Bundle(null) /** - * A [Bundle] protocol that serves every `https` host **except** the ones + * A [Bundle] protocol that serves data from the bundle source, but passes * the [passthrough] builder sends to the network: * * ```kotlin @@ -121,13 +96,15 @@ public sealed class WebViewBundleProtocol { * To instead serve only a specific domain, invert it with a predicate, e.g. * `bundle { passthrough { host -> !host.endsWith(".wvb") } }`. */ - public fun bundle(passthrough: BundlePassthrough.() -> Unit): Bundle = + @Suppress("unUsed") + fun bundle(passthrough: BundlePassthrough.() -> Unit): Bundle = Bundle(BundlePassthrough().apply(passthrough)) /** * A [Local] dev-proxy protocol mapping full request hosts to local base * URLs, e.g. `local(mapOf("app.wvb" to "http://10.0.2.2:3000"))`. */ - public fun local(hosts: Map): Local = Local(hosts) + @Suppress("unUsed") + fun local(hosts: Map): Local = Local(hosts) } } diff --git a/lib/src/main/kotlin/dev/wvb/WebViewSetup.kt b/lib/src/main/kotlin/dev/wvb/WebViewSetup.kt deleted file mode 100644 index 45782b6..0000000 --- a/lib/src/main/kotlin/dev/wvb/WebViewSetup.kt +++ /dev/null @@ -1,108 +0,0 @@ -package dev.wvb - -import android.annotation.SuppressLint -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -import android.webkit.WebSettings -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.webkit.ServiceWorkerClientCompat -import androidx.webkit.ServiceWorkerControllerCompat -import androidx.webkit.WebViewFeature - -/** - * Configuration for [WebViewBundle.install], applied to the [WebView] before it - * starts loading. - * - * Sensible defaults make a typical SPA bundle work out of the box; override any - * field in the `install(webView) { ... }` lambda, or set - * [applyRecommendedSettings] to `false` and configure the WebView yourself. The - * [configureWebView] hook runs last as an escape hatch for anything not exposed - * here. - * - * @property delegate a [WebViewClient] whose callbacks are preserved; the - * bundle-serving client wraps it. - * @property applyRecommendedSettings apply the WebView settings below. Set `false` - * to leave [WebView.getSettings] untouched. - * @property javaScriptEnabled SPA bundles need this; defaults `true` (the platform - * default is `false`). - * @property domStorageEnabled enable DOM storage; defaults `true` (platform - * default is `false`). - * @property mixedContentMode the bundle origin is always `https`, so the default - * forbids mixed content. - * @property allowFileAccess / [allowContentAccess] hardened off by default. - * @property webContentsDebuggingEnabled enable `chrome://inspect` process-wide; - * gate on a debug build. Applied independently of [applyRecommendedSettings], - * and only ever enables (never force-disables) the process-global flag. - * @property installServiceWorker route Service Worker requests through the bundle - * (see [installServiceWorkerClient]); defaults `true`. - * @property configureWebView final hook to customize the [WebView] after the - * options above are applied. - */ -public class InstallOptions { - public var delegate: WebViewClient? = null - public var applyRecommendedSettings: Boolean = true - public var javaScriptEnabled: Boolean = true - public var domStorageEnabled: Boolean = true - public var mixedContentMode: Int = WebSettings.MIXED_CONTENT_NEVER_ALLOW - public var allowFileAccess: Boolean = false - public var allowContentAccess: Boolean = false - public var webContentsDebuggingEnabled: Boolean = false - public var installServiceWorker: Boolean = true - public var configureWebView: ((WebView) -> Unit)? = null - - @SuppressLint("SetJavaScriptEnabled") - internal fun applySettings(webView: WebView) { - if (applyRecommendedSettings) { - webView.settings.apply { - javaScriptEnabled = this@InstallOptions.javaScriptEnabled - domStorageEnabled = this@InstallOptions.domStorageEnabled - mixedContentMode = this@InstallOptions.mixedContentMode - allowFileAccess = this@InstallOptions.allowFileAccess - allowContentAccess = this@InstallOptions.allowContentAccess - @Suppress("DEPRECATION") - allowFileAccessFromFileURLs = false - @Suppress("DEPRECATION") - allowUniversalAccessFromFileURLs = false - } - } - // Independent of applyRecommendedSettings, so it is honored even when the - // recommended per-WebView settings are skipped. This only ever *enables* - // debugging: the flag is process-global, so we never force-disable it and - // clobber a value the host app set for its own WebViews. - if (webContentsDebuggingEnabled) { - WebView.setWebContentsDebuggingEnabled(true) - } - } -} - -/** - * Routes Service Worker network requests through this bundle's request handler. - * - * Service Workers fetch resources off the document's network stack, so they - * **bypass** [android.webkit.WebViewClient.shouldInterceptRequest]: a bundle that - * registers a Service Worker would otherwise see its worker requests escape to the - * network. This installs an `androidx.webkit` Service Worker client that delegates - * to [WebViewBundle.handleRequest], mirroring the main interceptor. - * - * Called automatically by [WebViewBundle.install] unless - * [InstallOptions.installServiceWorker] is `false`. The Service Worker controller - * is **process-global**: this replaces any Service Worker client previously set in - * the process, so the last instance to install wins. Returns `true` when - * installed, or `false` when the WebView implementation does not support Service - * Worker interception. - */ -public fun WebViewBundle.installServiceWorkerClient(): Boolean { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.SERVICE_WORKER_BASIC_USAGE) || - !WebViewFeature.isFeatureSupported(WebViewFeature.SERVICE_WORKER_SHOULD_INTERCEPT_REQUEST) - ) { - return false - } - ServiceWorkerControllerCompat.getInstance().setServiceWorkerClient( - object : ServiceWorkerClientCompat() { - override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? = - handleRequest(request) - }, - ) - return true -} diff --git a/testapp/src/main/assets/bundles/hacker-news/hacker-news_0.0.1.wvb b/testapp/src/main/assets/bundles/hacker-news/hacker-news_0.0.1.wvb index ff309bc..91eac0b 100644 Binary files a/testapp/src/main/assets/bundles/hacker-news/hacker-news_0.0.1.wvb and b/testapp/src/main/assets/bundles/hacker-news/hacker-news_0.0.1.wvb differ diff --git a/testapp/src/main/assets/bundles/manifest.json b/testapp/src/main/assets/bundles/manifest.json index f1c78a1..ffc24ae 100644 --- a/testapp/src/main/assets/bundles/manifest.json +++ b/testapp/src/main/assets/bundles/manifest.json @@ -1,7 +1,6 @@ { "manifestVersion": 1, "entries": { - "next": { "versions": { "1.0.0": {} }, "currentVersion": "1.0.0" }, "hacker-news": { "versions": { "0.0.1": {} }, "currentVersion": "0.0.1" } } } diff --git a/testapp/src/main/assets/bundles/next/next_1.0.0.wvb b/testapp/src/main/assets/bundles/next/next_1.0.0.wvb deleted file mode 100644 index 865fc2c..0000000 Binary files a/testapp/src/main/assets/bundles/next/next_1.0.0.wvb and /dev/null differ diff --git a/testapp/src/main/kotlin/dev/wvb/testapp/MainActivity.kt b/testapp/src/main/kotlin/dev/wvb/testapp/MainActivity.kt index 099bf45..6240faa 100644 --- a/testapp/src/main/kotlin/dev/wvb/testapp/MainActivity.kt +++ b/testapp/src/main/kotlin/dev/wvb/testapp/MainActivity.kt @@ -1,11 +1,16 @@ package dev.wvb.testapp +import android.annotation.SuppressLint import android.app.Activity +import android.os.Build import android.os.Bundle import android.util.Log +import android.view.KeyEvent import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.WindowInsets import android.webkit.WebResourceError import android.webkit.WebResourceRequest +import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import android.widget.LinearLayout @@ -13,23 +18,15 @@ import dev.wvb.WebViewBundle import dev.wvb.WebViewBundleConfig import dev.wvb.WebViewBundleProtocol -/** - * Minimal WebView host for the E2E suite. - * - * It builds a [WebViewBundle], installs it on a full-screen [WebView], and loads - * the builtin `hacker-news` bundle. The app carries no test logic of its own — all - * scenarios live in the e2e suite (`@wvb-playground/webview-hacker-news`) and drive - * this WebView through Appium's WEBVIEW context. - */ class MainActivity : Activity() { - private lateinit var wvb: WebViewBundle private lateinit var webView: WebView + @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - wvb = WebViewBundle( + val wvb = WebViewBundle.getInstance( this, WebViewBundleConfig( protocols = listOf(WebViewBundleProtocol.bundle()), @@ -38,18 +35,43 @@ class MainActivity : Activity() { ) webView = WebView(this) + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + allowFileAccess = false + allowContentAccess = false + } + WebView.setWebContentsDebuggingEnabled(true) wvb.install(webView) { delegate = errorLoggingClient - // Required so the e2e harness can attach via the WEBVIEW (chromedriver) context. - webContentsDebuggingEnabled = true } - setContentView( - LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - addView(webView, LinearLayout.LayoutParams(MATCH_PARENT, 0).apply { weight = 1f }) - }, - ) + // Navigate to -1 when back button clicked. + webView.setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP && !event.isCanceled && webView.canGoBack()) { + webView.goBack() + true + } else { + false + } + } + + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(webView, LinearLayout.LayoutParams(MATCH_PARENT, 0).apply { weight = 1f }) + } + setContentView(root) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + root.setOnApplyWindowInsetsListener { view, insets -> + val safe = insets.getInsets( + WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout(), + ) + view.setPadding(safe.left, safe.top, safe.right, safe.bottom) + WindowInsets.CONSUMED + } + } Log.i(TAG, "loading $START_URL") webView.loadUrl(START_URL) @@ -57,7 +79,6 @@ class MainActivity : Activity() { override fun onDestroy() { webView.destroy() - wvb.close() super.onDestroy() }