> ## Documentation Index
> Fetch the complete documentation index at: https://docs.go.gbgplc.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Troubleshooting

> Diagnose and resolve common GBGBridge integration issues on Android.

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:

```kotlin theme={null}
// 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.

```kotlin theme={null}
// 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`.

```kotlin theme={null}
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:

```kotlin theme={null}
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.

```javascript theme={null}
// 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:

```kotlin theme={null}
// 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:

```kotlin theme={null}
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):

```kotlin theme={null}
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:
   ```javascript theme={null}
   window.GBGBridge.postMessage(JSON.stringify(message));
   ```
   The iOS path does nothing on Android:
   ```javascript theme={null}
   // 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:
   ```javascript theme={null}
   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.

<Note>
  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.
</Note>

***

### 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:

```javascript theme={null}
{
  "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:**

```kotlin theme={null}
val camera = CameraDetector.check(context)
host.documentCapture.permissionState = camera.permissionState
```

<Note>
  `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.
</Note>

***

### 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:

```kotlin theme={null}
// 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:

```xml theme={null}
<!-- 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>
```

```xml theme={null}
<!-- 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](#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](#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`                                                                                                     |

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:

```kotlin theme={null}
// 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:

```kotlin theme={null}
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:**

```kotlin theme={null}
// 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:

```kotlin theme={null}
// 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 { }`:

```kotlin theme={null}
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:

```kotlin theme={null}
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 `BridgeCapabilityHandler`s 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`:

```kotlin theme={null}
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

* [FAQ](/docs/go-v2/developer-integration/sdks/android/faq) — Common questions
* [Messaging Guide](/docs/go-v2/developer-integration/sdks/android/messaging) — Message flow details
* [Security Guide](/docs/go-v2/developer-integration/sdks/android/security) — Security best practices
