Skip to main content
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:
ApproachBest ForSetup
Typed slotsDocument/selfie captureBridgeHost(hostVersion = ...)
Custom capabilityNFC, biometrics, or any non-camera capabilityBridgeHost(hostVersion = ...) + registerCustomCapability()
Configuration-basedFull manual controlBridgeHost(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 slots are the recommended way to declare capture capabilities. Setting a handler on a slot simultaneously declares support and provides the implementation.

Basic Setup

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

PropertyCapability IDAction ID
host.documentCapturecamera.documentcamera.document.capture
host.selfieCapturecamera.selfiecamera.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.
// 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.
// 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:
@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)).
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.

Busy Rejection

If a request arrives while another is already active on the same slot, the SDK automatically responds with an error:
{
  "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:
val camera = CameraDetector.check(context)
host.documentCapture.permissionState = camera.permissionState
host.selfieCapture.permissionState = camera.permissionState
This information appears in the capability.query response:
{
  "camera.document": {
    "supported": true,
    "version": "1.0",
    "permissionState": "granted"
  }
}
The PermissionState enum carries the wire tokens, which match iOS exactly:
Enum valueWire token
GRANTED"granted"
DENIED"denied"
NOT_DETERMINED"notDetermined"
RESTRICTED"restricted"
NOT_APPLICABLE"notApplicable"
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.
Update the slot after your runtime permission request resolves:
@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:
// 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.
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.

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.
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,
        ),
      )
    }
  }
}
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.
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.
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())
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(...).
With this approach, you implement BridgeCapabilityHandler for each action:
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.

Query Response

The web journey receives:
{
  "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:
CapabilityiOS NativeWeb (iframe)Android Native
Camera captureYesLimitedYes
NFC chip readYes (iPhone 7+)NoYes (varies)
Face ID / Touch IDYesNoFingerprint/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:
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:
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:
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.
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