Engagement Manager
The MdocEngagementManager is the central object for mDoc operations. It owns the full lifecycle: creating engagements, tracking their state, transitioning into data transfer, and cleaning up when done. Most wallet and verifier UIs only need to interact with this one object and the TransferManager it produces.
Obtaining the Engagement Manager
The manager lives in the session scope, so you get it from the session graph. One instance per session.
- Android/Kotlin
- iOS/Swift
val engagementManager = session.graph.mdocEngagementManager
let engagementManager = session.graph.mdocEngagementManager
Key Properties
The manager exposes reactive state via StateFlow properties, which makes it straightforward to bind to UI frameworks like Compose or SwiftUI.
- Android/Kotlin
- iOS/Swift
val eventHub: MdocEventHub = engagementManager.eventHub
val sharedParameters: SharedParameters = engagementManager.sharedParameters
val engagementsByType: StateFlow<Map<EngagementType, EngagementInstance>>
val qrEngagement: StateFlow<EngagementInstance?>
val nfcEngagement: StateFlow<EngagementInstance?>
val toAppEngagement: StateFlow<EngagementInstance?>
val activeEngagement: StateFlow<EngagementInstance?>
val transferInstances: StateFlow<Map<Uuid, TransferInstance>>
let eventHub: MdocEventHub = engagementManager.eventHub
let sharedParameters: SharedParameters = engagementManager.sharedParameters
let engagementsByType: StateFlow<Map<EngagementType, EngagementInstance>>
let qrEngagement: StateFlow<EngagementInstance?>
let nfcEngagement: StateFlow<EngagementInstance?>
let toAppEngagement: StateFlow<EngagementInstance?>
let activeEngagement: StateFlow<EngagementInstance?>
let transferInstances: StateFlow<Map<Uuid, TransferInstance>>
A few things to note:
- One engagement per type. The manager holds at most one QR engagement, one NFC engagement, and one TO_APP engagement at the same time. Creating a new engagement of the same type replaces the previous one.
- One active engagement. While multiple engagement types can be prepared simultaneously (e.g., QR and NFC both waiting for a verifier), only one can be active at a time.
activeEngagementtracks which one is currently in a transfer. - Event hub. The
eventHubis the single place to observe everything that happens. See Events and UI for details. - Shared parameters. BLE UUIDs and the ephemeral key alias are managed here and shared across engagement types so that a QR engagement and an NFC engagement advertise the same BLE service.
Engagement Types
| Type | When to use |
|---|---|
QR | The holder displays a QR code on screen and the verifier scans it. Most common for in-person flows. |
NFC | The holder taps their device on the verifier's reader. Quick, but requires NFC hardware on both sides. |
TO_APP | The verifier sends a deep link (mdoc:, mdoc://, or mdoc-openid4vp://) to the holder's app. Used for online verification and cross-device flows per ISO 18013-7. |
QR and NFC are "holder-initiated": the holder creates the engagement and waits for a verifier. TO_APP is "verifier-initiated": the verifier provides a URI and the holder's app responds.
Creating QR and NFC Engagements
For QR and NFC, you create the engagement up front and then wait for a verifier to connect. The createEngagement builder lets you configure which engagement types to prepare and which transports to offer for the data transfer phase.
- Android/Kotlin
- iOS/Swift
val engagement = engagementManager.createEngagement {
engagement {
qr { } // prepare a QR code engagement
nfc { } // also prepare NFC engagement
}
retrieval {
ble {
centralClientMode = true
peripheralServerMode = true
}
}
}
// Display the QR code
val qrUri = engagement.getEngagementUri()
showQrCode(qrUri)
let engagement = try await engagementManager.createEngagement {
engagement {
qr { }
nfc { }
}
retrieval {
ble {
centralClientMode = true
peripheralServerMode = true
}
}
}
let qrUri = try await engagement.getEngagementUri()
showQrCode(uri: qrUri)
The builder has two blocks:
engagement { }configures which engagement methods to enable. You can enableqr,nfc, or both. Each has optional configuration (e.g., custom QR scheme).retrieval { }configures how data will actually be transferred once a verifier connects. The most common option isblewith one or both modes enabled. You can also configurenfc,website(REST API), oroid4vpretrieval here.
After createEngagement returns, the engagement is live. The QR code URI is ready, and if NFC was enabled, the device is listening for taps. The manager emits QrShow and other events so your UI knows what to display.
Creating TO_APP Engagement
TO_APP engagement works the other way around. Instead of creating an engagement and waiting, you receive a URI from a verifier (via deep link, universal link, or intent) and hand it to the manager.
- Android/Kotlin
- iOS/Swift
val result = engagementManager.toApp(
mdocUri = uri,
autoStart = true
)
if (result.isOk) {
val engagement = result.value
// engagement is already started if autoStart = true
} else {
handleError(result.error)
}
let result = engagementManager.toApp(
mdocUri: uri,
autoStart: true
)
if result.isOk {
let engagement = result.value
// engagement is already started if autoStart = true
} else {
handleError(error: result.error)
}
The autoStart parameter controls whether the engagement immediately begins the transfer phase or waits for you to call engagement.start() manually. Use autoStart = false when you need to inspect the engagement or show a confirmation screen before connecting.
The URI scheme determines which transport is used:
| Scheme | Transport |
|---|---|
mdoc: (opaque, no slashes) | BLE or NFC reverse engagement |
mdoc:// (hierarchical) | REST API (ISO 18013-7 Annex A) |
mdoc-openid4vp:// | OID4VP (ISO 18013-7 Annex B) |
Working with EngagementInstance
An EngagementInstance represents one engagement session. It carries the engagement data (ephemeral keys, transport options), exposes the current state, and provides the start() method to transition into data transfer.
- Android/Kotlin
- iOS/Swift
val engagement = engagementManager.qrEngagement.value!!
val id: Uuid = engagement.id
val isActive: StateFlow<Boolean> = engagement.isActive
// The URI to put in a QR code
val uri: String = engagement.getEngagementUri()
// The device's ephemeral public key for this session
val ephemeralKey: CoseKeyType = engagement.getEphemeralKey()
// The full DeviceEngagement CBOR structure
val deviceEngagement: CborEncodedItem<DeviceEngagement> = engagement.getDeviceEngagement()
// Which transports the engagement advertises
val retrievalMethods: Set<DeviceRetrievalMethod> = engagement.getRetrievalMethods()
// Current lifecycle state
val state: MdocEngagementStateType = engagement.getCurrentState()
// Per-instance event stream
engagement.events.collect { event ->
// handle events for this specific engagement
}
let engagement = engagementManager.qrEngagement.value!
let id: Uuid = engagement.id
let isActive: StateFlow<Bool> = engagement.isActive
let uri: String = try await engagement.getEngagementUri()
let ephemeralKey: CoseKeyType = try await engagement.getEphemeralKey()
let deviceEngagement = try await engagement.getDeviceEngagement()
let retrievalMethods = engagement.getRetrievalMethods()
let state = engagement.getCurrentState()
for await event in engagement.events {
// handle events for this specific engagement
}
Each engagement instance also has its own events stream. This is useful when you have multiple engagement types prepared simultaneously and want to react to events for a specific one. For most UIs, the centralized eventHub on the manager is easier to work with.
Starting a Transfer
Calling start() on an engagement transitions it into the data transfer phase and returns a TransferManager. This is the point where the IDK sets up session encryption, connects the transport, and begins listening for the verifier's request.
- Android/Kotlin
- iOS/Swift
// Exception-based API
val transferManager: TransferManager = engagement.start()
// Result-based API (no exceptions)
val result = engagement.tryOps().start()
if (result.isOk) { /* use result.value */ }
else { /* handle result.error */ }
let transferManager = try await engagement.start()
// Or result-based
let result = try await engagement.tryOps().start()
The IDK provides two API styles throughout: an exception-based one (call directly, catch on failure) and a result-based one via tryOps() (returns IdkResult). They do the same thing; pick whichever fits your error-handling style.
Once you have the TransferManager, see the Transfer Manager guide for receiving requests and sending responses.
Event Hub
The MdocEventHub collects events from all engagements and transfers into a single stream. This is the recommended way to drive UI state.
- Android/Kotlin
- iOS/Swift
val eventHub = engagementManager.eventHub
// Everything in one stream
eventHub.allEvents.collect { event ->
when (event) {
is MdocEngagementEvent -> handleEngagementEvent(event)
is MdocRetrievalEvent -> handleRetrievalEvent(event)
}
}
// Or only engagement events
eventHub.engagementEvents.collect { event ->
val state = event.state
val engagementId = event.engagementId
}
// Or only transfer events
eventHub.retrievalEvents.collect { event ->
// ...
}
let eventHub = engagementManager.eventHub
for await event in eventHub.allEvents {
if let e = event as? MdocEngagementEvent {
handleEngagementEvent(event: e)
} else if let r = event as? MdocRetrievalEvent {
handleRetrievalEvent(event: r)
}
}
The hub also provides lookup methods for current state:
getCurrentStateByEngagement()returns aStateFlow<Map<Uuid, MdocEngagementState>>so you can observe state per engagement ID.getRecentEvents(count)returns the last N events with sequence numbers, useful for debugging.
See the Events and UI guide for the full event type reference and filtering options.
Closing Engagements
Closing an engagement tears down its transport connection and releases resources (BLE GATT services, NFC listeners, etc.). Always close when a transfer completes, the user navigates away, or an unrecoverable error occurs.
- Android/Kotlin
- iOS/Swift
// Close by type
engagementManager.closeQrEngagement()
engagementManager.closeNfcEngagement()
engagementManager.closeToAppEngagement()
// Close by reference or ID
engagementManager.closeEngagementByInstance(engagement)
engagementManager.closeEngagementById(engagementId)
// Tear down everything
engagementManager.closeAll()
engagementManager.closeQrEngagement()
engagementManager.closeNfcEngagement()
engagementManager.closeToAppEngagement()
engagementManager.closeEngagementByInstance(engagement: engagement)
engagementManager.closeEngagementById(id: engagementId)
engagementManager.closeAll()
For BLE, closing on the holder side waits for the reader to disconnect first (per ISO 18013-5). The IDK handles this automatically.
Auto Restart
In wallet apps, you typically want to be ready for the next presentation as soon as the current one finishes. enableAutoRestart configures the manager to automatically clean up completed or errored engagements and re-create the NFC engagement so the device is immediately ready for another tap.
- Android/Kotlin
- iOS/Swift
engagementManager.enableAutoRestart(backgroundNfcConfig)
engagementManager.enableAutoRestart(config: backgroundNfcConfig)
Without auto-restart, you need to manually close the old engagement and create a new one after each transfer.
Shared Parameters
BLE requires both the holder and the verifier to agree on a service UUID. The SharedParameters object manages these UUIDs (and the ephemeral key alias used by the KMS) so they stay consistent across QR and NFC engagements in the same session.
- Android/Kotlin
- iOS/Swift
val sharedParams = engagementManager.sharedParameters
// BLE service UUIDs
val centralClientUuid: StateFlow<Uuid> = sharedParams.bleCentralClientUuid
val peripheralServerUuid: StateFlow<Uuid> = sharedParams.blePeripheralServerUuid
// KMS alias for the ephemeral key
val keyAlias: StateFlow<String> = sharedParams.ephemeralKeyAlias
// Generate fresh UUIDs and a new ephemeral key for the next session
sharedParams.regenerate()
// Some verifier implementations expect the same UUID for both BLE modes
sharedParams.useSameUuidForBothModes()
let sharedParams = engagementManager.sharedParameters
let centralClientUuid: StateFlow<Uuid> = sharedParams.bleCentralClientUuid
let peripheralServerUuid: StateFlow<Uuid> = sharedParams.blePeripheralServerUuid
let keyAlias: StateFlow<String> = sharedParams.ephemeralKeyAlias
sharedParams.regenerate()
sharedParams.useSameUuidForBothModes()
Call regenerate() between presentations to ensure each session uses fresh cryptographic material. The manager does this automatically when auto-restart is enabled.
Engagement Lifecycle
An engagement progresses through these states:
| State | What it means |
|---|---|
INITIALIZING | The engagement is being set up (keys generated, transport prepared) |
CONNECTING | A verifier has been detected and the transport is connecting |
CONNECTED | Transport is connected, ready to start the data transfer |
TRANSFERRING | start() has been called and data is being exchanged |
COMPLETED | Transfer finished successfully |
ERROR | Something went wrong (timeout, transport failure, invalid data) |
These are the high-level states. Under the hood, the manager emits more granular MdocEngagementEvent subtypes that give your UI finer control:
| Event | When it fires |
|---|---|
Initializing | Engagement setup has begun |
Start | Engagement is live and waiting for a verifier |
QrShow | A QR code is ready to display |
QrHide | The QR code should be hidden (verifier connected or engagement closed) |
NfcEngagement | An NFC tap was detected |
RestApiEngagement | A REST API connection was initiated |
Connecting | Transport is connecting |
Connected | Transport is connected |
Disconnected | Transport disconnected (may be expected at end of transfer) |
Canceled | Engagement was canceled by the user or system |
Error | Something failed; the event carries error details |
Transfer | Transfer phase started |
Debug | Diagnostic information (useful for logging) |
Data | Raw data passed through the transport, with direction indicating IN or OUT |
QrShow and QrHide are particularly useful for driving QR code visibility in your UI without polling.
Complete Example
A full flow from deep link to credential response:
- Android/Kotlin
- iOS/Swift
class MdocPresentationHandler(
private val engagementManager: MdocEngagementManager,
private val documentProvider: DocumentProvider
) {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
fun handleIncomingUri(uri: String) {
scope.launch {
val result = engagementManager.toApp(mdocUri = uri, autoStart = false)
if (result.isOk) {
val engagement = result.value
val transferManager = engagement.start()
handleTransfer(transferManager)
} else {
handleError(result.error)
}
}
}
private suspend fun handleTransfer(transferManager: TransferManager) {
val deviceRequest = transferManager.receiveDeviceRequest()
val userApproved = showConsentDialog(deviceRequest)
if (userApproved) {
val deviceResponse = transferManager.createResponse(
deviceRequest = deviceRequest,
documentProvider = documentProvider
)
transferManager.sendDeviceResponse(deviceResponse)
} else {
val errorResponse = DeviceResponse.Builder()
.withStatus(DeviceResponseStatus(20u))
.build()
transferManager.sendDeviceResponse(errorResponse)
}
}
fun cleanup() {
engagementManager.closeAll()
scope.cancel()
}
}
class MdocPresentationHandler {
private let engagementManager: MdocEngagementManager
private let documentProvider: DocumentProvider
init(engagementManager: MdocEngagementManager, documentProvider: DocumentProvider) {
self.engagementManager = engagementManager
self.documentProvider = documentProvider
}
func handleIncomingUri(uri: String) async {
let result = engagementManager.toApp(mdocUri: uri, autoStart: false)
if result.isOk {
do {
let engagement = result.value
let transferManager = try await engagement.start()
try await handleTransfer(transferManager: transferManager)
} catch {
handleError(error: error)
}
} else {
handleError(error: result.error)
}
}
private func handleTransfer(transferManager: TransferManager) async throws {
let deviceRequest = try await transferManager.receiveDeviceRequest()
let userApproved = await showConsentDialog(request: deviceRequest)
if userApproved {
let deviceResponse = try await transferManager.createResponse(
deviceRequest: deviceRequest,
documentProvider: documentProvider
)
try await transferManager.sendDeviceResponse(deviceResponse: deviceResponse)
} else {
let errorResponse = DeviceResponse.Builder()
.withStatus(DeviceResponseStatus(value: 20))
.build()
try await transferManager.sendDeviceResponse(deviceResponse: errorResponse)
}
}
func cleanup() {
engagementManager.closeAll()
}
}