Skip to main content
A production-style integration demonstrating custom configuration, lifecycle management, error handling, capability negotiation, and graceful degradation for environment-sensitive features like camera hardware.
The iOS version of this example uses passport NFC chip reading as its custom capability. NFC is not currently part of the Android SDK surface, so this page instead demonstrates the Android-only configuration features — allowedOrigins, the capabilitiesProvider constructor parameter, a custom BootstrapInjectingWebViewClient, and terminal dispose() — and uses an illustrative location.read capability to show the custom-capability pattern. For the iOS counterpart, see the iOS advanced integration example.

What this example demonstrates

  • A full BridgeConfiguration — static capability map, custom bootstrap script, and the Android-only allowedOrigins message-level origin gate
  • Dynamic capability state via the capabilitiesProvider constructor parameter (the Android replacement for iOS’s mutable capability map)
  • Typed capability slots for document and selfie capture with CameraDetector permission state
  • Custom capability registration with a manual BridgeResponder
  • A custom BootstrapInjectingWebViewClient subclass for navigation policy and load-error logging, passed via attach(webView, client = ...)
  • Lifecycle management: background/foreground events, WebView swap via detach()/attach(), and terminal dispose()
  • Error handling via lastError and delegate.onError
  • Runtime capability detection with pre-launch validation and graceful degradation

Complete source

The full app below is a single Jetpack Compose file that demonstrates pre-launch capability validation, typed slots for document and selfie capture, a custom location.read capability, lifecycle event forwarding, and delegate-based message observation. Read through the section banners (// ── ... ──) to navigate; each block is the production-style version of a pattern shown elsewhere in the docs.

Architecture overview

Key patterns demonstrated

The sections below pull the load-bearing fragments out of the listing and explain the behavior that isn’t obvious from the code alone — including where the Android SDK deliberately diverges from iOS.

Full BridgeConfiguration

This example exercises every BridgeConfiguration field rather than relying on the BridgeHost(hostVersion:) convenience constructor:
capabilities is defensively snapshotted when the host is constructed — mutating the map you passed in later does not change responses. bootstrapScript replaces the SDK’s default bootstrap; it must still establish window.GBGBridge.receive, or the web side has no way to receive native messages. allowedOrigins is an opt-in, Android-only message-level gate (null disables it). Inbound messages are checked against the normalized origin of the WebView’s main-frame URL; on rejection the message is dropped, lastError is set, and delegate.onError fires with a SecurityException. The constructor validates the list up front: an empty list or any malformed entry (missing host, non-http(s) scheme) throws IllegalArgumentException. Entries are normalized to scheme://host[:port] — case-insensitive, default ports elided — so "HTTPS://journey.example.com:443" matches "https://journey.example.com".
allowedOrigins is not a security boundary. Messages from sub-frames are checked against the main-frame URL, and the URL is read at delivery time, so a fast navigation can race the check. Treat it as defense-in-depth layered on top of navigation-level filtering in shouldOverrideUrlLoading — which is what the custom WebViewClient in this example provides.

Dynamic capability state with capabilitiesProvider

iOS exposes a mutable capabilities map on the host; on Android the merged capabilities property is a read-only snapshot. The replacement is the capabilitiesProvider constructor parameter — a lambda re-evaluated on every capability read, so dynamic state (like a permission granted mid-journey) is always current without polling or manual refresh calls:
When a provider is supplied, it takes the place of the static capabilities map in BridgeConfiguration, so it should return the complete picture (the example folds the static baseline in explicitly). Merge precedence at the same capability id, lowest to highest: registerCustomCapability registrations, then the static map or provider, then typed slots with a non-null handler. An unused slot never shadows — so the provider’s location.read entry overrides the auto-registered custom-capability entry with live permission state, while the camera ids come from the typed slots once their handlers are set.

Custom WebViewClient

JourneyWebViewClient subclasses the SDK’s BootstrapInjectingWebViewClient and is passed to attach(webView, client = ...). It adds an origin allowlist in shouldOverrideUrlLoading (the actual navigation gate) and logs the failure modes that otherwise present as a silent blank page — main-frame load errors and HTTP 4xx/5xx responses. The base class injects the bridge bootstrap in onPageStarted; if you override that method, call super.onPageStarted or the bridge never initializes.
When you pass a custom client to attach(), the client’s constructor owns the bootstrap script — the bootstrapScript in BridgeConfiguration applies only when attaching without a client. The SDK’s default bootstrap literal is internal, so a host installing its own client must supply the literal itself; the example shares a single CUSTOM_BOOTSTRAP constant between the configuration and the client to keep the two paths consistent.

Pre-launch capability validation

Before starting the journey, the launcher checks whether all required capabilities are available:
This prevents users from starting a journey that will fail partway through due to missing hardware.

Lifecycle events and teardown

The app sends events when the screen stops and starts again, using a LifecycleEventObserver. The web journey can use these to pause/resume timers, save state, or handle session timeouts.
Teardown happens at two levels. The retry path swaps the WebView: detach() then attach() on a fresh instance. On Android, detach() also cancels any in-flight typed-slot captures and clears pendingRequests/receivedMessages — a deliberate divergence from iOS, which preserves the buffers — so a re-attach starts without ghost entries in Compose lists. When the screen leaves composition, the example calls dispose(), an Android-only terminal teardown with no iOS equivalent (ARC handles it there): it detaches and cancels the typed slots’ coroutine scopes, and afterwards state-mutating methods throw IllegalStateException (detach(), dispose(), clearError(), the getters, and the delegate setter remain safe). Wrap it in try/catch and log, because removeJavascriptInterface can throw on a WebView already in shutdown — in a View-system app, do the same from onDestroy() or a ViewModel’s onCleared(). One related divergence to know about: calling sendEvent or respond while no WebView is attached fires delegate.onMessageSent (so you still see the intent in traces) but the message is silently dropped at transport, and no lastError is recorded — iOS sets lastError = "WebView not attached" in that situation.

Error handling with lastError and onError

Android surfaces bridge failures through two complementary channels. host.lastError is a human-readable description of the most recent failure — unlike iOS it has a private setter, so only the SDK writes it; hosts read it and reset it with clearError() (the retry button does this). delegate.onError is an Android-only Throwable channel that fires for decode failures, handler exceptions, outbound encode failures, and allowedOrigins rejections. The example routes it into a Compose error banner:

Permission state with CameraDetector

CameraDetector.check(context) detects camera hardware and permission status. The result is assigned to the typed slots’ permissionState, which is automatically included in capability query responses:
On Android, CameraDetector reports only GRANTED or NOT_DETERMINED — the platform cannot distinguish “never asked” from “permanently denied” without integrator state. After running your own runtime permission request, set the richer state on the slots yourself, as onCameraPermissionResult does:
The web journey can then check permissionState before attempting capture and prompt the user to grant access if needed.

Typed Slots vs Custom Capabilities

This example demonstrates both patterns side by side:
  • Document and selfie capture use typed slots (host.documentCapture, host.selfieCapture) — the SDK handles result encoding and busy rejection automatically, and the UI observes activeRequest (a StateFlow) to know when to present a capture surface.
  • location.read uses registerCustomCapability() — the handler receives a BridgeResponder and builds the response manually. (On iOS, this slot in the example is filled by NFC chip reading.)

Graceful Degradation

Camera hardware is detected at runtime. On a device without one (some tablets, TVs, and misconfigured emulators), the launcher shows a warning, no slot handlers are set, and the capability map reports the camera capabilities as unsupported — a capture request that arrives anyway is automatically answered with an UNSUPPORTED response by the slot. Similarly, the location.read handler responds with a recoverable error when permission has not been granted.

Running this example

To run the example end-to-end, follow these setup steps:
  1. Add GBGBridge from Maven Central:
  2. Add to AndroidManifest.xml:
  3. Update the journey URLs in JourneyConfig — and the matching allowedOrigins and allowedHosts entries — to your web journey endpoint.
  4. Build and run.

Next steps