Skip to content

Commit 6df1151

Browse files
committed
chore: scanning + od4vp
1 parent 5e9ee11 commit 6df1151

7 files changed

Lines changed: 371 additions & 89 deletions

File tree

example/holder/app/composeApp/src/androidMain/AndroidManifest.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2020
xmlns:tools="http://schemas.android.com/tools">
2121
<uses-permission android:name="android.permission.INTERNET"/>
22+
<uses-permission android:name="android.permission.CAMERA"/>
2223

2324

2425
<!-- Necessary to perform any Bluetooth classic or BLE communication, such as requesting a
@@ -68,6 +69,10 @@
6869
android:name="android.hardware.bluetooth_le"
6970
android:required="false"/>
7071

72+
<!-- Camera for QR code scanning -->
73+
<uses-feature
74+
android:name="android.hardware.camera"
75+
android:required="false"/>
7176

7277
<!-- NFC engagement -->
7378
<uses-feature

example/holder/app/composeApp/src/androidMain/kotlin/com/sphereon/kiwa/sample/app/NfcEngagementNavigationServiceImpl.kt

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import kotlinx.coroutines.Job
2929
import kotlinx.coroutines.SupervisorJob
3030
import kotlinx.coroutines.flow.MutableStateFlow
3131
import kotlinx.coroutines.flow.StateFlow
32+
import kotlinx.coroutines.withContext
3233
import kotlinx.coroutines.launch
3334
import me.tatarka.inject.annotations.Inject
3435
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
@@ -143,30 +144,24 @@ class NfcEngagementNavigationServiceImpl(
143144
monitoringJob?.cancel()
144145
monitoringJob = null
145146

146-
if (userContextManager.isAnonymous()) {
147-
log.warn("NFCNAV: Not monitoring engagement events for anonymous session")
148-
return
149-
}
150147

151148
monitoringJob = serviceScope.launch {
152149
log.info("NFCNAV: Started monitoring for NFC engagement")
153150
log.info("NFCNAV: Collecting from engagement manager: ${engagementManager.hashCode()}")
154151

155-
// Monitor engagement events directly - SessionUiProjector ignores Connecting events!
156-
// So we can't rely on sessionState.phase transitions for NFC engagements
157-
engagementManager.eventHub.engagementEvents.collect { event ->
158-
log.info("NFCNAV: *** Engagement event received: $event")
152+
// Monitor NFC engagement directly instead of waiting for Connecting events
153+
// In peripheral server mode (NFC handoff), the engagement is created but may not emit
154+
// a Connecting event until much later in the flow
155+
engagementManager.nfcEngagement.collect { nfcEng ->
156+
log.info("NFCNAV: *** NFC engagement changed: ${nfcEng?.id}")
159157

160-
// When we get a Connecting event for an NFC engagement, navigate to the screen
161-
if (event is MdocEngagementEvent.Connecting) {
162-
val nfcEng = engagementManager.nfcEngagement.value
163-
if (nfcEng != null && nfcEng.id.toString() != navigatedEngagementId) {
164-
log.info("NFCNAV: NFC Connecting event for engagement ${nfcEng.id}, navigating to engagement screen")
165-
navigatedEngagementId = nfcEng.id.toString()
166-
handleNfcConnectingEvent()
167-
} else {
168-
log.debug("NFCNAV: Connecting event but no NFC engagement or already navigated")
169-
}
158+
if (nfcEng != null && nfcEng.id.toString() != navigatedEngagementId) {
159+
log.info("NFCNAV: NFC engagement created: ${nfcEng.id}, navigating to engagement screen")
160+
navigatedEngagementId = nfcEng.id.toString()
161+
handleNfcConnectingEvent()
162+
} else if (nfcEng == null) {
163+
log.debug("NFCNAV: NFC engagement cleared")
164+
navigatedEngagementId = null
170165
}
171166
}
172167
}
@@ -231,15 +226,19 @@ class NfcEngagementNavigationServiceImpl(
231226
return
232227
}
233228
try {
234-
val success = navigateToNfcEngagement()
235-
if (success) {
236-
log.info("NFCNAV: Successfully navigated to engagement screen for NFC connecting")
237-
} else {
238-
log.warn("NFCNAV: Failed to navigate to engagement screen")
229+
// Ensure navigation happens on Main thread with immediate dispatch
230+
withContext(Dispatchers.Main.immediate) {
231+
val success = navigateToNfcEngagement()
232+
if (success) {
233+
log.info("NFCNAV: Successfully navigated to engagement screen for NFC connecting")
234+
// Yield to allow Compose to recompose with the new backstack state
235+
kotlinx.coroutines.yield()
236+
} else {
237+
log.warn("NFCNAV: Failed to navigate to engagement screen")
238+
}
239239
}
240-
241240
} catch (e: Exception) {
242-
log.error("NFCNAV: Failed to handle NFC connecting event: ${e.message}")
241+
log.error("NFCNAV: Failed to handle NFC connecting event: ${e.message}", exception = e)
243242
}
244243
}
245244

example/holder/ui/elicense/impl/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ kotlin {
7272
implementation(libs.amz.kotlin.inject.contribute.public)
7373
implementation(libs.kottage)
7474
implementation(libs.qrcode.kotlin)
75+
implementation(libs.easyqrscan)
7576
implementation(projects.example.holder.ui.auth.kiwaExampleHolderUiAuthPublic)
7677
implementation(projects.example.holder.ui.auth.kiwaExampleHolderUiAuthImpl)
7778
implementation(projects.example.holder.ui.core.kiwaExampleHolderUiCorePublic)

example/holder/ui/elicense/impl/src/commonMain/kotlin/com/sphereon/kiwa/sample/ui/elicense/engagement/qr/MdocEngagementPresenterImpl.kt

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ class MdocEngagementPresenterImpl(
8888
// Collect active engagement from manager - single source of truth
8989
val activeEngagement by engagementManager.activeEngagement.collectAsState()
9090

91+
// Track QR scanner mode
92+
var showQrScanner by remember { mutableStateOf(false) }
93+
9194
// Log state on every recomposition to understand state transitions
9295
log.debug("=== PRESENTER RECOMPOSITION ===")
9396
log.debug("Engagement Manager Instance: ${engagementManager.hashCode()}")
@@ -152,6 +155,7 @@ class MdocEngagementPresenterImpl(
152155
when (event) {
153156
UiStateEvent.ShowQr -> {
154157
log.debug("User clicked 'Show QR' - creating QR engagement via manager")
158+
showQrScanner = false
155159
presenterScope.launch {
156160
val engagementResult = engagementManager.createEngagement {
157161
engagement { qr {} }
@@ -166,7 +170,15 @@ class MdocEngagementPresenterImpl(
166170
}
167171
}
168172

173+
UiStateEvent.ShowQrScanner -> {
174+
log.debug("User clicked 'Scan QR' - enabling QR scanner mode")
175+
showQrScanner = true
176+
// Don't create engagement yet - wait for QR scan
177+
// The scanner will trigger onQrScanned when a code is detected
178+
}
179+
169180
UiStateEvent.Stopped -> {
181+
showQrScanner = false
170182
log.debug("Stopped event - closing all engagements and navigating back")
171183
presenterScope.launch {
172184
engagementManager.closeAll()
@@ -188,6 +200,92 @@ class MdocEngagementPresenterImpl(
188200
}
189201
}
190202

203+
// Handle QR code scanned for reverse engagement
204+
val onQrScanned = remember(presenterScope) {
205+
{ scannedData: String ->
206+
log.info("📷 QR SCANNED! Length: ${scannedData.length}, First 30 chars: '${scannedData.take(30)}'")
207+
log.debug("Full scanned data: $scannedData")
208+
when {
209+
// 18013-7 website via deeplink/QR
210+
scannedData.startsWith("mdoc://") -> {
211+
log.info("VALID mdoc:// URI - Initiating toApp with website retrieval (18013-7)")
212+
showQrScanner = false
213+
presenterScope.launch {
214+
runCatching {
215+
engagementManager.toApp(scannedData)
216+
.onSuccess {
217+
log.debug("toApp (website) initiated successfully")
218+
}
219+
.onFailure { error ->
220+
log.error(
221+
"toApp (website) failed: ${error.message}",
222+
exception = (error as? com.sphereon.core.api.error.IdkError)?.exception
223+
)
224+
showQrScanner = false
225+
}
226+
}.onFailure { e ->
227+
log.error("Exception calling toApp (website)", exception = e)
228+
showQrScanner = false
229+
}
230+
}
231+
}
232+
233+
// 18013-5 reverse engagement with BLE transfer
234+
scannedData.startsWith("mdoc:") && !scannedData.startsWith("mdoc://") && !scannedData.startsWith("mdoc-openid4vp://") -> {
235+
log.info("VALID mdoc: URI - Initiating toApp with BLE retrieval (18013-5)")
236+
showQrScanner = false
237+
presenterScope.launch {
238+
runCatching {
239+
engagementManager.toApp(scannedData)
240+
.onSuccess {
241+
log.debug("toApp (BLE) initiated successfully")
242+
}
243+
.onFailure { error ->
244+
log.error(
245+
"toApp (BLE) failed: ${error.message}",
246+
exception = (error as? com.sphereon.core.api.error.IdkError)?.exception
247+
)
248+
showQrScanner = false
249+
}
250+
}.onFailure { e ->
251+
log.error("Exception calling toApp (BLE)", exception = e)
252+
showQrScanner = false
253+
}
254+
}
255+
}
256+
257+
// OpenID4VP
258+
scannedData.startsWith("mdoc-openid4vp://") -> {
259+
log.info("VALID mdoc-openid4vp:// URI - Initiating toApp with OID4VP retrieval")
260+
showQrScanner = false
261+
presenterScope.launch {
262+
runCatching {
263+
engagementManager.toApp(scannedData)
264+
.onSuccess {
265+
log.debug("toApp (OID4VP) initiated successfully")
266+
}
267+
.onFailure { error ->
268+
log.error(
269+
"toApp (OID4VP) failed: ${error.message}",
270+
exception = (error as? com.sphereon.core.api.error.IdkError)?.exception
271+
)
272+
showQrScanner = false
273+
}
274+
}.onFailure { e ->
275+
log.error("Exception calling toApp (OID4VP)", exception = e)
276+
showQrScanner = false
277+
}
278+
}
279+
}
280+
281+
else -> {
282+
log.warn("⚠️ Invalid QR code: Expected 'mdoc://', 'mdoc:', or 'mdoc-openid4vp://' - got prefix: ${scannedData.take(20)}")
283+
}
284+
}
285+
Unit
286+
}
287+
}
288+
191289
val onContinue = remember(presenterScope) {
192290
{ selector: MapDrivenDocRequestSelector ->
193291
log.debug("Document selection confirmed, starting sharing")
@@ -244,26 +342,38 @@ class MdocEngagementPresenterImpl(
244342

245343
// Build model based purely on SessionUiState - it's the source of truth
246344
// Special case: If TERMINAL state but no active engagement AND we never had an engagement,
247-
// this is stale state from a previous session - treat as INITIAL
345+
// this is stale state from a previous session - treat as INITIAL with toggle UI
248346
val model = when {
249347
sessionState.phase == UiPhase.TERMINAL && activeEngagement == null && !hasHadEngagement.value -> {
250-
log.debug(">>> Stale TERMINAL state detected (never had engagement in this session) - treating as INITIAL")
251-
MdocEngagementPresenter.Model.Initial(onEvent)
348+
log.debug(">>> Stale TERMINAL state detected (never had engagement in this session) - returning Initial with toggle")
349+
MdocEngagementPresenter.Model.Initial(
350+
showQr = false,
351+
showQrScanner = showQrScanner,
352+
onQrScanned = onQrScanned,
353+
onStateEvent = onEvent
354+
)
252355
}
253356

254357
// If phase is ENGAGEMENT but there's no active engagement, it's stale state - treat as INITIAL
255358
sessionState.phase == UiPhase.ENGAGEMENT && activeEngagement == null -> {
256-
log.debug(">>> Stale ENGAGEMENT state detected (no active engagement) - treating as INITIAL")
257-
MdocEngagementPresenter.Model.Initial(onEvent)
359+
log.debug(">>> Stale ENGAGEMENT state detected (no active engagement) - returning Initial with toggle")
360+
MdocEngagementPresenter.Model.Initial(
361+
showQr = false,
362+
showQrScanner = showQrScanner,
363+
onQrScanned = onQrScanned,
364+
onStateEvent = onEvent
365+
)
258366
}
259367

260368
sessionState.phase == UiPhase.ENGAGEMENT -> {
261-
log.debug(">>> Returning Model: ENGAGEMENT (showQr=${sessionState.qrMode == QrMode.DISPLAY})")
369+
log.debug(">>> Returning Model: ENGAGEMENT (showQr=${sessionState.qrMode == QrMode.DISPLAY}, showQrScanner=$showQrScanner)")
262370
// Show QR or NFC prompt based on SessionUiState
263371
MdocEngagementPresenter.Model.Engagement(
264372
qrImage = if (sessionState.qrMode == QrMode.DISPLAY) qrImage else null,
265373
engagementEvent = null,
266374
showQr = sessionState.qrMode == QrMode.DISPLAY,
375+
showQrScanner = showQrScanner,
376+
onQrScanned = onQrScanned,
267377
onStateEvent = onEvent
268378
)
269379
}
@@ -330,14 +440,24 @@ class MdocEngagementPresenterImpl(
330440

331441
null -> {
332442
log.debug(">>> Returning Model: INITIAL (phase=TERMINAL, terminalOutcome=null)")
333-
MdocEngagementPresenter.Model.Initial(onEvent)
443+
MdocEngagementPresenter.Model.Initial(
444+
showQr = false,
445+
showQrScanner = showQrScanner,
446+
onQrScanned = onQrScanned,
447+
onStateEvent = onEvent
448+
)
334449
}
335450
}
336451
}
337452

338453
else -> {
339454
log.debug(">>> Returning Model: INITIAL (phase=${sessionState.phase})")
340-
MdocEngagementPresenter.Model.Initial(onEvent)
455+
MdocEngagementPresenter.Model.Initial(
456+
showQr = false,
457+
showQrScanner = showQrScanner,
458+
onQrScanned = onQrScanned,
459+
onStateEvent = onEvent
460+
)
341461
}
342462
}
343463

0 commit comments

Comments
 (0)