KMPwright

Android

Drive an Android WebView with KMPwright, including attaching to your own WebView, threading, client forwarding, renderer crashes, screenshots and the limits of WebView interception.

On Android, KMPwright drives an android.webkit.WebView in your app's process. It either attaches to a WebView you already show or creates and owns a detached one.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val webView = WebView(this)
        setContentView(webView)

        // Main thread: the constructor checks it.
        val browser = Browser(AndroidPlatformContext(this, webView = webView))
        val page = browser.page()

        lifecycleScope.launch {
            page.goto("https://example.com")
            page.getByRole("link", name = "Learn more").click()
        }
    }
}

AndroidPlatformContext

public class AndroidPlatformContext(
    public val context: Context,
    public val webView: WebView? = null,
    public val delegateWebViewClient: WebViewClient? = null,
    public val delegateWebChromeClient: WebChromeClient? = null
) : PlatformContext

Prop

Type

Your WebView vs a KMPwright-created one

webView = yourWebViewwebView = null
Default page (browser.page())Drives your WebViewDrives a new WebView KMPwright creates
WebView settingsYours, except javaScriptEnabled is set to true (every locator, wait and assertion runs page JavaScript)javaScriptEnabled, domStorageEnabled, no file or content access, MIXED_CONTENT_NEVER_ALLOW, useWideViewPort, loadWithOverviewMode
KmpwrightConfig.userAgentReplaces the WebView's user agentApplied at creation
KmpwrightConfig.viewportNot applied: your layout sizes the WebViewApplied at creation, in CSS pixels
On page.close()Clients restored, init scripts removed, loading stopped. The WebView stays alive.WebView removed from its parent and destroyed
Visible on screenYesNo: it is not attached to a window

browser.newPage() always creates a new KMPwright-owned WebView (on the main thread). There is no public API to get that WebView, so use webView = yourWebView (or kmpwright-compose) when the user should see the page.

Initialization and storage

Creating a Browser with an AndroidPlatformContext records context.applicationContext.filesDir. Saved auth profiles (AuthManager) live in filesDir/kmpwright/auth. If you use AuthManager before creating any Browser, call this first:

KmpwrightAndroid.initialize(applicationContext)

Otherwise AuthManager throws KmpwrightException: "Kmpwright storage is not initialized on Android. Create a Browser with AndroidPlatformContext or call KmpwrightAndroid.initialize(context) before using auth profiles or saved state."

StorageState.save(path, password) and page.autosaveStorageState(path, ...) write to the path you pass. Use a path under context.filesDir.

Threading

WebView is single-threaded. KMPwright confines all WebView work to the main looper internally, so:

  • Create the Browser on the main thread. The constructor throws IllegalStateException otherwise: "Browser(AndroidPlatformContext) must be called on the Android main thread (WebView is single-threaded). Create it from Activity/Fragment/Compose code or wrap it in withContext(Dispatchers.Main)."
  • Every other API can be called from any dispatcher. newPage(), navigation, evaluation, cookies, screenshots and input switch to the main thread themselves.
  • Route handlers run on Dispatchers.Default, not on the main thread, so a busy UI cannot stall network interception.

Your clients keep working

KMPwright installs its own WebViewClient and WebChromeClient on the WebView and forwards every non-deprecated callback to a delegate:

  1. delegateWebViewClient / delegateWebChromeClient if you passed them,
  2. otherwise the clients that were on the WebView when the Browser attached.

On page.close() the original clients are put back. Two exceptions run before your client:

  • shouldInterceptRequest: the domain filter runs first (it is a security boundary), then your client. If your client returns a response, KMPwright routes do not see that request.
  • onRenderProcessGone: your client is called, then KMPwright always returns true so the renderer crash does not kill your app.

Do not assign webView.webViewClient = … after creating the Browser. That replaces KMPwright's client and disables navigation tracking, routing and the domain filter. Pass your client as delegateWebViewClient instead.

Coordinates

All public coordinates are CSS pixels, the same as getBoundingClientRect(): page.mouse, page.touchscreen, the position of locator clicks, the clip of page.screenshot, Locator.boundingBox() and setViewportSize. KMPwright converts them to WebView pixels with devicePixelRatio times the visual-viewport (pinch-zoom) scale, falling back to the display density.

Screenshot output is in device pixels times the screenshot scale, so a 400×800 CSS viewport on a 2.625x display produces a 1050×2100 image.

Renderer crashes

When the renderer process dies (crash or killed by the system):

  • pending navigations finish with ok = false and error "Page crashed: the Android WebView renderer process crashed" (or "was killed by the system"),
  • pending and later evaluations throw KmpwrightException: "Page crashed: the Android WebView renderer process is gone",
  • AndroidWebViewState.rendererGone becomes true,
  • a KMPwright-owned WebView is removed and destroyed (unless your delegate's onRenderProcessGone returned true),
  • your WebView is not touched: remove and destroy it yourself, then create a new Browser.
val state = page.androidWebViewState() ?: return  // null on iOS and JVM
lifecycleScope.launch {
    state.collect { s ->
        if (s.rendererGone) {
            (webView.parent as? ViewGroup)?.removeView(webView)
            webView.destroy()
            // recreate the WebView and the Browser
        }
    }
}

Page.androidWebViewState()

public fun Page.androidWebViewState(): StateFlow<AndroidWebViewState>?

Android-only extension. Returns the observable state of a page created by an Android Browser, or null for other pages. Updated on the main thread from WebView callbacks.

Prop

Type

Dialogs

Dialogs reach automation only while a page.onDialog handler (including acceptDialogs() / dismissDialogs()) or a waitForDialog is active. Otherwise WebView hands them to your WebChromeClient or its native dialog UI, and no DialogEvent is emitted on page.events.dialog.

While automation handles them, KMPwright answers alert, confirm, prompt and beforeunload with the newest handler; with only a waitForDialog pending, the dialog is dismissed. A handler that throws or does not answer within TimeoutConfig.handler (30 s by default) dismisses the dialog.

This differs from iOS and JVM, which dismiss dialogs when nothing handles them, like Playwright.

Route interception limits

page.route works through shouldInterceptRequest (capability RouteInterception = Partial):

  • route.fulfill(...) and route.abort(...) work for every request WebView passes to shouldInterceptRequest (documents, subresources, fetch, XHR).
  • route.resume(...) without overrides lets WebView load the request normally. With overrides (url, method, headers, postData) KMPwright re-issues the request itself with HttpURLConnection, copying cookies from CookieManager and writing Set-Cookie back. Only http(s) targets are allowed; a 3xx response fails with "Route continue received an unfollowable redirect".
  • shouldInterceptRequest does not expose request bodies, so route.request.postData is null on Android.
  • A handler that does not decide within TimeoutConfig.handler (30 s by default) aborts the request with error code timedout; WebView's network thread waits for it meanwhile. A handler that throws aborts it with failed.
  • Requests your own WebViewClient.shouldInterceptRequest answered never reach KMPwright routes.

See Network.

Domain filter

page.setAllowedDomains(listOf("example.com", "*.example.com")) is enforced natively (capability DomainFiltering = Partial):

  • requests to other hosts fail as network errors in shouldInterceptRequest (fetch, XHR, images, iframes),
  • navigations, including main-frame server redirects, are cancelled; goto returns ok = false with "Blocked by KMPwright domain filter: host".

It is Partial because WebView has no hook for:

  • server redirects of subresource requests (an open redirect on an allowed host reaches its target),
  • WebSocket connections,
  • requests made by service workers.

Screenshots and PDF

  • Viewport: PixelCopy of the WebView's window region when the WebView is attached to an Activity window. KMPwright copies until two consecutive frames match, so half-rasterized tiles are not captured. Otherwise (detached WebView, PixelCopy failure or 500 ms timeout) it draws the view into a bitmap.
  • Full page: scroll and stitch. KMPwright measures the document, hides the scrollbars, scrolls tile by tile, copies each viewport, stitches them into one bitmap and restores the original scroll position. Bitmaps are capped at 128 MiB.
  • Format: PNG, JPEG and WEBP (quality applies to JPEG and WEBP). omitBackground = true throws KmpwrightUnsupportedException: "omitBackground is not supported on Android WebView: it always paints an opaque page background".
  • PDF (Pdf = Partial): paginated from rendered viewport tiles. Paper size (format, default Letter; width, height), landscape and margin work. displayHeaderFooter, header/footer templates, scale != 1.0, printBackground = false and pageRanges throw KmpwrightUnsupportedException. Documents over 16 megapixels or 128 pages are rejected.

Other differences

FeatureAndroid behavior
browser.newContext() / BrowserContextUnsupported. Throws KmpwrightUnsupportedException: "Android WebView cannot provide isolated BrowserContext semantics in-process. Use Browser pages directly or separate device/browser instances." All WebViews in a process share cookies and storage.
Browser farm leases (browserFarmSlot)Rejected: independent leases need isolated contexts. Use a separate host process per owner.
setExtraHTTPHeadersOnly an empty map is accepted. Any header throws "This backend cannot apply extra HTTP headers securely without exposing them to page JavaScript."
setHTTPCredentialsBound to one host: the page's host when you set them, or the first host you then navigate to. KMPwright sends them as a Basic Authorization header on goto requests to that origin and answers onReceivedHttpAuthRequest challenges from that host (once per host and realm per load).
addInitScriptUses WebViewCompat.addDocumentStartJavaScript (InitScripts = Partial). Throws "This Android WebView does not support document-start scripts" when the WebView provider lacks DOCUMENT_START_SCRIPT. Scripts are limited to KmpwrightConfig.allowedOriginRules when set.
evaluateWebView's evaluateJavascript does not await Promises; KMPwright polls a Promise bridge instead. Results over 4 MiB fail.
Locator actionsSynthesized in the page by the KMPwright runtime (DOM events). page.touchscreen and page.keyboard dispatch native MotionEvent/KeyEvents.
page.mouse / page.keyboardRequire InputMode.Hybrid (the default). page.touchscreen is unavailable with InputMode.JsOnly (KmpwrightUnsupportedException).
emulateMedia(colorScheme)JavaScript fallback only (matchMedia and the root color-scheme). CSS @media (prefers-color-scheme) rules keep following the app theme.
setGeolocationJavaScript patch of navigator.geolocation.
emulateNetworkConditionsOnly offline. Latency or throughput throw KmpwrightUnsupportedException.
setDeviceScaleFactorThrows KmpwrightUnsupportedException: "Android WebView device scale is determined by the host display".
emulateDevice(device)Applies the viewport size and user agent. The scale factor, mobile layout and touch support come from the device.
frameLocatorSame-origin frames only (CrossOriginFrames = Unsupported): page JavaScript cannot enter cross-origin iframes.
Network historyWebView reports no response for requests it loads itself. The main-frame document gets its status when the page finishes; 4xx/5xx responses get status and headers from onReceivedHttpError; other subresources finish without a response. resourceType is "document" or "other". Response bodies are unavailable.
pageErrors() / events.pageErrorCollected by a document-start hook when the WebView supports DOCUMENT_START_SCRIPT; otherwise from Chromium's "Uncaught …" console messages.
cookies()Limited to origins this WebView has loaded. Full attributes need the GET_COOKIE_INFO WebView feature.
Downloads, file chooserUnsupported (waitForDownload, waitForFileChooser, setDownloadPath, setDownloadBehavior throw). setInputFiles with a non-empty list throws "setInputFiles is not supported in mobile WebView"; an empty list (clear) works.
Tracing, profiler, video, screencast, CDP targetsUnsupported (CDP only).
browser.version()product = "Android WebView", the installed WebView provider version, and the user agent.

The full list is on Capabilities.

R8 and ProGuard

kmpwright-core ships no consumer ProGuard rules. It does not register JavaScript interfaces (addJavascriptInterface) or use reflection on its own classes. Its @Serializable classes rely on the rules that kotlinx-serialization ships with its artifact. The repository's sample app builds with isMinifyEnabled = false, so a minified build is not exercised there; run your release build's tests if you minify.

Debugging the WebView

Enable remote debugging in debug builds and open chrome://inspect on your computer:

if (BuildConfig.DEBUG) WebView.setWebContentsDebuggingEnabled(true)

KMPwright does not call this for you. The sample app enables it in MainActivity.

On this page