Android

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

ItemValue
minSdk24 (Android 7.0)
JVM target17 (Java and Kotlin source/target 17)
AndroidXRequired (android.useAndroidX=true, needed by androidx.webkit)
System WebViewMust support WEB_MESSAGE_LISTENER (modern WebView / Chrome 88+)
Library namespacedev.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:

build.gradle.kts
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.

MainActivity.kt
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 alias

The manifest needs the internet permission. For local development against a cleartext dev server or remote, also enable usesCleartextTraffic.

AndroidManifest.xml
<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.wvb

The 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:

manifest.json
{
  "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).

MainActivity.kt
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:

SignatureAlgorithmValid VerifyingKeyFormat
ECDSA_SECP256R1, ECDSA_SECP384R1SEC1, SPKI_DER, SPKI_PEM
ED25519SPKI_DER, SPKI_PEM, RAW (32-byte key via der)
RSA_PKCS1_V15, RSA_PSSPKCS1_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.

On this page