The web journey asks the host to do something. Expects a response.
Response
Native → Web
The host returns the result of a request.
Event
Either direction
An asynchronous notification. No response expected.
All messages share the same envelope structure (BridgeMessage) with a correlationId for pairing requests and responses. Message data values are kotlinx.serialization.json.JsonElement — build payloads with JsonPrimitive(...) or buildJsonObject { }, and read values with extensions like jsonPrimitive.contentOrNull.
BridgeHost is main-thread-only. Every state-mutating call (sendEvent, respond, register, and the rest) asserts the main thread at runtime and throws IllegalStateException off-main. Inbound messages from the web journey are posted to the main looper before any handler or delegate runs. If you do work on a background thread, hop back before touching the host — withContext(Dispatchers.Main), Handler(Looper.getMainLooper()).post { }, or runOnUiThread { }.
Events are fire-and-forget messages from the native host to the web journey. Use them to notify the web journey of state changes, user actions, or lifecycle transitions.
// Send a simple eventhost.sendEvent("host.ready", data = mapOf( "timestamp" to JsonPrimitive(System.currentTimeMillis())))// Send an event with structured datahost.sendEvent("user.action", data = mapOf( "action" to JsonPrimitive("tapped_help"), "screen" to JsonPrimitive("document_capture")))// Nested objects via buildJsonObjecthost.sendEvent("host.deviceInfo", data = mapOf( "device" to buildJsonObject { put("model", Build.MODEL) put("osVersion", Build.VERSION.RELEASE) }))
sendEvent returns Unit — there is no delivery confirmation. The SDK generates a correlationId of the form android-event-{uuid} for each event (the iOS SDK uses ios-event-{uuid}).
For capture operations, use the typed slots on BridgeHost. Setting a handler declares support and routes requests automatically. The handler is a suspend lambda that runs on Dispatchers.Main:
The SDK encodes CaptureResult into the bridge protocol format automatically. See the Capability Handling Guide for the full Compose integration pattern.
Using Custom Capabilities or BridgeCapabilityHandler
For other capabilities, use registerCustomCapability() or implement BridgeCapabilityHandler:
// Custom capability (lambda-based)host.registerCustomCapability("device.info") { request, responder -> responder.respond( status = BridgeResponseStatus.SUCCESS, data = mapOf("model" to JsonPrimitive(Build.MODEL)) )}// BridgeCapabilityHandler interface (class-based)host.register(handler = DeviceInfoHandler())
Unlike iOS, where handle is async, the Kotlin BridgeCapabilityHandler.handle(request, responder) signature is synchronous. For asynchronous work, retain the responder, do the work off the main thread, then hop back to the main thread and respond:
class DeviceInfoHandler(private val scope: CoroutineScope) : BridgeCapabilityHandler { override val action = "device.info" override fun handle(request: BridgeMessage, responder: BridgeResponder) { scope.launch(Dispatchers.IO) { val model = loadDeviceModel() withContext(Dispatchers.Main) { responder.respond( status = BridgeResponseStatus.SUCCESS, data = mapOf("model" to JsonPrimitive(model)) ) } } }}
Call responder.respond(...) exactly once — subsequent calls on the same responder are silently ignored.
For requests that don’t map to a single handler — or when you want centralized request handling — use BridgeHostDelegate. All four delegate methods have default no-op implementations, so override only what you need. onMessageSent and onError are Android-only additions with no iOS equivalent.
class JourneyCoordinator : BridgeHostDelegate { override fun onUnhandledRequest(host: BridgeHost, request: BridgeMessage) { when (request.payload.action) { "journey.complete" -> { val result = request.payload.data?.get("result")?.jsonPrimitive?.contentOrNull handleJourneyComplete(result) host.respond( to = request.correlationId, status = BridgeResponseStatus.ACKNOWLEDGED ) } else -> host.respond( to = request.correlationId, status = BridgeResponseStatus.UNSUPPORTED, error = BridgeErrorPayload( code = "UNSUPPORTED_ACTION", message = "Action '${request.payload.action}' is not supported", recoverable = false ) ) } } override fun onMessage(host: BridgeHost, message: BridgeMessage) { // Log all inbound messages for debugging Log.d("Bridge", "${message.type}: ${message.payload.action}") } private fun handleJourneyComplete(result: String?) { // Navigate to results screen, etc. }}
Set the delegate on your host:
val coordinator = JourneyCoordinator() // keep a strong referencehost.delegate = coordinator
The delegate property is backed by a WeakReference — the host does not keep your delegate alive. Assigning an inline instance (host.delegate = JourneyCoordinator()) with no other strong reference means callbacks silently stop firing once the delegate is garbage collected. Hold the delegate in a property of an object with a matching lifetime (Activity, Fragment, ViewModel, or a remember-ed controller in Compose).
If a request arrives with no registered handler, it is stored in pendingRequests (and onUnhandledRequest fires). You can respond to it later:
// In a Compose screen, with pendingRequests copied into state// from a delegate callbackpendingRequests.forEach { request -> key(request.correlationId) { Row { Text(request.payload.action) Button(onClick = { host.respond( to = request.correlationId, status = BridgeResponseStatus.SUCCESS, data = mapOf("approved" to JsonPrimitive(true)) ) }) { Text("Approve") } Button(onClick = { host.respond( to = request.correlationId, status = BridgeResponseStatus.CANCELLED ) }) { Text("Deny") } } }}
pendingRequests is an immutable snapshot taken on each read — it is not observable. Re-read it after delegate callbacks fire and copy it into your own UI state. BridgeMessage has no id property on Android; key Compose lists by correlationId.
BridgeHost has two respond overloads, and they behave differently. Both return Unit.The lookup overload — respond(to, status, data, error) — finds the matching request in pendingRequests by correlationId, removes it, and sends the response using the action recorded on the original request. If no pending request matches the correlationId, the call silently no-ops. Use it for requests that landed on the unhandled path.The explicit-action overload — respond(to, action, status, data, error) — sends a response without requiring a pending entry; you supply the action yourself. It deduplicates by correlationId: a second call with the same correlationId silently no-ops. If the send fails (encode error, or no WebView attached), the dedupe entry is rolled back so a retry with the same correlationId works once the underlying problem is fixed.
There is a retry asymmetry between the overloads. The lookup overload consumes the pending entry before sending — if that send then fails, retrying via the lookup overload silently no-ops because the entry is gone. Retry with the explicit-action overload instead, recovering the action from the original BridgeMessage (for example, one you captured in onUnhandledRequest).
When responding to a request, you supply a status, optional data, and an optional error payload. The patterns below cover the three response shapes you’ll use most: success-with-data, error, and cancellation.
responder.respond( status = BridgeResponseStatus.SUCCESS, data = mapOf( "imageBase64" to JsonPrimitive("..."), "width" to JsonPrimitive(1920), "height" to JsonPrimitive(1080) ))
responder.respond( status = BridgeResponseStatus.ERROR, error = BridgeErrorPayload( code = "PERMISSION_DENIED", message = "Camera permission was denied by the user", recoverable = true // User can grant permission in Settings ))
responder.respond( status = BridgeResponseStatus.UNSUPPORTED, error = BridgeErrorPayload( code = "NOT_AVAILABLE", message = "This capability is not available on this device", recoverable = false ))
Use ACKNOWLEDGED when the operation will take time and you want to inform the web journey that the request was received.
// First, acknowledge receiptresponder.respond( status = BridgeResponseStatus.ACKNOWLEDGED, data = mapOf("estimatedDuration" to JsonPrimitive(5000)))// Later, send the actual result as an eventhost.sendEvent("camera.capture.complete", data = mapOf( "imageBase64" to JsonPrimitive("..."), "correlationId" to JsonPrimitive(request.correlationId)))
The Android SDK has no Combine-style publishers. Observe traffic through the delegate callbacks, and read the receivedMessages / pendingRequests snapshot properties when you need current state:
class MessageLogger : BridgeHostDelegate { override fun onMessage(host: BridgeHost, message: BridgeMessage) { // Every inbound message Log.d("Bridge", "Total messages: ${host.receivedMessages.size}") if (message.payload.action == "journey.progress") { // Update progress UI } } override fun onMessageSent(host: BridgeHost, message: BridgeMessage) { // Every outbound message, fired before transport (Android-only) Log.d("Bridge", "Outbound: ${message.payload.action}") }}
receivedMessages holds every inbound message, capped at 200 entries (BridgeHost.MAX_RECEIVED_MESSAGES) — older entries are evicted from the head. Both properties return an immutable copy on each read, so for Compose, copy them into your own state from the delegate callbacks rather than reading them directly in composition.
onMessageSent fires for every outbound envelope the SDK was asked to send — including envelopes that are then dropped at transport (no WebView attached) or fail to encode. Treat it as an intent trace, not delivery confirmation.
Bridge messaging surfaces errors through two channels: lastError, a read-only string property on the host (clear it with clearError(); hosts cannot write it directly, unlike iOS), and BridgeHostDelegate.onError, an Android-only Throwable channel that fires for decode failures, handler exceptions, encode failures, and origin-gate rejections.
If GBGBridge cannot decode an incoming message, lastError is set with a description, onError fires, and the message is not added to receivedMessages. If an outbound message fails to encode, lastError is set, onError fires, and the message is dropped — the explicit-action respond overload rolls back its dedupe entry in this case so a retry can succeed.
If you call sendEvent or respond before attaching a WebView (or after detach()), the message is silently dropped at transport. onMessageSent still fires — it records intent, not delivery — and no lastError is recorded. This deliberately diverges from iOS, which sets lastError = "WebView not attached". Since respond and sendEvent return Unit, there is no per-call failure signal; make sure the host is attached before sending.
Exceptions thrown by capability handlers are caught by the SDK and routed to onError. If the handler had not yet responded, lastError is also set and an ERROR response with code HANDLER_FAILURE (recoverable = false) is dispatched to the web journey. A handler that responded successfully and then threw is reported via onError only — the request itself still succeeded.
Outbound delivery into the page is fire-and-forget: the injected script no-ops when window.GBGBridge.receive is not defined (for example, after the page has navigated away), and no error is reported back to the host. This differs from iOS, where WebKit evaluation failures set lastError.