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 photographycamera.selfie: Facial capture for liveness checksnfc.read: Reading NFC chips on identity documents
Three Ways to Declare Capabilities
GBGBridge provides three approaches, listed from most to least recommended:
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):
- Custom capabilities registered at runtime via
registerCustomCapability(). - The static
BridgeConfiguration.capabilitiesmap, or the dynamiccapabilitiesProviderwhen one is supplied. - 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 ahandler on a slot simultaneously declares support and provides the implementation.
Basic Setup
suspend lambda invoked on the main thread (Dispatchers.Main), so it can touch UI directly without explicit dispatching.
Available Slots
Handler-as-Declaration
With typed slots, there is no separate “declaration” step. A slot’sisSupported property is computed as handler != null && isEnabled. When the web journey sends a capability.query request, only slots with handlers appear as supported.
Returning Results
Typed slot handlers returnCaptureResult values — a sealed class. The SDK encodes them into the bridge protocol format automatically — no manual JsonElement map construction needed.
Compose Integration with awaitCompletion()
TheawaitCompletion() / complete() pattern bridges suspending handlers with Compose’s declarative presentation. Each slot exposes activeRequest as a StateFlow<BridgeMessage?>, which Compose can collect directly:
- Web journey sends a
camera.document.capturerequest. - Handler runs on the main thread; the slot sets
activeRequest, and the handler callsawaitCompletion()— suspends. - Compose observes the
activeRequestchange and presents the capture dialog. - Capture completes, the UI calls
host.documentCapture.complete(CaptureResult.Document(...)). - Handler resumes with the result; the SDK encodes and sends the response.
activeRequestresets tonull, the dialog leaves composition.
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:- No handler set →
unsupportedresponse with error codeUNSUPPORTED(recoverable: false). - A request is already active →
errorresponse with codeBUSY(recoverable: true). - Otherwise →
activeRequestis set and the handler is launched. - Handler throws →
errorresponse with codeHANDLER_FAILURE(recoverable: false), and the error is routed todelegate.onError. - Handler’s coroutine is cancelled (
CancellationException) →cancelledresponse.
Permission State
Each typed slot has apermissionState property. Populate it using CameraDetector so the web journey can check permissions before attempting capture:
capability.query response:
PermissionState enum carries the wire tokens, which match iOS exactly:
Update the slot after your runtime permission request resolves:
Enable/Disable at Runtime
ToggleisEnabled to temporarily disable a slot without removing the handler:
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, useregisterCustomCapability(). The version parameter defaults to "1.0" when omitted.
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.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.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(...).BridgeCapabilityHandler for each action:
Handler Lifecycle
- 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. - Request routing — When a matching request arrives,
handle(request, responder)is called synchronously on the main thread. - Response — Call
responder.respond(...)exactly once with the result (later calls no-op). - Unregistration — Call
host.unregister(action)to remove a handler. Unregistering"capability.query"removes the built-in query handler;CapabilityQueryHandleris public, so you can register a replacement.
Handler Failures
Exceptions thrown from a handler are caught by the host and routed todelegate.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 acapability.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: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:
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
Thecapability.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
UseCameraDetector for camera hardware and permission detection:
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.Pattern 2: Check Permissions Before Starting
With permission state in the capability query, the web journey can detect permission issues and prompt the user:"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 withunsupported when no handler is set. For custom capabilities, respond explicitly:
Dynamic Capability Updates
With typed slots, capability state is inherently dynamic:- Set or clear
handlerto change support status. - Toggle
isEnabledto temporarily disable a slot. - Update
permissionStatewhen permissions change (e.g., after returning from Settings).
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.
Next Steps
- Security Guide — Transport security and content policies
- Messaging Guide — Request/response patterns
- Troubleshooting Guide — Diagnosing capability-related issues