Skip to main content
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.

Prerequisites

You also need the GBGBridge Android SDK, published to Maven Central as com.gbg:gbgbridge-sdk. See the Getting Started guide for installation details.

Set Up the Backend

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.

1. Clone the reference repository

2. Install dependencies

3. Configure credentials

Copy the example environment file and fill in your GBG Go credentials:
Open .env in a text editor:

4. Start the server

The server authenticates with GBG Go on startup and listens on http://localhost:3000.

5. Test the server

Open a new terminal and verify it responds:
Expected output: {"ok":true} Leave the server running. You will need it for the rest of the tutorial.

Create Your Android Project

Open Android Studio and create a new project:
  1. File → New → New Project
  2. Choose the Empty Activity template (the Compose/Material 3 one).
  3. Set the name to GBG Go Reference and the package to com.gbg.go.reference.
  4. Set the minimum SDK to API 24.
  5. Click Finish.

Add GBGBridge via Gradle

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:
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.

Configure the manifest

Add these entries to AndroidManifest.xml:
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.

File structure

Create the following package structure under app/src/main/java/com/gbg/go/reference/:

App Entry Point and Navigation

The app is a single ComponentActivity hosting a Compose NavHost with three destinations: Setup, Journey, and Result. Replace the contents of MainActivity.kt with:
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.

Configuration Screen

Create SetupScreen.kt in the ui/setup package:
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.

Calling Your Backend

Create JourneyService.kt in the ui/journey package:
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:
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.

The Bridge Integration

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:
Now the screen composable that attaches the host to a WebView and presents the capture surface when a request arrives:
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:
There is a lot happening here. Let’s break it down in the sections below.

How the Bridge Works

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 Capability Handler Pattern

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:
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:
  1. The web journey sends a camera.document.capture request through the bridge.
  2. The host dispatches it to the registered handler on the main thread.
  3. The handler stores the responder and fires onActivate, which sets activeCapture Compose state.
  4. Compose presents CaptureStubScreen as a full-screen dialog.
  5. The user captures a photo, picks one from the library, or cancels.
  6. The UI callback routes the result to the handler, which calls responder.respond(...) with SUCCESS, CANCELLED, or ERROR.
  7. The handler clears its responder and fires onDeactivate, dismissing the capture surface.
  8. The bridge delivers the response to the journey under the request’s correlation ID.
The SDK also provides built-in typed slotshost.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.

Why the delegate is strongly held

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.

Why the client goes to attach()

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 WebChromeClient after attach(), 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.

Why DisposableEffect cancels then detaches

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.

Observing terminal events

When the journey reaches a terminal state, the web side emits a one-way bridge EVENTjourney.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.

Handling Capture Requests

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:
  1. 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.
  2. 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.
  3. 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: 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.

Run the App

  1. Make sure the companion server is running. Run node index.mjs in the server/ directory.
  2. Start an Android emulator (API 24+) or connect a device.
  3. Press Run in Android Studio (or build with ./gradlew assembleDebug).
  4. On the Setup screen, leave the server URL as http://10.0.2.2:3000 (emulator).
  5. Tap Start Journey.
  6. The journey loads in the WebView. When it requests a document capture, the capture surface appears as a full-screen overlay.
  7. Tap the shutter to capture — or Use placeholder image on an emulator without a camera feed. The result is sent back to the journey.
  8. When the journey finishes, the app navigates to the Result screen, where Fetch outcome retrieves the authoritative state from the companion server.

Common Pitfalls

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.

Emulator vs device

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:
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).

Cleartext HTTP is blocked by default

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.

Camera on the emulator

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.

Connect token expiry

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.

The delegate is weakly referenced

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.

WebView client ordering

Pass your custom WebViewClient to attach(webView, client = ...) rather than calling configure() separately first, and set any custom WebChromeClient after attach(). Getting the order wrong silently replaces your clients, which usually surfaces as missing console logs or a bootstrap that never injects.

Cancel handlers before detach

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.

What’s Next

  • Tutorial Part 2: Integrate Smart Capture SDKs — Replace the stub capture surface with production document scanning and face capture with liveness detection.
  • API Reference — Full documentation for BridgeHost, BridgeWebViewConfigurator, CaptureCapability, and all message types.
  • Concepts — Deeper dive into the bridge architecture, message protocol, and capability system.
  • Capability Handling — Raw handlers, typed slots, and custom capabilities in depth.
  • Capture Screens — Capture-UI patterns: CameraX preview, the system photo picker, and placeholder paths.
  • View-system integration — Use BridgeHost.attach(...) with a WebView in an Activity or Fragment instead of Compose. See Embedding.