Tutorial: Build an Android Identity Verification App
Step-by-step guide to building a complete Android app that runs a GBG Go identity verification journey with document and selfie capture.
This tutorial walks you through building a complete Android app that runs a GBG Go identity verification journey. By the end, you will have a working Jetpack Compose app that launches a web-based journey, handles document and selfie capture requests from the journey, and returns results back to the web via the native bridge.The app you build has three screens — a configuration form, a WebView running the journey, and a result screen — plus a full-screen capture surface that appears when the journey requests a photo. The bridge wiring itself lives in a single file.
Reference app: The complete source code for this tutorial is available at gbg-go-android-reference. You can clone it and run it immediately, or follow this tutorial to build it step by step.
The Android app does not call the GBG Go API directly. Instead, it calls a lightweight companion server that creates journey sessions using the GBG Go Core SDK. This keeps your API credentials on the server, never on the device.
The SDK is on Maven Central, so no repository setup is needed beyond the mavenCentral() entry that new projects already have. In app/build.gradle.kts, add the serialization plugin and the dependencies the app uses:
plugins { id("com.android.application") id("org.jetbrains.kotlin.android") // Required for Compose with Kotlin 2.0+. id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.serialization")}dependencies { // The GBGBridge SDK, published to Maven Central. implementation("com.gbg:gbgbridge-sdk:0.1.0-alpha01") implementation("androidx.navigation:navigation-compose:2.8.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("com.squareup.okhttp3:okhttp:4.12.0") // CameraX powers the live preview in the capture surface. The JPEG // hand-off to the bridge goes through ImageCapture → Bitmap, so the // bridge wiring is independent of the camera library you choose. val cameraXVersion = "1.3.4" implementation("androidx.camera:camera-core:$cameraXVersion") implementation("androidx.camera:camera-camera2:$cameraXVersion") implementation("androidx.camera:camera-lifecycle:$cameraXVersion") implementation("androidx.camera:camera-view:$cameraXVersion")}
You will also need the standard Compose BOM, Material 3, and activity-compose dependencies that the Empty Activity template generates — see the reference app’s app/build.gradle.kts for the complete file.
CAMERA is requested at runtime later; declaring the camera feature as required="false" keeps the app installable on devices without one (the capture surface falls back to the photo picker).The networkSecurityConfig attribute is the Android equivalent of iOS’s NSAllowsLocalNetworking: Android blocks cleartext HTTP by default (API 28+), so requests to the local companion server would fail without it. Create res/xml/network_security_config.xml scoped to loopback hosts only:
Never ship android:usesCleartextTraffic="true" unscoped in a production app. A domain-config allowlist limited to loopback hosts, like the one above, permits local development without weakening the rest of the app. For physical-device LAN testing, the reference app overrides this file in the debug source set only.
The app is a single ComponentActivity hosting a Compose NavHost with three destinations: Setup, Journey, and Result. Replace the contents of MainActivity.kt with:
class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { GBGGoReferenceTheme { Surface(modifier = Modifier.fillMaxSize()) { RootNavGraph() } } } }}@Composableprivate fun RootNavGraph() { val navController = rememberNavController() NavHost(navController = navController, startDestination = "setup") { composable("setup") { SetupScreen( onJourneyStarted = { url, serverUrl -> val encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8.name()) val encodedServer = URLEncoder.encode(serverUrl, StandardCharsets.UTF_8.name()) navController.navigate("journey/$encodedServer/$encodedUrl") }, ) } composable("journey/{serverUrl}/{url}") { backStackEntry -> val args = backStackEntry.arguments val serverUrl = URLDecoder.decode( args?.getString("serverUrl").orEmpty(), StandardCharsets.UTF_8.name(), ) val decoded = URLDecoder.decode( args?.getString("url").orEmpty(), StandardCharsets.UTF_8.name(), ) JourneyScreen( journeyUrl = decoded, onBack = { navController.popBackStack() }, onJourneyEnded = { status, instanceId, interactionId -> // Replace the journey on the back stack so system-back from Result // returns to Setup, not the now-disposed WebView. val sid = URLEncoder.encode(status.wireValue, StandardCharsets.UTF_8.name()) val iid = URLEncoder.encode(instanceId.orEmpty(), StandardCharsets.UTF_8.name()) val ixn = URLEncoder.encode(interactionId.orEmpty(), StandardCharsets.UTF_8.name()) val srv = URLEncoder.encode(serverUrl, StandardCharsets.UTF_8.name()) navController.navigate("result/$srv/$sid/$iid/$ixn") { popUpTo("journey/{serverUrl}/{url}") { inclusive = true } } }, ) } composable("result/{serverUrl}/{status}/{instanceId}/{interactionId}") { backStackEntry -> // Decode the route args (mirror of the encoding above), then: ResultScreen( serverUrl = serverUrl, status = JourneyTerminalStatus.fromWire(statusRaw), instanceId = instanceId, interactionId = interactionId, onStartOver = { navController.popBackStack(route = "setup", inclusive = false) }, ) } }}
Two things to note. The journey URL is passed as a URL-encoded navigation argument. And when the journey ends, the journey route is popped off the back stack as the result route is pushed — pressing back from the Result screen returns to Setup, never to a torn-down WebView.
private const val PREFS_NAME = "gbg_go_reference_prefs"private const val KEY_SERVER_URL = "serverURL"private const val KEY_RESOURCE_ID = "resourceId"// `localhost` from inside the Android emulator points at the emulator// itself, not the host machine. `10.0.2.2` is the canonical loopback alias// for the host.private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000"@OptIn(ExperimentalMaterial3Api::class)@Composablefun SetupScreen(onJourneyStarted: (journeyUrl: String, serverUrl: String) -> Unit) { val context = LocalContext.current val prefs = remember { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } var serverUrl by remember { mutableStateOf(prefs.getString(KEY_SERVER_URL, null) ?: DEFAULT_SERVER_URL) } var resourceId by remember { mutableStateOf(prefs.getString(KEY_RESOURCE_ID, null) ?: "") } var isLoading by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf<String?>(null) } // Persist on change so the user doesn't have to re-enter between launches. LaunchedEffect(serverUrl, resourceId) { prefs.edit() .putString(KEY_SERVER_URL, serverUrl) .putString(KEY_RESOURCE_ID, resourceId) .apply() } val scope = rememberCoroutineScope() Scaffold( topBar = { TopAppBar(title = { Text("GBG Go Reference") }) }, ) { padding -> Column( modifier = Modifier .fillMaxSize() .padding(padding) .padding(16.dp) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { OutlinedTextField( value = serverUrl, onValueChange = { serverUrl = it }, label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), ) OutlinedTextField( value = resourceId, onValueChange = { resourceId = it }, label = { Text("Resource ID (optional)") }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) errorMessage?.let { message -> Text( text = message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, ) } Button( onClick = { isLoading = true errorMessage = null scope.launch { try { val trimmedServerUrl = serverUrl.trim() val response = JourneyService.startJourney( serverUrl = trimmedServerUrl, resourceId = resourceId.trim().takeIf { it.isNotEmpty() }, ) onJourneyStarted(response.journeyUrl, trimmedServerUrl) } catch (error: Throwable) { errorMessage = error.message ?: error::class.java.simpleName } finally { isLoading = false } } }, enabled = !isLoading && serverUrl.isNotBlank(), modifier = Modifier.fillMaxWidth(), ) { if (isLoading) { CircularProgressIndicator( color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp, modifier = Modifier.height(20.dp), ) } else { Text("Start Journey", fontWeight = FontWeight.SemiBold) } } } }}
The form collects two values:
Server URL — the companion server address. Defaults to http://10.0.2.2:3000, which works on the Android emulator (10.0.2.2 is the emulator’s loopback alias for the host machine). On a physical device, use your machine’s LAN IP address (e.g. http://192.168.1.100:3000).
Resource ID — identifies which journey template to run. Leave blank to use the server’s default.
Both values persist across launches via SharedPreferences.
Create JourneyService.kt in the ui/journey package:
object JourneyService { @Serializable data class StartResponse( val journeyUrl: String, val instanceId: String, val connectToken: String? = null, val expiresIn: Int? = null, ) class JourneyServiceException( message: String, cause: Throwable? = null, ) : RuntimeException(message, cause) private val json = Json { ignoreUnknownKeys = true } private val client = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build() suspend fun startJourney(serverUrl: String, resourceId: String?): StartResponse = withContext(Dispatchers.IO) { val base = serverUrl.trimEnd('/') if (base.isEmpty() || (!base.startsWith("http://") && !base.startsWith("https://"))) { throw JourneyServiceException( "Invalid server URL: '$serverUrl'. Use a full http(s):// URL " + "(e.g. http://10.0.2.2:3000).", ) } val endpoint = "$base/api/journey/start" val bodyPayload: Map<String, JsonPrimitive> = if (resourceId.isNullOrEmpty()) emptyMap() else mapOf("resourceId" to JsonPrimitive(resourceId)) val bodyJson = json.encodeToString(JsonObject(bodyPayload)) val body = bodyJson.toRequestBody("application/json".toMediaType()) val request = Request.Builder() .url(endpoint) .post(body) .header("Content-Type", "application/json") .build() val response = try { client.newCall(request).execute() } catch (error: IOException) { throw JourneyServiceException( "Cannot reach the server at $endpoint: ${error.message}. Is it running?", error, ) } response.use { resp -> val raw = resp.body?.string().orEmpty() if (!resp.isSuccessful) { throw JourneyServiceException("Server returned ${resp.code}: $raw") } try { json.decodeFromString(StartResponse.serializer(), raw) } catch (error: Throwable) { throw JourneyServiceException( "Unexpected server response: ${error.message ?: error::class.java.simpleName}", error, ) } } }}
JourneyService makes a single POST request to the companion server’s /api/journey/start endpoint. The server authenticates with GBG Go, creates a journey session, registers a mobile device, and returns a journey URL. The Android app never touches API credentials directly.The StartResponse includes:
Field
Purpose
journeyUrl
The URL to load in the WebView
instanceId
Unique journey session identifier
connectToken
Short-lived device authentication token
expiresIn
Token validity in seconds (~120s)
The connect token expires quickly. If the journey URL fails to load, go back and tap “Start Journey” again to get a fresh token.
The reference app’s JourneyService also has a fetchState(serverUrl, instanceId) function that calls the companion server’s GET /api/journey/:instanceId/state endpoint. The Result screen uses it at the end of the tutorial to fetch the authoritative journey outcome.
This is the core of the integration. Create JourneyScreen.kt in the ui/journey package. It contains three pieces: a BridgeController that owns the host, a delegate that observes bridge traffic, and the composable that wires both to a WebView.Start with the controller and delegate:
private const val TAG = "JourneyScreen"private const val HOST_VERSION = "1.0.0"// Mirrors the SDK's default bootstrap, which is `internal` and so not// visible here. We need the literal because installing our own// WebViewClient means we own the bootstrap string passed to// BootstrapInjectingWebViewClient's constructor.private const val DEFAULT_BOOTSTRAP_SCRIPT: String = "window.GBGBridge = window.GBGBridge || {}; " + "window.GBGBridge.receive = window.GBGBridge.receive || function(){};"/** * The strongly-held bag of host + delegate + handlers. Created once per * journeyUrl via `remember` and torn down by DisposableEffect.onDispose. * * Why a separate class rather than a pile of `remember { }` state in the * composable: BridgeHost.delegate is stored as a WeakReference. If we * constructed the delegate inline at host.delegate = SomeImpl(), it would * be eligible for GC the moment composition finished and callbacks would * silently stop firing. Holding it as a property of a `remember`d * controller keeps it pinned until the screen leaves composition. */private class BridgeController(hostVersion: String) { val delegate = LoggingDelegate() val documentHandler = CameraCaptureHandler(action = "camera.document.capture") val selfieHandler = CameraCaptureHandler(action = "camera.selfie.capture") // The static capability map advertised on `capability.query`. Both // entries declare `supported = true` so the web journey knows it can // request these actions. val host: BridgeHost = BridgeHost( configuration = BridgeConfiguration( hostVersion = hostVersion, capabilities = mapOf( "camera.document" to BridgeCapabilityInfo(supported = true, version = "1.0"), "camera.selfie" to BridgeCapabilityInfo(supported = true, version = "1.0"), ), ), ).also { host -> host.delegate = delegate host.register(documentHandler) host.register(selfieHandler) }}private class LoggingDelegate : BridgeHostDelegate { var onErrorBanner: ((String?) -> Unit)? = null // The web journey emits `journey.completed` / `journey.failed` / // `journey.abandoned` as one-way bridge EVENTs (not REQUESTs) when the // journey reaches a terminal state. Because events don't go through the // request/response handler dispatch path, the only place to observe // them on the host side is `onMessage`. var onTerminalEvent: ((status: JourneyTerminalStatus, instanceId: String?, interactionId: String?) -> Unit)? = null override fun onMessage(host: BridgeHost, message: BridgeMessage) { Log.d(TAG, "FROM WEB ${message.type.name.lowercase()} ${message.payload.action}") onErrorBanner?.invoke(host.lastError) if (message.type == BridgeMessageType.EVENT) { val terminal = when (message.payload.action) { "journey.completed" -> JourneyTerminalStatus.COMPLETED "journey.failed" -> JourneyTerminalStatus.FAILED "journey.abandoned" -> JourneyTerminalStatus.ABANDONED else -> null } if (terminal != null) { val data = message.payload.data val instanceId = data?.get("instanceId")?.jsonPrimitive?.contentOrNull val interactionId = data?.get("interactionId")?.jsonPrimitive?.contentOrNull onTerminalEvent?.invoke(terminal, instanceId, interactionId) } } } override fun onMessageSent(host: BridgeHost, message: BridgeMessage) { Log.d(TAG, "TO WEB ${message.type.name.lowercase()} ${message.payload.action}") } override fun onError(host: BridgeHost, error: Throwable) { Log.e(TAG, "Bridge error", error) onErrorBanner?.invoke(host.lastError ?: error.message) }}
Now the screen composable that attaches the host to a WebView and presents the capture surface when a request arrives:
@OptIn(ExperimentalMaterial3Api::class)@Composablefun JourneyScreen( journeyUrl: String, onBack: () -> Unit, onJourneyEnded: (status: JourneyTerminalStatus, instanceId: String?, interactionId: String?) -> Unit,) { val context = LocalContext.current // The BridgeHost lives for the lifetime of this screen. Compose's // `remember` keeps it across recompositions but releases on navigation // away — DisposableEffect below detaches the WebView edge first. val controller = remember(journeyUrl) { BridgeController(hostVersion = HOST_VERSION) } var hostErrorBanner by remember { mutableStateOf<String?>(null) } // Single-flight: only one capture handler can be active at a time. var activeCapture by remember { mutableStateOf<CaptureMode?>(null) } // Modern system photo picker for the "choose from library" capture rung. val pickDocumentImage = rememberLauncherForActivityResult( ActivityResultContracts.PickVisualMedia(), ) { uri -> controller.documentHandler.onPickerResult(context, uri) } val pickSelfieImage = rememberLauncherForActivityResult( ActivityResultContracts.PickVisualMedia(), ) { uri -> controller.selfieHandler.onPickerResult(context, uri) } // Wire the handlers' "I need a UI" callbacks back to Compose state. // The terminal-event hook navigates forward; guard against re-entry // with a one-shot flag. var terminalFired by remember { mutableStateOf(false) } LaunchedEffect(controller) { controller.documentHandler.onActivate = { activeCapture = CaptureMode.Document } controller.documentHandler.onDeactivate = { if (activeCapture == CaptureMode.Document) activeCapture = null } controller.selfieHandler.onActivate = { activeCapture = CaptureMode.Selfie } controller.selfieHandler.onDeactivate = { if (activeCapture == CaptureMode.Selfie) activeCapture = null } controller.delegate.onErrorBanner = { hostErrorBanner = it } controller.delegate.onTerminalEvent = { status, instanceId, interactionId -> if (!terminalFired) { terminalFired = true onJourneyEnded(status, instanceId, interactionId) } } } Scaffold( topBar = { TopAppBar( title = { Text("Journey") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, ) }, ) { padding -> Box(modifier = Modifier.fillMaxSize().padding(padding)) { AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> WebView(ctx).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) // On debuggable builds, expose this WebView to chrome://inspect. val debuggable = (ctx.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 if (debuggable) { WebView.setWebContentsDebuggingEnabled(true) } // attach() configures the WebView internally, so hand the // custom WebViewClient straight to attach() — a configure() // call before attach() would just be re-run and clobbered. controller.host.attach( webView = this, client = DiagnosticWebViewClient(DEFAULT_BOOTSTRAP_SCRIPT), ) // The WebChromeClient is NOT installed by attach() and must be // set AFTER it — attach()'s internal configure() installs a // plain WebChromeClient, so setting ours earlier would be // overwritten. webChromeClient = DiagnosticWebChromeClient() loadUrl(journeyUrl) } }, ) hostErrorBanner?.let { error -> // Thin red banner at the bottom of the journey view (omitted for // brevity — see the reference app). } } } activeCapture?.let { mode -> val activeHandler = when (mode) { CaptureMode.Document -> controller.documentHandler CaptureMode.Selfie -> controller.selfieHandler } val activePicker = when (mode) { CaptureMode.Document -> pickDocumentImage CaptureMode.Selfie -> pickSelfieImage } CaptureStubScreen( mode = mode, onCapture = { bitmap -> activeHandler.onCameraResult(bitmap) }, onPickFromLibrary = { activePicker.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), ) }, onUseStubImage = { activeHandler.completeWithStubImage() }, onCancel = { activeHandler.cancelActive("User dismissed capture") }, ) } DisposableEffect(controller) { onDispose { // Cancel any in-flight handler so the JS side doesn't hang waiting on // a response that will never arrive (rotation, navigation away, etc.). try { controller.documentHandler.cancelActive("Journey screen disposed") controller.selfieHandler.cancelActive("Journey screen disposed") } finally { controller.host.detach() } } }}
The screen also installs a custom WebViewClient and WebChromeClient for diagnostics. The WebViewClient subclasses the SDK’s BridgeWebViewConfigurator.BootstrapInjectingWebViewClient so the bridge bootstrap keeps working while adding logging for failures that otherwise present as a silent blank page:
private class DiagnosticWebViewClient( bootstrapScript: String,) : BridgeWebViewConfigurator.BootstrapInjectingWebViewClient(bootstrapScript) { override fun onReceivedError( view: WebView, request: android.webkit.WebResourceRequest, error: android.webkit.WebResourceError, ) { super.onReceivedError(view, request, error) val msg = "load error ${error.errorCode} (${error.description}) for ${request.url}" if (request.isForMainFrame) Log.e(TAG, "WebView $msg") else Log.d(TAG, "WebView sub-resource $msg") } // The reference app also overrides onReceivedHttpError (4xx/5xx logging) // and onReceivedSslError (log + cancel — never call handler.proceed()).}private class DiagnosticWebChromeClient : android.webkit.WebChromeClient() { override fun onConsoleMessage(message: android.webkit.ConsoleMessage): Boolean { Log.d(TAG, "WebView console ${message.message()} (${message.sourceId()}:${message.lineNumber()})") return true }}
There is a lot happening here. Let’s break it down in the sections below.
The BridgeHost is the native side of a bidirectional communication channel between your Kotlin code and the JavaScript running inside the WebView. When the web journey needs a native capability — like capturing a document photo — it sends a request message through the bridge by calling window.GBGBridge.postMessage(...). Your Kotlin code handles the request and sends a response back, which the web receives via window.GBGBridge.receive(...).Unlike iOS, there is no BridgeWebView wrapper on Android. You create a plain WebView (here via Compose’s AndroidView) and call host.attach(webView). The attach call configures the WebView for you: it enables JavaScript and DOM storage, registers the GBGBridge JavaScript interface, and installs a WebViewClient that injects the bootstrap script when each page starts loading. You provide the WebView and the URL — everything else is automatic.
The journey requests captures using the wire-level actions camera.document.capture and camera.selfie.capture. The app handles them by registering a BridgeCapabilityHandler for each action — a small interface with an action property and a handle(request, responder) method:
internal class CameraCaptureHandler( override val action: String,) : BridgeCapabilityHandler { private var activeResponder: BridgeResponder? = null /** Set by JourneyScreen — invoked on the main thread when a request arrives. */ var onActivate: (() -> Unit)? = null /** Set by JourneyScreen — invoked when the request is resolved. */ var onDeactivate: (() -> Unit)? = null override fun handle(request: BridgeMessage, responder: BridgeResponder) { if (activeResponder != null) { // The SDK doesn't enforce single-flight, so the handler does it here. responder.respond( status = BridgeResponseStatus.ERROR, error = BridgeErrorPayload( code = "BUSY", message = "A $action request is already active", recoverable = true, ), ) return } activeResponder = responder onActivate?.invoke() } fun onCameraResult(bitmap: Bitmap?) { val responder = activeResponder ?: return if (bitmap == null) { cancelled(responder, "User cancelled camera capture") return } success(responder, bitmap) } fun onPickerResult(context: Context, uri: Uri?) { val responder = activeResponder ?: return if (uri == null) { // The user backed out of the system photo picker. That dismisses the // picker only — it must NOT cancel the whole capture request. Leave // the request active and return to the capture surface. return } val bitmap = try { decodeBitmap(context, uri) } catch (error: Throwable) { error(responder, "PICKER_FAILED", "Failed to read selected image: ${error.message}") return } if (bitmap == null) { error(responder, "DECODE_FAILED", "Unable to decode selected image") return } success(responder, bitmap) } fun completeWithStubImage() { val responder = activeResponder ?: return success(responder, syntheticPlaceholderBitmap()) } fun cancelActive(reason: String) { val responder = activeResponder ?: return cancelled(responder, reason) } private fun success(responder: BridgeResponder, bitmap: Bitmap) { val base64 = bitmapToBase64Jpeg(bitmap) responder.respond( status = BridgeResponseStatus.SUCCESS, data = mapOf( "imageBase64" to JsonPrimitive(base64), "imageWidth" to JsonPrimitive(bitmap.width), "imageHeight" to JsonPrimitive(bitmap.height), "mimeType" to JsonPrimitive("image/jpeg"), ), ) finish() } private fun cancelled(responder: BridgeResponder, message: String) { responder.respond( status = BridgeResponseStatus.CANCELLED, error = BridgeErrorPayload(code = "CANCELLED", message = message, recoverable = true), ) finish() } private fun error(responder: BridgeResponder, code: String, message: String) { responder.respond( status = BridgeResponseStatus.ERROR, error = BridgeErrorPayload(code = code, message = message, recoverable = true), ) finish() } private fun finish() { activeResponder = null onDeactivate?.invoke() }}
handle is synchronous — it does not suspend or block. For async work like presenting a camera UI, the pattern is: store the responder, return immediately, and call responder.respond(...) later on the main thread when the user finishes. The responder accepts exactly one response; subsequent calls are no-ops.The full request lifecycle:
The web journey sends a camera.document.capture request through the bridge.
The host dispatches it to the registered handler on the main thread.
The handler stores the responder and fires onActivate, which sets activeCapture Compose state.
Compose presents CaptureStubScreen as a full-screen dialog.
The user captures a photo, picks one from the library, or cancels.
The UI callback routes the result to the handler, which calls responder.respond(...) with SUCCESS, CANCELLED, or ERROR.
The handler clears its responder and fires onDeactivate, dismissing the capture surface.
The bridge delivers the response to the journey under the request’s correlation ID.
The SDK also provides built-in typed slots — host.documentCapture and host.selfieCapture, each a CaptureCapability with a suspending handler and an activeRequest StateFlow. They are the closest Android analogue to the pattern the iOS tutorial uses. The reference app deliberately uses raw handlers to show the general mechanism that works for any capability. See Capability Handling for both approaches.
BridgeHost.delegate is stored as a WeakReference — the host does not keep your delegate alive. An inline assignment like host.delegate = LoggingDelegate() with no other strong reference works until the next garbage collection, then callbacks silently stop firing. This is the Android mirror image of iOS’s [weak host] capture advice: on iOS you weaken your reference to avoid a retain cycle; on Android the SDK already holds weakly, so you must hold strongly. That is why BridgeController — itself pinned by remember { } — owns the delegate as a property.
host.attach(webView, client = ...) runs the SDK’s WebView configuration internally, installing the client you pass (or a default one) plus a plain WebChromeClient. Two ordering rules follow:
Do not call BridgeWebViewConfigurator.configure() yourself before attach() — attach would re-run configuration and clobber it.
Set your custom WebChromeClientafterattach(), or the configurator’s plain one overwrites yours.
Because the custom client subclasses BootstrapInjectingWebViewClient and calls super.onPageStarted, the bridge bootstrap script keeps being injected on every page load. The bootstrap literal is passed to the subclass constructor since the SDK’s default is internal.
When the user navigates away (or the journey ends), onDispose runs two steps in order. First it cancels any in-flight capture handler so the JS side receives a CANCELLED response instead of hanging forever on a reply that will never arrive. Then it calls host.detach(), which removes the JavaScript interface and tears down the WebView↔host edge. Detach is idempotent, so it is safe even if the screen is disposed twice.
When the journey reaches a terminal state, the web side emits a one-way bridge EVENT — journey.completed, journey.failed, or journey.abandoned — carrying instanceId and interactionId in its data. Events do not go through the request/response handler dispatch path, so the place to observe them is the delegate’s onMessage. The LoggingDelegate above matches on the action, extracts the IDs, and invokes onTerminalEvent, which navigates to the Result screen.The bridge event tells you that the journey ended; the GBG Go journeys.getState API tells you what the outcome was. The reference app’s ResultScreen demonstrates the second step with a “Fetch outcome” button that calls the companion server’s GET /api/journey/:instanceId/state endpoint. In production, webhook-driven backends are usually preferable to polling from the device.
The CaptureStubScreen is part of the reference app (the Android SDK does not ship built-in capture views — see Capture Screens). It is a unified, full-screen Compose Dialog used for both document and selfie requests, offering three rungs of capture affordance in priority order:
Live CameraX preview + shutter button — back camera for documents, front camera for selfies, with a framing guide overlay. This is the path a production app would replace with a dedicated capture SDK.
System photo picker (ActivityResultContracts.PickVisualMedia) — for choosing an existing image. Useful on devices without a usable camera. Backing out of the picker does not cancel the capture request; the user returns to the capture surface.
Synthetic placeholder bitmap — a tertiary “Use placeholder image” button so the emulator path can exercise the end-to-end bridge round-trip without a real camera.
The CAMERA permission is requested at runtime inside the screen. If it is denied, or the device has no camera, the preview area falls back to a static framing guide and the library and placeholder options carry the user through.The screen accepts callbacks rather than touching the bridge directly:
Callback
Called when
onCapture
The user taps the shutter. Receives the captured Bitmap.
onPickFromLibrary
The user taps the library button. The caller launches the photo picker.
onUseStubImage
The user opts for the synthetic placeholder.
onCancel
The user dismisses the surface without capturing.
Each callback routes to the matching CameraCaptureHandler method, which sends the response — a base64-encoded JPEG with its dimensions and MIME type — back to the journey.
Swap point: The capture surface is deliberately swappable — the bridge only sees BridgeResponder.respond(...) calls, so you can replace CaptureStubScreen with any capture UI without touching the bridge wiring. Tutorial Part 2: Integrate Smart Capture SDKs walks through doing exactly that — replacing the stub surface with production document scanning and face capture with liveness detection. See Capture Screens for the available capture-UI patterns.
A few rough edges trip up most teams running the tutorial app for the first time — emulator-vs-device URLs, Android’s cleartext-HTTP block, and the SDK’s weak delegate reference. The notes below explain how to avoid them.
From inside the emulator, localhost and 127.0.0.1 route to the emulator itself, not your machine. Use the loopback alias http://10.0.2.2:3000 (the Setup screen’s default), or forward the port and keep using 127.0.0.1:
adb reverse tcp:3000 tcp:3000
On a physical device, the companion server is not on localhost at all — use your machine’s LAN IP instead (e.g. http://192.168.1.100:3000), and make sure your network security config permits cleartext to that host (the reference app does this with a permissive debug-source-set override).
Android blocks cleartext HTTP on API 28+. Without a network_security_config.xml allowlisting your dev hosts, requests to the companion server fail — and a blocked journey URL presents as a blank WebView. If the Setup screen shows a network error or the journey never loads, check the network security config first. The diagnostic WebViewClient and the onConsoleMessage forwarding in the tutorial exist precisely to make these failures visible in logcat; on debuggable builds you can also inspect the WebView via chrome://inspect.
The emulator’s camera feed is synthetic or absent. The capture surface degrades gracefully: use the system photo picker or the Use placeholder image button to complete the round-trip. This is expected behaviour, not a bug.
The connect token from the server is short-lived (~120 seconds). If the journey URL does not load in time, go back and tap “Start Journey” again for a fresh token.
host.delegate is a WeakReference. Assigning an inline instance with no other strong reference (host.delegate = LoggingDelegate()) works briefly, then callbacks silently stop after garbage collection. Keep the delegate as a property of an object that outlives the host — like the remember-pinned BridgeController in this tutorial.
Pass your custom WebViewClient to attach(webView, client = ...) rather than calling configure() separately first, and set any custom WebChromeClientafterattach(). Getting the order wrong silently replaces your clients, which usually surfaces as missing console logs or a bootstrap that never injects.
If the screen is disposed while a capture request is in flight, detaching without responding leaves the web journey waiting forever. Always cancel active handlers (sending a CANCELLED response) before calling host.detach(), as the DisposableEffect in this tutorial does.