Skip to main content
A production-style integration demonstrating custom configuration, lifecycle management, error handling, capability negotiation, and graceful degradation for environment-sensitive features like camera hardware.
The iOS version of this example uses passport NFC chip reading as its custom capability. NFC is not currently part of the Android SDK surface, so this page instead demonstrates the Android-only configuration features — allowedOrigins, the capabilitiesProvider constructor parameter, a custom BootstrapInjectingWebViewClient, and terminal dispose() — and uses an illustrative location.read capability to show the custom-capability pattern. For the iOS counterpart, see the iOS advanced integration example.

What this example demonstrates

  • A full BridgeConfiguration — static capability map, custom bootstrap script, and the Android-only allowedOrigins message-level origin gate
  • Dynamic capability state via the capabilitiesProvider constructor parameter (the Android replacement for iOS’s mutable capability map)
  • Typed capability slots for document and selfie capture with CameraDetector permission state
  • Custom capability registration with a manual BridgeResponder
  • A custom BootstrapInjectingWebViewClient subclass for navigation policy and load-error logging, passed via attach(webView, client = ...)
  • Lifecycle management: background/foreground events, WebView swap via detach()/attach(), and terminal dispose()
  • Error handling via lastError and delegate.onError
  • Runtime capability detection with pre-launch validation and graceful degradation

Complete source

The full app below is a single Jetpack Compose file that demonstrates pre-launch capability validation, typed slots for document and selfie capture, a custom location.read capability, lifecycle event forwarding, and delegate-based message observation. Read through the section banners (// ── ... ──) to navigate; each block is the production-style version of a pattern shown elsewhere in the docs.
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.gbg.gbgbridge.capabilities.CameraDetector
import com.gbg.gbgbridge.capabilities.CaptureResult
import com.gbg.gbgbridge.capabilities.PermissionState
import com.gbg.gbgbridge.core.BridgeCapabilityInfo
import com.gbg.gbgbridge.core.BridgeConfiguration
import com.gbg.gbgbridge.core.BridgeHost
import com.gbg.gbgbridge.core.BridgeHostDelegate
import com.gbg.gbgbridge.models.BridgeErrorPayload
import com.gbg.gbgbridge.models.BridgeMessage
import com.gbg.gbgbridge.models.BridgeMessageType
import com.gbg.gbgbridge.models.BridgeResponseStatus
import com.gbg.gbgbridge.webview.BridgeWebViewConfigurator
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import java.io.ByteArrayOutputStream

private const val TAG = "AdvancedJourney"

// ── Custom bootstrap script ──
//
// The SDK's default bootstrap plus an app-specific marker the page can probe.
// Hosts that install a custom WebViewClient own this literal: the subclass
// constructor receives it, and the first two statements MUST be preserved or
// the web side has no receive() entry point.
private const val CUSTOM_BOOTSTRAP: String =
  "window.GBGBridge = window.GBGBridge || {}; " +
    "window.GBGBridge.receive = window.GBGBridge.receive || function(){}; " +
    "window.__advancedHostReady = true;"

// ── App entry point ──

class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      MaterialTheme {
        JourneyLauncherScreen()
      }
    }
  }
}

// ── Journey configuration ──

/** Centralized configuration for the identity verification journey. */
data class JourneyConfig(
  val url: String,
  val requiredCapabilities: List<String>,
  val hostVersion: String,
) {
  companion object {
    val documentAndSelfie = JourneyConfig(
      url = "https://journey.example.com/document-selfie",
      requiredCapabilities = listOf("camera.document", "camera.selfie"),
      hostVersion = "1.0.0",
    )

    val documentOnly = JourneyConfig(
      url = "https://journey.example.com/document",
      requiredCapabilities = listOf("camera.document"),
      hostVersion = "1.0.0",
    )
  }
}

// ── Device capability detection ──

class DeviceCapabilities(val cameraResult: CameraDetector.Result) {
  companion object {
    fun detect(context: Context): DeviceCapabilities =
      DeviceCapabilities(cameraResult = CameraDetector.check(context))
  }

  fun isSupported(capability: String): Boolean = when (capability) {
    "camera.document", "camera.selfie" -> cameraResult.hardwareAvailable
    "location.read" -> true // permission can be requested at runtime
    else -> false
  }
}

// ── Journey launcher (pre-launch validation) ──

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JourneyLauncherScreen() {
  val context = LocalContext.current
  val deviceCapabilities = remember { DeviceCapabilities.detect(context) }

  var selectedJourney by remember { mutableStateOf(JourneyConfig.documentAndSelfie) }
  var showJourney by remember { mutableStateOf(false) }
  var showCapabilityWarning by remember { mutableStateOf(false) }

  fun missingCapabilities(journey: JourneyConfig): List<String> =
    journey.requiredCapabilities.filterNot { deviceCapabilities.isSupported(it) }

  fun attemptLaunch(journey: JourneyConfig) {
    selectedJourney = journey
    if (missingCapabilities(journey).isEmpty()) {
      showJourney = true
    } else {
      showCapabilityWarning = true
    }
  }

  if (showJourney) {
    JourneyContainerScreen(
      config = selectedJourney,
      onDismiss = { showJourney = false },
    )
    return
  }

  Scaffold(topBar = { TopAppBar(title = { Text("Verification") }) }) { padding ->
    Column(
      modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
      verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
      Text("Identity Verification", style = MaterialTheme.typography.titleLarge)

      JourneyOptionCard(
        title = "Document + Selfie Verification",
        subtitle = "Document capture and selfie match",
        requirements = JourneyConfig.documentAndSelfie.requiredCapabilities,
        deviceCapabilities = deviceCapabilities,
        onClick = { attemptLaunch(JourneyConfig.documentAndSelfie) },
      )

      JourneyOptionCard(
        title = "Document Verification",
        subtitle = "Document capture only",
        requirements = JourneyConfig.documentOnly.requiredCapabilities,
        deviceCapabilities = deviceCapabilities,
        onClick = { attemptLaunch(JourneyConfig.documentOnly) },
      )

      DeviceCapabilitySummary(capabilities = deviceCapabilities)
    }
  }

  if (showCapabilityWarning) {
    val missing = missingCapabilities(selectedJourney)
    AlertDialog(
      onDismissRequest = { showCapabilityWarning = false },
      title = { Text("Capability Warning") },
      text = {
        Text(
          "This journey requires ${missing.joinToString(", ")} which " +
            "${if (missing.size == 1) "is" else "are"} not available on this " +
            "device. The journey may not complete successfully.",
        )
      },
      confirmButton = {
        TextButton(
          onClick = {
            showCapabilityWarning = false
            showJourney = true
          },
        ) { Text("Continue Anyway") }
      },
      dismissButton = {
        TextButton(onClick = { showCapabilityWarning = false }) { Text("Cancel") }
      },
    )
  }
}

// ── Journey state ──

sealed interface JourneyState {
  data object Loading : JourneyState
  data object Active : JourneyState
  data class Completed(val status: String, val instanceId: String?) : JourneyState
  data class Error(val message: String) : JourneyState
}

// ── Journey container (full integration) ──

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun JourneyContainerScreen(
  config: JourneyConfig,
  onDismiss: () -> Unit,
) {
  val context = LocalContext.current

  // The controller strongly holds the delegate: BridgeHost.delegate is a
  // WeakReference, so an inline delegate with no other reference would
  // silently stop firing after the next GC.
  val controller = remember(config) {
    BridgeController(config = config, appContext = context.applicationContext)
  }

  var journeyState by remember { mutableStateOf<JourneyState>(JourneyState.Loading) }
  var bridgeError by remember { mutableStateOf<String?>(null) }
  var webViewGeneration by remember { mutableIntStateOf(0) }

  // Route delegate callbacks into Compose state.
  LaunchedEffect(controller) {
    controller.delegate.onJourneyEvent = { state -> journeyState = state }
    controller.delegate.onBridgeError = { message -> bridgeError = message }
  }

  // Ask for the camera permission once if it has never been determined, then
  // push the result into the typed slots so capability.query reflects it.
  val requestCameraPermission = rememberLauncherForActivityResult(
    ActivityResultContracts.RequestPermission(),
  ) { granted -> controller.onCameraPermissionResult(granted) }

  LaunchedEffect(controller) {
    if (controller.cameraAvailable &&
      controller.host.documentCapture.permissionState == PermissionState.NOT_DETERMINED
    ) {
      requestCameraPermission.launch(Manifest.permission.CAMERA)
    }
  }

  // Forward background/foreground transitions to the web journey so it can
  // pause timers, save state, or handle session timeouts.
  val lifecycleOwner = LocalLifecycleOwner.current
  DisposableEffect(lifecycleOwner, controller) {
    val observer = LifecycleEventObserver { _, event ->
      when (event) {
        Lifecycle.Event.ON_STOP -> controller.host.sendEvent("host.background")
        Lifecycle.Event.ON_START -> controller.host.sendEvent("host.foreground")
        else -> Unit
      }
    }
    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
  }

  // Terminal teardown. dispose() detaches, cancels the typed slots' coroutine
  // scopes, and locks the host — state-mutating calls throw afterwards.
  // removeJavascriptInterface can throw on a WebView that is already shutting
  // down, so wrap defensively and log.
  DisposableEffect(controller) {
    onDispose {
      try {
        controller.host.dispose()
      } catch (e: Exception) {
        Log.w(TAG, "Bridge teardown failed", e)
      }
    }
  }

  Scaffold(
    topBar = {
      TopAppBar(
        title = { Text("Verification") },
        actions = {
          TextButton(
            onClick = {
              controller.host.sendEvent(
                "journey.cancel",
                data = mapOf("reason" to JsonPrimitive("user_dismissed")),
              )
              onDismiss()
            },
          ) { Text("Cancel") }
        },
      )
    },
  ) { padding ->
    Box(modifier = Modifier.fillMaxSize().padding(padding)) {
      // key() forces a brand-new WebView when webViewGeneration changes — the
      // retry path below. detach() before attach() is idempotent (a no-op on
      // the first build) and clears the message buffers, so the new attach
      // session starts clean.
      key(webViewGeneration) {
        AndroidView(
          modifier = Modifier.fillMaxSize(),
          factory = { ctx ->
            WebView(ctx).apply {
              layoutParams = ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT,
              )
              controller.host.detach()
              controller.host.attach(
                webView = this,
                client = JourneyWebViewClient(
                  bootstrapScript = CUSTOM_BOOTSTRAP,
                  allowedHosts = setOf("journey.example.com"),
                  onPageReady = {
                    if (journeyState == JourneyState.Loading) {
                      journeyState = JourneyState.Active
                    }
                  },
                  onLoadError = { message -> journeyState = JourneyState.Error(message) },
                ),
              )
              loadUrl(config.url)
            }
          },
        )
      }

      when (val state = journeyState) {
        JourneyState.Loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))

        is JourneyState.Error -> JourneyErrorOverlay(
          message = state.message,
          onRetry = {
            controller.host.clearError()
            bridgeError = null
            journeyState = JourneyState.Loading
            webViewGeneration++ // swap in a fresh WebView
          },
        )

        is JourneyState.Completed -> JourneyCompleteOverlay(result = state, onDone = onDismiss)

        JourneyState.Active -> Unit
      }

      bridgeError?.let { message ->
        Surface(
          color = MaterialTheme.colorScheme.errorContainer,
          shape = MaterialTheme.shapes.small,
          modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
        ) {
          Text(
            text = message,
            modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
            style = MaterialTheme.typography.bodySmall,
          )
        }
      }
    }
  }

  // Typed-slot capture UI. activeRequest is a StateFlow the SDK sets while a
  // capture is in flight; observing it is the Compose-idiomatic way to know
  // when to present a capture surface.
  val documentRequest by controller.host.documentCapture.activeRequest.collectAsState()
  val selfieRequest by controller.host.selfieCapture.activeRequest.collectAsState()

  if (documentRequest != null) {
    CaptureSheet(
      title = "Document capture",
      onComplete = {
        val png = placeholderImage(Bitmap.CompressFormat.PNG)
        controller.host.documentCapture.complete(
          CaptureResult.Document(imageData = png, width = 640, height = 400),
        )
      },
      onCancel = { controller.host.documentCapture.cancelIfBusy("User dismissed capture") },
    )
  }

  if (selfieRequest != null) {
    CaptureSheet(
      title = "Selfie capture",
      onComplete = {
        val jpeg = placeholderImage(Bitmap.CompressFormat.JPEG)
        controller.host.selfieCapture.complete(
          CaptureResult.Selfie(
            previewImageData = jpeg,
            width = 640,
            height = 400,
            // Production selfie flows receive these blobs from the capture
            // vendor — placeholders keep this example self-contained.
            encryptedBlob = jpeg,
            unencryptedBlob = jpeg,
          ),
        )
      },
      onCancel = { controller.host.selfieCapture.cancelIfBusy("User dismissed capture") },
    )
  }
}

// ── Bridge controller (full configuration) ──

private class BridgeController(
  config: JourneyConfig,
  private val appContext: Context,
) {
  val cameraAvailable: Boolean = CameraDetector.check(appContext).hardwareAvailable

  // Strongly held — see the note at the remember { } site above.
  val delegate = JourneyDelegate()

  // Construction-time baseline. BridgeConfiguration snapshots this map
  // defensively; it is what capability.query reports when no
  // capabilitiesProvider is supplied.
  private val staticCapabilities: Map<String, BridgeCapabilityInfo> = mapOf(
    "camera.document" to BridgeCapabilityInfo(supported = cameraAvailable, version = "1.0"),
    "camera.selfie" to BridgeCapabilityInfo(supported = cameraAvailable, version = "1.0"),
  )

  val host: BridgeHost = BridgeHost(
    configuration = BridgeConfiguration(
      hostVersion = config.hostVersion,

      // Static capability map — the baseline declaration.
      capabilities = staticCapabilities,

      // Custom bootstrap. NOTE: because attach() receives a custom
      // WebViewClient in this app, the client's constructor script is the one
      // injected; this field applies when attach() is called without a
      // client. Sharing one constant keeps the two in sync.
      bootstrapScript = CUSTOM_BOOTSTRAP,

      // Android-only message-level origin gate. Inbound messages are dropped
      // (lastError + delegate.onError with a SecurityException) unless the
      // WebView's main-frame origin matches an entry. Validated at
      // construction: an empty list or a malformed entry throws
      // IllegalArgumentException.
      allowedOrigins = listOf("https://journey.example.com"),
    ),

    // Android-only: re-evaluated on every capability read, replacing both the
    // static map above and iOS's mutable `capabilities` property. Return the
    // complete picture — typed slots with a handler still win at their id.
    capabilitiesProvider = { staticCapabilities + liveCapabilities() },
  )

  init {
    host.delegate = delegate

    if (cameraAvailable) {
      // Typed slots: setting a handler declares the capability supported and
      // takes precedence over static/provider entries for camera.document and
      // camera.selfie. Each handler suspends until the capture UI calls
      // complete() or cancelIfBusy().
      host.documentCapture.handler = { _ -> host.documentCapture.awaitCompletion() }
      host.selfieCapture.handler = { _ -> host.selfieCapture.awaitCompletion() }

      val camera = CameraDetector.check(appContext)
      host.documentCapture.permissionState = camera.permissionState
      host.selfieCapture.permissionState = camera.permissionState
    }

    // Custom capability — the manual counterpart to typed slots. It
    // auto-appears in capability.query with supported = true; the provider
    // above overrides its entry with live permission state (explicit
    // configuration is authoritative over runtime registrations).
    host.registerCustomCapability("location.read", version = "1.0") { _, responder ->
      if (!locationPermitted()) {
        responder.respond(
          status = BridgeResponseStatus.ERROR,
          error = BridgeErrorPayload(
            code = "LOCATION_PERMISSION_DENIED",
            message = "Location permission has not been granted.",
            recoverable = true,
          ),
        )
      } else {
        // In a real app, read a coarse location fix here.
        responder.respond(
          status = BridgeResponseStatus.SUCCESS,
          data = mapOf(
            "latitude" to JsonPrimitive(51.5072),
            "longitude" to JsonPrimitive(-0.1276),
          ),
        )
      }
    }
  }

  /** Called from the runtime-permission launcher result. */
  fun onCameraPermissionResult(granted: Boolean) {
    // CameraDetector can only report GRANTED vs NOT_DETERMINED — after our
    // own permission flow we know the real answer, so set the richer state.
    val state = if (granted) PermissionState.GRANTED else PermissionState.DENIED
    host.documentCapture.permissionState = state
    host.selfieCapture.permissionState = state
  }

  private fun liveCapabilities(): Map<String, BridgeCapabilityInfo> = mapOf(
    "location.read" to BridgeCapabilityInfo(
      supported = true,
      version = "1.0",
      permissionState = (
        if (locationPermitted()) PermissionState.GRANTED else PermissionState.NOT_DETERMINED
        ).wireValue,
    ),
  )

  private fun locationPermitted(): Boolean =
    ContextCompat.checkSelfPermission(
      appContext,
      Manifest.permission.ACCESS_COARSE_LOCATION,
    ) == PackageManager.PERMISSION_GRANTED
}

// ── Journey delegate (message observation and routing) ──

private class JourneyDelegate : BridgeHostDelegate {
  /** Journey lifecycle changes derived from terminal bridge EVENTs. */
  var onJourneyEvent: ((JourneyState) -> Unit)? = null

  /** Bridge-level failures surfaced for an error banner. */
  var onBridgeError: ((String) -> Unit)? = null

  override fun onMessage(host: BridgeHost, message: BridgeMessage) {
    // The web journey reports terminal states as one-way EVENTs. Events never
    // enter the request dispatch path, so the delegate is the only place to
    // observe them.
    if (message.type != BridgeMessageType.EVENT) return
    val data = message.payload.data
    val instanceId = data?.get("instanceId")?.jsonPrimitive?.contentOrNull

    when (message.payload.action) {
      "journey.completed" ->
        onJourneyEvent?.invoke(JourneyState.Completed(status = "completed", instanceId = instanceId))

      "journey.abandoned" ->
        onJourneyEvent?.invoke(JourneyState.Completed(status = "abandoned", instanceId = instanceId))

      "journey.failed" ->
        onJourneyEvent?.invoke(JourneyState.Error("The verification journey reported a failure."))
    }
  }

  override fun onMessageSent(host: BridgeHost, message: BridgeMessage) {
    // Android-only outbound hook: fires for every message the host sends,
    // before transport — including sends attempted while detached.
    Log.d(TAG, "TO WEB ${message.payload.action} ${message.correlationId}")
  }

  override fun onUnhandledRequest(host: BridgeHost, request: BridgeMessage) {
    // The request is already parked in host.pendingRequests; the lookup
    // respond overload finds and removes it.
    host.respond(
      to = request.correlationId,
      status = BridgeResponseStatus.UNSUPPORTED,
      error = BridgeErrorPayload(
        code = "UNSUPPORTED_ACTION",
        message = "Action '${request.payload.action}' is not supported by this host",
        recoverable = false,
      ),
    )
  }

  override fun onError(host: BridgeHost, error: Throwable) {
    // Fires for decode failures, handler exceptions, encode failures, and
    // allowedOrigins rejections. lastError is read-only on Android — the SDK
    // writes it; the host surfaces it and resets it via clearError().
    Log.e(TAG, "Bridge error", error)
    onBridgeError?.invoke(host.lastError ?: error.message ?: "Unknown bridge error")
  }
}

// ── Custom WebViewClient (navigation policy + error logging) ──

private class JourneyWebViewClient(
  bootstrapScript: String,
  private val allowedHosts: Set<String>,
  private val onPageReady: () -> Unit,
  private val onLoadError: (String) -> Unit,
) : BridgeWebViewConfigurator.BootstrapInjectingWebViewClient(bootstrapScript) {

  // Navigation policy: keep the WebView pinned to the journey origin. This
  // is the primary gate — the message-level allowedOrigins check in
  // BridgeConfiguration layers on top of it.
  override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
    val host = request.url.host ?: return true
    if (host in allowedHosts) return false // load in the WebView
    Log.w(TAG, "Blocked navigation to ${request.url}")
    return true // block (or hand off to an external browser via an Intent)
  }

  // The base class injects the bridge bootstrap in onPageStarted. We don't
  // override it here; if you do, call super.onPageStarted or the bridge never
  // initializes.

  override fun onPageFinished(view: WebView?, url: String?) {
    super.onPageFinished(view, url)
    onPageReady()
  }

  override fun onReceivedError(
    view: WebView,
    request: WebResourceRequest,
    error: WebResourceError,
  ) {
    super.onReceivedError(view, request, error)
    if (request.isForMainFrame) {
      Log.e(TAG, "Load error ${error.errorCode} (${error.description}) for ${request.url}")
      onLoadError("The journey failed to load (${error.description}).")
    }
  }

  override fun onReceivedHttpError(
    view: WebView,
    request: WebResourceRequest,
    errorResponse: WebResourceResponse,
  ) {
    super.onReceivedHttpError(view, request, errorResponse)
    if (request.isForMainFrame) {
      Log.e(TAG, "HTTP ${errorResponse.statusCode} for ${request.url}")
      onLoadError("The journey failed to load (HTTP ${errorResponse.statusCode}).")
    }
  }
}

// ── Supporting composables ──

@Composable
private fun JourneyOptionCard(
  title: String,
  subtitle: String,
  requirements: List<String>,
  deviceCapabilities: DeviceCapabilities,
  onClick: () -> Unit,
) {
  val allMet = requirements.all { deviceCapabilities.isSupported(it) }
  Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
    Row(
      modifier = Modifier.fillMaxWidth().padding(16.dp),
      verticalAlignment = Alignment.CenterVertically,
    ) {
      Column(modifier = Modifier.weight(1f)) {
        Text(title, style = MaterialTheme.typography.titleMedium)
        Text(subtitle, style = MaterialTheme.typography.bodySmall)
      }
      Text(if (allMet) "Ready" else "Limited", style = MaterialTheme.typography.labelMedium)
    }
  }
}

@Composable
private fun DeviceCapabilitySummary(capabilities: DeviceCapabilities) {
  Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
    Text("Device capabilities", style = MaterialTheme.typography.labelMedium)
    Text(
      "Camera: ${if (capabilities.cameraResult.hardwareAvailable) "available" else "not available"}" +
        " · Permission: ${capabilities.cameraResult.permissionState.wireValue}",
      style = MaterialTheme.typography.bodySmall,
    )
  }
}

@Composable
private fun CaptureSheet(
  title: String,
  onComplete: () -> Unit,
  onCancel: () -> Unit,
) {
  AlertDialog(
    onDismissRequest = onCancel,
    title = { Text(title) },
    text = {
      Text(
        "Production apps present a CameraX preview or photo picker here — " +
          "see the capture screens guide. This example completes the slot " +
          "with a placeholder image.",
      )
    },
    confirmButton = { TextButton(onClick = onComplete) { Text("Use placeholder image") } },
    dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
  )
}

@Composable
private fun JourneyErrorOverlay(message: String, onRetry: () -> Unit) {
  Column(
    modifier = Modifier.fillMaxSize().padding(24.dp),
    verticalArrangement = Arrangement.Center,
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {
    Text("Verification Error", style = MaterialTheme.typography.titleMedium)
    Spacer(Modifier.height(8.dp))
    Text(message, style = MaterialTheme.typography.bodyMedium)
    Spacer(Modifier.height(16.dp))
    Button(onClick = onRetry) { Text("Retry") }
  }
}

@Composable
private fun JourneyCompleteOverlay(result: JourneyState.Completed, onDone: () -> Unit) {
  Column(
    modifier = Modifier.fillMaxSize().padding(24.dp),
    verticalArrangement = Arrangement.Center,
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {
    Text(
      if (result.status == "completed") "Verification Complete" else "Verification Ended",
      style = MaterialTheme.typography.titleMedium,
    )
    result.instanceId?.let {
      Spacer(Modifier.height(8.dp))
      Text("Instance: $it", style = MaterialTheme.typography.bodySmall)
    }
    Spacer(Modifier.height(16.dp))
    Button(onClick = onDone) { Text("Done") }
  }
}

// ── Helpers ──

private fun placeholderImage(format: Bitmap.CompressFormat): ByteArray {
  val bitmap = Bitmap.createBitmap(640, 400, Bitmap.Config.ARGB_8888)
  bitmap.eraseColor(android.graphics.Color.DKGRAY)
  return ByteArrayOutputStream().use { out ->
    bitmap.compress(format, 90, out)
    out.toByteArray()
  }
}

Architecture overview

Key patterns demonstrated

The sections below pull the load-bearing fragments out of the listing and explain the behavior that isn’t obvious from the code alone — including where the Android SDK deliberately diverges from iOS.

Full BridgeConfiguration

This example exercises every BridgeConfiguration field rather than relying on the BridgeHost(hostVersion:) convenience constructor:
BridgeConfiguration(
  hostVersion = config.hostVersion,
  capabilities = staticCapabilities,                       // static baseline, snapshotted at construction
  bootstrapScript = CUSTOM_BOOTSTRAP,                      // custom JS injected on page start
  allowedOrigins = listOf("https://journey.example.com"),  // Android-only origin gate
)
capabilities is defensively snapshotted when the host is constructed — mutating the map you passed in later does not change responses. bootstrapScript replaces the SDK’s default bootstrap; it must still establish window.GBGBridge.receive, or the web side has no way to receive native messages. allowedOrigins is an opt-in, Android-only message-level gate (null disables it). Inbound messages are checked against the normalized origin of the WebView’s main-frame URL; on rejection the message is dropped, lastError is set, and delegate.onError fires with a SecurityException. The constructor validates the list up front: an empty list or any malformed entry (missing host, non-http(s) scheme) throws IllegalArgumentException. Entries are normalized to scheme://host[:port] — case-insensitive, default ports elided — so "HTTPS://journey.example.com:443" matches "https://journey.example.com".
allowedOrigins is not a security boundary. Messages from sub-frames are checked against the main-frame URL, and the URL is read at delivery time, so a fast navigation can race the check. Treat it as defense-in-depth layered on top of navigation-level filtering in shouldOverrideUrlLoading — which is what the custom WebViewClient in this example provides.

Dynamic capability state with capabilitiesProvider

iOS exposes a mutable capabilities map on the host; on Android the merged capabilities property is a read-only snapshot. The replacement is the capabilitiesProvider constructor parameter — a lambda re-evaluated on every capability read, so dynamic state (like a permission granted mid-journey) is always current without polling or manual refresh calls:
BridgeHost(
  configuration = configuration,
  capabilitiesProvider = { staticCapabilities + liveCapabilities() },
)
When a provider is supplied, it takes the place of the static capabilities map in BridgeConfiguration, so it should return the complete picture (the example folds the static baseline in explicitly). Merge precedence at the same capability id, lowest to highest: registerCustomCapability registrations, then the static map or provider, then typed slots with a non-null handler. An unused slot never shadows — so the provider’s location.read entry overrides the auto-registered custom-capability entry with live permission state, while the camera ids come from the typed slots once their handlers are set.

Custom WebViewClient

JourneyWebViewClient subclasses the SDK’s BootstrapInjectingWebViewClient and is passed to attach(webView, client = ...). It adds an origin allowlist in shouldOverrideUrlLoading (the actual navigation gate) and logs the failure modes that otherwise present as a silent blank page — main-frame load errors and HTTP 4xx/5xx responses. The base class injects the bridge bootstrap in onPageStarted; if you override that method, call super.onPageStarted or the bridge never initializes.
When you pass a custom client to attach(), the client’s constructor owns the bootstrap script — the bootstrapScript in BridgeConfiguration applies only when attaching without a client. The SDK’s default bootstrap literal is internal, so a host installing its own client must supply the literal itself; the example shares a single CUSTOM_BOOTSTRAP constant between the configuration and the client to keep the two paths consistent.

Pre-launch capability validation

Before starting the journey, the launcher checks whether all required capabilities are available:
val missing = journey.requiredCapabilities.filterNot { deviceCapabilities.isSupported(it) }

if (missing.isEmpty()) {
  showJourney = true
} else {
  showCapabilityWarning = true // let the user choose to continue or cancel
}
This prevents users from starting a journey that will fail partway through due to missing hardware.

Lifecycle events and teardown

The app sends events when the screen stops and starts again, using a LifecycleEventObserver. The web journey can use these to pause/resume timers, save state, or handle session timeouts.
val observer = LifecycleEventObserver { _, event ->
  when (event) {
    Lifecycle.Event.ON_STOP -> controller.host.sendEvent("host.background")
    Lifecycle.Event.ON_START -> controller.host.sendEvent("host.foreground")
    else -> Unit
  }
}
Teardown happens at two levels. The retry path swaps the WebView: detach() then attach() on a fresh instance. On Android, detach() also cancels any in-flight typed-slot captures and clears pendingRequests/receivedMessages — a deliberate divergence from iOS, which preserves the buffers — so a re-attach starts without ghost entries in Compose lists. When the screen leaves composition, the example calls dispose(), an Android-only terminal teardown with no iOS equivalent (ARC handles it there): it detaches and cancels the typed slots’ coroutine scopes, and afterwards state-mutating methods throw IllegalStateException (detach(), dispose(), clearError(), the getters, and the delegate setter remain safe). Wrap it in try/catch and log, because removeJavascriptInterface can throw on a WebView already in shutdown — in a View-system app, do the same from onDestroy() or a ViewModel’s onCleared(). One related divergence to know about: calling sendEvent or respond while no WebView is attached fires delegate.onMessageSent (so you still see the intent in traces) but the message is silently dropped at transport, and no lastError is recorded — iOS sets lastError = "WebView not attached" in that situation.

Error handling with lastError and onError

Android surfaces bridge failures through two complementary channels. host.lastError is a human-readable description of the most recent failure — unlike iOS it has a private setter, so only the SDK writes it; hosts read it and reset it with clearError() (the retry button does this). delegate.onError is an Android-only Throwable channel that fires for decode failures, handler exceptions, outbound encode failures, and allowedOrigins rejections. The example routes it into a Compose error banner:
override fun onError(host: BridgeHost, error: Throwable) {
  Log.e(TAG, "Bridge error", error)
  onBridgeError?.invoke(host.lastError ?: error.message ?: "Unknown bridge error")
}

Permission state with CameraDetector

CameraDetector.check(context) detects camera hardware and permission status. The result is assigned to the typed slots’ permissionState, which is automatically included in capability query responses:
val camera = CameraDetector.check(appContext)
host.documentCapture.permissionState = camera.permissionState
On Android, CameraDetector reports only GRANTED or NOT_DETERMINED — the platform cannot distinguish “never asked” from “permanently denied” without integrator state. After running your own runtime permission request, set the richer state on the slots yourself, as onCameraPermissionResult does:
fun onCameraPermissionResult(granted: Boolean) {
  val state = if (granted) PermissionState.GRANTED else PermissionState.DENIED
  host.documentCapture.permissionState = state
  host.selfieCapture.permissionState = state
}
The web journey can then check permissionState before attempting capture and prompt the user to grant access if needed.

Typed Slots vs Custom Capabilities

This example demonstrates both patterns side by side:
  • Document and selfie capture use typed slots (host.documentCapture, host.selfieCapture) — the SDK handles result encoding and busy rejection automatically, and the UI observes activeRequest (a StateFlow) to know when to present a capture surface.
  • location.read uses registerCustomCapability() — the handler receives a BridgeResponder and builds the response manually. (On iOS, this slot in the example is filled by NFC chip reading.)

Graceful Degradation

Camera hardware is detected at runtime. On a device without one (some tablets, TVs, and misconfigured emulators), the launcher shows a warning, no slot handlers are set, and the capability map reports the camera capabilities as unsupported — a capture request that arrives anyway is automatically answered with an UNSUPPORTED response by the slot. Similarly, the location.read handler responds with a recoverable error when permission has not been granted.

Running this example

To run the example end-to-end, follow these setup steps:
  1. Add GBGBridge from Maven Central:
    implementation("com.gbg:gbgbridge-sdk:0.1.0-alpha01")
    
  2. Add to AndroidManifest.xml:
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-feature
      android:name="android.hardware.camera"
      android:required="false" />
    
    <!-- Only for this example's illustrative location.read capability -->
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    
  3. Update the journey URLs in JourneyConfig — and the matching allowedOrigins and allowedHosts entries — to your web journey endpoint.
  4. Build and run.

Next steps