Skip to main content
Version: v0.10

Event and UI handling

Overview

This guide covers two approaches for integrating mdoc engagement and transfer events into your UI:

The Session UI Projection layer provides a simplified, opinionated view of the mdoc engagement and transfer lifecycle, specifically designed for quick UI integration. Instead of handling complex raw engagement and retrieval events, UIs can bind to a single state flow (sessionState) that provides deterministic, UI-friendly state information.

Best for:

  • Standard wallet applications with typical UI patterns
  • Quick integration with sensible defaults
  • Developers who want deterministic QR visibility and phase transitions
  • Teams that prefer a single state object over coordinating multiple event streams

2. Direct EventHub Access (Advanced)

For applications requiring fine-grained control, you can bypass the UI projection layer and access raw events directly from the MdocEventHub. This approach gives you complete control over event handling and state management.

Best for:

  • Integration with existing state management frameworks (Redux, MVI, etc.)
  • Custom UI logic that differs from the opinionated projection
  • Applications that need to track multiple engagements simultaneously
  • Advanced debugging and event inspection
  • Full control over every event in the lifecycle

Implementation details:

  • SessionUiState.kt - All types implemented and verified
  • SessionUiProjector.kt - Full projection logic implemented
  • MdocEventHub.kt - Exposes both sessionState/sessionEvents (projection) and allEvents/engagementEvents/transferEvents (direct access)
  • MdocEngagementManager.kt The starting point providing access to the above

Key Concepts

Design Principles

  1. Deterministic State Transitions - Clear, predictable phase progression: ENGAGEMENT → TRANSFER → TERMINAL
  2. Single Source of Truth - One state flow to bind, no need to coordinate multiple event streams
  3. UI-Centric Events - Simplified events that map directly to UI actions
  4. Built-in Logic - QR visibility, suspension tracking, and progress hints handled automatically
  5. Non-Breaking - Optional layer on top of existing event system

Architecture

Approach 1: UI Projection (Recommended)

Raw Events (Engagement + Retrieval)

SessionUiProjector

SessionEvent (simplified)

SessionUiState (deterministic)

Your UI

Approach 2: Direct EventHub Access

MdocEngagementManager

MdocEventHub

┌───────┴───────┐
│ │
allEvents engagementEvents + transferEvents
│ │
└───────┬───────┘

Your Custom State Management

Your UI

Both approaches are fully supported and can be used simultaneously if needed (e.g., using sessionState for main UI while observing raw events for analytics).

Core Types

UiPhase

High-level phases of the session lifecycle:

  • ENGAGEMENT - QR display, NFC handover preparation
  • TRANSFER - Connection, data exchange, user interaction
  • TERMINAL - Session finished (success, error, declined, etc.)

TerminalOutcome

Classification of terminal states for appropriate UI feedback:

  • SUCCESS - Transfer completed successfully, data was shared
  • DECLINED - User declined to share the requested data
  • CANCELED - Session was canceled (by user or system)
  • ERROR - An error occurred during the session

NfcMode

NFC engagement state for UI display logic, determined by whether an NFC engagement exists and whether it is the active engagement:

  • DISABLED - No NFC engagement exists
  • BACKGROUND - NFC engagement exists but is not the active engagement
  • FOREGROUND - NFC engagement exists and is the active engagement

QrMode

QR display state for UI:

  • NONE - No QR engagement active, no scanning in progress
  • DISPLAY - Regular QR engagement active (display QR code for reader to scan)
  • SCAN - UI-managed scanning mode (user is scanning reader's QR code)

IMPORTANT: SCAN mode is different from the other modes:

  • NONE and DISPLAY are set automatically by the projector based on engagements
  • SCAN is UI-managed and set manually by your code
  • Scanning happens before creating a TO_APP engagement
  • The UI opens a scanner, user scans to get a URI, then UI creates the engagement

SessionUiState

The main state object containing all UI-relevant information:

data class SessionUiState(
val phase: UiPhase, // Current phase
val substateLabel: String, // Human-readable label
val nfcMode: NfcMode, // NFC mode (DISABLED/BACKGROUND/FOREGROUND)
val qrMode: QrMode, // QR mode (NONE/DISPLAY/SCAN)
val isSuspended: Boolean, // Engagement suspended?
val progressHint: Float?, // Progress (0.0-1.0)
val terminalOutcome: TerminalOutcome?, // Terminal classification
val terminalMessage: String?, // Terminal details
val userInteractionRequired: Boolean, // User must act?
val deviceRequest: ByteArray? // Device Request from Reader to review
)

SessionEvent

Simplified events that drive state transitions. These are mapped from raw MdocEngagementEvent and MdocRetrievalEvent:

  • QrShow - QR code is being shown (from MdocEngagementEvent.QrShow)
  • NfcPromptShown - NFC prompt should be shown (from MdocEngagementEvent.NfcEngagement)
  • NfcHandoverSuccess - NFC handover succeeded (from MdocEngagementEvent.Connected when NFC)
  • TransferConnecting - Transfer is connecting (from MdocEngagementEvent.Connecting or MdocRetrievalEvent.TransmissionTypeSelected)
  • TransferConnected - Transfer connected successfully (from MdocEngagementEvent.Connected non-NFC or retrieval connected)
  • UserInteractionRequired(deviceRequest) - User must review and accept/decline (from MdocRetrievalEvent.DocumentsSelectionProcessStart)
  • UserAccepted - User accepted the document request (from MdocRetrievalEvent.DocumentsSelectionProcessAccepted)
  • UserDeclined - User declined the document request (from MdocRetrievalEvent.DocumentsSelectionProcessDeclined)
  • TransferProgress(fraction) - Transfer progress update (from MdocRetrievalEvent.SessionDataReceived at 0.3, SessionDataSend at 0.7)
  • Terminal(outcome, message) - Session reached terminal state (from various terminal events with appropriate outcome classification)

Usage Examples

Real-World Example: For a complete sample app implementation using sessionState and the UI projection layer, see the Kiwa Sample Holder App.

This example demonstrates:

  • Collecting sessionState with collectAsState() in Jetpack Compose
  • Handling all three phases (ENGAGEMENT, TRANSFER, TERMINAL)
  • QR code generation based on qrMode
  • User consent dialogs with document selection
  • Proper cleanup and error handling
  • Integration with a sample document provider
class WalletActivity : AppCompatActivity() {
private lateinit var manager: MdocEngagementManager
private var currentTransferManager: TransferManager? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Track active transfer manager
lifecycleScope.launch {
manager.activeEngagement.collect { engagement ->
currentTransferManager = engagement?.transferManager
}
}

// Collect session state
lifecycleScope.launch {
manager.eventHub.sessionState.collect { state ->
updateUI(state)
}
}
}

private fun updateUI(state: SessionUiState) {
when (state.phase) {
UiPhase.ENGAGEMENT -> handleEngagement(state)
UiPhase.TRANSFER -> handleTransfer(state)
UiPhase.TERMINAL -> handleTerminal(state)
}
}

private fun handleEngagement(state: SessionUiState) {
// Update status
statusText.text = state.substateLabel

// Handle QR based on mode
when (state.qrMode) {
QrMode.DISPLAY -> {
// Show QR code for reader to scan
qrCodeView.visibility = View.VISIBLE
qrScannerView.visibility = View.GONE
showQrButton.text = "Show QR"
}
QrMode.SCAN -> {
// Show scanner for scanning reader's QR
qrCodeView.visibility = View.GONE
qrScannerView.visibility = View.VISIBLE
showQrButton.text = "Scan QR"
}
QrMode.NONE -> {
qrCodeView.visibility = View.GONE
qrScannerView.visibility = View.GONE
}
}

// Handle NFC based on mode
when (state.nfcMode) {
NfcMode.DISABLED -> {
// No NFC available
nfcPrompt.visibility = View.GONE
nfcIcon.visibility = View.GONE
}
NfcMode.BACKGROUND -> {
// Show icon but not active prompt
nfcPrompt.visibility = View.GONE
nfcIcon.visibility = View.VISIBLE
nfcIcon.alpha = 0.5f // Dimmed to indicate background
}
NfcMode.FOREGROUND -> {
// Show active "tap to share" prompt
nfcPrompt.visibility = View.VISIBLE
nfcIcon.visibility = View.VISIBLE
nfcIcon.alpha = 1.0f
nfcPrompt.text = "Hold near reader to share"
}
}

// Handle suspension
if (state.isSuspended) {
overlayView.visibility = View.VISIBLE
statusText.text = "Suspended - another session active"
}
}

private fun handleTransfer(state: SessionUiState) {
statusText.text = state.substateLabel

// QR and scanner hidden during transfer
qrCodeView.visibility = View.GONE
qrScannerView.visibility = View.GONE

when {
state.userInteractionRequired -> {
// User must review and accept/decline
currentTransferManager?.let { transferMgr ->
showConsentDialog(state.deviceRequest!!, transferMgr)
}
}
state.progressHint != null -> {
// Show progress
progressBar.progress = (state.progressHint * 100).toInt()
progressBar.visibility = View.VISIBLE
}
else -> {
// Show spinner for connecting/processing
progressBar.isIndeterminate = true
progressBar.visibility = View.VISIBLE
}
}
}

private fun handleTerminal(state: SessionUiState) {
progressBar.visibility = View.GONE

when (state.terminalOutcome) {
TerminalOutcome.SUCCESS -> {
showSuccessDialog(
title = "Success!",
message = "Your data was shared successfully"
)
}
TerminalOutcome.DECLINED -> {
showInfoDialog(
title = "Request Declined",
message = "You chose not to share your data"
)
}
TerminalOutcome.CANCELED -> {
showInfoDialog(
title = "Canceled",
message = state.terminalMessage ?: "Session was canceled"
)
}
TerminalOutcome.ERROR -> {
showErrorDialog(
title = "Error",
message = state.terminalMessage ?: "An error occurred"
)
}
}
}

private fun showConsentDialog(deviceRequestBytes: ByteArray, transferManager: TransferManager) {
// Parse the device request using the helper extension
val tempState = SessionUiState(
phase = UiPhase.TRANSFER,
substateLabel = "",
nfcMode = NfcMode.DISABLED,
qrMode = QrMode.NONE,
isSuspended = false,
progressHint = null,
terminalOutcome = null,
terminalMessage = null,
userInteractionRequired = true,
deviceRequest = deviceRequestBytes
)
val parsed = tempState.parseDeviceRequest()

if (parsed == null) {
// Handle parse error
return
}

// Decode the device request for creating response
val deviceRequest = DeviceRequest.Decoder.decodeCbor(deviceRequestBytes)

// Display requested documents and attributes
val requestSummary = parsed.documents.joinToString("\n\n") { doc ->
val attrs = doc.nameSpaces.flatMap { (ns, attributes) ->
attributes.map { "$ns/$it" }
}
"${doc.docType}:\n${attrs.joinToString("\n")}"
}

AlertDialog.Builder(this)
.setTitle("Share your data?")
.setMessage("The verifier is requesting:\n\n$requestSummary")
.setPositiveButton("Share") { _, _ ->
// User accepted - create and send response with documents
lifecycleScope.launch {
val response = transferManager.createResponse(
deviceRequest = deviceRequest,
documentProvider = myDocumentProvider // Provides requested documents
)
transferManager.sendDeviceResponse(response)
// This will trigger DocumentsSelectionProcessAccepted event
}
}
.setNegativeButton("Decline") { _, _ ->
// User declined - close the session without sending documents
lifecycleScope.launch {
manager.closeAll()
// This will trigger DocumentsSelectionProcessDeclined event
}
}
.setCancelable(false)
.show()
}
}

State Transition Flow

QR Display Flow (Regular QR)

ENGAGEMENT (qrMode=DISPLAY, nfcMode=BACKGROUND)
↓ QrShow
ENGAGEMENT (qrMode=DISPLAY, "Scan the QR with reader")
↓ TransferConnecting
TRANSFER (qrMode=NONE, "Connecting…") ← QR automatically switched to NONE
↓ TransferConnected
TRANSFER ("Connected")
↓ UserInteractionRequired
TRANSFER (userInteractionRequired=true, "Review request")
↓ UserAccepted
TRANSFER (userInteractionRequired=false, "Sharing credentials…")
↓ TransferProgress(0.7)
TRANSFER (progressHint=0.7, "Transferring…")
↓ Terminal(SUCCESS, "Transfer completed successfully")
TERMINAL (terminalOutcome=SUCCESS, qrMode=NONE, "Completed")

Reverse/ToApp Flow (Scan Then Engage)

Important: Scanning happens before creating the engagement, so it's not reflected in session state.

[UI-managed scanning - not in sessionState]
↓ User opens scanner (your code manages this)
↓ User scans reader's QR code
↓ Get URI from scanned QR
↓ Create TO_APP engagement with URI
ENGAGEMENT (qrMode=NONE, nfcMode=BACKGROUND)
↓ TransferConnecting (engagement starts immediately)
TRANSFER (qrMode=NONE, "Connecting…")
↓ TransferConnected
TRANSFER ("Connected")
↓ [continues as normal transfer flow]

Note: The qrMode stays NONE throughout because there's no QR code to display. The scanning was done before the engagement existed.

NFC Foreground Flow

ENGAGEMENT (nfcMode=FOREGROUND, qrMode=NONE)
↓ NfcPromptShown
ENGAGEMENT ("Hold near reader")
↓ User taps phone to reader
↓ NfcHandoverSuccess
TRANSFER (nfcMode=FOREGROUND, "Connecting…")
↓ TransferConnected
TRANSFER ("Connected")
↓ UserInteractionRequired
TRANSFER (userInteractionRequired=true, "Review request")
↓ UserDeclined
TERMINAL (terminalOutcome=DECLINED, "User declined to share data")

NFC Background Flow (QR active, NFC available)

ENGAGEMENT (nfcMode=BACKGROUND, qrMode=DISPLAY)
↓ User shows QR to reader, but reader uses NFC instead
↓ NfcHandoverSuccess (NFC takes over)
TRANSFER (nfcMode=FOREGROUND, qrMode=NONE, "Connecting…")
↓ [continues as above]

Key Rules & Behaviors

NFC Mode Management

The nfcMode property automatically tracks NFC engagement status based on whether an NFC engagement exists and whether it is the currently active engagement:

DISABLED - No NFC engagement exists:

  • No NFC engagement has been created
  • Typical scenarios: iOS mdoc holder (NFC not available for holder apps), Android with NFC hardware disabled
  • UI should not show any NFC elements

BACKGROUND - NFC engagement exists but is not the active engagement:

  • An NFC engagement has been created but another engagement (QR or TO_APP) is currently active
  • The system can still respond to NFC taps, but focus is on the other engagement
  • UI should show a small NFC icon/indicator (dimmed/inactive state)
  • Don't show active "tap to share" prompt
  • User can tap their phone to a reader to switch NFC to foreground

FOREGROUND - NFC engagement exists and is the active engagement:

  • The NFC engagement is currently the active engagement (no other engagement is active, or NFC became active)
  • UI should show prominent "tap to share" prompt
  • This is the primary engagement method the user should use

QR Mode Management

The qrMode property tracks QR display state:

NONE - No QR engagement active (default state)

DISPLAY - Regular QR engagement active (ISO 18013-5)

  • Automatically set when QR engagement is created
  • Display QR code for reader to scan
  • Reader scans holder's QR

SCAN - UI-managed scanning mode (ISO 18013-7 pre-engagement)

  • NOT set automatically - you manage this yourself
  • Used for reverse/ToApp engagement flow
  • User scans reader's QR to get URI
  • Then you create TO_APP engagement with that URI

Important: For reverse/ToApp engagements, scanning happens before creating the engagement:

// Your own scanning state (not from sessionState)
var isScanningQr by remember { mutableStateOf(false) }

// User taps "Scan QR"
Button(onClick = { isScanningQr = true }) {
Text("Scan QR")
}

// Show scanner when YOUR state says so
if (isScanningQr) {
QrScanner { scannedUri ->
isScanningQr = false
// NOW create the engagement with scanned URI
manager.createEngagement(
EngagementType.TO_APP,
config.copy(readerEngagementUri = scannedUri)
)
// state.qrMode stays NONE (no QR to display)
}
}

// Show QR code when engagement says so
if (state.qrMode == QrMode.DISPLAY) {
QrCodeDisplay()
}

The projector only sets qrMode to DISPLAY (for QR engagements). You handle the scanning UI yourself.

User Interaction

  • userInteractionRequired=true when DocumentsSelectionProcessStart received
  • deviceRequest contains CBOR-encoded DeviceRequest for decoding
  • UI should display consent dialog and wait for user decision
  • User response handled by transfer manager (accept/decline methods)
  • UserAccepted → continue transfer
  • UserDeclined → immediate transition to TERMINAL with DECLINED outcome

Suspension

  • isSuspended mirrors activeEngagement?.isActive == false
  • Suspended engagements won't respond to connection attempts
  • UI should show overlay or disabled state when suspended

Progress Tracking

  • progressHint is null during connection/setup phases
  • progressHint is 0.0-1.0 during data transfer
  • Use indeterminate progress indicator when progressHint == null

Terminal States

Always check terminalOutcome to provide appropriate feedback:

  • SUCCESS → Show success message, confetti, checkmark
  • DECLINED → Show neutral message, explain user choice
  • CANCELED → Show neutral message, allow retry
  • ERROR → Show error dialog with details, offer retry

Advanced Usage

Observing Raw Events (Optional)

If you need access to detailed events alongside the UI state:

// Observe simplified session events
launch {
manager.eventHub.sessionEvents.collect { event ->
when (event) {
is SessionEvent.UserInteractionRequired -> {
logAnalytics("consent_shown")
}
is SessionEvent.Terminal -> {
logAnalytics("session_ended", mapOf(
"outcome" to event.outcome.name
))
}
else -> {}
}
}
}

// Observe UI state
launch {
manager.eventHub.sessionState.collect { state ->
updateUI(state)
}
}

Direct EventHub Access (Alternative to UI Projection)

If you need more control or prefer to build your own state management, you can bypass the UI projection layer entirely and access the raw event streams directly from the EventHub. The EventHub provides powerful filtering capabilities through built-in methods and standard Flow operators:

class CustomStateManager(private val manager: MdocEngagementManager) {
private val eventHub = manager.eventHub

// Build your own state management by observing raw events
fun observeEvents() {
// Option 1: Observe all events (engagement + transfer)
lifecycleScope.launch {
eventHub.allEvents.collect { event ->
when (event) {
is MdocEngagementEvent.QrShow -> handleQrShow(event)
is MdocEngagementEvent.Connected -> handleConnected(event)
is MdocRetrievalEvent.DocumentsSelectionProcessStart -> handleUserConsent(event)
is MdocRetrievalEvent.SessionDataSend -> handleTransferComplete(event)
// ... handle all other events
}
}
}

// Option 2: Observe engagement events only
lifecycleScope.launch {
eventHub.engagementEvents.collect { event ->
when (event) {
is MdocEngagementEvent.QrShow -> {
// Show QR code
displayQrCode(event.qrCodeData)
}
is MdocEngagementEvent.NfcEngagement -> {
// Show NFC prompt
showNfcPrompt()
}
is MdocEngagementEvent.Connecting -> {
// Connection starting
showConnectingState()
}
is MdocEngagementEvent.Connected -> {
// Connection established
showConnectedState()
}
is MdocEngagementEvent.Error -> {
// Handle error
showError(event.reason)
}
// ... other engagement events
}
}
}

// Option 3: Observe transfer/retrieval events only
lifecycleScope.launch {
eventHub.transferEvents.collect { event ->
when (event) {
is MdocRetrievalEvent.TransmissionTypeSelected -> {
// Transfer type selected
log("Transfer starting with ${event.transmissionType}")
}
is MdocRetrievalEvent.DocumentsSelectionProcessStart -> {
// User needs to consent
val deviceRequest = DeviceRequest.Decoder.decodeCbor(event.data)
showConsentDialog(deviceRequest)
}
is MdocRetrievalEvent.DocumentsSelectionProcessAccepted -> {
// User accepted
log("User accepted request")
}
is MdocRetrievalEvent.SessionDataSend -> {
// Data sent
log("Response sent to reader")
}
is MdocRetrievalEvent.SessionTerminationSend -> {
// Session completed successfully
showSuccess()
}
is MdocRetrievalEvent.Error -> {
// Transfer error
showError(event.error.message ?: "Transfer failed")
}
// ... other transfer events
}
}
}
}

// Use state tracking helpers for building custom state
fun trackState() {
lifecycleScope.launch {
// Track latest engagement events per engagement
eventHub.latestEventByEngagement().collect { eventsMap ->
eventsMap.forEach { (engagementId, event) ->
log("Engagement $engagementId: ${event::class.simpleName}")
}
}
}

lifecycleScope.launch {
// Track latest transfer events per engagement
eventHub.latestTransferEventByEngagement().collect { transfersMap ->
transfersMap.forEach { (engagementId, event) ->
log("Transfer for engagement $engagementId: ${event::class.simpleName}")
}
}
}

lifecycleScope.launch {
// Track current engagement state per engagement
eventHub.getCurrentStateByEngagement().collect { statesMap ->
statesMap.forEach { (engagementId, state) ->
log("Engagement $engagementId state: $state")
}
}
}

lifecycleScope.launch {
// Track current transfer state per engagement
eventHub.getCurrentTransferStateByEngagement().collect { transferStatesMap ->
transferStatesMap.forEach { (engagementId, state) ->
log("Transfer state for engagement $engagementId: $state")
}
}
}
}

// Built-in event filtering methods
fun useBuiltInFilters() {
lifecycleScope.launch {
// Filter: Only final state events (order >= 200)
// Detects when engagements or transfers finish
eventHub.finalStateEvents().collect { event ->
log("Session ended: ${event::class.simpleName}")
handleSessionCompletion(event)
}
}

lifecycleScope.launch {
// Filter: Only active transfer events (order 100-199)
// Track ongoing data exchange
eventHub.activeTransferEvents().collect { event ->
log("Active transfer: ${event::class.simpleName}")
updateTransferProgress(event)
}
}

lifecycleScope.launch {
// Filter: Events within custom state order range
// State orders: 0-99 (engagement), 100-199 (transfer), 200+ (final)
eventHub.eventsInStateRange(minOrder = 100, maxOrder = 150).collect { event ->
log("Mid-transfer event: ${event::class.simpleName}")
}
}

lifecycleScope.launch {
// Filter: Transfer completion events only
eventHub.onTransferCompletion.collect { event ->
log("Transfer completed: ${event::class.simpleName}")
handleCompletion(event)
}
}
}

// Standard Flow operators for custom filtering
fun customFiltering() {
lifecycleScope.launch {
// Filter by event type using Flow operators
eventHub.allEvents
.filter { it is MdocEngagementEvent.Connected || it is MdocEngagementEvent.Error }
.collect { event ->
log("Connection-related event: ${event::class.simpleName}")
}
}

lifecycleScope.launch {
// Filter by engagement ID
val targetEngagementId = manager.activeEngagement.value?.id
eventHub.allEvents
.filter { it.engagementId == targetEngagementId }
.collect { event ->
log("Event for active engagement: ${event::class.simpleName}")
}
}
}

// Access event history for debugging
fun debugEventHistory() {
// Get recent events from buffer
val recentEvents = eventHub.getRecentEvents(count = 20)
recentEvents.forEach { event ->
log("${event::class.simpleName} at ${event.time}")
}

// Get recent engagement events only
val recentEngagementEvents = eventHub.getRecentEngagementEvents(count = 10)
recentEngagementEvents.forEach { event ->
log("Engagement: ${event::class.simpleName}")
}

// Get recent transfer events only
val recentTransferEvents = eventHub.getRecentTransferEvents(count = 10)
recentTransferEvents.forEach { event ->
log("Transfer: ${event::class.simpleName}")
}

// Get complete event timeline with sequence numbers
val timeline = eventHub.getEventTimeline()
timeline.forEach { sequencedEvent ->
log("#${sequencedEvent.sequenceNumber}: ${sequencedEvent.event::class.simpleName}")
}

// Configure buffer size if needed
eventHub.setEventBufferSize(50)
val currentSize = eventHub.getEventBufferSize()
log("Event buffer size: $currentSize")
}

// State cleanup
fun cleanup() {
// Clear all state when closing all engagements
eventHub.clearAllState()

// Or clear state for a specific engagement
val engagementId = manager.activeEngagement.value?.id
if (engagementId != null) {
eventHub.clearStateForEngagement(engagementId)
}
}
}

When to use direct EventHub access:

  • You need fine-grained control over every event
  • You're integrating with existing state management (Redux, MVI, etc.)
  • You want to build custom UI logic that differs from the opinionated projection
  • You need to track multiple engagements simultaneously with custom logic
  • You're debugging complex state transitions

When to use UI Projection (sessionState/sessionEvents):

  • You want quick, simple integration with sensible defaults
  • You're building a standard mdoc holder wallet UI
  • You want deterministic QR visibility and phase transitions
  • You prefer a single state object over coordinating multiple event streams

Example: Custom State Machine using EventHub

class CustomStateMachine(private val eventHub: MdocEventHub) {
sealed class AppState {
object Idle : AppState()
data class ShowingQR(val qrData: String, val hasNfcBackground: Boolean) : AppState()
object Connecting : AppState()
data class WaitingForConsent(val request: DeviceRequest) : AppState()
object Transferring : AppState()
data class Completed(val success: Boolean, val message: String?) : AppState()
}

private val _state = MutableStateFlow<AppState>(AppState.Idle)
val state: StateFlow<AppState> = _state.asStateFlow()

init {
// Build state machine from raw events
lifecycleScope.launch {
eventHub.allEvents.collect { event ->
_state.value = when (event) {
is MdocEngagementEvent.QrShow -> {
val hasNfc = eventHub.getCurrentStateByEngagement().value.any { (_, state) ->
state.name.contains("NFC", ignoreCase = true)
}
AppState.ShowingQR(event.qrCodeData, hasNfc)
}
is MdocEngagementEvent.Connecting -> AppState.Connecting
is MdocRetrievalEvent.DocumentsSelectionProcessStart -> {
AppState.WaitingForConsent(DeviceRequest.Decoder.decodeCbor(event.data))
}
is MdocRetrievalEvent.SessionDataSend -> AppState.Transferring
is MdocRetrievalEvent.SessionTerminationSend -> {
AppState.Completed(success = true, message = "Transfer successful")
}
is MdocRetrievalEvent.Error -> {
AppState.Completed(success = false, message = event.error.message)
}
else -> _state.value // Keep current state for other events
}
}
}
}
}

Handling Multiple Sessions

The UI projection automatically handles active engagement switching:

// Manager automatically updates sessionState based on activeEngagement
// You don't need to track which engagement is active
manager.eventHub.sessionState.collect { state ->
// This state is always for the currently active engagement
updateUI(state)

if (state.isSuspended) {
// Current engagement is suspended, another is active
showSuspendedOverlay()
}
}

Decoding Device Request

When userInteractionRequired=true, use the built-in helper:

// Simple parsing using the extension function
manager.eventHub.sessionState.collect { state ->
if (state.userInteractionRequired) {
val parsed = state.parseDeviceRequest()
if (parsed != null) {
// Display consent dialog
val summary = parsed.documents.joinToString("\n\n") { doc ->
val attrs = doc.nameSpaces.flatMap { (ns, attributes) ->
attributes.map { "$ns/$it" }
}
"${doc.docType}:\n${attrs.joinToString("\n")}"
}
showConsentDialog(summary)
} else {
// Handle parse error
showError("Invalid device request")
}
}
}

Common UI Patterns

Pattern 1: NFC-Only App (Android Wallet)

class WalletActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Create background NFC on app start
lifecycleScope.launch {
manager.createEngagement(EngagementType.NFC, nfcConfig)
}

// Observe session state
lifecycleScope.launch {
manager.eventHub.sessionState.collect { state ->
when (state.nfcMode) {
NfcMode.DISABLED -> {
// Should not happen on Android, but handle it
showNfcDisabledDialog()
}
NfcMode.BACKGROUND -> {
// On secondary screens, show small NFC icon
nfcIcon.visibility = View.VISIBLE
nfcIcon.alpha = 0.5f
nfcPrompt.visibility = View.GONE
}
NfcMode.FOREGROUND -> {
// On main screen, show prominent NFC prompt
nfcIcon.visibility = View.VISIBLE
nfcIcon.alpha = 1.0f
nfcPrompt.visibility = View.VISIBLE
}
}
}
}
}
}

Pattern 2: QR-Primary with Background NFC (Android/iOS)

class ShareActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Create background NFC if available (Android only)
if (isAndroid && nfcAvailable) {
lifecycleScope.launch {
manager.createEngagement(EngagementType.NFC, nfcConfig)
}
}

// Main screen with QR buttons
showQrButton.setOnClickListener {
lifecycleScope.launch {
manager.createEngagement(EngagementType.QR, qrConfig)
}
}

scanQrButton.setOnClickListener {
// Open QR scanner (UI-managed, not engagement-driven)
openQrScanner { scannedUri ->
// After scanning, create TO_APP engagement with the URI
lifecycleScope.launch {
manager.createEngagement(
EngagementType.TO_APP,
toAppConfig.copy(readerEngagementUri = scannedUri)
)
}
}
}

// Observe session state
lifecycleScope.launch {
manager.eventHub.sessionState.collect { state ->
// Handle NFC indicator
when (state.nfcMode) {
NfcMode.DISABLED -> nfcIcon.visibility = View.GONE
NfcMode.BACKGROUND -> {
nfcIcon.visibility = View.VISIBLE
nfcIcon.alpha = 0.5f
}
NfcMode.FOREGROUND -> {
nfcIcon.visibility = View.VISIBLE
nfcIcon.alpha = 1.0f
}
}

// Handle QR modal (only DISPLAY mode shows engagement QR)
when (state.qrMode) {
QrMode.NONE -> {
hideQrModal()
}
QrMode.DISPLAY -> {
showQrDisplayModal()
}
QrMode.SCAN -> {
// SCAN is UI-managed, not set by projector
// This case typically won't occur from sessionState
}
}
}
}
}
}

Pattern 3: QR-Only App (iOS mdoc Holder)

class ShareViewController: UIViewController {
private var isScanning = false

override func viewDidLoad() {
super.viewDidLoad()

// No NFC on iOS holder - state.nfcMode will always be DISABLED

// Observe session state
Task {
for await state in manager.eventHub.sessionState {
await MainActor.run {
// NFC always disabled
nfcIcon.isHidden = true

// Handle engagement-driven QR display
if state.qrMode == .display {
showQrCodeView()
} else if !isScanning {
hideQrView()
}
}
}
}
}

@IBAction func showQrPressed() {
// Create QR engagement to display QR code
Task {
try await manager.createEngagement(type: .QR, config: qrConfig)
// state.qrMode will become DISPLAY
}
}

@IBAction func scanQrPressed() {
// Open scanner (UI-managed, before engagement)
isScanning = true
showQrScanner { [weak self] scannedUri in
guard let self = self else { return }
self.isScanning = false

// NOW create TO_APP engagement with scanned URI
Task {
try await self.manager.createEngagement(
type: .TO_APP,
config: self.toAppConfig.copy(readerEngagementUri: scannedUri)
)
// state.qrMode stays NONE (no QR to display)
}
}
}

private func showQrScanner(onScan: @escaping (String) -> Void) {
// Show camera-based QR scanner
let scanner = QRScannerViewController()
scanner.onScanComplete = onScan
present(scanner, animated: true)
}
}

Pattern 4: Modal QR with Persistent NFC

// Main activity with background NFC
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Background NFC throughout app lifetime
lifecycleScope.launch {
manager.createEngagement(EngagementType.NFC, nfcConfig)
}

showQrButton.setOnClickListener {
// Show modal fragment with QR
QrModalFragment().show(supportFragmentManager, "qr")
}
}
}

// Modal fragment for QR
class QrModalFragment : DialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

// Create QR engagement when modal opens
lifecycleScope.launch {
manager.createEngagement(EngagementType.QR, qrConfig)
}

// Observe state
lifecycleScope.launch {
manager.eventHub.sessionState.collect { state ->
// NFC stays BACKGROUND while QR modal is open
// qrMode switches to DISPLAY

when (state.phase) {
UiPhase.TRANSFER -> {
// Transfer started, dismiss modal
dismiss()
}
UiPhase.TERMINAL -> {
// Show result and close
showResult(state)
dismiss()
}
else -> {
// Show QR code
renderQrCode(state)
}
}
}
}
}

override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
// Close QR engagement when modal dismissed
lifecycleScope.launch {
manager.closeQrEngagement()
}
}
}

Troubleshooting

NFC Shows Wrong Mode

Issue: NFC shows as FOREGROUND when it should be BACKGROUND

Solution: Check that you're creating the correct engagement as active. Only one engagement can be active at a time. The nfcMode is automatically determined based on which engagement is active.

// Correct - QR becomes active, NFC goes to background
manager.createEngagement(EngagementType.NFC, config) // Creates NFC in background
manager.createEngagement(EngagementType.QR, config) // QR becomes active, NFC stays background

// The nfcMode is automatically computed:
// - DISABLED if no NFC engagement exists
// - FOREGROUND if NFC engagement is the active engagement
// - BACKGROUND if NFC engagement exists but another engagement is active

QR Mode Confusion

Issue: Expecting qrMode to automatically become SCAN for reverse engagements

Solution: Remember that SCAN mode is UI-managed, not engagement-managed. The projector will never set qrMode to SCAN. You manage scanning in your UI code before creating the TO_APP engagement.

// Correct - UI manages scanning before engagement
var isScanningQr by remember { mutableStateOf(false) }

Button(onClick = { isScanningQr = true }) { Text("Scan QR") }

if (isScanningQr) {
QrScanner { uri ->
isScanningQr = false
// Create TO_APP engagement with scanned URI
manager.createEngagement(EngagementType.TO_APP, config.copy(readerEngagementUri = uri))
// state.qrMode will be NONE (no QR to display)
}
}

// Display QR when engagement says so
if (state.qrMode == QrMode.DISPLAY) {
QrCodeDisplay()
}

// Wrong - expecting sessionState.qrMode to be SCAN
// The projector doesn't set SCAN mode - you control it

User Interaction Not Triggered

Issue: Consent dialog never appears

Solution: Check that you're handling userInteractionRequired in TRANSFER phase:

when (state.phase) {
UiPhase.TRANSFER -> {
if (state.userInteractionRequired) {
showConsentDialog(state.deviceRequest!!)
}
}
// ...
}

Terminal State Shows Wrong Outcome

Issue: Success shows as error, or vice versa

Solution: Ensure you're checking terminalOutcome, not just terminalMessage:

// Correct
when (state.terminalOutcome) {
TerminalOutcome.SUCCESS -> showSuccess()
TerminalOutcome.ERROR -> showError()
}

// Wrong
if (state.terminalMessage?.contains("error") == true) {
showError() // Message might not contain "error"
}

Best Practices

  1. Trust the State - Don't try to replicate the projector's logic. Trust qrMode, nfcMode, userInteractionRequired, etc.

  2. Use substateLabel - Display it to users for real-time feedback about what's happening

  3. Check terminalOutcome - Always use the enum, not string matching on messages

  4. Handle Suspension - Always check isSuspended and show appropriate UI

  5. Decode Lazily - Only decode deviceRequest when needed (when showing consent dialog)

  6. One Collector - Collect sessionState once and route to different handlers based on phase

  7. Null Progress - Use indeterminate progress when progressHint == null, determinate when present