Replace the stub capture surface with the production Smart Capture SDKs for document scanning and face capture with liveness detection.
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:
The 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 device
The 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.
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.
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 } } } } }}
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:
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.
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.
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:
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.
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.captureimport android.content.Contextimport android.content.pm.PackageManagerimport android.os.Buildobject 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.
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.captureimport android.app.Activityimport android.content.Contextimport android.content.Intentimport androidx.activity.result.ActivityResultimport com.gbg.go.reference.ui.journey.CameraCaptureHandlerimport com.gbg.smartcapture.commons.SmartCaptureExceptionimport com.gbg.smartcapture.documentcamera.DocumentCameraActivityimport com.gbg.smartcapture.documentcamera.DocumentProcessingStateimport com.gbg.smartcapture.documentcamera.DocumentScannerConfigimport com.gbg.smartcapture.facecamera.FaceCameraActivityinternal 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})") } }}
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.
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.
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.
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 }}
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.
Find your machine’s LAN IP (e.g. ipconfig getifaddr en0 on macOS).
Connect a physical Android device and select it in Android Studio.
Press Run (or ./gradlew :app:assembleDebug then install).
On the Setup screen, enter http://<your-ip>:3000 as the server URL.
Tap Start Journey.
When the journey requests a document capture, the Smart Capture document scanner opens — with a guided overlay, edge detection, and auto-capture.
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.
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.
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.
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.
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).