Skip to main content
This tutorial picks up where Part 1 left off. You already have a working Android app that runs a GBG Go identity journey with the stub capture surface. Now you will replace those stubs with the real Smart Capture SDKs — adding guided document scanning, face capture with liveness detection, and an encrypted biometric blob. The change is small. The bridge architecture you built in Part 1 was designed so that swapping the capture UI is a localised edit. The handler registration, the BridgeResponder.respond(...) contract, and all bridge wiring stay the same. Only the UI that produces the image changes.
Reference app: The complete source code is on the part-2-smart-capture branch of the reference repository. To see exactly what changed from Part 1, diff the branches:
git diff main..part-2-smart-capture

Prerequisites

Everything from Part 1, plus:
RequirementNotes
Smart Capture credentialsThe AWS access key / secret for the Smart Capture Maven repository, obtained from your GBG account representative. The SDKs are not on Maven Central and will not resolve without them.
Physical Android deviceThe Smart Capture document scanner and liveness SDK need real camera hardware and sensors. On an emulator the app falls back to the stub surface automatically.

Required dependencies

The Smart Capture SDKs are published as Android libraries to a credentialed S3 Maven repository:
ArtifactPurpose
com.gbg.smartcapture:documentcameraDocument scanning with guided capture, auto-crop, and quality scoring
com.gbg.smartcapture:facecameraFace capture with liveness detection and an encrypted biometric blob
com.gbg.smartcapture:commonsShared types (SmartCaptureException, theming) used by both

Start from Part 1

Check out the Part 2 branch:
git clone https://github.com/gbgplc/gbg-go-android-reference.git
cd gbg-go-android-reference
git checkout part-2-smart-capture
Or if you already have the repo:
git checkout part-2-smart-capture
The companion server is unchanged — start it the same way as Part 1:
cd server
npm install
node index.mjs

Add the Smart Capture dependencies

Unlike iOS — where the SDKs ship as binary frameworks you drop into the project — the Android SDKs are ordinary Maven dependencies. The only wrinkle is that they live in a credentialed S3 repository, so you configure that repository and supply your credentials, then declare the dependencies.

1. Configure the S3 repository

In settings.gradle.kts, register the Smart Capture repository inside dependencyResolutionManagement. It is added only when credentials are present, so a checkout without them produces a clear warning rather than a confusing failure deep in the build:
dependencyResolutionManagement {
  repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
  repositories {
    google()
    mavenCentral()

    val awsAccessKey = providers.gradleProperty("awsAccessKey").orNull
    val awsSecretKey = providers.gradleProperty("awsSecretKey").orNull
    if (awsAccessKey != null && awsSecretKey != null) {
      // Releases hold the published com.gbg.smartcapture artifacts; snapshots
      // hold transitive SNAPSHOT dependencies (e.g. com.gbg.smartcapture:camera).
      listOf("releases", "snapshots").forEach { channel ->
        maven {
          url = uri("s3://maven-mobile-repo/$channel")
          credentials(AwsCredentials::class.java) {
            accessKey = awsAccessKey
            secretKey = awsSecretKey
          }
        }
      }
    }
  }
}

2. Add your credentials (never commit them)

Put the access key and secret in your user-global Gradle properties — ~/.gradle/gradle.properties, which lives outside any repository and so can never be committed:
awsAccessKey=YOUR_ACCESS_KEY_ID
awsSecretKey=YOUR_SECRET_ACCESS_KEY
CI environments can instead export them as environment variables, which Gradle maps to the same properties:
export ORG_GRADLE_PROJECT_awsAccessKey=YOUR_ACCESS_KEY_ID
export ORG_GRADLE_PROJECT_awsSecretKey=YOUR_SECRET_ACCESS_KEY
Do not put these in the repository’s gradle.properties, local.properties, or settings.gradle.kts. The reference repo is public, and committed S3 credentials would leak. The user-global file is the correct home.

3. Declare the dependencies

In app/build.gradle.kts, add the three artifacts alongside the GBGBridge SDK:
dependencies {
  implementation("com.gbg:gbgbridge-sdk:0.1.0-alpha01")

  // Smart Capture SDKs — served from the credentialed S3 repo above.
  implementation("com.gbg.smartcapture:commons:1.1.2")
  implementation("com.gbg.smartcapture:documentcamera:1.1.2")
  implementation("com.gbg.smartcapture:facecamera:1.1.2")

  // ... the rest of Part 1's dependencies are unchanged
}
Sync Gradle. The Smart Capture libraries — including their bundled native .so liveness libraries — are now on the classpath.

Choose Smart Capture or the stub at runtime

iOS gates Smart Capture behind a compile-time flag (SMART_CAPTURE_ENABLED) and falls back to stubs on the Simulator. Android takes a slightly different approach for two reasons:
  1. The branch is the toggle. Because the SDKs are Maven dependencies (not committed binaries), the part-2-smart-capture branch simply depends on them directly. The “with vs without Smart Capture” choice is the branch you check out — main for stubs, this branch for Smart Capture — so there is no per-build compile flag.
  2. Emulators still need a fallback. The Smart Capture document scanner and liveness SDK need real camera hardware and sensors. The Android emulator’s synthetic camera isn’t enough, so the app decides at runtime whether to use Smart Capture or fall back to the stub surface.
Create DeviceCapabilities.kt in the ui/capture/ package:
package com.gbg.go.reference.ui.capture

import android.content.Context
import android.content.pm.PackageManager
import android.os.Build

object DeviceCapabilities {

  /**
   * True when this build should use the Smart Capture SDKs: a real device
   * (not an emulator) that actually has a camera. Otherwise fall back to
   * [CaptureStubScreen].
   */
  fun supportsSmartCapture(context: Context): Boolean =
    hasCamera(context) && !isProbablyEmulator()

  private fun hasCamera(context: Context): Boolean =
    context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)

  private fun isProbablyEmulator(): Boolean {
    val fingerprint = Build.FINGERPRINT.lowercase()
    val model = Build.MODEL.lowercase()
    val product = Build.PRODUCT.lowercase()
    val hardware = Build.HARDWARE.lowercase()
    val brand = Build.BRAND.lowercase()

    return fingerprint.startsWith("generic") ||
      fingerprint.startsWith("unknown") ||
      fingerprint.contains("emulator") ||
      model.contains("emulator") ||
      model.contains("android sdk built for") ||
      product.contains("sdk_gphone") ||
      product.contains("emulator") ||
      hardware.contains("goldfish") ||
      hardware.contains("ranchu") ||
      (brand.startsWith("generic") && Build.DEVICE.lowercase().startsWith("generic"))
  }
}
This is the Android analogue of iOS’s #if SMART_CAPTURE_ENABLED && !targetEnvironment(simulator) guard.

Document Capture with Smart Capture

The Smart Capture SDKs are Activity-based: you launch DocumentCameraActivity (or FaceCameraActivity) with StartActivityForResult and read the result back when it finishes. This differs from the iOS SDKs, which embed inline SwiftUI views — but it slots into the same handler seam. Create SmartCaptureLauncher.kt in ui/capture/. It is the only file that touches the com.gbg.smartcapture types — it maps the SDK Activities onto the existing CameraCaptureHandler:
package com.gbg.go.reference.ui.capture

import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.ActivityResult
import com.gbg.go.reference.ui.journey.CameraCaptureHandler
import com.gbg.smartcapture.commons.SmartCaptureException
import com.gbg.smartcapture.documentcamera.DocumentCameraActivity
import com.gbg.smartcapture.documentcamera.DocumentProcessingState
import com.gbg.smartcapture.documentcamera.DocumentScannerConfig
import com.gbg.smartcapture.facecamera.FaceCameraActivity

internal object SmartCaptureLauncher {

  /** Intent that launches the Smart Capture document scanner. */
  fun documentIntent(context: Context): Intent =
    DocumentCameraActivity.getIntent(context, DocumentScannerConfig())

  /** Intent that launches the Smart Capture face/liveness camera. */
  fun selfieIntent(context: Context): Intent =
    Intent(context, FaceCameraActivity::class.java)

  /** Map a document-scanner result onto the handler. */
  fun handleDocumentResult(result: ActivityResult, handler: CameraCaptureHandler) {
    when (result.resultCode) {
      Activity.RESULT_OK -> when (val state = DocumentCameraActivity.latestResult) {
        is DocumentProcessingState.Success ->
          handler.completeWithSmartCaptureImage(state.image.toBitmap(true))
        is DocumentProcessingState.Failure ->
          handler.failActive(state.message ?: "Document capture failed")
        else ->
          handler.failActive("Document capture returned no result")
      }
      Activity.RESULT_CANCELED -> handler.cancelActive("User cancelled document capture")
      else -> handler.failActive("Document capture error (code ${result.resultCode})")
    }
  }

  /** Map a face-camera result onto the handler. */
  fun handleSelfieResult(result: ActivityResult, handler: CameraCaptureHandler) {
    when (result.resultCode) {
      Activity.RESULT_OK -> {
        val faceResult = FaceCameraActivity.getResult()
        if (faceResult != null) {
          handler.completeWithSmartCaptureSelfie(
            previewPhoto = faceResult.previewPhoto,
            encryptedBlob = faceResult.encryptedBlob,
          )
        } else {
          handler.failActive("Face capture returned no result")
        }
      }
      Activity.RESULT_CANCELED -> handler.cancelActive("User cancelled face capture")
      FaceCameraActivity.RESULT_ERROR -> {
        @Suppress("DEPRECATION")
        val exception =
          result.data?.getSerializableExtra(FaceCameraActivity.ERROR_OBJECT) as? SmartCaptureException
        handler.failActive(exception?.message ?: "Face capture failed")
      }
      else -> handler.failActive("Face capture error (code ${result.resultCode})")
    }
  }
}

How document capture works

  • DocumentCameraActivity.getIntent(context, DocumentScannerConfig()) builds the launch intent. The default DocumentScannerConfig() enables guided auto-capture; you can pass a DocumentSide / DocumentType to constrain it.
  • The result is not returned in the Intent — the SDK exposes it as DocumentCameraActivity.latestResult, a sealed DocumentProcessingState.Result (Success or Failure), read after RESULT_OK.
  • DocumentProcessingState.Success.image is a DocumentImage carrying the JPEG bytes, dimensions, rotation, and quality flags (isGood, isSharp, isGlareFree, isAdequateDpi). image.toBitmap(true) returns a rotation-corrected bitmap, which we forward to the handler.

What you get vs the stub

FeatureCaptureStubScreenSmart Capture document
Document edge detectionNoYes
Auto-crop and perspective correctionNoYes
Blur / glare / resolution scoringNoYes
Guided capture overlayNoYes
Auto-capture on quality thresholdNoYes

Face Capture with Liveness

Face capture follows the same pattern — launch FaceCameraActivity, then read the result:
  • On RESULT_OK, FaceCameraActivity.getResult() returns a FaceCameraResult with previewPhoto (a Bitmap) and encryptedBlob (a ByteArray of encrypted biometric data for server-side liveness verification).
  • On FaceCameraActivity.RESULT_ERROR, the ERROR_OBJECT extra carries a SmartCaptureException describing the failure.
  • RESULT_CANCELED means the user dismissed the camera.
The mapping for all three cases is in handleSelfieResult above.

What you get vs the stub

FeatureCaptureStubScreenSmart Capture face
Face detection and positioningNoYes
Liveness detectionNoYes (passive)
Guided selfie overlayNoYes
Encrypted biometric blobNo (raw JPEG only)Real encrypted data
Server-side liveness validationFailsPasses

Forward the encrypted blob

The stub handler only sends imageBase64 / dimensions / mimeType. The face SDK additionally produces an encrypted liveness blob, which the journey needs for server-side validation — matching the iOS reference, whose selfie result carries the same blob. Add Smart Capture entry points to CameraCaptureHandler.kt. They build the same response shape as the stub paths, with the selfie path adding encryptedBlob:
/** Smart Capture document result: a (rotation-corrected) document image. */
fun completeWithSmartCaptureImage(bitmap: Bitmap) {
  val responder = activeResponder ?: return
  success(responder, bitmap)
}

/**
 * Smart Capture selfie result: the preview photo plus the encrypted biometric
 * blob from the liveness check, forwarded base64-encoded under `encryptedBlob`.
 */
fun completeWithSmartCaptureSelfie(previewPhoto: Bitmap, encryptedBlob: ByteArray) {
  val responder = activeResponder ?: return
  success(
    responder,
    previewPhoto,
    extraData = mapOf(
      "encryptedBlob" to JsonPrimitive(Base64.encodeToString(encryptedBlob, Base64.NO_WRAP)),
    ),
  )
}

/** Smart Capture failure (SDK error / no result) for the active request. */
fun failActive(message: String) {
  val responder = activeResponder ?: return
  error(responder, "CAPTURE_FAILED", message)
}

private fun success(
  responder: BridgeResponder,
  bitmap: Bitmap,
  extraData: Map<String, JsonElement> = emptyMap(),
) {
  val base64 = bitmapToBase64Jpeg(bitmap)
  responder.respond(
    status = BridgeResponseStatus.SUCCESS,
    data = buildMap {
      put("imageBase64", JsonPrimitive(base64))
      put("imageWidth", JsonPrimitive(bitmap.width))
      put("imageHeight", JsonPrimitive(bitmap.height))
      put("mimeType", JsonPrimitive("image/jpeg"))
      putAll(extraData)
    },
  )
  finish()
}
The handler stays SDK-agnostic — it deals in plain Bitmap / ByteArray, so SmartCaptureLauncher does all the mapping from the com.gbg.smartcapture types.

The Swap Pattern

Open JourneyScreen.kt. Three additions wire Smart Capture in, and the existing stub block is guarded so it only renders on the fallback path. First, decide once which path this device uses, and register the activity launchers:
// Decide Smart Capture vs stub once for this screen.
val useSmartCapture = remember { DeviceCapabilities.supportsSmartCapture(context) }

// The SDKs present full-screen Activities, launched via StartActivityForResult.
val launchSmartCaptureDocument = rememberLauncherForActivityResult(
  ActivityResultContracts.StartActivityForResult(),
) { result ->
  SmartCaptureLauncher.handleDocumentResult(result, controller.documentHandler)
}

val launchSmartCaptureSelfie = rememberLauncherForActivityResult(
  ActivityResultContracts.StartActivityForResult(),
) { result ->
  SmartCaptureLauncher.handleSelfieResult(result, controller.selfieHandler)
}
Then launch the SDK Activity when a capture becomes active. Keying the effect on activeCapture makes it fire exactly once per activation:
LaunchedEffect(activeCapture, useSmartCapture) {
  if (!useSmartCapture) return@LaunchedEffect
  when (activeCapture) {
    CaptureMode.Document -> launchSmartCaptureDocument.launch(
      SmartCaptureLauncher.documentIntent(context),
    )
    CaptureMode.Selfie -> launchSmartCaptureSelfie.launch(
      SmartCaptureLauncher.selfieIntent(context),
    )
    null -> Unit
  }
}
Finally, guard the Part 1 stub surface so it renders only on the fallback path:
if (!useSmartCapture) {
  activeCapture?.let { mode ->
    // ... the Part 1 CaptureStubScreen block, unchanged
  }
}

What didn’t change

Look at what surrounds these additions — nothing changed:
  • The BridgeController construction (host, delegate, capability map) is identical.
  • The handler registration and the onActivate / onDeactivate callbacks driving activeCapture are identical.
  • The BridgeResponder.respond(...) contract and the wire format are identical (bar the extra encryptedBlob field on selfie success).
  • The companion server is identical.
This is the whole point of the architecture. The bridge integration layer is stable; only the capture UI swaps.

Why runtime instead of a compile flag?

The single-flight activeCapture state already names which capture is in flight; useSmartCapture names how to satisfy it. Because the SDKs are Maven dependencies that this branch always pulls in, there is nothing to conditionally compile — the only real decision is at runtime: does this hardware support Smart Capture, or do we fall back to the stub? That maps cleanly onto a remembered boolean and a guarded composable.

Test on a physical device

  1. Make sure the companion server is running.
  2. Find your machine’s LAN IP (e.g. ipconfig getifaddr en0 on macOS).
  3. Connect a physical Android device and select it in Android Studio.
  4. Press Run (or ./gradlew :app:assembleDebug then install).
  5. On the Setup screen, enter http://<your-ip>:3000 as the server URL.
  6. Tap Start Journey.
  7. When the journey requests a document capture, the Smart Capture document scanner opens — with a guided overlay, edge detection, and auto-capture.
  8. When the journey requests a selfie, the FaceCamera SDK opens — with face positioning and liveness detection, returning the encrypted blob to the journey.
On an emulator the app uses the stub surface automatically. This is by design.

Common Pitfalls

Credentials not configured

If awsAccessKey / awsSecretKey aren’t set, the Smart Capture repository isn’t registered and the build fails at dependency resolution with Could not resolve com.gbg.smartcapture:.... Add the credentials to ~/.gradle/gradle.properties (see Add your credentials), or build the main branch for the stub-only Part 1 app.

Smart Capture views don’t appear on a device

If capture still shows the stub surface on real hardware, DeviceCapabilities.supportsSmartCapture() returned false. Check the emulator-detection heuristic against your device’s Build fingerprints — some devices report unusual values.

Native library stripping warning

The build logs Unable to strip the following libraries ... libFaceIad.so, .... This is a warning, not an error — the SDK’s prebuilt native liveness libraries are packaged as-is. The build still succeeds and the APK runs.

Capture appears to hang

The handler enforces single-flight: a second capture request for the same action while one is active gets a BUSY error. If a capture seems stuck, confirm the previous request resolved (the LaunchedEffect only re-launches when activeCapture transitions, so a handler that never calls back will block the next request).

What’s Next

  • API Reference — Full documentation for BridgeHost, BridgeWebViewConfigurator, and result types.
  • Capture Screens — The stub capture surface and the swap seam in detail.
  • Capability Handling — Raw handlers, typed slots, custom capabilities, and permission states.