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.
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.capabilities map, or the dynamic capabilitiesProvider when 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 slots are the recommended way to declare capture capabilities. Setting a handler on a slot simultaneously declares support and provides the implementation.
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 sethost.documentCapture.isSupported // false// Supported — handler is sethost.documentCapture.handler = { request -> /* ... */ }host.documentCapture.isSupported // true// Temporarily disabled — handler set but isEnabled is falsehost.documentCapture.isEnabled = falsehost.documentCapture.isSupported // false
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.
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:
Web journey sends a camera.document.capture request.
Handler runs on the main thread; the slot sets activeRequest, and the handler calls awaitCompletion() — suspends.
Compose observes the activeRequest change 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.
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.
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.permissionStatehost.selfieCapture.permissionState = camera.permissionState
This information appears in the capability.query response:
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"
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:
@Composablefun 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) } }}
Toggle isEnabled to temporarily disable a slot without removing the handler:
// Disable selfie capture for this journeyhost.selfieCapture.isEnabled = false// Re-enable laterhost.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.
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.
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. }}
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; CapabilityQueryHandler is public, so you can register a replacement.
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.
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.
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.
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 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.
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) != nullif (nfcSupported) { host.registerCustomCapability("nfc.read", version = "1.0") { request, responder -> // Handle NFC }}
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.
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 -> ResultIf camera.document is unsupported (web fallback exists):Journey: Document Capture (web) -> NFC Read -> Face Match -> ResultIf 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.
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.
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, ),)
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.