Skip to main content
Version: v0.25.0 (Latest)

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.

val 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.

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

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. activeEngagement tracks which one is currently in a transfer.
  • Event hub. The eventHub is 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

TypeWhen to use
QRThe holder displays a QR code on screen and the verifier scans it. Most common for in-person flows.
NFCThe holder taps their device on the verifier's reader. Quick, but requires NFC hardware on both sides.
TO_APPThe 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.

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)

The builder has two blocks:

  • engagement { } configures which engagement methods to enable. You can enable qr, 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 is ble with one or both modes enabled. You can also configure nfc, website (REST API), or oid4vp retrieval 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.

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)
}

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:

SchemeTransport
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.

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
}

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.

// 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 */ }

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.

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 ->
// ...
}

The hub also provides lookup methods for current state:

  • getCurrentStateByEngagement() returns a StateFlow<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.

// 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()

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.

engagementManager.enableAutoRestart(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.

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()

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:

StateWhat it means
INITIALIZINGThe engagement is being set up (keys generated, transport prepared)
CONNECTINGA verifier has been detected and the transport is connecting
CONNECTEDTransport is connected, ready to start the data transfer
TRANSFERRINGstart() has been called and data is being exchanged
COMPLETEDTransfer finished successfully
ERRORSomething 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:

EventWhen it fires
InitializingEngagement setup has begun
StartEngagement is live and waiting for a verifier
QrShowA QR code is ready to display
QrHideThe QR code should be hidden (verifier connected or engagement closed)
NfcEngagementAn NFC tap was detected
RestApiEngagementA REST API connection was initiated
ConnectingTransport is connecting
ConnectedTransport is connected
DisconnectedTransport disconnected (may be expected at end of transfer)
CanceledEngagement was canceled by the user or system
ErrorSomething failed; the event carries error details
TransferTransfer phase started
DebugDiagnostic information (useful for logging)
DataRaw 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:

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()
}
}