Skip to main content
This document explains the architecture and design principles behind the GBGBridge Android SDK. Understanding these concepts will help you integrate effectively and make informed decisions about how to structure your host application.

Overview

GBGBridge solves a fundamental problem: web-based identity verification journeys need access to native device capabilities (camera, NFC, biometrics) that are not available — or are severely limited — in a browser context. Rather than building separate native UIs for every journey variation, GBGBridge lets a single web journey drive the user experience while delegating capability-intensive operations to the native host.

Key Terminology

TermDefinition
JourneyA web-based identity verification flow (e.g., document capture + face match). The journey runs as HTML/JavaScript inside a WebView.
HostThe native Android application that embeds the journey WebView. The host provides native capabilities.
BridgeThe communication layer between the web journey and the native host. Comprises the window.GBGBridge JavaScript namespace on the web side and BridgeHost on the native side.
CapabilityA native feature that the host can provide (e.g., camera.document, camera.selfie). Capabilities can be declared via typed slots (recommended) or in configuration.
Typed SlotA built-in CaptureCapability property on BridgeHost (e.g., documentCapture, selfieCapture). Setting a handler on a slot declares the capability as supported.
HandlerA native Kotlin object implementing BridgeCapabilityHandler that fulfills requests for a specific action, or a suspend lambda set on a typed slot.
Permission StateMetadata about the native permission status for a capability (e.g., granted, denied, notDetermined). Reported in capability query responses.
MessageA structured JSON envelope exchanged between the web journey and the native host. Every message has a type (request, response, or event), a correlation ID, and a payload.
ActionA string identifier for the operation a message relates to (e.g., "camera.document.capture", "capability.query").
ResponderA callback object (BridgeResponder) provided to handlers, used to send a response back to the web journey.
Bootstrap ScriptA JavaScript snippet injected as each page starts loading that initializes the window.GBGBridge namespace and receive() function.

Architecture

GBGBridge is organized as a small set of collaborating components: a message router (BridgeHost), capability handlers, and WebView configuration utilities that wire the native side to the web journey’s JavaScript.

Component Diagram

Unlike iOS, there is no BridgeWebView wrapper type and no WebView factory. You create a standard WebView yourself (or via AndroidView { WebView(it) } in Compose) and call host.attach(webView) — the host configures the WebView internally.

Layers

  1. Capability Layer — Typed slots (CaptureCapability) and custom capabilities declare what the host supports. CameraDetector provides hardware and permission detection.
  2. Routing Layer — BridgeHost receives messages from the WebView, decodes them, and routes requests to typed slot handlers, custom capability handlers, or BridgeCapabilityHandler implementations.
  3. Handler Layer — Typed slots handle capture requests via suspend lambdas and return strongly-typed CaptureResult values. The BridgeCapabilityHandler interface supports arbitrary request handling.
  4. Transport Layer — BridgeWebViewConfigurator and BootstrapInjectingWebViewClient configure the WebView, inject the bootstrap script, and wire up the message channels via a JavaScript interface.
  5. Observation Layer — BridgeHostDelegate lets you observe all inbound and outbound messages, handle unrouted requests, and receive error notifications.

Message Protocol

All communication uses structured JSON envelopes. The protocol version is 1.0, and the wire format is identical to the iOS SDK — a web journey that speaks the bridge protocol works unchanged on both platforms.

Message Envelope

{
  "version": "1.0",
  "correlationId": "uuid-string",
  "type": "request | response | event",
  "timestamp": 1700000000000,
  "payload": {
    "action": "camera.document.capture",
    "data": { },
    "status": "success | error | cancelled | unsupported | acknowledged",
    "error": {
      "code": "CAMERA_DENIED",
      "message": "User denied camera access",
      "recoverable": true
    }
  }
}
The timestamp is epoch milliseconds. Decoding is lenient: fractional timestamp values (as produced by some senders) are accepted and rounded.

Message Types

TypeDirectionPurpose
requestWeb → NativeThe web journey asks the host to perform an action.
responseNative → WebThe host sends the result of a request back to the web journey.
eventEither directionAn asynchronous notification with no expected response.

Message Flow

Correlation IDs

Every request has a correlationId. When the host responds, it includes the same correlationId so the web journey can match responses to their original requests. Events generate their own unique correlation IDs (on Android these take the form android-event-{uuid}).

Response Statuses

StatusMeaning
successThe request completed successfully. Check data for the result.
errorThe request failed. Check error for details.
cancelledThe user cancelled the operation (e.g., dismissed a capture screen).
unsupportedThe requested capability is not available on this host.
acknowledgedThe request was received and is being processed asynchronously.

Request Routing

When a message arrives from the web journey:
  1. The raw JSON arrives on the WebView render thread and is posted to the main looper before any further processing.
  2. BridgeHost decodes the JSON envelope into a BridgeMessage.
  3. The message is appended to receivedMessages (observable; the buffer retains the most recent 200 messages).
  4. The delegate’s onMessage(host, message) is called.
  5. If the message type is request:
    • If a handler is registered for the message’s action, the handler’s handle(request, responder) is called.
    • If no handler is registered, the message is added to pendingRequests and the delegate’s onUnhandledRequest(host, request) is called. You can respond later via the lookup respond(to, status, data, error) overload.
  6. If the message type is response or event, no routing occurs — it is stored and reported to the delegate.
Handler exceptions are caught by the host: the error is routed to delegate.onError, and if the handler had not yet responded, an error response with code HANDLER_FAILURE is dispatched automatically so the web journey is never left waiting.

Typed Capability Slots

GBGBridge provides typed capability slots as the recommended way to declare and handle well-known capabilities. A typed slot is a CaptureCapability instance exposed as a property on BridgeHost.

Built-in Slots

SlotPropertyCapability IDAction ID
Document Capturehost.documentCapturecamera.documentcamera.document.capture
Selfie Capturehost.selfieCapturecamera.selfiecamera.selfie.capture

Handler-as-Declaration

Setting a handler on a typed slot simultaneously declares support and provides the implementation:
// This single line declares the capability AND provides the handler
host.documentCapture.handler = { request ->
  host.documentCapture.awaitCompletion()
}
When handler is set and isEnabled is true, the slot reports isSupported = true. This means the capability query response automatically reflects the current state — no separate configuration step is needed.

Automatic Features

Typed slots provide several features automatically:
  • Capability query integration — Supported slots appear in capability.query responses with version and permission state metadata.
  • Result encoding — Handlers return strongly-typed CaptureResult values. The SDK encodes them into the bridge protocol format (base64 image data, dimensions, etc.).
  • Busy rejection — If a request arrives while another is already active, the slot responds with an error (code BUSY, recoverable) automatically.
  • Compose reactivity — activeRequest is a StateFlow, so UI can react to capture requests: call collectAsState() in Compose, or launchIn a lifecycle scope in the View system.

Permission State

Each typed slot has a permissionState property that can be populated using CameraDetector:
val camera = CameraDetector.check(context)
host.documentCapture.permissionState = camera.permissionState
host.selfieCapture.permissionState = camera.permissionState
This information is included in the capability.query response, allowing the web journey to detect permission issues before attempting capture. Note that CameraDetector reports only GRANTED or NOT_DETERMINED — Android cannot distinguish a never-asked permission from a permanently-denied one without integrator-held state. After running your own permission flow, set the richer DENIED or RESTRICTED states on the slot yourself.

Capability Negotiation

Before attempting to use a native feature, the web journey sends a capability.query request. GBGBridge includes a built-in handler (CapabilityQueryHandler) that responds automatically. When using the BridgeHost(hostVersion) convenience constructor, the response is built dynamically from typed slots and runtime-registered custom capabilities. When using BridgeHost(configuration, capabilitiesProvider), the static BridgeConfiguration map — or the dynamic capabilitiesProvider, which is re-evaluated on every read — is merged in as well. On collision, typed slots with a handler take precedence over configuration entries, which take precedence over runtime-registered custom capabilities. This pattern allows the web journey to:
  • Adapt its UI — Show or hide steps based on available capabilities.
  • Prevent errors — Avoid requesting capabilities that aren’t supported.
  • Check permissions — Detect whether native permissions have been granted before attempting operations.
  • Degrade gracefully — Fall back to alternative flows when capabilities are missing.

Query Response Format

{
  "environment": "android",
  "hostVersion": "1.0.0",
  "capabilities": {
    "camera.document": {
      "supported": true,
      "version": "1.0",
      "permissionState": "granted"
    },
    "camera.selfie": {
      "supported": true,
      "version": "1.0",
      "permissionState": "notDetermined"
    }
  }
}
For each capability, supported and version are always emitted (version is JSON null when unset). The permissionState field is included only when the typed slot or configuration provides it. See the Capability Handling Guide for detailed patterns including typed slots, custom capabilities, and environment-specific behavior.

Transport Mechanism

The bridge uses Android’s two standard WebView communication channels: a JavaScript interface for inbound messages and evaluateJavascript for outbound messages.

Web → Native (Incoming)

The web journey calls:
window.GBGBridge.postMessage(JSON.stringify(message));
The GBGBridge object is installed by BridgeHost via addJavascriptInterface when you call attach(). The interface accepts a JSON string (not an object). This is the load-bearing platform difference: on iOS the same call is window.webkit.messageHandlers.gbgBridge.postMessage(messageObject). Web code targeting both platforms must branch, for example:
if (window.GBGBridge?.postMessage) {
  // Android
  window.GBGBridge.postMessage(JSON.stringify(message));
} else if (window.webkit?.messageHandlers?.gbgBridge) {
  // iOS
  window.webkit.messageHandlers.gbgBridge.postMessage(message);
}
Incoming messages arrive on the WebView render thread and are posted to the main looper before any handler or delegate code runs. Stale messages from a detached or replaced attach session are dropped.

Native → Web (Outgoing)

The host evaluates JavaScript on the WebView:
window.GBGBridge.receive(json);
The receive() function is established by the bootstrap script that BootstrapInjectingWebViewClient injects when each page starts loading.

Bootstrap Script

The default bootstrap script is:
window.GBGBridge = window.GBGBridge || {};
window.GBGBridge.receive = window.GBGBridge.receive || function(){};
This creates a no-op receive() function alongside the native postMessage interface. The web journey replaces receive() with its own implementation once loaded. You can supply a custom bootstrap script via BridgeConfiguration.bootstrapScript if your web journey requires additional initialization. If you pass your own BootstrapInjectingWebViewClient subclass to attach(), the configuration’s script is ignored — the client owns its script, so pass it to the subclass constructor instead, and call super.onPageStarted to keep the injection. Bootstrap timing diverges from iOS: iOS uses WKUserScript at document start, which is guaranteed to run before any page JavaScript. Android injects via evaluateJavascript in onPageStarted (main frame only), which is best-effort — a head script that synchronously calls window.GBGBridge.receive could in principle race the injection.

Threading Model

BridgeHost is a main-thread-only class (the Android analogue of iOS’s @MainActor). This is enforced in three layers:
  • @MainThread annotations — Lint flags off-main calls at build time.
  • Main-looper posting — Inbound JavaScript messages arrive on the WebView render thread and are posted to the main looper before any decoding, handler, or delegate code runs.
  • Runtime assertion — Every public state-mutating method asserts the main thread at runtime and throws IllegalStateException when called off-main, so violations fail loudly at the call site.
Two further rules apply to handlers and responders:
  • BridgeCapabilityHandler.handle(request, responder) is synchronous (unlike iOS’s async handle). For asynchronous work, retain the responder, do the work, hop back to the main thread, then call responder.respond(...).
  • BridgeResponder.respond() is main-thread-only and should be called exactly once — subsequent calls are no-ops. To hop back to the main thread use withContext(Dispatchers.Main), Handler(Looper.getMainLooper()).post { }, or runOnUiThread { }. (This diverges from iOS, where the responder may be called from any thread.)
Typed slot handlers are suspend lambdas launched on Dispatchers.Main, so they can suspend freely (e.g., on awaitCompletion()) and resume on the main thread.

Host Lifecycle

Unlike iOS, where ARC tears the bridge down automatically, Android requires explicit lifecycle calls on BridgeHost. There are three: attach(), detach(), and dispose().

Attach

attach(webView, client = null) configures the WebView (JavaScript and DOM storage enabled, bootstrap-injecting WebViewClient installed), registers the GBGBridge JavaScript interface, and starts routing messages. Attaching calls the configurator internally — do not call BridgeWebViewConfigurator.configure() yourself beforehand, and set any custom WebChromeClient after attach().

Detach

detach() cancels any in-flight typed-slot captures, removes the JavaScript interface, and clears the per-session state: the response dedupe tracking and the pendingRequests and receivedMessages buffers. This diverges from iOS, which preserves the message buffers across detach — Android clears them so a re-attach starts with clean observable lists rather than ghost entries in Compose UIs. detach() is idempotent. Sending while detached also diverges from iOS: respond and sendEvent with no WebView attached still fire delegate.onMessageSent (as an intent trace) and then drop the message silently at the transport — no lastError is recorded, whereas iOS sets lastError = "WebView not attached".

Dispose

dispose() is an Android-only terminal teardown with no iOS equivalent: it detaches and cancels the typed slots’ coroutine scopes. After dispose(), state-mutating methods (attach, register, unregister, registerCustomCapability, both respond overloads, sendEvent) throw IllegalStateException. Still safe after dispose: detach(), dispose() itself, clearError(), all getters (which return empty or null), and setting delegate. Call dispose() from onDestroy or onCleared, and wrap it defensively — removeJavascriptInterface can throw on a WebView that is already shutting down:
override fun onCleared() {
  try {
    host.dispose()
  } catch (e: Exception) {
    Log.w("Bridge", "Bridge teardown failed", e)
  }
  super.onCleared()
}

Design Rationale

Why a message protocol instead of direct method calls?

A message protocol decouples the web journey from the native host. The web journey doesn’t need to know whether it’s running in an Android app, an iOS app, or an iframe — it sends the same messages regardless. This also allows the protocol to evolve without breaking existing integrations.

Why declare capabilities upfront?

Capability declaration serves two purposes: it lets the web journey adapt before hitting a dead end, and it gives the host application explicit control over what features are exposed. A host app might have camera hardware but choose not to expose camera capture for a particular journey.

Why typed capability slots?

Typed slots eliminate a class of integration errors. With the configuration-based approach, integrators had to keep capability declarations and handler registrations in sync manually — using matching string keys. Typed slots make this impossible to get wrong: setting a handler declares support in a single step. They also provide automatic result encoding, busy rejection, and permission state reporting.

Why is BridgeHost main-thread-only?

Android WebView APIs must be accessed on the main thread. Since BridgeHost interacts directly with the WebView for both receiving and sending messages, constraining it to the main thread — with lint, looper posting, and runtime assertions — eliminates a class of threading bugs and makes violations fail loudly with IllegalStateException instead of corrupting WebView state.

Why is the delegate weakly referenced?

BridgeHost.delegate is backed by a WeakReference, so the host never keeps your delegate (and anything it captures — an Activity, Fragment, or Compose scope) alive. The trade-off: you must hold a strong reference to the delegate yourself. An inline host.delegate = SomeImpl() with no other strong reference will silently stop firing after garbage collection.

Why a read-only capabilities map and a capabilitiesProvider?

iOS exposes a mutable capabilities dictionary; Android instead exposes capabilities as a read-only merged snapshot of typed slots, configuration, and runtime-registered custom capabilities — writing to a merged view would be ambiguous. For dynamic capability state, pass a capabilitiesProvider lambda to the constructor: it is re-evaluated on every read, so capability state stays current without polling.

Why JsonElement instead of a custom JSON type?

iOS defines a JSONValue enum because Foundation lacks a typed JSON model. Kotlin already has one: kotlinx.serialization.json.JsonElement, which integrates directly with the SDK’s @Serializable message models. Using it means there is no parallel JSON type to learn or convert — construct values with JsonPrimitive(...) or buildJsonObject { }, and read them with data["key"]?.jsonPrimitive?.contentOrNull.

Why these dependencies?

Unlike the iOS SDK, which has zero external dependencies, the Android SDK depends on three ubiquitous libraries: androidx.annotation (the @MainThread lint contract), kotlinx-serialization-json (message encoding and the JsonElement model), and kotlinx-coroutines-android — the latter exposed as an api dependency because coroutine types appear on the public surface of CaptureCapability.

Next Steps