Mobile Core Concepts
WebView and Custom Tabs
Why embedding web content inside an Android app is a security and performance decision, and the seam an interviewer probes when "show this web page" lands in a design.
Almost every Android app eventually has to show web content - a help center, a privacy policy, an OAuth screen, a rich-text editor, a magic-link landing page. Android gives you four surfaces, and the choice is mostly a security and UX decision, not a rendering one. WebView is a Chromium embed running inside your process; Chrome Custom Tabs is the user's installed browser opened inside your task; a Trusted Web Activity is a PWA shipped as an app; a plain Intent.ACTION_VIEW hands the URL off entirely. This page covers the four surfaces, the JavaScript-bridge threat model, the multi-process WebView renderer, and the decision tree for picking the right one. The native-vs-WebView frame-sync problem lives in How Rendering Works; App Links and origin verification live in IPC; the broader URL / file:// threat model lives in Security and Crypto.
The four surfaces
The question to ask first is whose browser am I being. WebView makes you the browser - you ship the Chromium engine, the cookie jar, the JS runtime, and the security surface. CCT makes the user's browser be the browser - you just open a window into it. TWA is a CCT in disguise. A plain intent doesn't open anything inside your app at all.
| Surface | What you ship | Who owns cookies / autofill | JS bridge | Right shape for |
|---|---|---|---|---|
WebView | Chromium engine, your origin, your bridge | Your app | Yes | Rich editor, in-app help, third-party iframe you must wrap, OAuth where you absolutely must read tokens (you shouldn't) |
| Chrome Custom Tabs | Nothing - intent into user's browser | User's browser (shared with their session) | No | "Open this URL" - external link, OAuth, magic-link landing, anything you don't need DOM access to |
| Trusted Web Activity | The PWA + a verified origin claim | User's browser | No (but the PWA is yours) | Shipping an existing PWA on Play Store without writing native code |
Intent.ACTION_VIEW | Nothing | Wherever the URL ends up | No | "Hand it off entirely" - the user leaves your app and may not come back |
The default instinct of "WebView for everything" is wrong. WebView is the heaviest, slowest, most security-sensitive, and most version-fragmented surface; CCT is faster, safer, and shares the user's session. WebView is only correct when you need DOM-level control over your own content (an embedded editor, a chart widget, an in-app docs panel where you inject JS) - not for "open this URL" cases.
WebView fundamentals
WebView is an Android View subclass whose pixels are produced by an embedded Chromium instance. Three classes do almost all the configuration: WebSettings (engine-level switches), WebViewClient (navigation and resource-loading hooks), WebChromeClient (JS dialogs, console messages, progress, file pickers).
webView.settings.apply {
javaScriptEnabled = true // off by default since API 17
domStorageEnabled = true // localStorage / sessionStorage
allowFileAccess = false // no file:// reads
allowContentAccess = false // no content:// reads
mixedContentMode = MIXED_CONTENT_NEVER_ALLOW
}
webView.webViewClient = AppWebViewClient()
webView.webChromeClient = AppWebChromeClient()
webView.loadUrl("https://docs.example.com/help")javaScriptEnabled is false by default. The two allow* flags default to true for apps targeting API 29 and below, and to false for targetSdk 30+ (Android 11) - so on a legacy target an out-of-the-box WebView loading remote HTML can read local file:// and content:// URIs if a redirect or script reaches for them. Turn both off for any WebView touching non-local origins; the historical CVE pattern is "load attacker-controlled HTML, attacker JS reads file:///data/data/com.example/files/auth.txt."
WebViewClient.shouldOverrideUrlLoading() is the navigation gate. The default behavior is "load every URL the page asks for inside the WebView" - clicking a mailto: link tries to load mailto: as a page. The right shape is to whitelist your own origin and route everything else to the system browser via Intent.ACTION_VIEW.
class AppWebViewClient : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, req: WebResourceRequest): Boolean {
val url = req.url
if (url.host == "docs.example.com") return false // load in WebView
view.context.startActivity(Intent(Intent.ACTION_VIEW, url))
return true // handled out-of-band
}
override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
recreateWebView(); return true // see multi-process section
}
}WebChromeClient covers everything else: onJsAlert / onJsConfirm / onJsPrompt for surfacing (or suppressing) JS dialogs, onConsoleMessage for capturing console.log output during development, onShowFileChooser for <input type="file">, and onProgressChanged for a loading bar.
The JavaScript interface
addJavascriptInterface(bridge, "Android") is the channel from page JS into your Kotlin code. The bridge object's @JavascriptInterface-annotated methods become callable as Android.methodName(...) from inside the page.
class JsBridge(private val context: Context) {
@JavascriptInterface
fun setTheme(name: String) { // called as Android.setTheme("dark") in JS
if (name !in allowedThemes) return // treat every input as untrusted
context.applyTheme(name)
}
}
webView.addJavascriptInterface(JsBridge(context), "Android")The threat model has a fault line at API 17. Before Jelly Bean MR1, every public method of the bridge was reachable from JS, including methods Java reflection exposes. A compromised page could call Android.getClass().getClassLoader().loadClass("java.lang.Runtime").getMethod("exec", ...).invoke(...) and run arbitrary commands with the app's UID - the canonical Android RCE pattern (CVE-2012-6636 and its descendants). API 17+ restricts the bridge to methods explicitly annotated @JavascriptInterface, killing reflection escape.
The reflection hole is closed, but the bridge is still a public API surface for every script the page loads, including ads, analytics, and any third-party iframe. Two rules:
- Treat every argument as untrusted. A page injection can call any bridge method with any string. Validate inputs, whitelist enums, never pass a parameter directly to
Runtime.exec, file I/O, or SQL. - Scope the bridge to one origin.
WebViewCompat.addWebMessageListener(...)lets you bind a bridge to a specific origin list - other origins (an injected iframe, a partner redirect) get no bridge. RawaddJavascriptInterfaceis global to the WebView regardless of which page is loaded.
Even with origin scoping, the bridge runs with your app's UID. If the page is compromised, the bridge is the lateral movement. Audit it like a public API endpoint.
Multi-process WebView
Since API 24 (introduced) / API 26 (default), each WebView runs its renderer in a separate sandboxed process - typically com.google.android.webview:sandboxed_process0. The host app talks to the renderer over IPC; the renderer talks to the GPU process and (on Chrome 76+) directly to SurfaceFlinger.
Two consequences shape every design that ships a WebView.
A renderer crash no longer crashes your app. A page that triggers a V8 OOM, a Blink layout bug, or a bad shader kills only the renderer process. WebViewClient.onRenderProcessGone(view, detail) is invoked with the reason - crash vs system kill for memory. If you don't override it, the WebView is left as a non-functional zombie and the app keeps running with a visibly broken surface. The right shape is to log the reason, remove the broken WebView, and either retry or fall back to native UI.
The renderer is also a separate OOM-kill target. Even while your app is foregrounded, the system can kill the renderer under pressure - RenderProcessGoneDetail.didCrash() returns false for this case. Apps that keep many WebViews alive (a tabbed browser, a feed of webview cards) are first in line for renderer reaps; design for the callback to fire. Renderer priority follows visibility by default; webView.setRendererPriorityPolicy(RENDERER_PRIORITY_IMPORTANT, false) keeps an offscreen renderer alive when you need a prerendered surface.
WebView updates and Mainline
com.google.android.webview (or its variants - Trichrome on Chrome devices, the Samsung WebView fork) is a separate APK on every modern Android device. Since API 24 the Play Store updates it independently; since API 29 it's part of Mainline, the set of platform components Google ships through Play Services without an OEM ROM update.
The practical consequences:
- Version skew is large. A Pixel 9 may have last month's WebView with Chrome 132 Blink; a cheap import-only device with Play Services restrictions may be stuck on a 2022 build. JS that runs fine on your test device can throw
SyntaxErroron a real user device. - Don't ship cutting-edge JS without polyfills. Check
caniuseagainst a five-year-old Chrome version as the floor. - Log the WebView package and version in crash reports.
WebViewCompat.getCurrentWebViewPackage(context)returns both, which lets you correlate a rendering bug to a specific WebView build an OEM rolled out.
Treat WebView like a third-party SDK whose version you don't control.
Chrome Custom Tabs
Chrome Custom Tabs (CCT) is a protocol, not a class. Any installed browser that implements it (Chrome, Samsung Internet, Firefox, Edge, Brave) can serve a Custom Tab session - the user's default browser does it by default, with fallbacks if it doesn't support CCT.
The basic launch is two lines:
CustomTabsIntent.Builder()
.setShowTitle(true)
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder().setToolbarColor(brand).build())
.build()
.launchUrl(context, Uri.parse("https://docs.example.com/help"))The page opens in the user's browser process (their cookies, autofill, password manager, extensions) but stays inside your app's task: the close button returns to your activity, not the home screen. That's the win over Intent.ACTION_VIEW - the user doesn't lose your app to switch to the URL.
The warmup / mayLaunchUrl protocol pre-binds the browser process and optionally pre-renders the target URL before the user taps:
CustomTabsClient.bindCustomTabsService(context, "com.android.chrome",
object : CustomTabsServiceConnection() {
override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) {
client.warmup(0L) // browser process ready
val session = client.newSession(callback)
session?.mayLaunchUrl(uri, null, null) // prefetch + prerender
}
override fun onServiceDisconnected(name: ComponentName) {}
})CCT is the right answer for almost every "show this URL" case:
- OAuth and magic-link flows. The user is already signed in inside their browser; CCT inherits that session. WebView starts cold and forces the user to re-authenticate, which most providers now block as a security smell (Google's "embedded browsers are not supported" policy).
- Documentation, marketing pages, support articles. No bridge needed, no DOM access needed.
- Anything you don't need to script. The point of CCT is that you give up DOM access for security and performance.
The threat-model collapse is the key point: with CCT, your app has zero code path through the page's content. No JS bridge, no cookie jar in your process, no rendering surface in your address space. Compromising the page can't compromise your app.
Trusted Web Activities
A Trusted Web Activity (TWA) is a CCT with the browser chrome hidden, launched full-bleed from your app, claiming origin ownership the same way App Links do. The browser is still running the PWA, but the user sees no address bar, no back button, no browser branding - it looks like a native app.
<activity android:name="androidx.browser.trusted.LauncherActivity">
<meta-data android:name="android.support.customtabs.trusted.DEFAULT_URL"
android:value="https://app.example.com/" />
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.example.com" />
</intent-filter>
</activity>Origin verification works exactly like App Links. The TWA APK declares the origin; the origin's /.well-known/assetlinks.json declares the package and signing-cert SHA-256. The browser fetches the file at install time and only hides chrome if verification succeeds. A misconfigured TWA silently shows the URL bar instead of the full-bleed PWA - the same degrade-not-fail pattern App Links has. The assetlinks.json machinery is covered in IPC.
TWA is the right answer for a narrow case: you already have a production-quality PWA, want to ship it on Play Store, and don't need native-only APIs (BiometricPrompt, deep background work, OS integration). Bubblewrap is the official scaffolding tool. The win is one codebase for web and Android; the loss is everything the web platform doesn't expose, plus a meaningful chunk of your Play Store "app" being downloaded over the network on first launch.
See also
- How Rendering Works - the parallel-pipeline section covers WebView's frame timing meeting SurfaceFlinger; this page assumes that model and focuses on the surface choice.
- Security and Crypto -
file://URI exposure, origin verification, and the threat-model framing this page builds on. - IPC - App Links /
assetlinks.jsonorigin verification is introduced there; TWA reuses the same mechanism. - Network Protocols - HTTPS, mixed-content rules, and TLS behavior inside the WebView use the same substrate the rest of the app does.
- NDK and JNI - the renderer process is sandboxed natively; some WebView debugging crosses into the JNI surface.
Used in
- Doc Editor App - the rich-text editor surface is the canonical "embed a WebView in your own UI" case; the bridge between the native frame and the editor JS is the essential IPC contract.
- Newsfeed App - third-party embed cards (tweets, videos, articles) are the case for WebView-per-card, with the renderer-process model and the warmup / detach lifecycle as deep dives.
- Messenger App - link previews and the in-message browser open via Chrome Custom Tabs; OAuth for connected services also goes through CCT.
- Crash Reporter SDK - the in-app crash-details viewer and the privacy-policy surface are the simplest "open this URL" case; CCT is the right answer over a WebView.
- Feature Flag SDK - a debug panel that surfaces remote dashboards uses CCT to inherit the user's existing dashboard session rather than reauthenticating in an embedded WebView.
Done reading? Mark it so it sticks in your dashboard.