Event and UI handling
Overview
This guide covers two approaches for integrating mdoc engagement and transfer events into your UI:
1. UI Projection Layer (Recommended for most apps)
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 verifiedSessionUiProjector.kt- Full projection logic implementedMdocEventHub.kt- Exposes bothsessionState/sessionEvents(projection) andallEvents/engagementEvents/transferEvents(direct access)MdocEngagementManager.ktThe starting point providing access to the above
Key Concepts
Design Principles
- Deterministic State Transitions - Clear, predictable phase progression: ENGAGEMENT → TRANSFER → TERMINAL
- Single Source of Truth - One state flow to bind, no need to coordinate multiple event streams
- UI-Centric Events - Simplified events that map directly to UI actions
- Built-in Logic - QR visibility, suspension tracking, and progress hints handled automatically
- 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:
NONEandDISPLAYare set automatically by the projector based on engagementsSCANis 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 (fromMdocEngagementEvent.QrShow)NfcPromptShown- NFC prompt should be shown (fromMdocEngagementEvent.NfcEngagement)NfcHandoverSuccess- NFC handover succeeded (fromMdocEngagementEvent.Connectedwhen NFC)TransferConnecting- Transfer is connecting (fromMdocEngagementEvent.ConnectingorMdocRetrievalEvent.TransmissionTypeSelected)TransferConnected- Transfer connected successfully (fromMdocEngagementEvent.Connectednon-NFC or retrieval connected)UserInteractionRequired(deviceRequest)- User must review and accept/decline (fromMdocRetrievalEvent.DocumentsSelectionProcessStart)UserAccepted- User accepted the document request (fromMdocRetrievalEvent.DocumentsSelectionProcessAccepted)UserDeclined- User declined the document request (fromMdocRetrievalEvent.DocumentsSelectionProcessDeclined)TransferProgress(fraction)- Transfer progress update (fromMdocRetrievalEvent.SessionDataReceivedat 0.3,SessionDataSendat 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
sessionStateand the UI projection layer, see the Kiwa Sample Holder App.This example demonstrates:
- Collecting
sessionStatewithcollectAsState()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
- Kotlin/Android
- iOS/Swift
- Compose
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()
}
}
class WalletViewController: UIViewController {
private var manager: MdocEngagementManager!
private var stateObserver: Task<Void, Never>?
override func viewDidLoad() {
super.viewDidLoad()
// Observe session state using Kotlin Flow adapter
stateObserver = Task {
for await state in manager.eventHub.sessionState {
await updateUI(state: state)
}
}
}
deinit {
stateObserver?.cancel()
}
@MainActor
private func updateUI(state: SessionUiState) {
switch state.phase {
case .engagement:
handleEngagement(state: state)
case .transfer:
handleTransfer(state: state)
case .terminal:
handleTerminal(state: state)
}
}
private func handleEngagement(state: SessionUiState) {
statusLabel.text = state.substateLabel
// Handle QR mode
switch state.qrMode {
case .display:
qrCodeView.isHidden = false
case .none, .scan:
qrCodeView.isHidden = true
}
// Handle NFC mode
switch state.nfcMode {
case .disabled:
nfcPromptView.isHidden = true
nfcIcon.isHidden = true
case .background:
nfcPromptView.isHidden = true
nfcIcon.isHidden = false
nfcIcon.alpha = 0.5
case .foreground:
nfcPromptView.isHidden = false
nfcIcon.isHidden = false
nfcIcon.alpha = 1.0
}
// Handle suspension
if state.isSuspended {
overlayView.isHidden = false
statusLabel.text = "Suspended - another session active"
}
}
private func handleTransfer(state: SessionUiState) {
statusLabel.text = state.substateLabel
qrCodeView.isHidden = true
if state.userInteractionRequired {
showConsentSheet(deviceRequest: state.deviceRequest!)
} else if let progress = state.progressHint {
progressView.progress = progress
progressView.isHidden = false
} else {
activityIndicator.startAnimating()
}
}
private func handleTerminal(state: SessionUiState) {
activityIndicator.stopAnimating()
switch state.terminalOutcome {
case .success:
showAlert(title: "Success!", message: "Data shared successfully")
case .declined:
showAlert(title: "Declined", message: "You chose not to share")
case .canceled:
showAlert(title: "Canceled", message: state.terminalMessage ?? "Session canceled")
case .error:
showAlert(title: "Error", message: state.terminalMessage ?? "An error occurred")
default:
break
}
}
}
@Composable
fun WalletScreen(manager: MdocEngagementManager) {
val sessionState by manager.eventHub.sessionState.collectAsState()
WalletContent(state = sessionState)
}
@Composable
fun WalletContent(state: SessionUiState) {
Box(modifier = Modifier.fillMaxSize()) {
when (state.phase) {
UiPhase.ENGAGEMENT -> EngagementView(state)
UiPhase.TRANSFER -> TransferView(state)
UiPhase.TERMINAL -> TerminalView(state)
}
if (state.isSuspended) {
SuspendedOverlay()
}
}
}
@Composable
fun EngagementView(state: SessionUiState) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = state.substateLabel, style = MaterialTheme.typography.h6)
Spacer(modifier = Modifier.height(16.dp))
// Show QR code when in DISPLAY mode
if (state.qrMode == QrMode.DISPLAY) {
QRCodeDisplay()
}
// Show NFC prompt when in FOREGROUND mode
if (state.nfcMode == NfcMode.FOREGROUND) {
NfcPromptDisplay()
}
// Show small NFC icon when in BACKGROUND mode
if (state.nfcMode == NfcMode.BACKGROUND) {
NfcBackgroundIcon()
}
}
}
@Composable
fun TransferView(state: SessionUiState) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = state.substateLabel)
when {
state.userInteractionRequired -> {
ConsentDialog(deviceRequest = state.deviceRequest!!)
}
state.progressHint != null -> {
LinearProgressIndicator(progress = state.progressHint)
}
else -> {
CircularProgressIndicator()
}
}
}
}
@Composable
fun TerminalView(state: SessionUiState) {
val (icon, color, title) = when (state.terminalOutcome) {
TerminalOutcome.SUCCESS -> Triple(
Icons.Default.CheckCircle,
Color.Green,
"Success!"
)
TerminalOutcome.DECLINED -> Triple(
Icons.Default.Cancel,
Color.Orange,
"Declined"
)
TerminalOutcome.ERROR -> Triple(
Icons.Default.Error,
Color.Red,
"Error"
)
else -> Triple(
Icons.Default.Info,
Color.Gray,
"Completed"
)
}
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(text = title, style = MaterialTheme.typography.h5)
state.terminalMessage?.let { message ->
Spacer(modifier = Modifier.height(8.dp))
Text(text = message, style = MaterialTheme.typography.body2)
}
}
}
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=truewhenDocumentsSelectionProcessStartreceiveddeviceRequestcontains 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 transferUserDeclined→ immediate transition to TERMINAL with DECLINED outcome
Suspension
isSuspendedmirrorsactiveEngagement?.isActive == false- Suspended engagements won't respond to connection attempts
- UI should show overlay or disabled state when suspended
Progress Tracking
progressHintisnullduring connection/setup phasesprogressHintis0.0-1.0during 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
-
Trust the State - Don't try to replicate the projector's logic. Trust
qrMode,nfcMode,userInteractionRequired, etc. -
Use
substateLabel- Display it to users for real-time feedback about what's happening -
Check
terminalOutcome- Always use the enum, not string matching on messages -
Handle Suspension - Always check
isSuspendedand show appropriate UI -
Decode Lazily - Only decode
deviceRequestwhen needed (when showing consent dialog) -
One Collector - Collect
sessionStateonce and route to different handlers based on phase -
Null Progress - Use indeterminate progress when
progressHint == null, determinate when present