SenseCrypt Docs
SDKs & appsMobile SDK

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 registrationFlow
val driver = loginFlow.faceCaptureDriver()        // or registrationFlow

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

MemberPurpose
currentState() / currentStateCurrent normalized state.
captureStateStream() / stateStream 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

StatePayloadWhat the surface does
awaitingCapturelastError: FaceCaptureRetryReason?, attemptsUsed, attemptsMaxStream frames; submit the first that passes the gate. On a non-nil lastError, show the matching message first.
processingOn-device work (matching, liveness, minting, key generation). Stop submitting frames; show working chrome.
postingResultNetwork POST in flight; show a full-screen scrim.
recoverablePostFailurereason: FailureReasonShow a Try-again sheet whose action calls retry().
finishedoutcome: CaptureOutcomeTerminal — 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
    }
}
ParameterDefaultPurpose
driverFrom faceCaptureDriver().
pausedfalseStops the submit loop. Set this while your own sheet is up.
titleFontName / bodyFontNamenilFont family names for brand typography. nil uses system faces.
onCompleteTerminal 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:

ModeBehaviour
.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 */ },
)
ParameterPurpose
pausedStops frame submission while your own sheet is up.
showSweepRing, showMeshDotsProcessing chrome. The view doesn't observe flow state, so you toggle these when your collector reaches matchingFace / runningLiveness.
lastRetryReasonDrives the retry pill's message.
showPostingScrimFull-screen scrim during the POST.
onSessionReadyFires once the capture session exists — stash the FaceCaptureSessionHandle.
onFaceLostThe face left the frame. Use it to drop a stale liveness-failure state so a fresh face starts clean.
onPassingFrameA 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:

  1. Request camera permission yourself.
  2. Run a camera session and produce a FaceCaptureSessionHandle.
  3. Observe the driver's FaceCaptureUiState and stop submitting frames outside awaitingCapture.
  4. Call submitFaceCapture, retry, and cancel on the driver.
  5. Render the retry reasons and the attemptsUsed/attemptsMax budget.

The state contract is the same either way — the bundled views are consumers of the same public driver API you'd be using.

  • Login — where the driver comes from.
  • Registration — the signup capture branch.
  • ReferenceCaptureOutcome, FaceCaptureRetryReason, HeadPose, FaceMetrics.
  • Install — camera permission and CameraX dependencies.

On this page