> ## Documentation Index
> Fetch the complete documentation index at: https://docs.go.gbgplc.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 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](https://github.com/gbgplc/gbg-go-android-reference). You can clone it and run it immediately, or follow this tutorial to build it step by step.

## Prerequisites

| Requirement        | Version              | Notes                                                                           |
| ------------------ | -------------------- | ------------------------------------------------------------------------------- |
| Android Studio     | Hedgehog (2023.1.1)+ | JDK 17 bundled                                                                  |
| Android SDK        | Platform 34          | `compileSdk = 34`, `targetSdk = 34`                                             |
| Device or emulator | API 24+              | Android 7.0 Nougat or newer (`minSdk = 24`)                                     |
| Node.js            | 18+                  | For the companion server                                                        |
| GBG Go credentials | —                    | Client ID, secret, username, password. Contact your GBG account representative. |

You also need the GBGBridge Android SDK, published to [Maven Central](https://central.sonatype.com/artifact/com.gbg/gbgbridge-sdk) as `com.gbg:gbgbridge-sdk`. See the [Getting Started](/docs/go-v2/developer-integration/sdks/android/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

```bash theme={null}
git clone https://github.com/gbgplc/gbg-go-android-reference.git
cd gbg-go-android-reference/server
```

### 2. Install dependencies

```bash theme={null}
npm install
```

### 3. Configure credentials

Copy the example environment file and fill in your GBG Go credentials:

```bash theme={null}
cp .env.example .env
```

Open `.env` in a text editor:

```dotenv theme={null}
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

```bash theme={null}
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:

```bash theme={null}
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:

```kotlin theme={null}
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`](https://github.com/gbgplc/gbg-go-android-reference/blob/main/app/build.gradle.kts) for the complete file.

### Configure the manifest

Add these entries to `AndroidManifest.xml`:

```xml theme={null}
<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:

```xml theme={null}
<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>
```

<Warning>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.</Warning>

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

```kotlin expandable theme={null}
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:

```kotlin expandable theme={null}
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:

```kotlin expandable theme={null}
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)      |

<Note>The connect token expires quickly. If the journey URL fails to load, go back and tap "Start Journey" again to get a fresh token.</Note>

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:

```kotlin expandable theme={null}
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:

```kotlin expandable theme={null}
@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:

```kotlin theme={null}
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:

```kotlin expandable theme={null}
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.

<Note>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](/docs/go-v2/developer-integration/sdks/android/capability-handling) for both approaches.</Note>

### 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 **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.

## Handling Capture Requests

The `CaptureStubScreen` is part of the reference app (the Android SDK does not ship built-in capture views — see [Capture Screens](/docs/go-v2/developer-integration/sdks/android/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:

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

<Tip>**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](/docs/go-v2/developer-integration/sdks/android/tutorial-smart-capture) walks through doing exactly that — replacing the stub surface with production document scanning and face capture with liveness detection. See [Capture Screens](/docs/go-v2/developer-integration/sdks/android/capture-screens) for the available capture-UI patterns.</Tip>

## 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`:

```bash theme={null}
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](/docs/go-v2/developer-integration/sdks/android/tutorial-smart-capture)** — Replace the stub capture surface with production document scanning and face capture with liveness detection.
* **[API Reference](/docs/go-v2/developer-integration/sdks/android/api-reference)** — Full documentation for `BridgeHost`, `BridgeWebViewConfigurator`, `CaptureCapability`, and all message types.
* **[Concepts](/docs/go-v2/developer-integration/sdks/android/concepts)** — Deeper dive into the bridge architecture, message protocol, and capability system.
* **[Capability Handling](/docs/go-v2/developer-integration/sdks/android/capability-handling)** — Raw handlers, typed slots, and custom capabilities in depth.
* **[Capture Screens](/docs/go-v2/developer-integration/sdks/android/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](/docs/go-v2/developer-integration/sdks/android/embedding).
