Skip to main content
The iOS SDK ships stub camera views (StubDocumentCameraView, StubSelfieCameraView) to paper over the ceremony of wrapping UIImagePickerController in SwiftUI and the Simulator’s complete lack of a camera. The GBGBridge Android SDK ships no capture views — deliberately. On Android, ActivityResultContracts.TakePicturePreview() already is a working stub camera: it returns a Bitmap in one line on real devices, and the emulator provides a synthetic camera scene, so there is far less ceremony to hide. Shipping Compose views inside the SDK would also force a Jetpack Compose dependency onto every View-system host. Instead, this page shows two ways to build your own capture surface and wire it to the bridge:
  • The reference app’s CaptureStubScreen — a full-featured Compose capture surface with a live CameraX preview, system photo picker, and emulator-friendly placeholder. Copy it as-is or use it as a worked example.
  • A minimal TakePicturePreview-based handler for hosts that don’t want a CameraX dependency.
Development and testing only. Both approaches take a plain photo (or generate a placeholder bitmap). They do not perform document detection, auto-cropping, liveness checks, or biometric encryption, and placeholder images will not pass server-side verification. Do not ship the placeholder path in production.

When You Need a Capture Surface

The web journey requests native capture through the camera.document.capture and camera.selfie.capture actions. A development capture surface lets you:
  • Verify early integration — Prove the bridge protocol, capability negotiation, and data flow before a production capture SDK is available or configured.
  • Test on the emulator / in CI — The emulator’s synthetic camera works with both CameraX and TakePicturePreview, and a placeholder bitmap path covers environments with no camera at all.
  • Prototype — Build the host app shell with a capture flow that “just works” while you focus on other parts of the integration.

The Reference Capture Surface

The reference app’s CaptureStubScreen is a full-screen Compose Dialog used for both document and selfie requests — a CaptureMode enum drives the copy, the framing guide (ID-card rectangle for documents, oval for selfies), and the camera selector. It offers three rungs of capture affordance, in priority order:
RungMechanismWhen it applies
1. Live preview + shutterCameraX (Preview + ImageCapture), back camera for document, front camera for selfieDevice has a camera and the CAMERA permission is granted. This is the path a production app replaces with a real capture SDK.
2. Photo pickerActivityResultContracts.PickVisualMedia, image-onlyChoosing an existing image — useful on devices without a usable camera, and the main path on tablets.
3. Placeholder imageSynthetic bitmap drawn in codeA tertiary text button so the screen still reads as a capture surface, but available so the emulator path can exercise the end-to-end bridge round-trip without a real camera.
The screen requests the runtime CAMERA permission in-place when it opens (the manifest declares the permission with <uses-feature android:name="android.hardware.camera" android:required="false" />, so camera-less devices can still install the app). If permission is denied, the device has no camera, or CameraX fails to bind, the preview area falls back to a static framing guide on a neutral backdrop with an explanatory caption — the photo picker and placeholder rungs carry the user through regardless.

Wiring it to the bridge

The reference app pairs CaptureStubScreen with a raw BridgeCapabilityHandler per action (CameraCaptureHandler). The handler retains the BridgeResponder when a request arrives, signals the UI to present the capture surface, and exposes success / cancel / error completion paths the UI drives once the user finishes:
internal class CameraCaptureHandler(
  override val action: String,
) : BridgeCapabilityHandler {

  private var activeResponder: BridgeResponder? = null

  /** Set by the host UI — invoked on the main thread when a request arrives. */
  var onActivate: (() -> Unit)? = null
  var onDeactivate: (() -> Unit)? = null

  override fun handle(request: BridgeMessage, responder: BridgeResponder) {
    if (activeResponder != null) {
      // The SDK doesn't enforce single-flight for raw handlers, so do 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 cancelActive(reason: String) {
    val responder = activeResponder ?: return
    cancelled(responder, reason)
  }

  // success(...) / cancelled(...) respond on the BridgeResponder, clear
  // activeResponder, and invoke onDeactivate — see "Returning the Captured
  // Image" below for the response shape.
}
The journey screen mirrors the handler’s activate / deactivate callbacks into Compose state, and shows CaptureStubScreen while a request is active:
var activeCapture by remember { mutableStateOf<CaptureMode?>(null) }

val pickImage = rememberLauncherForActivityResult(
  ActivityResultContracts.PickVisualMedia(),
) { uri -> activeHandler.onPickerResult(context, uri) }

LaunchedEffect(controller) {
  controller.documentHandler.onActivate = { activeCapture = CaptureMode.Document }
  controller.documentHandler.onDeactivate = {
    if (activeCapture == CaptureMode.Document) activeCapture = null
  }
  // ...same pair for selfieHandler with CaptureMode.Selfie
}

activeCapture?.let { mode ->
  CaptureStubScreen(
    mode = mode,
    onCapture = { bitmap -> activeHandler.onCameraResult(bitmap) },
    onPickFromLibrary = {
      pickImage.launch(
        PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
      )
    },
    onUseStubImage = { activeHandler.completeWithStubImage() },
    onCancel = { activeHandler.cancelActive("User dismissed capture") },
  )
}
Dismissing the capture surface (the close button or the system back press) cancels the active request, which sends a cancelled response so the web journey can recover. Also cancel any in-flight request before tearing the screen down — the reference app does this in DisposableEffect.onDispose, calling cancelActive(...) on both handlers before host.detach(), so the JavaScript side never hangs waiting for a response that will never arrive.
Picker back-out does not cancel the capture request. If the user opens the system photo picker and backs out without choosing an image, the picker returns a null URI. The handler must treat that as “picker dismissed” — not “capture cancelled” — and simply return to the capture surface, leaving the request active. Responding cancelled here would send a spurious CANCELLED to the journey and tear down a capture surface that still offers the camera and placeholder paths.

Minimal Alternative: TakePicturePreview

If you don’t want a CameraX dependency, ActivityResultContracts.TakePicturePreview() launches the system camera app and returns a small preview Bitmap (or null if the user backs out). Wire it to the same raw handler — the only change is what onActivate does:
val documentHandler = remember {
  CameraCaptureHandler(action = "camera.document.capture")
}

val takePicture = rememberLauncherForActivityResult(
  ActivityResultContracts.TakePicturePreview(),
) { bitmap ->
  // null bitmap = user backed out of the camera app → cancelled response.
  documentHandler.onCameraResult(bitmap)
}

LaunchedEffect(documentHandler) {
  documentHandler.onActivate = { takePicture.launch(null) }
}
This works on real devices and on the emulator (which renders a synthetic camera scene), with no in-app camera UI to build. The trade-offs: the returned bitmap is a low-resolution preview, you get no framing guidance, and you cannot brand the capture experience.
TakePicturePreview itself needs no runtime permission — but if your manifest declares android.permission.CAMERA (for example, because another screen uses CameraX), Android requires the permission to be granted before launching the contract, otherwise it throws a SecurityException. Check and request it first if your manifest declares it.

Returning the Captured Image

However the Bitmap was produced — shutter, picker, placeholder, or TakePicturePreview — the bridge response is the same: encode it as base64 JPEG and respond with success and the standard image keys.
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"),
    ),
  )
}

private fun bitmapToBase64Jpeg(bitmap: Bitmap, quality: Int = 85): String {
  val output = ByteArrayOutputStream()
  bitmap.compress(Bitmap.CompressFormat.JPEG, quality, output)
  return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP)
}
KeyValue
imageBase64Base64-encoded JPEG (the reference app uses quality 85, Base64.NO_WRAP)
imageWidth / imageHeightPixel dimensions of the captured image
mimeType"image/jpeg"

Typed-slot equivalent

If your host uses the typed capture slots (host.documentCapture / host.selfieCapture) instead of raw handlers, complete the slot with a CaptureResult.Document and the SDK builds the same wire response for you:
// Setup: the handler shows your capture UI, then suspends until complete().
host.documentCapture.handler = { request ->
  showCaptureUi()
  host.documentCapture.awaitCompletion()
}

// Capture UI, once a Bitmap is in hand:
val output = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, output)
host.documentCapture.complete(
  CaptureResult.Document(
    imageData = output.toByteArray(),
    width = bitmap.width,
    height = bitmap.height,
    mimeType = "image/jpeg",
  ),
)
Note that CaptureResult.Document defaults mimeType to "image/png" — pass "image/jpeg" explicitly when you compress to JPEG. On dismissal, call complete(CaptureResult.Cancelled(reason = "User dismissed")) or cancelIfBusy(...). See the Capability Handling Guide for the full slot lifecycle.

Emulator Support

Unlike the iOS Simulator, the Android emulator ships a synthetic camera — a rendered 3D scene that CameraX, the system camera app, and TakePicturePreview all treat as a real camera. That means rungs 1 and 2 of the reference capture surface work on the emulator unmodified. The placeholder-bitmap rung exists for the cases that remain: emulator images without the virtual camera, CI environments, and quick round-trip tests where you just want a deterministic bitmap through the bridge without aiming at the virtual scene.

Swapping for Production Capture SDKs

The capture surface is deliberately swappable. The handler registration, the BridgeResponder (or typed-slot complete()) contract, and the wire format all stay the same — only the UI that produces the Bitmap changes. When you adopt a production capture SDK, replace the CameraX rung (or the whole CaptureStubScreen) with the SDK’s capture view, construct the same response from its output, and delete the placeholder path.
Tutorial Part 2: Integrate Smart Capture SDKs walks through exactly this swap with the GBG Smart Capture SDKs — guided document scanning and face capture with liveness detection — slotting into the same handler seam shown on this page (the Android counterpart of the iOS Stub Camera Views swap).

Next Steps