Setup
Serve and update Webview Bundles inside an Android WebView with the webview-bundle-android Kotlin library.
The webview-bundle-android library wires the Webview Bundle Rust core into an Android WebView. Give it a WebView; it intercepts requests over ordinary https://<bundle>.wvb/ URLs, serves files from a bundle you ship in the APK, and pulls newer bundles over the air (OTA) from a remote without an app-store release.
Requirements
| Item | Value |
|---|---|
minSdk | 24 (Android 7.0) |
| JVM target | 17 (Java and Kotlin source/target 17) |
| AndroidX | Required (android.useAndroidX=true, needed by androidx.webkit) |
| System WebView | Must support WEB_MESSAGE_LISTENER (modern WebView / Chrome 88+) |
| Library namespace | dev.wvb |
The bridge attaches through WebViewCompat.addWebMessageListener. On a device whose System WebView is too old for WEB_MESSAGE_LISTENER, the bridge is not attached and the library logs a warning — serving still works, only the JavaScript bridge is unavailable.
Runtime dependencies are pulled in transitively: JNA (net.java.dev.jna:jna, loads the native libwvb_ffi.so), kotlinx-coroutines-core, and androidx.webkit. Consumer R8/ProGuard rules ship with the library, so you do not add keep rules for the FFI yourself.
Add the dependency:
dependencies {
implementation("dev.wvb:webview-bundle-android:<version>")
}See Platform support for the status of every platform.
Quick start
Obtain the process-wide WebViewBundle singleton with WebViewBundle.getInstance, install it onto your WebView, then load a bundle URL. The bundle name is the first label of the host, so https://app.wvb/ serves the bundle named app.
import android.os.Bundle
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
import dev.wvb.WebViewBundle
import dev.wvb.WebViewBundleConfig
import dev.wvb.WebViewBundleProtocol
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
private lateinit var handle: AutoCloseable
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val wvb = WebViewBundle.getInstance(
this,
WebViewBundleConfig(
protocols = listOf(WebViewBundleProtocol.bundle()),
),
)
webView = WebView(this)
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
allowFileAccess = false
allowContentAccess = false
}
handle = wvb.install(webView) { }
webView.loadUrl("https://app.wvb/")
setContentView(webView)
}
override fun onDestroy() {
handle.close()
webView.destroy()
super.onDestroy()
}
}getInstance honors config only on the first call per process; later calls return the existing instance and ignore the passed config. getInstance also has shorthand aliases:
WebViewBundle.getInstance(context, config) // canonical
WebViewBundle(context, config) // invoke operator
webViewBundle(context, config) // top-level fun
wvb(context, config) // short aliasThe manifest needs the internet permission. For local development against a cleartext dev server or remote, also enable usesCleartextTraffic.
<uses-permission android:name="android.permission.INTERNET" />
<application
android:usesCleartextTraffic="true">
<!-- ... -->
</application>usesCleartextTraffic="true" is for local development only. Production bundles are served over
https:// from inside the app and remote endpoints should use TLS, so leave cleartext disabled in
release builds.
How serving works
No custom URL scheme is registered. install() sets a WebViewClient whose shouldInterceptRequest inspects ordinary http/https requests and serves matching ones from the bundle source. For each request, the handler lowercases the host and walks your registered protocols in order; the first matcher to accept the host wins. The bundle protocol takes the bundle name from the first host label and returns the file at the request path. No match returns null and the WebView loads from the network.
If a handler throws, the library synthesizes a 500 text/plain response and calls the optional onError callback. See Protocol handling for how a request path maps to a file inside a .wvb.
install() also attaches the native bridge as window.wvbAndroid on the main frame. Use the install options to register extra native handlers, wrap your own WebViewClient, or skip the bridge:
val handle = wvb.install(webView) {
delegate = myWebViewClient // your callbacks are preserved
disableBridge = false // set true to skip window.wvbAndroid
bridge = {
handler("greet") { params -> "hello" }
}
}If you want the serving seam without the bridge at all, use the low-level client directly:
webView.webViewClient = wvb.createWebViewClient(delegate = myWebViewClient)Builtin bundles
Ship the bundle your app starts with inside the APK under assets/bundles/ — a manifest.json plus the .wvb files it references.
app/src/main/assets/bundles/
├── manifest.json
└── app/
└── app_0.1.0.wvbThe native source reads files, not asset streams, so the library copies assets/bundles/ into the app's filesDir. This is controlled by SourceOptions:
import dev.wvb.SourceOptions
WebViewBundleConfig(
protocols = listOf(WebViewBundleProtocol.bundle()),
source = SourceOptions(
builtinAssetsDir = "bundles", // APK assets/<dir>; null disables extraction
// builtinDir defaults to <filesDir>/wvb/builtin (read-only)
// remoteDir defaults to <filesDir>/wvb/remote (writable)
),
)Re-extraction happens on each APK install or update and is additive — removed assets are not deleted. The manifest.json declares the bundle entries and current version, and may carry integrity and signature values:
{
"manifestVersion": 1,
"bundles": {
"app": {
"currentVersion": "0.1.0",
"versions": {
"0.1.0": {
"integrity": "sha256:n4bQgYhMfWWaL...",
"signature": "..."
}
}
}
}
}See Bundle sources for the manifest format.
Protocols
A protocol decides which hosts a WebView request is served from. Register protocols in WebViewBundleConfig.protocols; they are evaluated in order and the first matching one wins, so register bundle() last because it matches every host.
WebViewBundleProtocol.bundle() serves entries from the bundle source for every host, using the first host label as the bundle name. Pass a passthrough block to send named hosts to the network instead:
WebViewBundleProtocol.bundle {
passthrough("api.example.com")
passthroughDomain("analytics.example.com")
passthrough { host -> host.endsWith(".cdn.example.com") }
}WebViewBundleProtocol.local(hosts) is a development proxy. It maps a full request host to a local base URL so you keep your bundler's hot reload while developing. On the Android emulator, 10.0.2.2 reaches the host machine's loopback:
WebViewBundleProtocol.local(
mapOf("app.wvb" to "http://10.0.2.2:3000"),
)A typical dev configuration registers local() for the hosts you proxy and bundle() last as the fallback:
WebViewBundleConfig(
protocols = listOf(
WebViewBundleProtocol.local(mapOf("app.wvb" to "http://10.0.2.2:3000")),
WebViewBundleProtocol.bundle(),
),
)OTA updates
Pass a WebViewBundleUpdaterConfig as WebViewBundleConfig.updater. The library then builds a remote client and an updater, exposed as wvb.remote and wvb.updater (both null when no updater config is provided).
import android.util.Base64
import dev.wvb.IntegrityPolicy
import dev.wvb.SignatureAlgorithm
import dev.wvb.SignatureVerifierOptions
import dev.wvb.SignatureVerifyingKey
import dev.wvb.VerifyingKeyFormat
import dev.wvb.WebViewBundleConfig
import dev.wvb.WebViewBundleProtocol
import dev.wvb.WebViewBundleRemoteConfig
import dev.wvb.WebViewBundleUpdaterConfig
val wvb = WebViewBundle.getInstance(
this,
WebViewBundleConfig(
protocols = listOf(WebViewBundleProtocol.bundle()),
updater = WebViewBundleUpdaterConfig(
remote = WebViewBundleRemoteConfig(endpoint = "http://10.0.2.2:4313"),
channel = "stable",
integrityPolicy = IntegrityPolicy.STRICT,
signatureVerifier = SignatureVerifierOptions(
algorithm = SignatureAlgorithm.ED25519,
key = SignatureVerifyingKey(
format = VerifyingKeyFormat.SPKI_DER,
pem = null,
der = Base64.decode("MCowBQYDK2VwAyEA...", Base64.NO_WRAP),
),
),
),
onError = { error -> /* log */ },
),
)IntegrityPolicy.STRICT rejects a bundle whose digest does not match; OPTIONAL verifies when present and skips when absent; NONE skips the check. The integrity string uses SHA-2 (sha256, sha384, or sha512). The signature verifier proves who published the bundle. Not every algorithm and key-format pair is valid:
SignatureAlgorithm | Valid VerifyingKeyFormat |
|---|---|
ECDSA_SECP256R1, ECDSA_SECP384R1 | SEC1, SPKI_DER, SPKI_PEM |
ED25519 | SPKI_DER, SPKI_PEM, RAW (32-byte key via der) |
RSA_PKCS1_V15, RSA_PSS | PKCS1_DER, PKCS1_PEM, SPKI_DER, SPKI_PEM |
An unsupported pair throws Exception.Signature when the updater is built. pem is read for *_PEM formats; der for DER, SEC1, and RAW.
On the Android emulator, a remote running on the host machine is reachable at
http://10.0.2.2:4313 (the local remote's default port). The channel value is sent to the
remote as a query parameter so it can serve a specific release channel.
Driving updates from Kotlin
The updater runs a three-step cycle. Each call is a suspend function, so call it from a coroutine.
import kotlinx.coroutines.launch
lifecycleScope.launch {
val update = wvb.updater?.getUpdate("app") // check the remote, no download
if (update != null && update.isAvailable) {
wvb.updater?.downloadUpdate("app") // download latest, persist to remote dir
wvb.updater?.install("app", update.version) // verify, activate, prune old versions
}
}getUpdate reports whether a newer version exists without downloading. downloadUpdate fetches and stores the bundle (the latest version when you pass no version). install verifies integrity and signature on the staged bundle, makes it current, and prunes stale versions.
Driving updates from web JavaScript
The same cycle is available to your web app through the window.wvbAndroid bridge. The built-in updater commands are updaterGetUpdate, updaterDownload, and updaterInstall; they throw updater_not_initialized when no updater config was provided.
declare const wvbAndroid: {
postMessage(message: string): void;
};
// The bridge posts { name, params, success, error } and replies via callbacks.
// A small wrapper that resolves a Promise per command:
function invoke(name: string, params?: unknown): Promise<unknown> {
return new Promise((resolve, reject) => {
// ... wire success/error callbacks, then:
wvbAndroid.postMessage(JSON.stringify({ name, params }));
});
}
const update = await invoke('updaterGetUpdate', { name: 'app' });
await invoke('updaterDownload', { name: 'app' });
await invoke('updaterInstall', { name: 'app', version: '0.2.0' });For the remote HTTP contract, integrity, and signatures across platforms, see Remote bundles and the guide to building a remote.