Go back

Convert your web app into an Android app with Jetpack Compose and WebView

Android

Reuse your web application in a native Android shell while handling navigation, communication, offline states, uploads, and downloads.

Rewriting an existing web application in Kotlin takes time, duplicates business logic, and splits focus across two separate codebases. On the other hand, loading a URL inside a standard WebView can feel broken if users hit network errors, try to upload files, or use back navigation.

You can combine WebView with Jetpack Compose to reuse web code while keeping native controls, handling platform permissions, and using system services like DownloadManager and AlarmManager.

The WebView sandbox

WebView is a rendering engine based on the same Chromium source code that runs Google Chrome.

It runs in an isolated sandbox. It does not share cookies, session tokens, or local storage with Chrome or other browsers on the device.

Because your app hosts the WebView, your Kotlin code controls the container. You can inject scripts, intercept navigation, catch network failures, and send messages between JavaScript and native code.

Embedding WebView in Jetpack Compose

Jetpack Compose hosts classic Android views through the AndroidView composable.

By default, WebView turns off JavaScript and DOM storage. Modern web applications require both to work.

@Composable
fun WebAppScreen(
url: String,
modifier: Modifier = Modifier
) {
AndroidView(
factory = { context ->
WebView(context).apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
userAgentString = userAgentString.replace("; wv", "")
}
loadUrl(url)
}
},
modifier = modifier.fillMaxSize()
)
}

If your web application serves different layouts for phones and desktops, large Android devices like tablets and foldables receive the mobile layout by default. Removing the mobile token from the user agent string lets the server send desktop layouts to large screens.

Managing tab instances without losing state

You do not have to build your entire interface inside the web page. You can write top-level navigation, such as tabs, in Jetpack Compose.

If you replace WebView instances during tab switches, Compose destroys the old view and creates a new one. That reload resets scroll positions, drops form inputs, and clears JavaScript variables in memory.

To keep the state intact, store the active WebView instances in memory. You can hold them in a list and place them inside a single Box composable. Instead of removing inactive tabs from the screen, shift their offset:

data class TabInstance(
val id: String,
val title: String,
val webView: WebView
)
@Composable
fun MultiTabBrowser(
tabs: List<TabInstance>,
selectedTabIndex: Int,
onTabSelected: (Int) -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
LazyRow(modifier = Modifier.fillMaxWidth()) {
itemsIndexed(tabs) { index, tab ->
Card(
modifier = Modifier
.padding(horizontal = 4.dp, vertical = 8.dp)
.clickable { onTabSelected(index) },
colors = CardDefaults.cardColors(
containerColor = if (index == selectedTabIndex) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
}
)
) {
Text(
text = tab.title,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
}
Box(modifier = Modifier.fillMaxSize()) {
tabs.forEachIndexed { index, tab ->
AndroidView(
factory = { tab.webView },
modifier = Modifier
.fillMaxSize()
.offset(x = if (index == selectedTabIndex) 0.dp else 10000.dp)
)
}
}
}
}

Moving inactive views off the screen keeps their DOM, scroll position, and memory alive without triggering garbage collection.

Two-way communication between JavaScript and Kotlin

A hybrid app needs native code and the web page to exchange data.

Calling JavaScript from Kotlin

When native code needs to change the page, call evaluateJavascript on the WebView instance. You can use this to hide redundant elements, such as a banner asking the user to install the app:

webView.evaluateJavascript(
"""
document.getElementById('mobile-download-banner')?.remove();
""".trimIndent(),
null
)

The method takes an optional callback parameter if your script returns data to native code.

Calling Kotlin from JavaScript

Earlier Android code often relied on JavascriptInterface. That approach creates security risks when pages load untrusted external links.

WebViewCompat.addWebMessageListener provides a safer communication channel. It restricts message delivery to a list of allowed web origins:

val allowedOrigins = setOf("https://your-domain.com")
WebViewCompat.addWebMessageListener(
webView,
"nativeBridge",
allowedOrigins
) { _, message, _, _, replyProxy ->
val payload = message.data
scheduleNativeReminder(payload)
replyProxy.postMessage("{\"status\": \"success\"}")
}

On the web side, JavaScript checks for the injected object and sends data through standard browser messaging:

if (window.nativeBridge) {
window.nativeBridge.postMessage(JSON.stringify({
action: "SET_REMINDER",
timestamp: 1726000000000,
noteId: "42"
}));
window.nativeBridge.onmessage = (event) => {
console.log("Response from Android:", JSON.parse(event.data));
};
}

This pattern lets the web app trigger native push notifications, start background tasks with WorkManager, or read device hardware.

Handling offline states with native screens

Chromium displays an internal network error page when a device loses connectivity. That page exposes browser details to the user and prevents clean retries.

You can catch connection errors by providing a custom WebViewClient and checking the main frame:

@Composable
fun ResilientWebView(url: String) {
var isOffline by remember { mutableStateOf(false) }
var webViewRef by remember { mutableStateOf<WebView?>(null) }
if (isOffline) {
NoInternetScreen(onRetry = {
isOffline = false
webViewRef?.reload()
})
} else {
AndroidView(
factory = { context ->
WebView(context).apply {
webViewClient = object : WebViewClient() {
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
super.onReceivedError(view, request, error)
if (request?.isForMainFrame == true) {
isOffline = true
}
}
}
loadUrl(url)
webViewRef = this
}
},
modifier = Modifier.fillMaxSize()
)
}
}

When network lookups fail, the composable shows a native Compose screen with a retry button instead of a broken browser error.

Native hooks for file uploads and downloads

HTML file inputs and download links do not work out of the box in a default WebView. You must wire them to Android system services.

File uploads with the photo picker

HTML file inputs trigger onShowFileChooser in WebChromeClient. By default, WebView rejects the request.

You can override this method and launch the Android photo picker. The photo picker lets users pick media without granting full storage permissions:

class AppWebChromeClient(
private val onPickImage: (ValueCallback<Array<Uri>>?) -> Unit
) : WebChromeClient() {
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?
): Boolean {
onPickImage(filePathCallback)
return true
}
}

When the user selects an image, send the content Uri back to the filePathCallback to pass the file to the web page.

File downloads with DownloadManager

When a user clicks a download link, such as an exported PDF, setDownloadListener catches the event:

webView.setDownloadListener { url, userAgent, contentDisposition, mimetype, _ ->
val request = DownloadManager.Request(Uri.parse(url)).apply {
setMimeType(mimetype)
addRequestHeader("User-Agent", userAgent)
setDescription("Downloading document")
setTitle(URLUtil.guessFileName(url, contentDisposition, mimetype))
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
URLUtil.guessFileName(url, contentDisposition, mimetype)
)
}
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
dm.enqueue(request)
}

This passes the download task to Android’s DownloadManager. The system shows progress in the notification shade and saves the file to the Downloads folder.

Wrapping up

Using WebView inside an Android app does not mean settling for a static page wrapper.

Compose can handle outer navigation and screen state. WebViewCompat.addWebMessageListener sets up origin-checked two-way communication between JavaScript and Kotlin. Overriding WebChromeClient and setDownloadListener connects standard web events to native system pickers and background managers.