Skip to main content
This guide helps diagnose and resolve common issues when integrating GBGBridge.

Diagnostic Tools

BridgeHost exposes several built-in diagnostic surfaces โ€” error state, a message log, and a list of pending requests โ€” that should be your first stops when something looks off. Combined with Chrome DevTools (chrome://inspect) for the web side and a pair of diagnostic WebView clients for the load path, these cover most debugging scenarios.

Observing lastError

The first thing to check is BridgeHost.lastError. It captures the most recent error from message decoding, handler exceptions, outbound encoding, or origin-gate rejections. On Android it is read-only from the host appโ€™s side (the SDK writes it; you can only read it or reset it with clearError()), and it is a plain property rather than observable state โ€” to drive Compose UI from it, mirror it into Compose state from the delegate, which fires after every SDK-internal write:
// Read directly
Log.d(TAG, host.lastError ?: "No errors")

// Mirror into Compose state via the delegate
class LoggingDelegate : BridgeHostDelegate {
  var onErrorBanner: ((String?) -> Unit)? = null

  override fun onError(host: BridgeHost, error: Throwable) {
    Log.e(TAG, "Bridge error", error)
    onErrorBanner?.invoke(host.lastError ?: error.message)
  }
}
The delegate.onError(host, error) callback is an Android-only addition: it hands you the underlying Throwable for decode failures, handler exceptions, encode failures, and origin-gate rejections, alongside the human-readable lastError string.

Message Log

BridgeHost.receivedMessages contains every successfully decoded inbound message. Use it to verify message flow. It is a ring buffer capped at the last 200 messages (BridgeHost.MAX_RECEIVED_MESSAGES), and each read returns an immutable snapshot.
// Print all received messages
host.receivedMessages.forEach { message ->
  Log.d(TAG, "[${message.type.name.lowercase()}] ${message.payload.action} โ€” ${message.correlationId}")
}

Pending Request Inspection

BridgeHost.pendingRequests shows requests awaiting a response. If this list grows unexpectedly, it means requests are arriving for actions with no registered handler. If it exceeds 50 entries (BridgeHost.PENDING_REQUEST_LEAK_THRESHOLD), the host records a leak warning in lastError.
if (host.pendingRequests.isNotEmpty()) {
  Log.w(TAG, "Unhandled requests:")
  host.pendingRequests.forEach { req ->
    Log.w(TAG, "  - ${req.payload.action} (${req.correlationId})")
  }
}

Chrome DevTools (chrome://inspect)

For inspecting the web side of the bridge, enable WebView content debugging โ€” gated on FLAG_DEBUGGABLE so release builds never opt in:
val debuggable = (context.applicationInfo.flags and
  ApplicationInfo.FLAG_DEBUGGABLE) != 0
if (debuggable) {
  WebView.setWebContentsDebuggingEnabled(true)
}
Then:
  1. Run the app on a device or emulator connected via adb.
  2. In Chrome on your desktop, open chrome://inspect#devices.
  3. Find your appโ€™s WebView and click inspect.
  4. Use the console to inspect window.GBGBridge and test message sending.
// In the DevTools console:
console.log(window.GBGBridge);                 // Should show the bridge object
console.log(typeof window.GBGBridge.receive);  // Should be "function" once the bootstrap has run

Diagnostic WebView Clients

Several Android failure modes โ€” DNS errors, cleartext blocks, HTTP errors, SSL failures, JavaScript exceptions โ€” present as a silent blank WebView unless you log them yourself. Two small client subclasses make them all visible in logcat. This tooling is Android-specific: on iOS, Safari Web Inspector surfaces most of this for free. A WebViewClient that preserves the SDKโ€™s bootstrap injection (by extending BootstrapInjectingWebViewClient and calling super.onPageStarted) and logs load failures, distinguishing the main-frame error that blanks the screen from noisy sub-resource errors:
// The SDK's default bootstrap is internal โ€” when you install your own
// client, you supply the literal yourself and pass it to the constructor.
private const val BOOTSTRAP_SCRIPT: String =
  "window.GBGBridge = window.GBGBridge || {}; " +
    "window.GBGBridge.receive = window.GBGBridge.receive || function(){};"

class DiagnosticWebViewClient(
  bootstrapScript: String,
) : BridgeWebViewConfigurator.BootstrapInjectingWebViewClient(bootstrapScript) {

  override fun onReceivedError(
    view: WebView,
    request: WebResourceRequest,
    error: WebResourceError,
  ) {
    super.onReceivedError(view, request, error)
    val msg = "load error ${error.errorCode} (${error.description}) for ${request.url}"
    if (request.isForMainFrame) Log.e(TAG, "WebView $msg")
    else Log.d(TAG, "WebView sub-resource $msg")
  }

  override fun onReceivedHttpError(
    view: WebView,
    request: WebResourceRequest,
    errorResponse: WebResourceResponse,
  ) {
    super.onReceivedHttpError(view, request, errorResponse)
    val msg = "HTTP ${errorResponse.statusCode} for ${request.url}"
    if (request.isForMainFrame) Log.e(TAG, "WebView $msg")
    else Log.d(TAG, "WebView sub-resource $msg")
  }

  override fun onReceivedSslError(
    view: WebView,
    handler: SslErrorHandler,
    error: SslError,
  ) {
    // Log, then preserve the secure default (cancel). Do NOT call
    // handler.proceed() โ€” that would defeat TLS validation.
    Log.e(TAG, "WebView SSL error ${error.primaryError} for ${error.url}")
    handler.cancel()
  }
}
And a WebChromeClient that forwards the pageโ€™s console.* output to logcat, so JavaScript errors inside the journey are visible instead of vanishing into a blank screen:
class DiagnosticWebChromeClient : WebChromeClient() {
  override fun onConsoleMessage(message: ConsoleMessage): Boolean {
    val line = "WebView console [${message.messageLevel()}] " +
      "${message.message()} (${message.sourceId()}:${message.lineNumber()})"
    when (message.messageLevel()) {
      ConsoleMessage.MessageLevel.ERROR -> Log.e(TAG, line)
      ConsoleMessage.MessageLevel.WARNING -> Log.w(TAG, line)
      else -> Log.d(TAG, line)
    }
    return true
  }
}
Wire them up in the right order โ€” the custom WebViewClient goes into attach() (so the bootstrap injection survives), and the WebChromeClient is set after attach() (because attach() installs a plain one that would overwrite yours):
host.attach(webView, client = DiagnosticWebViewClient(BOOTSTRAP_SCRIPT))
webView.webChromeClient = DiagnosticWebChromeClient()
webView.loadUrl(journeyUrl)

Common Issues

Most bridge problems fall into a small set of recurring categories โ€” message routing, capability discovery, network and cleartext policy, threading, and lifecycle. Each entry below describes the symptoms, the likely causes, and the fix.

Messages Not Being Received by the Host

Symptoms: The web journey sends messages, but receivedMessages stays empty.

Possible causes

  1. Wrong messaging path. On Android the web journey must use exactly:
    window.GBGBridge.postMessage(JSON.stringify(message));
    
    The iOS path does nothing on Android:
    // iOS only โ€” silently absent on Android
    window.webkit.messageHandlers.gbgBridge.postMessage(message);
    
    Web code that targets both platforms must branch, for example by feature-detecting window.GBGBridge.postMessage. Note also that the Android entry point takes a JSON string โ€” pass the output of JSON.stringify, not a raw object.
  2. Host not attached. window.GBGBridge.postMessage only exists after host.attach(webView) installs the JavaScript interface. If you forgot to call attach() โ€” or called detach() before the page sent its message โ€” the web side has no native entry point at all.
  3. WebViewClient set directly on the WebView after attach. Setting webView.webViewClient = ... yourself replaces the SDKโ€™s bootstrap-injecting client. Pass your custom client to host.attach(webView, client = ...) instead (subclassing BootstrapInjectingWebViewClient), and call super.onPageStarted so the bootstrap keeps being injected.
  4. Stale attach session. Messages from a WebView that has since been detached or replaced by a newer attach() are dropped by design.
Fix: Call host.attach(webView) before loadUrl(), pass any custom WebViewClient through attach() rather than setting it directly, and make sure the web journey uses the Android messaging path.

Messages Not Being Received by the Web Journey

Symptoms: sendEvent(...) or respond(...) is called, but the web journey doesnโ€™t receive the message.

Possible causes

  1. WebView not attached. Unlike iOS, Android does not record a "WebView not attached" error in lastError โ€” the host fires delegate.onMessageSent (recording the intent to send) and then silently drops the message at the transport layer. If onMessageSent fires but nothing arrives on the web side, check that the host is actually attached.
  2. window.GBGBridge.receive not defined. The web journey must replace the no-op receive() installed by the bootstrap with its own implementation. Check from the DevTools console:
    console.log(window.GBGBridge.receive.toString());
    
  3. Bootstrap not injected. If you replaced the WebViewClient outside of attach() (see the previous issue), the bootstrap never runs and window.GBGBridge.receive may be missing entirely.
  4. Page navigated away. If the WebView navigated to a different page, the bridge context is lost. The bootstrap script re-runs on each page load (main frame only), but the web journey must reinitialize its receive() function.
  5. Encoding failure. Outbound messages that fail to encode are dropped, with lastError set and delegate.onError fired.
Fix: Ensure the web journey sets up window.GBGBridge.receive after page load and that the WebView is attached to the host.
Bootstrap timing on Android is best-effort: the script is injected via evaluateJavascript in onPageStarted, which is not guaranteed to run before the pageโ€™s own head scripts (unlike iOSโ€™s WKUserScript at document start). Web code that calls window.GBGBridge.receive synchronously at the top of the document could race the bootstrap โ€” defensive web-side initialization avoids this.

Message Decoding Failures

Symptoms: lastError reports a decode failure, and delegate.onError fires with the underlying exception.

Possible causes

  1. Malformed JSON. The web journey is sending a payload that doesnโ€™t match the BridgeMessage structure โ€” including passing a raw object instead of a JSON.stringifyโ€™d string to postMessage.
  2. Missing required fields. Every message must have: version, correlationId, type, timestamp, and payload (with at least action).
  3. Wrong type for type field. Must be one of: "request", "response", "event".
Fix: Validate the message structure on the web side before sending. A valid message looks like:
{
  "version": "1.0",
  "correlationId": "unique-id",
  "type": "request",
  "timestamp": Date.now(),
  "payload": {
    "action": "camera.capture",
    "data": {}
  }
}
The timestamp is epoch milliseconds; decoding is lenient enough to round a fractional value, but send an integer.

Capability Query Returns Unexpected Results

Symptoms: The web journey receives a capability.query response, but capabilities are missing or incorrect.

Possible causes

  1. Typed slot without a handler. When using BridgeHost(hostVersion), a slot only appears as supported if handler is set and isEnabled is true. If you forgot to set a handler, the slot wonโ€™t appear.
  2. Slot is disabled. Check that isEnabled hasnโ€™t been set to false on the slot.
  3. Capabilities not declared (configuration-based constructor). When using BridgeHost(configuration), only capabilities in the BridgeConfiguration.capabilities map (or returned by the capabilitiesProvider, if you passed one) are reported. An empty map means an empty query response.
  4. Custom capability not registered. If using registerCustomCapability(), ensure it was called before the query.
  5. Merge precedence. When the same capability id is declared in multiple places, the merged view is built lowest-to-highest: runtime registerCustomCapability entries, then the static configuration map or dynamic provider, then typed slots with a non-null handler. A higher-precedence entry shadows a lower one โ€” but an unused typed slot (no handler) never shadows anything.
Fix: For typed slots, verify handler is set: host.documentCapture.isSupported should be true. For configuration-based construction, review your BridgeConfiguration (or capabilitiesProvider, which is re-evaluated on every read).

Permission State Not Appearing in Query Response

Symptoms: The permissionState field is missing from the capability query response.

Possible causes

  1. Not using typed slots. Permission state is only included automatically when using typed slots that have permissionState set.
  2. CameraDetector not called. The default permissionState is NOT_DETERMINED. Call CameraDetector.check(context) and assign the result.
  3. Using configuration-based capabilities without permissionState. The BridgeCapabilityInfo permissionState field defaults to null. Set it explicitly.
Fix:
val camera = CameraDetector.check(context)
host.documentCapture.permissionState = camera.permissionState
CameraDetector only reports GRANTED or NOT_DETERMINED โ€” Android cannot distinguish โ€œnever askedโ€ from โ€œpermanently deniedโ€ without app-side state. After running your own runtime permission flow, set the richer DENIED or RESTRICTED values on the slot yourself.

Handler Not Called for Requests

Symptoms: A request arrives (visible in receivedMessages) but the handlerโ€™s handle() method is never called. The request appears in pendingRequests, and delegate.onUnhandledRequest fires.

Possible causes

  1. Typed slot handler not set. Ensure host.documentCapture.handler (or selfieCapture.handler) is set. Without a handler, requests for camera.document.capture go to pendingRequests.
  2. Action mismatch. For custom capabilities or BridgeCapabilityHandler, the action property must exactly match the requestโ€™s payload.action. Check for typos and case sensitivity.
  3. Handler not registered. For the register(handler) path, ensure it was called before the request arrived.
  4. Slot is disabled. Note that isEnabled is advisory โ€” it affects what capability.query reports but does not gate dispatch โ€” so a disabled slot with a handler still dispatches; the more likely cause is a missing handler.
Fix: For typed slots, verify the handler is set. For interface-based handlers, verify the action string matches:
// Typed slot โ€” action is "camera.document.capture"
host.documentCapture.handler = { request -> /* ... */ }

// Interface-based handler โ€” action must match exactly
override val action: String = "camera.document.capture"  // Must match web journey's payload.action
A request already sitting in pendingRequests can still be answered later with the lookup overload: host.respond(to = request.correlationId, status = ..., data = ...).

Cleartext HTTP Blocked

Symptoms: The WebView shows a blank page when loading an http:// URL. The diagnostic WebViewClient logs an error such as net::ERR_CLEARTEXT_NOT_PERMITTED. This is Androidโ€™s equivalent of iOS App Transport Security: cleartext HTTP is blocked by default on API 28+. Fix for local development: scope a network security config to your dev hosts only:
<!-- res/xml/network_security_config.xml -->
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">10.0.2.2</domain>
        <domain includeSubdomains="false">localhost</domain>
        <domain includeSubdomains="false">127.0.0.1</domain>
    </domain-config>
</network-security-config>
<!-- AndroidManifest.xml -->
<application
    android:networkSecurityConfig="@xml/network_security_config"
    ... >
A cleaner variant is the reference appโ€™s pattern: a locked-down main config, with a permissive override placed in the debug source set so release builds never carry it. Fix for production: Use HTTPS for all journey URLs. Never ship android:usesCleartextTraffic="true" unscoped.

WebView Shows Blank Page

Symptoms: The WebView renders but shows nothing. On Android nearly every load failure looks identical from the outside โ€” the Diagnostic WebView Clients above turn each one into a distinct logcat line.

Possible causes

Diagnostic log lineLikely causeFix
net::ERR_CLEARTEXT_NOT_PERMITTEDCleartext HTTP blocked (API 28+)See Cleartext HTTP Blocked
net::ERR_CONNECTION_REFUSED for a localhost URLOn the emulator, localhost/127.0.0.1 is the emulator itself, not your machineUse http://10.0.2.2:<port> (the emulatorโ€™s alias for the host), or run adb reverse tcp:3000 tcp:3000 and keep using 127.0.0.1
net::ERR_NAME_NOT_RESOLVED (or a long hang)DNS failure โ€” emulator DNS issues can make a hostname never resolveVerify the hostname resolves on-device; try the emulator with a known-good network
onReceivedSslError loggedTLS validation failed (self-signed cert, hostname mismatch, expired cert)Fix the certificate. Never call handler.proceed() to bypass it โ€” keep the secure cancel() default
onReceivedHttpError with 4xx/5xxServer responded with an error (expired journey URL, wrong path)Check the journey URL is fresh and correct
Console errors via onConsoleMessageJavaScript error in the journey pageInspect with chrome://inspect
Pay attention to the main-frame vs sub-resource distinction in the diagnostic client: only a main-frame failure blanks the whole screen; sub-resource errors (a missing image, a blocked analytics script) are usually noise. Fix: Test the URL in a regular browser first to rule out web-side issues, then read the diagnostic clientโ€™s logcat output to identify which failure mode youโ€™re in.

IllegalStateException: Must Be Called on the Main Thread

Symptoms: A call to respond(...), sendEvent(...), complete(...), or another state-mutating BridgeHost method throws IllegalStateException complaining about the calling thread. Cause: Every state-mutating BridgeHost and BridgeResponder method asserts the main thread at runtime (the Android analogue of iOSโ€™s @MainActor). This typically happens when a handler does its work on a background thread โ€” a network call, an image encode โ€” and then responds from that same thread. Fix: Do the heavy work off-main, then hop back before responding:
// From a coroutine
scope.launch(Dispatchers.IO) {
  val result = doExpensiveWork(request)
  withContext(Dispatchers.Main) {
    responder.respond(BridgeResponseStatus.SUCCESS, data = result)
  }
}

// From a callback-based API
Handler(Looper.getMainLooper()).post {
  responder.respond(BridgeResponseStatus.SUCCESS, data = result)
}

// From an Activity
runOnUiThread {
  host.sendEvent("my.event", data = payload)
}

IllegalStateException: Host Has Been Disposed

Symptoms: Calls to attach(), register(), respond(), sendEvent(), or registerCustomCapability() throw IllegalStateException referring to a disposed host. Cause: dispose() is terminal โ€” it tears the host down permanently, and afterwards all state-mutating methods throw. A common mistake is calling dispose() when you only meant to swap WebViews or temporarily disconnect. Fix: Use detach() / attach() to disconnect and reconnect WebViews on the same host โ€” detach() is idempotent and the host stays usable. Reserve dispose() for final teardown (Activity.onDestroy, ViewModel.onCleared), and wrap it defensively since removing the JavaScript interface can throw on a WebView already shutting down:
override fun onCleared() {
  try {
    host.dispose()
  } catch (e: Exception) {
    Log.w(TAG, "Bridge dispose failed", e)
  }
}
After dispose(), the safe calls are detach(), dispose(), clearError(), the delegate setter, and all getters (which return empty or null).

Memory Warnings or Crashes

Symptoms: App receives memory pressure or crashes when using the bridge.

Possible causes

  1. Large payloads. Sending very large Base64-encoded images through the bridge can cause memory pressure. Consider passing file paths instead of raw data.
  2. Message retention. receivedMessages is a ring buffer capped at 200 entries, so unlike iOS it cannot grow without bound โ€” but 200 retained messages that each carry a large Base64 image can still hold significant memory.
Fix for large payloads:
// Instead of sending raw image data:
// data = mapOf("imageBase64" to JsonPrimitive(largeBase64String))

// Send a file path:
responder.respond(
  status = BridgeResponseStatus.SUCCESS,
  data = mapOf("imagePath" to JsonPrimitive(tempFilePath)),
)
Fix for message retention: detach() clears receivedMessages and pendingRequests (a deliberate divergence from iOS, which preserves them), so detaching between sessions also releases the buffers.

Delegate Callbacks Silently Stop Firing

Symptoms: onMessage, onError, and the other delegate callbacks work at first, then stop โ€” often after a few seconds or after navigating around the app. No error is reported anywhere. (This section replaces the iOS pageโ€™s SwiftUI @StateObject guidance; the Android failure mode is different but equally silent.) Cause: BridgeHost.delegate is stored as a WeakReference โ€” the host does not keep your delegate alive. If nothing else holds a strong reference, the delegate is garbage-collected and callbacks silently stop:
// WRONG โ€” the inline delegate has no other strong reference.
// It becomes GC-eligible immediately and callbacks stop after the next GC.
host.delegate = object : BridgeHostDelegate {
  override fun onMessage(host: BridgeHost, message: BridgeMessage) { /* ... */ }
}
Fix: Hold the delegate strongly for as long as the host lives. The reference app does this with a controller class kept in remember { }:
class BridgeController(hostVersion: String) {
  val delegate = LoggingDelegate()  // strong reference, pinned by the controller

  val host: BridgeHost = BridgeHost(
    configuration = BridgeConfiguration(hostVersion = hostVersion),
  ).also { it.delegate = delegate }
}

// In the composable:
val controller = remember(journeyUrl) { BridgeController(hostVersion = "1.0.0") }
In a View-system app, the same applies: make the delegate a property of your Activity, Fragment, or ViewModel rather than an inline expression.

Typed Slot Returns โ€œBUSYโ€ Error

Symptoms: The web journey receives a BUSY error when sending a capture request. Cause: Capture slots are single-flight โ€” a previous capture request is still in progress on the same slot (activeRequest.value is non-null), so the new one is rejected with error code BUSY (recoverable). Fix: Ensure the previous request completes before another arrives. Call complete(...) when the capture UI finishes, and cancelIfBusy(...) when it is dismissed without a result โ€” for example when your full-screen capture dialog is dismissed:
Dialog(
  onDismissRequest = {
    host.documentCapture.cancelIfBusy("User dismissed capture")
    showCapture = false
  },
) { /* capture UI */ }
cancelIfBusy() is safe to call when idle, so it also belongs in your teardown path. If you use raw BridgeCapabilityHandlers instead of the typed slots, implement the equivalent single-flight guard yourself โ€” the reference appโ€™s CameraCaptureHandler returns BUSY from its own busy check.

Web Journey Hangs Waiting for a Response

Symptoms: The web journey sends a request and waits forever โ€” no response, no error.

Possible causes

  1. Handler never responded. A handler retained the responder (for async work) but a code path forgot to call respond(...), or the screen hosting the capture UI was torn down (rotation, navigation) with the request still in flight. Each responder must be called exactly once; later calls are no-ops.
  2. Sent while detached. If the host responded after detach() โ€” for example, from a delayed callback โ€” the message fired delegate.onMessageSent and then silently dropped at the transport. No lastError is recorded (a divergence from iOS), so onMessageSent firing is evidence of intent, not of delivery.
  3. Lookup respond found no match. The lookup overload respond(to = correlationId, ...) silently no-ops when no matching pending request exists โ€” for instance if detach() already cleared pendingRequests.
Fix: Cancel in-flight work on teardown so the web side gets a terminal cancelled response instead of silence. The reference app does this in DisposableEffect:
DisposableEffect(controller) {
  onDispose {
    try {
      controller.documentHandler.cancelActive("Journey screen disposed")
      controller.selfieHandler.cancelActive("Journey screen disposed")
    } finally {
      controller.host.detach()
    }
  }
}
With typed slots, detach() already cancels in-flight captures via cancelIfBusy() โ€” but raw handlers must cancel themselves, as above.

Debugging Checklist

When something isnโ€™t working, run through these checks in whichever order makes most sense for the symptom โ€” theyโ€™re independent, not sequential:
  • Check host.lastError for error messages (and clearError() between repro attempts).
  • Check host.receivedMessages to see if messages are arriving.
  • Check host.pendingRequests for unhandled requests.
  • Implement delegate.onError to capture the underlying exceptions.
  • For typed slots: verify handler is set and isSupported is true.
  • For typed slots: check activeRequest.value isnโ€™t stuck โ€” i.e. a previous request didnโ€™t complete.
  • Connect Chrome DevTools via chrome://inspect (with setWebContentsDebuggingEnabled on debug builds) to examine the web side.
  • Install the diagnostic WebViewClient/WebChromeClient pair and read logcat for load and console errors.
  • Verify handler action strings match request actions exactly.
  • Ensure host.attach(webView) was called before loadUrl(), any custom WebViewClient was passed to attach(), and the WebChromeClient was set after attach().
  • Confirm the journey URL is reachable and uses HTTPS (or a scoped cleartext config for local dev โ€” and 10.0.2.2, not localhost, on the emulator).
  • Check the manifest for the INTERNET permission (plus CAMERA if hosting camera capture).
  • Verify the delegate is strongly referenced โ€” not assigned inline.
  • Verify all bridge calls happen on the main thread.
  • Verify dispose() isnโ€™t being called on a host you still need โ€” use detach() to swap WebViews.

Next Steps