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

# Capability Handling

> Typed slots, custom capabilities, and handler patterns.

This guide explains how GBGBridge handles capabilities — the native features that a host application can provide to web journeys. It covers typed capability slots (recommended), custom capability registration, the legacy configuration approach, capability negotiation, permission state, and graceful degradation patterns.

## What is a Capability?

A **capability** is a native device feature that the host app can provide to the web journey. Examples include:

* `camera.document`: Document photography
* `camera.selfie`: Facial capture for liveness checks
* `nfc.read`: Reading NFC chips on identity documents

Capabilities are identified by string keys using dot-separated namespaces.

## Three Ways to Declare Capabilities

GBGBridge provides three approaches, listed from most to least recommended:

| Approach                | Best For                                      | Setup                                                          |
| ----------------------- | --------------------------------------------- | -------------------------------------------------------------- |
| **Typed slots**         | Document/selfie capture                       | `BridgeHost(hostVersion = ...)`                                |
| **Custom capability**   | NFC, biometrics, or any non-camera capability | `BridgeHost(hostVersion = ...)` + `registerCustomCapability()` |
| **Configuration-based** | Full manual control                           | `BridgeHost(configuration)` + `register(handler)`              |

All three can be used together. When the same capability ID appears in more than one source, the merged capability map is built with the following precedence (lowest to highest):

1. Custom capabilities registered at runtime via `registerCustomCapability()`.
2. The static `BridgeConfiguration.capabilities` map, or the dynamic `capabilitiesProvider` when one is supplied.
3. Typed slots with a non-null `handler` — an unused slot (no handler set) never shadows an entry from the other sources.

## Typed Capability Slots (Recommended)

Typed slots are the recommended way to declare capture capabilities. Setting a `handler` on a slot simultaneously declares support and provides the implementation.

### Basic Setup

```kotlin theme={null}
val host = BridgeHost(hostVersion = "1.0.0")

// Declare document capture support
host.documentCapture.handler = { request ->
  host.documentCapture.awaitCompletion()
}

// Declare selfie capture support
host.selfieCapture.handler = { request ->
  host.selfieCapture.awaitCompletion()
}
```

The handler is a `suspend` lambda invoked on the main thread (`Dispatchers.Main`), so it can touch UI directly without explicit dispatching.

### Available Slots

| Property               | Capability ID     | Action ID                 |
| ---------------------- | ----------------- | ------------------------- |
| `host.documentCapture` | `camera.document` | `camera.document.capture` |
| `host.selfieCapture`   | `camera.selfie`   | `camera.selfie.capture`   |

### Handler-as-Declaration

With typed slots, there is no separate "declaration" step. A slot's `isSupported` property is computed as `handler != null && isEnabled`. When the web journey sends a `capability.query` request, only slots with handlers appear as supported.

```kotlin theme={null}
// Not supported — no handler set
host.documentCapture.isSupported  // false

// Supported — handler is set
host.documentCapture.handler = { request -> /* ... */ }
host.documentCapture.isSupported  // true

// Temporarily disabled — handler set but isEnabled is false
host.documentCapture.isEnabled = false
host.documentCapture.isSupported  // false
```

### Returning Results

Typed slot handlers return `CaptureResult` values — a sealed class. The SDK encodes them into the bridge protocol format automatically — no manual `JsonElement` map construction needed.

```kotlin theme={null}
// Document capture success (mimeType defaults to "image/png")
CaptureResult.Document(
  imageData = imageData,
  width = 1920,
  height = 1080,
  mimeType = "image/jpeg",
)

// Selfie capture success (mimeType defaults to "image/jpeg")
CaptureResult.Selfie(
  previewImageData = previewData,
  width = 640,
  height = 480,
  encryptedBlob = encryptedData,
  unencryptedBlob = unencryptedData,
)

// User cancelled
CaptureResult.Cancelled(reason = "User dismissed camera")

// Failure
CaptureResult.Failed(
  code = "CAMERA_DENIED",
  message = "Camera permission was denied",
  recoverable = true,
)
```

### Compose Integration with awaitCompletion()

The `awaitCompletion()` / `complete()` pattern bridges suspending handlers with Compose's declarative presentation. Each slot exposes `activeRequest` as a `StateFlow<BridgeMessage?>`, which Compose can collect directly:

```kotlin expandable theme={null}
@Composable
fun JourneyScreen(journeyUrl: String, host: BridgeHost) {
  val activeDocumentRequest by host.documentCapture.activeRequest.collectAsState()

  LaunchedEffect(Unit) {
    host.documentCapture.handler = { request ->
      host.documentCapture.awaitCompletion()
    }
  }

  AndroidView(
    factory = { context ->
      WebView(context).also { webView ->
        host.attach(webView)
        webView.loadUrl(journeyUrl)
      }
    },
    modifier = Modifier.fillMaxSize(),
  )

  if (activeDocumentRequest != null) {
    DocumentCameraDialog(
      onCaptured = { imageData, width, height ->
        host.documentCapture.complete(
          CaptureResult.Document(
            imageData = imageData,
            width = width,
            height = height,
          )
        )
      },
      onDismiss = {
        // If dismissed without completing, cancel the request
        host.documentCapture.cancelIfBusy("Dismissed")
      },
    )
  }
}
```

The flow is:

1. Web journey sends a `camera.document.capture` request.
2. Handler runs on the main thread; the slot sets `activeRequest`, and the handler calls `awaitCompletion()` — suspends.
3. Compose observes the `activeRequest` change and presents the capture dialog.
4. Capture completes, the UI calls `host.documentCapture.complete(CaptureResult.Document(...))`.
5. Handler resumes with the result; the SDK encodes and sends the response.
6. `activeRequest` resets to `null`, the dialog leaves composition.

In the View system, collect `activeRequest` from a lifecycle scope instead (for example `host.documentCapture.activeRequest.onEach { ... }.launchIn(lifecycleScope)`).

<Note>
  `complete()` and `cancelIfBusy()` are main-thread-only. Calling them from a background callback (such as a CameraX executor) throws `IllegalStateException` — hop first with `Handler(Looper.getMainLooper()).post { ... }` or `withContext(Dispatchers.Main)`. `complete()` silently no-ops when the slot is idle, and `cancelIfBusy()` is safe to call when idle.
</Note>

### Busy Rejection

If a request arrives while another is already active on the same slot, the SDK automatically responds with an error:

```json theme={null}
{
  "status": "error",
  "error": {
    "code": "BUSY",
    "message": "A camera.document capture request is already in progress",
    "recoverable": true
  }
}
```

The full dispatch decision tree for a typed slot is:

1. No handler set → `unsupported` response with error code `UNSUPPORTED` (`recoverable: false`).
2. A request is already active → `error` response with code `BUSY` (`recoverable: true`).
3. Otherwise → `activeRequest` is set and the handler is launched.
4. Handler throws → `error` response with code `HANDLER_FAILURE` (`recoverable: false`), and the error is routed to `delegate.onError`.
5. Handler's coroutine is cancelled (`CancellationException`) → `cancelled` response.

### Permission State

Each typed slot has a `permissionState` property. Populate it using `CameraDetector` so the web journey can check permissions before attempting capture:

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

This information appears in the `capability.query` response:

```json theme={null}
{
  "camera.document": {
    "supported": true,
    "version": "1.0",
    "permissionState": "granted"
  }
}
```

The `PermissionState` enum carries the wire tokens, which match iOS exactly:

| Enum value       | Wire token        |
| ---------------- | ----------------- |
| `GRANTED`        | `"granted"`       |
| `DENIED`         | `"denied"`        |
| `NOT_DETERMINED` | `"notDetermined"` |
| `RESTRICTED`     | `"restricted"`    |
| `NOT_APPLICABLE` | `"notApplicable"` |

<Warning>
  `CameraDetector.check(context)` reports only `GRANTED` or `NOT_DETERMINED` — this diverges from iOS, where the system tracks the full authorization status. The Android runtime permission API cannot distinguish "never asked" from "permanently denied" without integrator state, so the detector stays conservative and lets you override the slot with the richer state (`DENIED` / `RESTRICTED`) once your own permission flow has resolved.
</Warning>

Update the slot after your runtime permission request resolves:

```kotlin theme={null}
@Composable
fun PermissionSetup(host: BridgeHost) {
  val context = LocalContext.current
  val launcher = rememberLauncherForActivityResult(
    ActivityResultContracts.RequestPermission()
  ) { granted ->
    val state = if (granted) PermissionState.GRANTED else PermissionState.DENIED
    host.documentCapture.permissionState = state
    host.selfieCapture.permissionState = state
  }

  LaunchedEffect(Unit) {
    if (CameraDetector.check(context).permissionState != PermissionState.GRANTED) {
      launcher.launch(Manifest.permission.CAMERA)
    }
  }
}
```

### Enable/Disable at Runtime

Toggle `isEnabled` to temporarily disable a slot without removing the handler:

```kotlin theme={null}
// Disable selfie capture for this journey
host.selfieCapture.isEnabled = false

// Re-enable later
host.selfieCapture.isEnabled = true
```

When disabled, the slot reports `isSupported = false` in capability queries.

<Note>
  `isEnabled` is advisory only — it affects `capability.query` responses but does **not** gate dispatch. A request that arrives while `isEnabled` is false is still routed to the handler if one is set. This matches iOS behavior.
</Note>

## Custom Capability Registration

For capabilities that don't have a typed slot, for example, NFC and biometrics, use `registerCustomCapability()`. The `version` parameter defaults to `"1.0"` when omitted.

```kotlin expandable theme={null}
val host = BridgeHost(hostVersion = "1.0.0")
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

host.registerCustomCapability("nfc.read", version = "1.0") { request, responder ->
  val adapter = NfcAdapter.getDefaultAdapter(context)
  if (adapter == null || !adapter.isEnabled) {
    responder.respond(
      status = BridgeResponseStatus.UNSUPPORTED,
      error = BridgeErrorPayload(
        code = "NFC_NOT_AVAILABLE",
        message = "NFC reading is not available on this device",
        recoverable = false,
      ),
    )
    return@registerCustomCapability
  }

  // The handler is synchronous — retain the responder for async work
  // and respond when it completes, back on the main thread.
  scope.launch {
    try {
      val chipData = readNfcChip()  // suspend function
      responder.respond(
        status = BridgeResponseStatus.SUCCESS,
        data = mapOf(
          "mrz" to JsonPrimitive(chipData.mrz),
          "photo" to JsonPrimitive(chipData.photoBase64),
        ),
      )
    } catch (error: Exception) {
      responder.respond(
        status = BridgeResponseStatus.ERROR,
        error = BridgeErrorPayload(
          code = "NFC_ERROR",
          message = error.message ?: "NFC read failed",
          recoverable = true,
        ),
      )
    }
  }
}
```

<Info>
  Unlike iOS, where `handle` is `async`, the Kotlin handler signature is **synchronous**. For asynchronous work, retain the `BridgeResponder`, do the work, hop back to the main thread, then call `responder.respond(...)`. The responder is main-thread-only and should be called exactly once — subsequent calls silently no-op.
</Info>

Custom capabilities automatically appear in `capability.query` responses as supported. If a typed slot with a non-null handler exists for the same ID, the typed slot takes precedence.

## Configuration-Based Approach (Legacy)

The configuration-based approach gives you full manual control. Use it when you need explicit capability maps with constraints.

```kotlin theme={null}
val configuration = BridgeConfiguration(
  hostVersion = "1.0.0",
  capabilities = mapOf(
    "camera.document" to BridgeCapabilityInfo(
      supported = true,
      version = "1.0",
      constraints = mapOf(
        "maxResolution" to JsonPrimitive(4096),
        "formats" to JsonArray(listOf(JsonPrimitive("jpeg"), JsonPrimitive("png"))),
      ),
      permissionState = "granted",
    ),
    "nfc.read" to BridgeCapabilityInfo(
      supported = true,
      version = "1.0",
    ),
  ),
)

val host = BridgeHost(configuration)
host.register(DocumentCaptureHandler())
host.register(NfcReadHandler())
```

<Note>
  The `constraints` field is carried on `BridgeCapabilityInfo` for protocol parity but is never emitted in the `capability.query` response — on either platform. The `capabilities` map is defensively snapshotted at host construction; `BridgeCapabilityInfo` is immutable, so derive variants with `.copy(...)`.
</Note>

With this approach, you implement `BridgeCapabilityHandler` for each action:

```kotlin theme={null}
class NfcReadHandler : BridgeCapabilityHandler {
  override val action = "nfc.read"

  override fun handle(request: BridgeMessage, responder: BridgeResponder) {
    // Synchronous entry point. Handle the request and call
    // responder.respond(...) — retain the responder for async work.
  }
}
```

### Handler Lifecycle

1. **Registration** — Call `host.register(handler)` before the web journey sends requests. Registration is last-write-wins: registering a second handler for the same action replaces the first.
2. **Request routing** — When a matching request arrives, `handle(request, responder)` is called synchronously on the main thread.
3. **Response** — Call `responder.respond(...)` exactly once with the result (later calls no-op).
4. **Unregistration** — Call `host.unregister(action)` to remove a handler. Unregistering `"capability.query"` removes the built-in query handler; `CapabilityQueryHandler` is public, so you can register a replacement.

### Handler Failures

Exceptions thrown from a handler are caught by the host and routed to `delegate.onError`. If the handler had not yet responded, the host also sets `lastError` and dispatches an `error` response with code `HANDLER_FAILURE` (`recoverable: false`), so the web journey is never left hanging. If the handler already responded successfully and then threw, only `onError` fires — the web side keeps the response it received.

## Capability Negotiation

Capability negotiation is how the web journey discovers what the host supports before it routes the user into a native-dependent step.

### How It Works

The web journey sends a `capability.query` request. GBGBridge's built-in `CapabilityQueryHandler` responds automatically.

When using `BridgeHost(hostVersion = ...)`, the response is built dynamically from typed slots and custom capabilities. When using `BridgeHost(configuration)`, the static `BridgeConfiguration` map (or the `capabilitiesProvider`, when supplied) is merged in as well, following the precedence rules described earlier.

```mermaid theme={null}
sequenceDiagram
    participant Web as Web Journey
    participant Host as BridgeHost
    participant QH as CapabilityQueryHandler

    Web->>Host: request: capability.query
    Host->>QH: route (built-in handler)
    QH->>Host: respond with capabilities + permissionState
    Host->>Web: response: { environment, hostVersion, capabilities }
    Note over Web: Adapt journey flow<br/>based on capabilities<br/>and permissions
```

### Query Response

The web journey receives:

```json theme={null}
{
  "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" },
    "nfc.read": { "supported": true, "version": "1.0" }
  }
}
```

Per capability, `supported` and `version` are always present (`version` is JSON `null` when unset), `permissionState` appears only when the capability provides permission metadata (typed slots always carry one; configuration entries only when `permissionState` is non-null), and `constraints` is never emitted.

## Environment-Specific Behavior

Web journeys run inside many different hosts, and the capability surface differs between them — this section covers how journeys and hosts handle those differences.

### The Problem

Not all environments support the same capabilities:

| Capability         | iOS Native      | Web (iframe) | Android Native          |
| ------------------ | --------------- | ------------ | ----------------------- |
| Camera capture     | Yes             | Limited      | Yes                     |
| NFC chip read      | Yes (iPhone 7+) | No           | Yes (varies)            |
| Face ID / Touch ID | Yes             | No           | Fingerprint/Face Unlock |

When a web journey includes an NFC step but the host doesn't support NFC, the journey needs to know *before* reaching that step.

### Detecting Environment from the Web Journey

The `capability.query` response includes an `environment` field (`"ios"`, `"android"`, or `"web"` for iframe hosts). The web journey uses both the environment and the capability flags to make routing decisions.

### Runtime Hardware Detection on Android

Use `CameraDetector` for camera hardware and permission detection:

```kotlin theme={null}
val camera = CameraDetector.check(context)
// camera.hardwareAvailable — whether camera hardware exists (FEATURE_CAMERA_ANY)
// camera.permissionState — GRANTED or NOT_DETERMINED (see the permission caveat above)
```

For NFC, check at initialization time:

```kotlin theme={null}
val nfcSupported = NfcAdapter.getDefaultAdapter(context) != null

if (nfcSupported) {
  host.registerCustomCapability("nfc.read", version = "1.0") { request, responder ->
    // Handle NFC
  }
}
```

## Graceful Degradation Patterns

When a capability isn't available, your integration has two main routes: fall back to a web-based equivalent, or check upfront and prevent the user from starting a journey that won't complete. The patterns below show both.

### Pattern 1: Fall Back to Web or Skip

The web journey checks capabilities and adapts its flow. If a web-based fallback exists for the capability, the journey uses it. If there is no web equivalent, the step is skipped entirely.

```
Journey: Document Capture -> NFC Read -> Face Match -> Result

If camera.document is unsupported (web fallback exists):
Journey: Document Capture (web) -> NFC Read -> Face Match -> Result

If NFC is unsupported (no web fallback):
Journey: Document Capture -> Face Match -> Result (NFC skipped)
```

The host app doesn't need to do anything special — the web journey handles fallback and routing decisions based on the capability query response.

### Pattern 2: Check Permissions Before Starting

With permission state in the capability query, the web journey can detect permission issues and prompt the user:

```
If camera.document.permissionState == "denied":
    Show "Please enable camera access in Settings" before starting capture
```

Remember that on Android the `"denied"` token only appears once your integration sets it after a runtime permission request — `CameraDetector` alone never reports it.

### Pattern 3: Respond with Unsupported Status

If the web journey sends a request for a capability the host doesn't support, typed slots automatically respond with `unsupported` when no handler is set. For custom capabilities, respond explicitly:

```kotlin theme={null}
responder.respond(
  status = BridgeResponseStatus.UNSUPPORTED,
  error = BridgeErrorPayload(
    code = "CAPABILITY_UNAVAILABLE",
    message = "NFC is not available on this device",
    recoverable = false,
  ),
)
```

## Dynamic Capability Updates

With typed slots, capability state is inherently dynamic:

* Set or clear `handler` to change support status.
* Toggle `isEnabled` to temporarily disable a slot.
* Update `permissionState` when permissions change (e.g., after returning from Settings).

For non-slot capabilities, Android adds a `capabilitiesProvider` constructor parameter — a lambda that is re-evaluated every time the capability map is read, so `capability.query` always reflects current state without polling. This replaces the mutable `capabilities` map iOS exposes; on Android, `host.capabilities` is a read-only merged snapshot.

```kotlin theme={null}
val host = BridgeHost(
  configuration = BridgeConfiguration(hostVersion = "1.0.0"),
  capabilitiesProvider = {
    mapOf(
      "nfc.read" to BridgeCapabilityInfo(
        supported = NfcAdapter.getDefaultAdapter(context)?.isEnabled == true,
        version = "1.0",
      ),
    )
  },
)
```

The web journey should re-query capabilities after significant state changes (e.g., after the app returns from background) to pick up any changes.

## Next Steps

* [Security Guide](/docs/go-v2/developer-integration/sdks/android/security) — Transport security and content policies
* [Messaging Guide](/docs/go-v2/developer-integration/sdks/android/messaging) — Request/response patterns
* [Troubleshooting Guide](/docs/go-v2/developer-integration/sdks/android/troubleshooting) — Diagnosing capability-related issues
