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

RequirementVersionNotes
Android StudioHedgehog (2023.1.1)+JDK 17 bundled
Android SDKPlatform 34compileSdk = 34, targetSdk = 34
Device or emulatorAPI 24+Android 7.0 Nougat or newer (minSdk = 24)
Node.js18+For the companion server
GBG Go credentialsClient ID, secret, username, password. Contact your GBG account representative.
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

git clone https://github.com/gbgplc/gbg-go-android-reference.git
cd gbg-go-android-reference/server

2. Install dependencies

npm install

3. Configure credentials

Copy the example environment file and fill in your GBG Go credentials:
cp .env.example .env
Open .env in a text editor:
GO_CLIENT_ID=your-client-id
GO_CLIENT_SECRET=your-client-secret
GO_USERNAME=api-user@example.com
GO_PASSWORD=your-password

# Regional server index: 0 = EU, 1 = US, 2 = AU
GO_SERVER_IDX=0

# Default resource ID for journeys
GO_RESOURCE_ID=a4c68509c24789888eb466@latest

PORT=3000

4. Start the server

node index.mjs
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:
curl http://localhost:3000/health
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:
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.

Configure the manifest

Add these entries 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" />

<application
    android:networkSecurityConfig="@xml/network_security_config"
    ...>
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:
<network-security-config>
  <domain-config cleartextTrafficPermitted="true">
    <domain includeSubdomains="true">localhost</domain>
    <domain includeSubdomains="true">10.0.2.2</domain>
    <domain includeSubdomains="true">127.0.0.1</domain>
  </domain-config>
</network-security-config>
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/:
com/gbg/go/reference/
├── MainActivity.kt                  # Single-Activity host + nav graph
├── ui/setup/SetupScreen.kt          # Server URL + resource id form
├── ui/journey/
│   ├── JourneyScreen.kt             # WebView host + bridge wiring
│   ├── JourneyService.kt            # Companion-server HTTP client
│   └── CameraCaptureHandler.kt      # camera.* capability handler
├── ui/capture/CaptureStubScreen.kt  # Full-screen capture surface
└── ui/result/ResultScreen.kt        # Terminal outcome + state fetch

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:
class MainActivity : ComponentActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      GBGGoReferenceTheme {
        Surface(modifier = Modifier.fillMaxSize()) {
          RootNavGraph()
        }
      }
    }
  }
}

@Composable
private 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.

Configuration Screen

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

Calling Your Backend

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:
FieldPurpose
journeyUrlThe URL to load in the WebView
instanceIdUnique journey session identifier
connectTokenShort-lived device authentication token
expiresInToken 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.

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:
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)
@Composable
fun 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.

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:
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:
  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:
CallbackCalled when
onCaptureThe user taps the shutter. Receives the captured Bitmap.
onPickFromLibraryThe user taps the library button. The caller launches the photo picker.
onUseStubImageThe user opts for the synthetic placeholder.
onCancelThe 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.

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

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.