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 isBridgeHost.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:
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.
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.
Chrome DevTools (chrome://inspect)
For inspecting the web side of the bridge, enable WebView content debugging โ gated onFLAG_DEBUGGABLE so release builds never opt in:
- Run the app on a device or emulator connected via adb.
- In Chrome on your desktop, open
chrome://inspect#devices. - Find your appโs WebView and click inspect.
- Use the console to inspect
window.GBGBridgeand test message sending.
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. AWebViewClient 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:
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:
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):
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, butreceivedMessages stays empty.
Possible causes
-
Wrong messaging path. On Android the web journey must use exactly:
The iOS path does nothing on Android: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 ofJSON.stringify, not a raw object. -
Host not attached.
window.GBGBridge.postMessageonly exists afterhost.attach(webView)installs the JavaScript interface. If you forgot to callattach()โ or calleddetach()before the page sent its message โ the web side has no native entry point at all. -
WebViewClient set directly on the WebView after attach. Setting
webView.webViewClient = ...yourself replaces the SDKโs bootstrap-injecting client. Pass your custom client tohost.attach(webView, client = ...)instead (subclassingBootstrapInjectingWebViewClient), and callsuper.onPageStartedso the bootstrap keeps being injected. -
Stale attach session. Messages from a WebView that has since been detached or replaced by a newer
attach()are dropped by design.
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
-
WebView not attached. Unlike iOS, Android does not record a
"WebView not attached"error inlastErrorโ the host firesdelegate.onMessageSent(recording the intent to send) and then silently drops the message at the transport layer. IfonMessageSentfires but nothing arrives on the web side, check that the host is actually attached. -
window.GBGBridge.receivenot defined. The web journey must replace the no-opreceive()installed by the bootstrap with its own implementation. Check from the DevTools console: -
Bootstrap not injected. If you replaced the WebViewClient outside of
attach()(see the previous issue), the bootstrap never runs andwindow.GBGBridge.receivemay be missing entirely. -
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. -
Encoding failure. Outbound messages that fail to encode are dropped, with
lastErrorset anddelegate.onErrorfired.
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
-
Malformed JSON. The web journey is sending a payload that doesnโt match the
BridgeMessagestructure โ including passing a raw object instead of aJSON.stringifyโd string topostMessage. -
Missing required fields. Every message must have:
version,correlationId,type,timestamp, andpayload(with at leastaction). -
Wrong type for
typefield. Must be one of:"request","response","event".
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 acapability.query response, but capabilities are missing or incorrect.
Possible causes
-
Typed slot without a handler. When using
BridgeHost(hostVersion), a slot only appears as supported ifhandleris set andisEnabledistrue. If you forgot to set a handler, the slot wonโt appear. -
Slot is disabled. Check that
isEnabledhasnโt been set tofalseon the slot. -
Capabilities not declared (configuration-based constructor). When using
BridgeHost(configuration), only capabilities in theBridgeConfiguration.capabilitiesmap (or returned by thecapabilitiesProvider, if you passed one) are reported. An empty map means an empty query response. -
Custom capability not registered. If using
registerCustomCapability(), ensure it was called before the query. -
Merge precedence. When the same capability id is declared in multiple places, the merged view is built lowest-to-highest: runtime
registerCustomCapabilityentries, 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.
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: ThepermissionState field is missing from the capability query response.
Possible causes
-
Not using typed slots. Permission state is only included automatically when using typed slots that have
permissionStateset. -
CameraDetector not called. The default
permissionStateisNOT_DETERMINED. CallCameraDetector.check(context)and assign the result. -
Using configuration-based capabilities without permissionState. The
BridgeCapabilityInfopermissionStatefield defaults tonull. Set it explicitly.
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 inreceivedMessages) but the handlerโs handle() method is never called. The request appears in pendingRequests, and delegate.onUnhandledRequest fires.
Possible causes
-
Typed slot handler not set. Ensure
host.documentCapture.handler(orselfieCapture.handler) is set. Without a handler, requests forcamera.document.capturego topendingRequests. -
Action mismatch. For custom capabilities or
BridgeCapabilityHandler, theactionproperty must exactly match the requestโspayload.action. Check for typos and case sensitivity. -
Handler not registered. For the
register(handler)path, ensure it was called before the request arrived. -
Slot is disabled. Note that
isEnabledis advisory โ it affects whatcapability.queryreports but does not gate dispatch โ so a disabled slot with a handler still dispatches; the more likely cause is a missing handler.
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 anhttp:// 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:
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 line | Likely cause | Fix |
|---|---|---|
net::ERR_CLEARTEXT_NOT_PERMITTED | Cleartext HTTP blocked (API 28+) | See Cleartext HTTP Blocked |
net::ERR_CONNECTION_REFUSED for a localhost URL | On the emulator, localhost/127.0.0.1 is the emulator itself, not your machine | Use 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 resolve | Verify the hostname resolves on-device; try the emulator with a known-good network |
onReceivedSslError logged | TLS 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/5xx | Server responded with an error (expired journey URL, wrong path) | Check the journey URL is fresh and correct |
Console errors via onConsoleMessage | JavaScript error in the journey page | Inspect with chrome://inspect |
IllegalStateException: Must Be Called on the Main Thread
Symptoms: A call torespond(...), 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:
IllegalStateException: Host Has Been Disposed
Symptoms: Calls toattach(), 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:
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
- Large payloads. Sending very large Base64-encoded images through the bridge can cause memory pressure. Consider passing file paths instead of raw data.
- Message retention.
receivedMessagesis 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.
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:
remember { }:
Typed Slot Returns โBUSYโ Error
Symptoms: The web journey receives aBUSY 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:
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
-
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. -
Sent while detached. If the host responded after
detach()โ for example, from a delayed callback โ the message fireddelegate.onMessageSentand then silently dropped at the transport. NolastErroris recorded (a divergence from iOS), soonMessageSentfiring is evidence of intent, not of delivery. -
Lookup respond found no match. The lookup overload
respond(to = correlationId, ...)silently no-ops when no matching pending request exists โ for instance ifdetach()already clearedpendingRequests.
cancelled response instead of silence. The reference app does this in DisposableEffect:
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.lastErrorfor error messages (andclearError()between repro attempts). - Check
host.receivedMessagesto see if messages are arriving. - Check
host.pendingRequestsfor unhandled requests. - Implement
delegate.onErrorto capture the underlying exceptions. - For typed slots: verify
handleris set andisSupportedistrue. - For typed slots: check
activeRequest.valueisnโt stuck โ i.e. a previous request didnโt complete. - Connect Chrome DevTools via
chrome://inspect(withsetWebContentsDebuggingEnabledon debug builds) to examine the web side. - Install the diagnostic WebViewClient/WebChromeClient pair and read logcat for load and console errors.
- Verify handler
actionstrings match request actions exactly. - Ensure
host.attach(webView)was called beforeloadUrl(), any custom WebViewClient was passed toattach(), and the WebChromeClient was set afterattach(). - Confirm the journey URL is reachable and uses HTTPS (or a scoped cleartext config for local dev โ and
10.0.2.2, notlocalhost, on the emulator). - Check the manifest for the
INTERNETpermission (plusCAMERAif 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 โ usedetach()to swap WebViews.
Next Steps
- FAQ โ Common questions
- Messaging Guide โ Message flow details
- Security Guide โ Security best practices