Wallet Issuance ยท SDK

Wallet SDK โ€” Integration Guide

Version 1.0.0-SNAPSHOT ยท Android (API 26+) ยท iOS 14+ (XCFramework) ยท Last updated: 2026-03-23

AndroidiOSKotlin MultiplatformOpenID4VCIISO 18013-5
01

Overview

The Wallet SDK is a Kotlin Multiplatform library for building mobile identity wallet applications. It provides a complete pipeline for:

  • Provisioning โ€” issuing credentials from an OpenID4VCI-compliant issuer via a two-phase flow.
  • Storage โ€” persisting credentials in a hardware-backed secure store using the library.
  • Presentation โ€” sharing credentials in-person (ISO 18013-5 QR/BLE), online (OpenID4VP deep links), and in the browser (Android Digital Credentials API).
  • Document Capture โ€” scanning and extracting structured data from physical identity documents.
  • Liveness Detection โ€” biometric face capture with optional on-device passive liveness.
  • MRTD Reading โ€” reading NFC chips embedded in passports and travel documents.

The SDK is built on Clean Architecture principles. Your application always interacts through stable engine interfaces.

text
Your App
    โ””โ”€ WalletSdk (singleton)
          โ”œโ”€ ProvisioningEngine  โ€” OpenID4VCI credential issuance
          โ”œโ”€ StorageEngine       โ€” credential vault (StateFlow, delete)
          โ”œโ”€ PresentationEngine  โ€” QR/BLE, deep link, DC API
          โ””โ”€ DocumentCaptureEngine โ€” OCR / barcode extraction
02

Prerequisites

Platform
Android
Requirement
API 26 (Android 8.0) minimum; API 29+ for the Digital Credentials API browser flow
Platform
iOS
Requirement
iOS 14+ โ€” macOS build machine required (iOS targets are skipped on Windows)
Platform
Kotlin
Requirement
2.2.0
Platform
JVM
Requirement
11
Platform
Google Play Services
Requirement
Required for Android Credential Manager (DC API flow)
Platform
Chrome
Requirement
128+ for web-based Digital Credentials support
PlatformRequirement
AndroidAPI 26 (Android 8.0) minimum; API 29+ for the Digital Credentials API browser flow
iOSiOS 14+ โ€” macOS build machine required (iOS targets are skipped on Windows)
Kotlin2.2.0
JVM11
Google Play ServicesRequired for Android Credential Manager (DC API flow)
Chrome128+ for web-based Digital Credentials support

You will need an API key issued by CredenceID to initialize the SDK. Contact your CredenceID account representative if you do not have one.

03

Installation

3.1 Android (Gradle)

Add the CredenceID Maven repository and the SDK dependency to your module's build.gradle.kts. Use the plain artifact notation โ€” do not append @aar or -android-release suffixes, as these bypass Gradle module metadata and strip transitive dependencies.

kotlin
// settings.gradle.kts or root build.gradle.kts
repositories {
    // CredenceID SDK โ€” publicly readable, covers releases and snapshots
    maven("https://nexus.credenceid.com/repository/credenceid-sdk-group/")
    // Required only if you use the liveness module with Innovatrics provider
    maven("https://maven.innovatrics.com/releases")
}

// app/build.gradle.kts
dependencies {
    // Core Wallet SDK (includes document capture and MRTD reader transitively)
    implementation("com.credenceid:wallet-sdk:1.0.0-SNAPSHOT")

    // Optional โ€” liveness capture module
    // Add this only if your app needs selfie or passive liveness capture.
    implementation("com.credenceid:wallet-sdk-liveness:1.0.0-SNAPSHOT")
}
โ„น๏ธ
Snapshot builds
Replace 1.0.0-SNAPSHOT with a specific release tag (e.g. 0.0.3) once a stable release is available. Use 1.0.0-SNAPSHOT during development to track the latest build.

Common mistakes to avoid:

kotlin
// โŒ Wrong โ€” @aar strips ALL transitive dependencies
implementation("com.credenceid:wallet-sdk:1.0.0-SNAPSHOT@aar")

// โŒ Wrong โ€” -android-release is an internal variant; always use the root coordinate
implementation("com.credenceid:wallet-sdk-android-release:1.0.0-SNAPSHOT")

// โœ… Correct
implementation("com.credenceid:wallet-sdk:1.0.0-SNAPSHOT")

3.2 iOS (XCFramework)

The SDK ships as a pre-built static WalletSDK.xcframework. Obtain the framework from your CredenceID repository or CI artifact.

  1. In Xcode, choose File โ†’ Add Files to "<Project>".
  2. Select WalletSDK.xcframework and add it to your target.
  3. In Target โ†’ General โ†’ Frameworks, Libraries, and Embedded Content, set the framework to Do Not Embed (it is a static framework โ€” embedding it causes a duplicate symbols error at link time).
โœ…
SKIE interop
StateFlow and other Kotlin coroutine primitives are automatically bridged to Swift's native AsyncSequence by the SKIE interop layer bundled inside the framework. No manual bridging is required.
04

SDK Initialization

The SDK is a process-scoped singleton accessed via WalletSdk.getInstance(). The call is thread-safe and idempotent โ€” repeated calls always return the same instance.

kotlin
import com.credenceid.wallet.sdk.WalletSdk

// Obtain the singleton โ€” safe to call from any thread, any time.
val sdk = WalletSdk.getInstance(apiKey = "your-api-key")

// Engines are lazily initialized on first access.
val provisioningEngine  = sdk.provisioningEngine
val storageEngine       = sdk.storageEngine
val presentationEngine  = sdk.presentationEngine
val documentCapture     = sdk.documentCaptureEngine

When to initialize: Call getInstance() the first time before you access any engine โ€” for example, in your Application.onCreate() or the first ViewModel that needs the SDK. There is no separate init() call required.

API key: The key is validated internally. Passing an empty or invalid key will not throw at construction time but may result in network requests failing during provisioning.

05

Credential Provisioning

Provisioning is a two-phase flow that follows the OpenID4VCI pre-authorized code specification:

Phase
Phase 1
Method
submitProvisioningRequest()
What happens
Submits the applicant's identity to the issuer backend. A PENDING credential is persisted locally.
Phase
Phase 2
Method
fetchReadyCredential()
What happens
The issuer processes the application and pushes a credential offer URI out-of-band. The SDK exchanges it for the signed mDoc and marks the credential VALID.
PhaseMethodWhat happens
Phase 1submitProvisioningRequest()Submits the applicant's identity to the issuer backend. A PENDING credential is persisted locally.
Phase 2fetchReadyCredential()The issuer processes the application and pushes a credential offer URI out-of-band. The SDK exchanges it for the signed mDoc and marks the credential VALID.

5.1 Phase 1 โ€” Submit Application

Build a ProvisioningParameters object with the issuer endpoint, credential type, and the applicant's identity data, then call submitProvisioningRequest().

kotlin
import com.credenceid.wallet.sdk.WalletSdk
import com.credenceid.wallet.sdk.domain.factory.provisioning.DigitalIdApplicationFactory
import com.credenceid.wallet.sdk.domain.model.provisioning.ProvisioningParameters

val sdk = WalletSdk.getInstance(apiKey = "your-api-key")

viewModelScope.launch {
    val params = ProvisioningParameters(
        issuerUrl              = "https://issuer.example.com/reviewcenter",
        credentialType         = "org.iso.18013.5.1.mDL",
        userAuthorizationToken = "pre-auth-token-from-your-backend",
        digitalIdApplication   = DigitalIdApplicationFactory.createSample(),
        // Replace createSample() with a real DigitalIDApplication built from
        // the applicant's captured document data and selfie.
    )

    sdk.provisioningEngine.submitProvisioningRequest(params)
        .onSuccess {
            // A PENDING DigitalCredential is now observable in storageEngine.storedCredentials.
            // Inform the user that their application is under review.
            showMessage("Application submitted โ€” waiting for issuer approval.")
        }
        .onFailure { error ->
            showError("Submission failed: ${error.message}")
        }
}

ProvisioningParameters fields:

Field
issuerUrl
Type
String
Description
Base URL of the issuer's proprietary REST API.
Field
credentialType
Type
String
Description
Credential type to request, e.g. "org.iso.18013.5.1.mDL".
Field
userAuthorizationToken
Type
String
Description
Pre-authorized code or auth token from your backend authorization layer.
Field
digitalIdApplication
Type
DigitalIDApplication
Description
Applicant identity data (name, DOB, document images, selfie, etc.) submitted in Phase 1.
FieldTypeDescription
issuerUrlStringBase URL of the issuer's proprietary REST API.
credentialTypeStringCredential type to request, e.g. "org.iso.18013.5.1.mDL".
userAuthorizationTokenStringPre-authorized code or auth token from your backend authorization layer.
digitalIdApplicationDigitalIDApplicationApplicant identity data (name, DOB, document images, selfie, etc.) submitted in Phase 1.

5.2 Phase 2 โ€” Fetch the Issued Credential

Once the issuer has approved and signed the credential, it delivers a openid-credential-offer:// URI to the device. The SDK supports two delivery methods:

  • Deep link โ€” the issuer redirects the user's browser to the openid-credential-offer:// URI, which Android or iOS routes to your app via its registered scheme.
  • QR code โ€” the issuer displays a QR code containing the offer URI. The user scans it in-app (or with the device camera), and your app extracts the raw string to pass to the SDK.

Pass the URI directly to fetchReadyCredential() regardless of how it was delivered.

kotlin
// --- Deep link delivery ---
// Register your scheme in AndroidManifest.xml:
// <intent-filter>
//   <action android:name="android.intent.action.VIEW" />
//   <category android:name="android.intent.category.DEFAULT" />
//   <category android:name="android.intent.category.BROWSABLE" />
//   <data android:scheme="openid-credential-offer" />
// </intent-filter>
//
// Then handle the incoming intent in your Activity:
viewModelScope.launch {
    // Example URI (deep link or decoded from QR):
    // openid-credential-offer://?credential_offer_uri=https://issuer.example.com/offers/abc123
    val credentialOfferUri = intent.data?.toString() ?: return@launch

    sdk.provisioningEngine.fetchReadyCredential(
        credentialOfferUri = credentialOfferUri,
        timeoutMillis      = 30_000L, // Optional โ€” default is 30 seconds
    )
    .onSuccess { credential ->
        // The credential is now VALID and observable in the storage engine.
        showSuccess("Credential ready: ${credential.displayMetadata["displayName"]}")
    }
    .onFailure { error ->
        when {
            error.message?.contains("TIMEOUT") == true ->
                showRetryDialog("The issuer did not respond in time. Try again?")
            else ->
                showError("Could not download credential: ${error.message}")
        }
    }
}

For the QR code path, decode the QR string using any standard library (e.g. ZXing / ML Kit Barcode Scanning) and pass the raw result string directly โ€” no further parsing is needed. The pre-authorized code is embedded inside the offer URI's JSON payload and extracted automatically by the underlying SDK library.

kotlin
// --- QR code delivery ---
// After scanning a QR code with your chosen library:
fun onQrCodeScanned(rawValue: String) {
    if (rawValue.startsWith("openid-credential-offer://")) {
        viewModelScope.launch {
            sdk.provisioningEngine.fetchReadyCredential(rawValue)
                .onSuccess { credential -> showSuccess(credential) }
                .onFailure { error -> showError(error.message) }
        }
    }
}

5.3 Complete Provisioning Flow Example

The following example demonstrates both phases together with a ViewModel, including UI state management:

kotlin
class ProvisioningViewModel : ViewModel() {

    private val sdk = WalletSdk.getInstance(apiKey = "your-api-key")

    sealed interface UiState {
        object Idle : UiState
        object Submitting : UiState
        object AwaitingIssuer : UiState
        data class Ready(val credential: DigitalCredential) : UiState
        data class Error(val message: String) : UiState
    }

    private val _uiState = MutableStateFlow<UiState>(UiState.Idle)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    // Called when the user taps "Apply"
    fun submitApplication(params: ProvisioningParameters) {
        _uiState.value = UiState.Submitting
        viewModelScope.launch {
            sdk.provisioningEngine.submitProvisioningRequest(params)
                .onSuccess { _uiState.value = UiState.AwaitingIssuer }
                .onFailure { _uiState.value = UiState.Error(it.message ?: "Unknown error") }
        }
    }

    // Called when a deep link or QR code scan delivers the credential offer URI
    fun onCredentialOfferReceived(credentialOfferUri: String) {
        viewModelScope.launch {
            sdk.provisioningEngine.fetchReadyCredential(credentialOfferUri)
                .onSuccess { _uiState.value = UiState.Ready(it) }
                .onFailure { _uiState.value = UiState.Error(it.message ?: "Download failed") }
        }
    }
}
06

Credential Storage & Vault Management

The StorageEngine exposes the credential vault as a reactive StateFlow. Your UI layer simply collects the flow โ€” no polling or manual refresh required.

6.1 Observing the Credential Vault

kotlin
import kotlinx.coroutines.flow.collectLatest

// In a ViewModel:
viewModelScope.launch {
    sdk.storageEngine.storedCredentials.collectLatest { credentials ->
        // Emitted immediately with the current list, then again on every change.
        // credentials: List<DigitalCredential>
        _credentialList.value = credentials
    }
}

DigitalCredential fields:

Field
id
Type
String
Description
Unique identifier assigned by the DocumentStore.
Field
type
Type
String
Description
Credential type, e.g. "org.iso.18013.5.1.mDL".
Field
provisioningStatus
Type
ProvisioningStatus
Description
PENDING while awaiting the issuer; VALID once issued; REVOKED if revoked.
Field
displayMetadata
Type
Map<String, String>
Description
Display-oriented metadata. Contains "displayName" when the issuer provides one.
FieldTypeDescription
idStringUnique identifier assigned by the DocumentStore.
typeStringCredential type, e.g. "org.iso.18013.5.1.mDL".
provisioningStatusProvisioningStatusPENDING while awaiting the issuer; VALID once issued; REVOKED if revoked.
displayMetadataMap<String, String>Display-oriented metadata. Contains "displayName" when the issuer provides one.

Displaying credentials in a Compose list:

kotlin
@Composable
fun CredentialList(viewModel: WalletViewModel) {
    val credentials by viewModel.credentials.collectAsStateWithLifecycle()

    LazyColumn {
        items(credentials) { credential ->
            CredentialCard(
                name   = credential.displayMetadata["displayName"] ?: credential.type,
                status = credential.provisioningStatus.name,
                // Show a visual indicator for PENDING credentials
                isPending = credential.provisioningStatus == ProvisioningStatus.PENDING,
            )
        }
    }
}

6.2 Deleting a Credential

Deletion permanently erases the mDoc payload and destroys the associated hardware-backed cryptographic keys in the SecureArea. This operation is irreversible.

kotlin
fun deleteCredential(credentialId: String) {
    viewModelScope.launch {
        sdk.storageEngine.deleteCredential(credentialId)
            .onSuccess {
                // storedCredentials flow automatically emits the updated list.
                showToast("Credential deleted.")
            }
            .onFailure { error ->
                showError("Could not delete credential: ${error.message}")
            }
    }
}
โœ…
Best practice
Always prompt the user for explicit confirmation before calling deleteCredential(). The operation destroys the hardware-backed key โ€” recovery requires re-provisioning from the issuer.
07

Credential Presentation

The SDK supports three presentation modes, all driven by the same PresentationEngine:

Mode
In-person
Trigger
User taps "Show credential"
Protocol
ISO 18013-5 (QR code + BLE)
Mode
Online
Trigger
Deep link from verifier website
Protocol
OpenID4VP / ISO 18013-7
Mode
Browser (DC API)
Trigger
Web page calls navigator.credentials.get()
Protocol
OpenID4VP, mdoc Annex C
ModeTriggerProtocol
In-personUser taps "Show credential"ISO 18013-5 (QR code + BLE)
OnlineDeep link from verifier websiteOpenID4VP / ISO 18013-7
Browser (DC API)Web page calls navigator.credentials.get()OpenID4VP, mdoc Annex C

7.1 How the Presentation Lifecycle Works

All presentation modes share the same state machine, expressed as a StateFlow<PresentationState>:

text
Idle
  โ””โ”€> EngagementReady   (QR code ready โ€” in-person only)
        โ””โ”€> Connecting  (verifier is connecting)
              โ””โ”€> RequestReceived  (verifier sent a request โ€” show consent UI)
                    โ””โ”€> ConsentGiven  (user approved โ€” signing in progress)
                          โ”œโ”€> Success        (credential delivered)
                          โ””โ”€> Error          (something went wrong)

Your UI observes this flow and updates accordingly at each transition.

7.2 In-Person Presentation (ISO 18013-5 QR/BLE)

The in-person flow starts by generating a Device Engagement QR code. The verifier scans it, establishes a secure BLE channel, and sends a credential request. The user consents and the SDK signs and returns the response.

kotlin
import com.credenceid.wallet.sdk.domain.model.presentation.PresentationState

viewModelScope.launch {
    sdk.presentationEngine
        .startInPersonPresentation()
        .collectLatest { state ->
            when (state) {
                is PresentationState.Idle -> {
                    // Initial state โ€” no action needed.
                }

                is PresentationState.EngagementReady -> {
                    // Display the QR code for the verifier to scan.
                    // state.qrCodePayload is the raw string; encode it as a QR image.
                    showQrCode(payload = state.qrCodePayload)
                }

                is PresentationState.Connecting -> {
                    // Verifier scanned the QR code โ€” hide the QR and show a spinner.
                    showConnectingSpinner()
                }

                is PresentationState.RequestReceived -> {
                    // Verifier sent a credential request. Display the consent screen.
                    showConsentScreen(
                        verifier = state.verifierIdentity,
                        claims   = state.requestedClaims,
                    )
                    // The user will call approveRequest() or rejectRequest() from the consent UI.
                }

                is PresentationState.Success -> {
                    showMessage("Credential shared successfully.")
                    navigateBack()
                }

                is PresentationState.Error -> {
                    val err = state.error
                    if (err.isRecoverable) {
                        showRetryDialog(err.code.name)
                    } else {
                        showError("Presentation failed: ${err.code.name}")
                    }
                }
            }
        }
}

// Called when the user taps "Share" on the consent screen
fun onUserApproved(selectedClaims: List<String>, biometricToken: ByteArray) {
    viewModelScope.launch {
        sdk.presentationEngine
            .approveRequest(claims = selectedClaims, biometricToken = biometricToken)
            .onFailure { showError("Could not sign response: ${it.message}") }
    }
}

// Called when the user taps "Decline"
fun onUserDeclined() {
    viewModelScope.launch {
        sdk.presentationEngine.rejectRequest()
    }
}

7.3 Online Presentation (Deep Link / OpenID4VP)

Online presentation is triggered by a deep link from a verifier's website. Register your app as a deep link handler for the verifier's scheme and pass the URL to startOnlinePresentation().

kotlin
// In your Activity โ€” handle incoming deep links:
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    val url = intent.data?.toString() ?: return

    viewModelScope.launch {
        sdk.presentationEngine
            .startOnlinePresentation(requestUrl = url)
            .collectLatest { state ->
                // Handle states identically to the in-person flow.
                // The EngagementReady state is skipped (no QR needed for online flows).
                handlePresentationState(state)
            }
    }
}

private fun handlePresentationState(state: PresentationState) {
    when (state) {
        is PresentationState.Connecting      -> showLoadingIndicator()
        is PresentationState.RequestReceived -> showConsentScreen(state.verifierIdentity, state.requestedClaims)
        is PresentationState.Success         -> showSuccess()
        is PresentationState.Error           -> showError(state.error.code.name)
        else -> { /* Idle / EngagementReady not expected in online flow */ }
    }
}

7.4 Digital Credentials API (DC API) โ€” Android Browser Flow

When a verifier's website calls navigator.credentials.get() from Chrome (v128+) or another supported browser, Android's Credential Manager matches the request against registered credentials in the SDK's DocumentStore and routes it to your app via the SDK's WalletPresentmentActivity (declared in the SDK's merged manifest โ€” no configuration needed in your app).

What the SDK handles automatically:

Responsibility
Credential registration
Implementation
Dual-path: GMS IdentityCredentialManager + Jetpack RegistryManager. Re-registered on every vault change.
Responsibility
Intent routing
Implementation
WalletPresentmentActivity is declared in the SDK manifest โ€” auto-merged into your app.
Responsibility
Request parsing
Implementation
Supports OpenID4VP (unsigned + signed) and mdoc Annex C (ISO 18013-7).
Responsibility
Response signing
Implementation
Handles DeviceResponse ECDSA signing and JWE/HPKE encryption.
ResponsibilityImplementation
Credential registrationDual-path: GMS IdentityCredentialManager + Jetpack RegistryManager. Re-registered on every vault change.
Intent routingWalletPresentmentActivity is declared in the SDK manifest โ€” auto-merged into your app.
Request parsingSupports OpenID4VP (unsigned + signed) and mdoc Annex C (ISO 18013-7).
Response signingHandles DeviceResponse ECDSA signing and JWE/HPKE encryption.

What your app must do: Observe activeSession and display a consent UI when a session arrives.

kotlin
// Observe in your Application or a long-lived ViewModel:
viewModelScope.launch {
    sdk.presentationEngine.activeSession.collect { session ->
        if (session != null) {
            // A verifier request is pending. Show your consent UI.
            showConsentDialog(
                claims   = session.requestedClaims,
                verifier = session.verifierIdentity,
                onApprove = { lifecycleScope.launch { session.approve() } },
                onReject  = { lifecycleScope.launch { session.reject() } },
            )
        }
    }
}

If you prefer the SDK's built-in Material3 bottom sheet, no code is needed at all โ€” it renders automatically inside the transparent WalletPresentmentActivity. Use the activeSession API only if you want a custom-themed consent UI.

7.5 Consent UI

The consent UI must surface the following information to the user.

Requested claims (requestedClaims: List<ClaimRequest>):

kotlin
data class ClaimRequest(
    val namespace: String,       // e.g. "org.iso.18013.5.1"
    val claimName: String,       // e.g. "family_name", "portrait", "age_over_18"
    val intentToRetain: Boolean  // true = verifier intends to store this data
)

Display each claimName in plain language. When intentToRetain is true, indicate clearly that the verifier will store that data element โ€” this disclosure is required by ISO 18013-5.

Verifier identity (verifierIdentity: VerifierInfo?):

kotlin
data class VerifierInfo(
    val commonName: String,
    val organization: String?,
    val isCertificateValid: Boolean,
    val trustLevel: TrustLevel // TRUSTED, UNKNOWN, WARNING
)

See Section 7.6 for how to handle each trust level.

Example consent composable:

kotlin
@Composable
fun ConsentScreen(
    claims: List<ClaimRequest>,
    verifier: VerifierInfo?,
    onApprove: () -> Unit,
    onReject: () -> Unit,
) {
    Column(modifier = Modifier.padding(16.dp)) {

        // Verifier identity
        verifier?.let {
            TrustLevelBadge(level = it.trustLevel)
            Text("${it.commonName} is requesting access to:")
        }

        // Requested claims
        claims.forEach { claim ->
            Row {
                Text("โ€ข ${claim.claimName}")
                if (claim.intentToRetain) {
                    Text(
                        text = " (will be stored)",
                        color = MaterialTheme.colorScheme.error,
                        style = MaterialTheme.typography.labelSmall,
                    )
                }
            }
        }

        // Action buttons
        Row {
            Button(onClick = onApprove) { Text("Share") }
            OutlinedButton(onClick = onReject) { Text("Decline") }
        }
    }
}

7.6 Verifier Trust Levels

Always reflect trust levels in your consent UI. The table below shows recommended user-facing messaging:

TrustLevel
TRUSTED
Meaning
Certificate validated against a known trust root.
UI guidance
Show a green verified indicator.
TrustLevel
UNKNOWN
Meaning
No certificate โ€” verifier is unauthenticated.
UI guidance
Show a warning. Ask the user to verify they are on the expected website.
TrustLevel
WARNING
Meaning
Certificate present but expired, self-signed, or from an untrusted root.
UI guidance
Show a prominent warning. Consider blocking sharing by default.
TrustLevelMeaningUI guidance
TRUSTEDCertificate validated against a known trust root.Show a green verified indicator.
UNKNOWNNo certificate โ€” verifier is unauthenticated.Show a warning. Ask the user to verify they are on the expected website.
WARNINGCertificate present but expired, self-signed, or from an untrusted root.Show a prominent warning. Consider blocking sharing by default.
kotlin
@Composable
fun TrustLevelBadge(level: TrustLevel) {
    val (color, label) = when (level) {
        TrustLevel.TRUSTED  -> Color(0xFF2E7D32) to "Verified"
        TrustLevel.UNKNOWN  -> Color(0xFFF57F17) to "Unverified"
        TrustLevel.WARNING  -> Color(0xFFC62828) to "Warning โ€” Certificate Issue"
    }
    Text(text = label, color = color, fontWeight = FontWeight.Bold)
}
08

Wallet Toolkit

The Wallet Toolkit regroups the capture and reading features that together feed the provisioning pipeline with verified identity data.

8.1 Document Capture

The DocumentCaptureEngine captures identity document images and extracts structured data (MRZ for passports and ID cards, PDF417 barcode for driving licences).

The engine transparently selects the provider defined in backend.

8.1.1 Capture State Lifecycle

text
Idle โ†’ CapturingFront โ†’ [CapturingBack] โ†’ Analyzing โ†’ Success
                                                     โ””โ”€ Error
State
Idle
Description
No session active (initial state).
State
CapturingFront
Description
Camera open for the front face or passport bio-page.
State
CapturingBack
Description
Front captured; camera open for the rear face (not used for passports).
State
Analyzing
Description
Images acquired; OCR / barcode extraction in progress.
State
Success(document)
Description
Capture and extraction complete.
State
Error(error)
Description
Session failed. See DocumentCaptureErrorCode.
StateDescription
IdleNo session active (initial state).
CapturingFrontCamera open for the front face or passport bio-page.
CapturingBackFront captured; camera open for the rear face (not used for passports).
AnalyzingImages acquired; OCR / barcode extraction in progress.
Success(document)Capture and extraction complete.
Error(error)Session failed. See DocumentCaptureErrorCode.

8.1.2 Capturing a National ID Card

The ID card flow captures front and back sides and extracts the MRZ from the back (TD1 format: 3 lines ร— 30 characters).

kotlin
import com.credenceid.wallet.sdk.domain.model.documentcapture.*

val engine = sdk.documentCaptureEngine

// Step 1 โ€” collect state changes to drive the UI.
viewModelScope.launch {
    engine.captureState.collectLatest { state ->
        when (state) {
            is DocumentCaptureState.Idle          -> showIdle()
            is DocumentCaptureState.CapturingFront -> showGuidance("Place the front of your ID in the frame")
            is DocumentCaptureState.CapturingBack  -> showGuidance("Now flip your ID over")
            is DocumentCaptureState.Analyzing      -> showSpinner("Reading document dataโ€ฆ")

            is DocumentCaptureState.Success -> {
                val doc = state.document

                // Full-resolution JPEG images โ€” send to your DocAuth / verification service.
                uploadToVerificationService(
                    front = doc.frontImageBytes,
                    back  = doc.backImageBytes,
                )

                // Parsed MRZ data (TD1) for pre-filling the provisioning form.
                when (val data = doc.extractedData) {
                    is DocumentExtractedData.Mrz -> {
                        autofillForm(
                            givenNames     = data.givenNames,
                            surname        = data.surname,
                            documentNumber = data.documentNumber,
                            dateOfBirth    = data.dateOfBirth,    // YYMMDD
                            dateOfExpiry   = data.dateOfExpiry,   // YYMMDD
                            nationality    = data.nationality,    // ISO 3166-1 alpha-3
                        )
                    }
                    null -> { /* extraction not applicable for this document */ }
                    else -> { /* unexpected type โ€” handle gracefully */ }
                }
            }

            is DocumentCaptureState.Error -> {
                when (state.error.code) {
                    DocumentCaptureErrorCode.PERMISSION_DENIED  -> requestCameraPermission()
                    DocumentCaptureErrorCode.GLARE_DETECTED     -> showHint("Move away from direct light and try again")
                    DocumentCaptureErrorCode.EDGE_NOT_DETECTED  -> showHint("Keep the entire card within the frame")
                    DocumentCaptureErrorCode.CAPTURE_TIMEOUT    -> showRetryDialog()
                    DocumentCaptureErrorCode.LOW_QUALITY        -> showRetryDialog("Image quality too low. Try again in better light.")
                    else -> showError(state.error.technicalMessage)
                }
            }
        }
    }
}

// Step 2 โ€” start the capture session.
// platformContext must be a FragmentActivity instance on Android.
engine.startCapture(
    platformContext = requireActivity(), // must be FragmentActivity
    parameters = DocumentCaptureParameters(
        documentType = CaptureDocumentType.ID_CARD,
        sides        = setOf(DocumentSide.FRONT, DocumentSide.BACK),
    ),
)

8.1.3 Capturing a Passport

Passport capture scans only the bio-page and extracts the MRZ in TD3 format (2 lines ร— 44 characters). backImageBytes will be null for passports.

kotlin
engine.startCapture(
    platformContext = requireActivity(),
    parameters = DocumentCaptureParameters(
        documentType = CaptureDocumentType.PASSPORT,
        sides        = setOf(DocumentSide.BIO_PAGE),
    ),
)

// In DocumentCaptureState.Success:
// doc.backImageBytes == null
// doc.extractedData  == DocumentExtractedData.Mrz (TD3 format)
//   mrz.rawLines contains the original 2 OCR lines for downstream DocAuth services.

8.1.4 Capturing a Driving Licence

Driving licence capture reads the PDF417 barcode from the rear of the card. The raw barcode payload (AAMVA or equivalent standard) is provided as a String for forwarding to your backend parser.

kotlin
engine.startCapture(
    platformContext = requireActivity(),
    parameters = DocumentCaptureParameters(
        documentType = CaptureDocumentType.DRIVING_LICENSE,
        sides        = setOf(DocumentSide.FRONT, DocumentSide.BACK),
    ),
)

// In DocumentCaptureState.Success:
// doc.extractedData == DocumentExtractedData.Pdf417
//   pdf417.rawData  == the complete AAMVA barcode string payload

Cancelling a session:

kotlin
// Safe to call at any time โ€” resets captureState to Idle.
engine.cancelCapture()

8.1.5 iOS / Swift Integration

StateFlow is automatically bridged to Swift's native AsyncSequence via the SKIE framework bundled in the XCFramework. No manual conversion is needed:

swift
let engine = walletSdk.documentCaptureEngine

Task {
    for await state in engine.captureState {
        switch state {
        case is DocumentCaptureStateIdle:
            showIdle()
        case is DocumentCaptureStateCapturingFront:
            showGuidance("Place the front of your ID in the frame")
        case is DocumentCaptureStateCapturingBack:
            showGuidance("Now flip your ID over")
        case is DocumentCaptureStateAnalyzing:
            showSpinner()
        case let success as DocumentCaptureStateSuccess:
            handleDocument(success.document)
        case let error as DocumentCaptureStateError:
            handleError(error.error.code.name)
        default:
            break
        }
    }
}

// Start capture
engine.startCapture(
    platformContext: nil, // iOS โ€” pass nil; context is resolved internally
    parameters: DocumentCaptureParameters(
        documentType: .idCard,
        sides: [.front, .back]
    )
)

8.2 MRTD / NFC Passport Reading

The MrtdReaderEngine reads NFC chips embedded in ICAO 9303-compliant passports and travel documents, extracting MRZ data (DG1) and the face biometric image (DG2) using the BAC or PACE authentication protocol.

Read state lifecycle:

text
Idle โ†’ WaitingForChip โ†’ Authenticating โ†’ Reading โ†’ Success
                                                  โ””โ”€ Error
kotlin
// Requires: MRZ data extracted from the document (e.g. via DocumentCaptureEngine first)
val mrtdEngine = sdk.mrtdReaderEngine

viewModelScope.launch {
    mrtdEngine.readState.collectLatest { state ->
        when (state) {
            is MrtdReadState.Idle            -> showIdle()
            is MrtdReadState.WaitingForChip  -> showNfcPrompt("Hold your phone to the passport chip")
            is MrtdReadState.Authenticating  -> showSpinner("Authenticatingโ€ฆ")
            is MrtdReadState.Reading         -> showSpinner("Reading chip dataโ€ฆ")

            is MrtdReadState.Success -> {
                val result = state.result
                // Structured MRZ fields
                val name = "${result.dg1Data.givenNames} ${result.dg1Data.surname}"
                // Raw face biometric image (JPEG) โ€” pass to your server for matching
                uploadFaceImage(result.dg2FaceImage)
            }

            is MrtdReadState.Error -> showError(state.error.technicalMessage)
        }
    }
}

// Start reading โ€” pass MRZ fields extracted from the passport scan
mrtdEngine.startReading(
    platformContext = requireActivity(),
    parameters = MrtdReadParameters(
        documentNumber = mrz.documentNumber,   // from DocumentExtractedData.Mrz
        dateOfBirth    = mrz.dateOfBirth,      // YYMMDD format
        dateOfExpiry   = mrz.dateOfExpiry,     // YYMMDD format
    ),
)

// Cancel if the user navigates away
mrtdEngine.cancelReading()
โœ…
Tip
Run DocumentCaptureEngine first (see Section 8.1) to extract the MRZ fields from the physical passport. Feed those directly into MrtdReadParameters to avoid manual data entry.

8.3 Liveness Capture

The liveness module (wallet-sdk-liveness) is an optional add-on for selfie capture. It is required when your issuer demands a selfie or biometric verification step during provisioning. Liveness scoring is performed server-side โ€” the module captures a JPEG frame using the device camera and returns it for upload to your backend.

Add the dependency:

kotlin
implementation("com.credenceid:wallet-sdk-liveness:1.0.0-SNAPSHOT")
โ„น๏ธ
Important lifecycle constraint
The StandardCameraEngine must be instantiated in Activity.onCreate() โ€” before onStart(). It registers an ActivityResultLauncher internally, and Android requires this registration to happen before the Activity starts.
kotlin
class MyActivity : FragmentActivity() {

    // Declare as a property โ€” created in onCreate before onStart.
    private lateinit var livenessEngine: StandardCameraEngine

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        livenessEngine = StandardCameraEngine(this) // โ† must be before setContent / onStart
        setContent { MyApp(engine = livenessEngine) }
    }
}

// In a ViewModel or composable โ€” trigger capture when the user reaches the selfie step:
viewModelScope.launch {
    runCatching { livenessEngine.capture() }
        .onSuccess { result ->
            val jpeg = (result as LivenessCaptureResult.Standard).jpegBytes
            // Send the JPEG to your backend for server-side liveness scoring.
            uploadSelfie(jpeg)
        }
        .onFailure { error ->
            // CaptureException is thrown if the user cancels or a hardware error occurs.
            showError("Capture failed: ${error.message}")
        }
}
09

Security (RASP)

The :security module provides Runtime Application Self-Protection (RASP). It monitors the device environment at runtime for threats that could compromise credential security.

Threat catalog:

Code
1001
Threat
Root / Jailbreak
Default behavior
Blocking
Code
1002
Threat
Emulator / Simulator
Default behavior
Blocking
Code
1010
Threat
Hooking framework (Frida, Xposed)
Default behavior
Blocking
Code
1011
Threat
Debugger attached
Default behavior
Configurable
Code
1020
Threat
UI overlay / tapjacking
Default behavior
Observe-only
CodeThreatDefault behavior
1001Root / JailbreakBlocking
1002Emulator / SimulatorBlocking
1010Hooking framework (Frida, Xposed)Blocking
1011Debugger attachedConfigurable
1020UI overlay / tapjackingObserve-only

9.1 Configuration

Initialize RASP once in your Application.onCreate():

kotlin
import com.credenceid.wallet.sdk.security.*

class MyApplication : Application() {

    override fun onCreate() {
        super.onCreate()

        val config = RaspConfig.Builder()
            .enableRootDetection()
            .enableEmulatorDetection()
            .enableHookingDetection()
            // enforceBlock = false makes debugger detection observe-only in CI/debug builds:
            .enableDebuggerDetection(enforceBlock = BuildConfig.DEBUG.not())
            .enableOverlayDetection()
            .enableSecureWindowFlag() // Applies FLAG_SECURE to every Activity automatically
            .build()

        // RaspPlatformContext wraps applicationContext โ€” do not pass `this` directly.
        RaspMonitor.initialize(RaspPlatformContext(applicationContext), config)
        RaspMonitor.start()
    }
}

iOS (AppDelegate / SceneDelegate):

swift
let config = RaspConfigBuilder()
    .enableRootDetection()
    .enableEmulatorDetection()
    .enableHookingDetection()
    .enableDebuggerDetection()
    .enableOverlayDetection()
    .build()

RaspMonitor.shared.initialize(context: RaspPlatformContext(), config: config)
RaspMonitor.shared.start()

9.2 Observing Violations

RaspMonitor.violations is a hot SharedFlow. Collect it in a lifecycle-aware scope:

kotlin
lifecycleScope.launch {
    RaspMonitor.violations.collect { violation ->
        // Log the threat internally โ€” never expose raw codes or details to end-users.
        logger.warn("RASP violation: ${violation.threat.code}")

        when (violation.threat) {
            is RaspThreat.RootedDevice            -> blockSdkOperations()
            is RaspThreat.HookingFrameworkDetected -> blockSdkOperations()
            is RaspThreat.EmulatorDetected         -> blockSdkOperations()
            is RaspThreat.OverlayDetected          -> showOverlayWarningDialog()
            is RaspThreat.DebuggerAttached         -> logAndMonitor()
        }
    }
}

9.3 Point-in-Time Checks

Run a synchronous check immediately before any sensitive operation (credential signing, biometric prompt):

kotlin
fun onPresentCredentialClicked() {
    if (!RaspMonitor.runImmediateCheck()) {
        // A blocking threat is active โ€” abort the operation.
        showSecurityError("This operation is not available on this device.")
        return
    }
    // Safe to proceed.
    startPresentation()
}

Shutting down RASP:

kotlin
// In Application.onTerminate() or your ViewModel's onCleared():
RaspMonitor.stop()
10

Logging

The :logging module provides two independent log pipelines that satisfy ISO/IEC 27002 Control 8.15 and ISO 18013-5 Annex C audit requirements.

Pipeline
DiagnosticLogger
Purpose
Developer-facing structured records, filterable by severity
Mandatory in production
No
Pipeline
AuditLogger
Purpose
Compliance-critical immutable audit trail โ€” always emitted
Mandatory in production
Yes
PipelinePurposeMandatory in production
DiagnosticLoggerDeveloper-facing structured records, filterable by severityNo
AuditLoggerCompliance-critical immutable audit trail โ€” always emittedYes

10.1 Setting Up the Logger

Configure the logger once in Application.onCreate() before any concurrent SDK usage:

kotlin
import com.credenceid.wallet.sdk.SdkLogger
import com.credenceid.wallet.sdk.logging.LogLevel

class MyApplication : Application() {

    override fun onCreate() {
        super.onCreate()

        SdkLogger.Builder()
            .setDiagnosticLogSink { json ->
                // Route to Logcat in debug builds โ€” do not use in production.
                if (BuildConfig.DEBUG) Log.d("WalletSDK", json)
            }
            .setAuditLogSink { json ->
                // MANDATORY in production โ€” route to an append-only, tamper-evident store.
                // Examples: write-once Room table, signed log stream, SIEM endpoint.
                myAuditRepository.persist(json)
            }
            .setMinDiagnosticLevel(
                if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN
            )
            .setSdkVersion(BuildConfig.WALLET_SDK_VERSION)
            .build()
    }
}
โ„น๏ธ
Audit log is mandatory in production
Operating without a durable AuditLogSink in production is a regulatory violation under ISO 18013-5 and ISO/IEC 27002. The audit sink must write to an append-only medium โ€” records must not be modifiable or deletable after writing.

iOS:

swift
SdkLoggerBuilder()
    .setDiagnosticLogSink { json in
        os_log("%{public}@", log: .default, type: .debug, json)
    }
    .setAuditLogSink { json in
        SecureAuditStore.shared.append(json)
    }
    .setMinDiagnosticLevel(.info)
    .build()

10.2 Log Levels

Level
VERBOSE
Use case
State transitions, byte counts, retry attempts
Recommended in production
โŒ Never
Level
DEBUG
Use case
Parsed responses, resolved endpoints
Recommended in production
โŒ Never
Level
INFO
Use case
SDK initialised, token refreshed, credential stored
Recommended in production
โœ… Low-volume use
Level
WARN
Use case
Recoverable anomalies, deprecated API usage
Recommended in production
โœ… Recommended
Level
ERROR
Use case
Non-recoverable failures
Recommended in production
โœ… Always
LevelUse caseRecommended in production
VERBOSEState transitions, byte counts, retry attemptsโŒ Never
DEBUGParsed responses, resolved endpointsโŒ Never
INFOSDK initialised, token refreshed, credential storedโœ… Low-volume use
WARNRecoverable anomalies, deprecated API usageโœ… Recommended
ERRORNon-recoverable failuresโœ… Always
โœ…
Note
setMinDiagnosticLevel has no effect on audit records โ€” audit records are always emitted regardless of level.

10.3 Privacy with Redacted<T>

Wrap sensitive values at the point of acquisition to prevent accidental PII leakage in logs:

kotlin
// Wrap immediately when received:
val accessToken: Redacted<String> = oauthResponse.token.redact()

// Safe to log โ€” always outputs "***REDACTED***", never the actual value:
DiagnosticLogger.d("OidcClient") { "Token acquired: $accessToken" }

// Unwrap only at the single call site that requires the raw value:
httpClient.setBearer(accessToken.unwrap())
// Every unwrap() site must be code-reviewed and justified in a comment.
11

Error Handling Patterns

All engine operations return kotlin.Result<T>. The SDK follows a consistent pattern across all engines:

kotlin
// Pattern 1 โ€” onSuccess / onFailure callbacks (recommended for simple cases)
sdk.provisioningEngine.fetchReadyCredential(uri)
    .onSuccess { credential -> updateUi(credential) }
    .onFailure { error -> showError(error.message) }

// Pattern 2 โ€” fold() for exhaustive handling
val result = sdk.storageEngine.deleteCredential(id)
result.fold(
    onSuccess = { showToast("Deleted.") },
    onFailure = { showError("Failed: ${it.message}") },
)

// Pattern 3 โ€” structured error handling by type
sdk.provisioningEngine.fetchReadyCredential(uri)
    .onFailure { throwable ->
        when (throwable) {
            is ProvisioningError -> when (throwable.code) {
                ProvisioningErrorCode.TIMEOUT       -> retryLater() // isRecoverable = true
                ProvisioningErrorCode.NETWORK_ERROR -> showNetworkError()
                ProvisioningErrorCode.INVALID_OFFER -> showError("Invalid credential offer")
                else                                -> logAndReport(throwable)
            }
            is CancellationException -> throw throwable // always re-throw coroutine cancellation
            else                     -> logAndReport(throwable)
        }
    }

PresentationError and DocumentCaptureError also carry an isRecoverable flag:

kotlin
is PresentationState.Error -> {
    val err = state.error
    if (err.isRecoverable) {
        // Offer the user a retry without restarting the full presentation flow.
        showRetryButton()
    } else {
        // Unrecoverable โ€” close the session and return to the credential list.
        showError("${err.code.name}: ${err.technicalMessage}")
        navigateBack()
    }
}
โ„น๏ธ
Never display technicalMessage to end-users
This field is for internal logs and crash reporting only. Map code enum values to user-friendly strings in your own string resources.
12

Architecture Reference

The SDK follows Clean Architecture (Hexagonal / Onion) with strict inward dependency flow:

text
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Your Application                                           โ”‚
โ”‚  ViewModels, Composables, Activities, Fragments             โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  Engine Interfaces (Domain Layer โ€” commonMain)              โ”‚
โ”‚  ProvisioningEngine ยท StorageEngine ยท PresentationEngine    โ”‚
โ”‚  DocumentCaptureEngine ยท MrtdReaderEngine                   โ”‚
โ”‚                                                             โ”‚
โ”‚  Domain Models (pure Kotlin โ€” no framework imports)         โ”‚
โ”‚  DigitalCredential ยท PresentationState ยท ClaimRequest โ€ฆ     โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  Engine Implementations (Data Layer)                        โ”‚
โ”‚  androidMain: WalletSdkImpl, WalletPresentmentActivity โ€ฆ    โ”‚
โ”‚  iosMain:     PassportChipReaderProvider โ€ฆ                  โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  Platform                                                   โ”‚
โ”‚  Android KeyStore ยท iOS Secure Enclave ยท NFC ยท BLE          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Dependency injection: Engines are wired internally via a SdkContainer (service locator pattern) and exposed through WalletSdkImpl. You never instantiate engine classes directly โ€” always access them via the WalletSdk singleton.

Coroutines: All suspension points are suspend fun and safe to call from any CoroutineScope. Use viewModelScope in ViewModels and lifecycleScope in Activities/Fragments. Never call suspend functions from GlobalScope.

Thread safety: WalletSdk.getInstance() is thread-safe via double-checked locking. StateFlow values are safe to read from any thread.

13

Testing

Running the Sample App

bash
# Install the Android sample app
./gradlew :walletsdksample:installDebug

# Run all unit tests with coverage
./gradlew test

# Generate an HTML coverage report
./gradlew koverHtmlReport

Unit Testing Your Integration Code

The SDK follows the Arrange-Act-Assert pattern. Use MockK for mocking engine interfaces:

kotlin
import io.mockk.*
import com.credenceid.wallet.sdk.domain.engine.StorageEngine
import com.credenceid.wallet.sdk.domain.model.DigitalCredential
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo

class WalletViewModelTest {

    private val mockStorage = mockk<StorageEngine>()
    private val credentialFlow = MutableStateFlow<List<DigitalCredential>>(emptyList())

    @BeforeEach
    fun setUp() {
        every { mockStorage.storedCredentials } returns credentialFlow
    }

    @Test
    fun `credential list updates when new credential is added`() = runTest {
        // Arrange
        val credential = DigitalCredential(
            id                 = "test-id",
            type               = "org.iso.18013.5.1.mDL",
            provisioningStatus = ProvisioningStatus.VALID,
            displayMetadata    = mapOf("displayName" to "My Driver's License"),
        )

        // Act
        credentialFlow.emit(listOf(credential))

        // Assert
        mockStorage.storedCredentials.value.size shouldBeEqualTo 1
        mockStorage.storedCredentials.value.first().id shouldBeEqualTo "test-id"
    }
}

RASP in CI Environments

Disable emulator detection when running tests in CI (emulators will trigger RASP_ENV_EMULATOR):

kotlin
val config = RaspConfig.Builder()
    .enableRootDetection()
    .enableEmulatorDetection(enforceBlock = false) // observe-only in CI
    .enableHookingDetection()
    .enableDebuggerDetection(enforceBlock = false) // observe-only in CI
    .build()
14

Troubleshooting

Transitive dependencies are missing after adding the SDK

You are likely using @aar notation or the -android-release variant. Use the root coordinate only:

kotlin
// โœ… Correct
implementation("com.credenceid:wallet-sdk:1.0.0-SNAPSHOT")

DC API presentation does not trigger (browser flow)

  • Verify the device is running Android API 29+ and Chrome 128+.
  • Confirm Google Play Services is up to date.
  • Ensure at least one credential has been provisioned with VALID status โ€” the SDK only registers VALID credentials with Credential Manager.

startCapture() crashes with IllegalStateException

The platformContext parameter must be a FragmentActivity instance, not a ComponentActivity or Context. Pass requireActivity() from a Fragment or this from your FragmentActivity.

StandardCameraEngine.capture() throws ActivityResultLauncher not registered

The engine must be created in Activity.onCreate() before onStart(). Move the StandardCameraEngine(this) call to onCreate().

Audit log records are not persisted

Ensure you have called SdkLogger.Builder().setAuditLogSink { ... }.build() before any concurrent SDK usage. Calling it after the SDK starts processing requests may result in dropped records.

RASP runImmediateCheck() returns false on an emulator during development

Set enforceBlock = false on enableEmulatorDetection() during development and CI builds. This makes violations observable without blocking operations.

iOS framework linker error: duplicate symbols

The framework is a static XCFramework. In Target โ†’ General โ†’ Frameworks, Libraries, and Embedded Content, set it to Do Not Embed.