UI components
The SenseCrypt Mobile SDK's bundled QR-scan and face-capture surfaces for SwiftUI, UIKit, and Jetpack Compose — plus the FaceCaptureDriver contract for building your own.
The SDK ships camera surfaces with permission gating, preview rendering, framing chrome, and retry messaging already built. You can use them as-is, restyle them with your own fonts, or ignore them entirely and drive the flows from your own UI.
NSCameraUsageDescription (iOS) must be on your app target. The SDK cannot inject it. On Android the AAR declares the CAMERA permission for you, but the bundled surfaces request the runtime grant themselves and report permissionDenied if refused. See Install.
The FaceCaptureDriver contract
Everything face-related routes through a driver, vended by whichever flow needs a capture:
let driver = loginFlow.faceCaptureDriver() // or registrationFlowval driver = loginFlow.faceCaptureDriver() // or registrationFlowThe driver normalizes login's and registration's very different state enums onto one FaceCaptureUiState, which is why a single capture screen serves both. Its surface is small:
| Member | Purpose |
|---|---|
currentState() / currentState | Current normalized state. |
captureStateStream() / state | Stream of transitions. iOS: AsyncStream. Android: SharedFlow. |
submitFaceCapture(captureSession:) | Hand over a passing capture. |
retry() | Re-fire the cached completion POST after a recoverable failure. |
cancel() | Abandon the capture. |
FaceCaptureUiState
| State | Payload | What the surface does |
|---|---|---|
awaitingCapture | lastError: FaceCaptureRetryReason?, attemptsUsed, attemptsMax | Stream frames; submit the first that passes the gate. On a non-nil lastError, show the matching message first. |
processing | — | On-device work (matching, liveness, minting, key generation). Stop submitting frames; show working chrome. |
postingResult | — | Network POST in flight; show a full-screen scrim. |
recoverablePostFailure | reason: FailureReason | Show a Try-again sheet whose action calls retry(). |
finished | outcome: CaptureOutcome | Terminal — tear down the camera. |
attemptsMax == 0 means no face-mismatch budget is enforced — that's the signup branch, which mints a fresh token and so can only fail liveness.
finished tells you only that the surface should exit. Detailed terminal handling — success screens, the minted key, error sheets — belongs to your app, driven by the underlying flow's own state stream.
iOS surfaces
SenseCryptFaceScanView
The flow-agnostic capture view. Drives a driver, submits passing frames, reports a CaptureOutcome.
SenseCryptFaceScanView(
driver: driver,
paused: false,
titleFontName: "WixMadeforDisplay-Bold",
bodyFontName: "Inter",
) { outcome in
switch outcome {
case .succeeded: // done
case .cancelled: // user backed out
case .failed(let reason): // see Errors
}
}| Parameter | Default | Purpose |
|---|---|---|
driver | — | From faceCaptureDriver(). |
paused | false | Stops the submit loop. Set this while your own sheet is up. |
titleFontName / bodyFontName | nil | Font family names for brand typography. nil uses system faces. |
onComplete | — | Terminal CaptureOutcome. |
paused matters more than it looks. While your give-up sheet is showing after N mismatches, the camera underneath keeps streaming — a later matching frame would drive the flow to success and dismiss your sheet by itself. Set paused: true while the sheet is up and clear it on "Try again".
SenseCryptFaceLoginView
A thin login-specific wrapper over SenseCryptFaceScanView. It takes the LoginFlow directly, vends the driver itself, and delivers a richer result carrying the relying-party label.
SenseCryptFaceLoginView(loginFlow: flow) { result in
switch result {
case .succeeded(let rpLabel): // signed in to rpLabel
case .cancelled: break
case .failed(let reason): break
case .givenUp(let lastReason): // user abandoned after retries
}
}SenseCryptFaceLoginResult adds givenUp(lastReason: FaceCaptureRetryReason) on top of the outcomes CaptureOutcome carries — use it to distinguish "walked away after failing" from an explicit cancel.
SenseCryptScanQrView
The QR scanner: camera preview, branded status pill, animated brackets and scan line, and an in-screen permission-denied gate.
SenseCryptScanQrView(
mode: .oneShot { result in
switch result {
case .decoded(let bytes, let format):
flow.submitQrPayload(payload: bytes)
case .cancelled: break
case .permissionDenied: break
case .error(let message): break
}
},
bodyFontName: "Inter"
)Two modes:
| Mode | Behaviour |
|---|---|
.oneShot(completion:) | Fires on the first decoded barcode. Your host view is expected to dismiss the scanner. |
.streaming(stream:) | Every decode goes to an AsyncStream.Continuation. Stays live until you dismiss it, then yields .cancelled. |
Use .streaming when a decode might be rejected and you want scanning to continue — pair it with resetToScan() on the login flow.
SenseCryptFaceCaptureView
The lower-level capture view, for one-shot capture that isn't attached to a flow. It emits SenseCryptFaceCaptureResult.captured(faceCropJpeg:headPose:metrics:) rather than driving a driver, and only supports .streaming mode.
Prefer SenseCryptFaceScanView for authentication. Reach for this one only when you need raw capture output.
Notable parameters: onSessionReady (fires once the capture session exists, just after the permission gate — stash the handle if you need to feed it to a flow yourself), showSweepRing / showMeshDots (processing chrome the host toggles, since this view doesn't observe flow state), hideTopChrome, and capturingPillHints.
UIKit hosts
Both scan surfaces have UIHostingController bridges, so a UIKit app needn't drop into SwiftUI:
let vc = SenseCryptScanQrViewController(mode: .oneShot { result in /* … */ })
present(vc, animated: true)
let faceVC = SenseCryptFaceCaptureViewController(mode: .streaming(stream: continuation))Android surfaces
Both are @Composable functions in ai.seventhsense.sensecrypt.authenticator.ui.
SenseCryptScanQrView
SenseCryptScanQrView(
modifier = Modifier.fillMaxSize(),
paused = alreadySubmitted,
bodyFontFamily = BodyFont,
onResult = { result ->
when (result) {
is SenseCryptScanQrResult.Decoded ->
flow.submitQrPayload(result.loginQrPayloadBytes)
SenseCryptScanQrResult.Cancelled,
SenseCryptScanQrResult.PermissionDenied -> cancel()
is SenseCryptScanQrResult.Error -> { /* let the flow surface it */ }
}
},
)SenseCryptFaceCaptureView
SenseCryptFaceCaptureView(
modifier = Modifier.fillMaxSize(),
paused = sheetShowing,
showSweepRing = processing,
showMeshDots = processing,
lastRetryReason = pillRetryReason,
showPostingScrim = posting,
titleFontFamily = HeadFont,
bodyFontFamily = BodyFont,
onSessionReady = { handle -> captureSessionHandle = handle },
onFaceLost = { livenessRetryActive = false },
onResult = { result -> /* Cancelled / PermissionDenied / Error */ },
)| Parameter | Purpose |
|---|---|
paused | Stops frame submission while your own sheet is up. |
showSweepRing, showMeshDots | Processing chrome. The view doesn't observe flow state, so you toggle these when your collector reaches matchingFace / runningLiveness. |
lastRetryReason | Drives the retry pill's message. |
showPostingScrim | Full-screen scrim during the POST. |
onSessionReady | Fires once the capture session exists — stash the FaceCaptureSessionHandle. |
onFaceLost | The face left the frame. Use it to drop a stale liveness-failure state so a fresh face starts clean. |
onPassingFrame | A frame passed the gate. |
Don't submit from onPassingFrame and poll the driver state for cached evidence — the two race each other. Pick one. Driving submission from your state collector is the more predictable of the two.
Where the two platforms differ
The bundled surfaces are close but not identical. Three differences will bite if you port code between them:
The driver's stream accessor is named differently. iOS uses captureStateStream() on the driver — stateStream() exists only on the per-flow types. Android uses the state property throughout.
Decoded carries different payloads. iOS emits decoded(bytes: Data, format: BarcodeFormat), so you can tell a QR from a PDF417. Android emits Decoded(loginQrPayloadBytes: ByteArray) with no format field. Code that branches on barcode format won't port.
Android's face-capture result has no success case. iOS SenseCryptFaceCaptureResult includes captured(faceCropJpeg:headPose:metrics:); Android's has only Cancelled, PermissionDenied, and Error. On Android the captured frame reaches you through the onSessionReady / onPassingFrame callbacks as a FaceCaptureSessionHandle instead — there is no equivalent of iOS's raw crop-and-metrics result.
None of this affects the authentication path, which goes through FaceCaptureDriver and is symmetric on both platforms. The differences are confined to the lower-level one-shot capture surface.
Building your own UI
Nothing requires the bundled surfaces. To replace them, you need to:
- Request camera permission yourself.
- Run a camera session and produce a
FaceCaptureSessionHandle. - Observe the driver's
FaceCaptureUiStateand stop submitting frames outsideawaitingCapture. - Call
submitFaceCapture,retry, andcancelon the driver. - Render the retry reasons and the
attemptsUsed/attemptsMaxbudget.
The state contract is the same either way — the bundled views are consumers of the same public driver API you'd be using.
Related
- Login — where the driver comes from.
- Registration — the signup capture branch.
- Reference —
CaptureOutcome,FaceCaptureRetryReason,HeadPose,FaceMetrics. - Install — camera permission and CameraX dependencies.