Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,18 @@
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/apduservice" />
</service>

<!-- ISO mdoc engagement/retrieval. Kept separate from wallet-role qualification above. -->
<service
android:name="id.walt.walletdemo.compose.logic.DemoMdocHostApduService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/mdoc_apdu_service" />
</service>
</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,19 @@ class MainActivity : FragmentActivity() {
config = walletConfig,
interactionContextProvider = { this@MainActivity },
)
val sharingSettings = createAndroidDemoSharingSettingsStore(applicationContext)
controller = WalletDemoController(
wallet = wallet,
pinStore = createAndroidDemoPinStore(applicationContext, walletConfig.walletId),
biometricAuthenticator = createAndroidDemoBiometricAuthenticator { this@MainActivity },
signingProtectionMode = walletConfig.signingProtectionMode,
signingProtectionStore = walletConfig.signingProtectionStore(applicationContext),
sharingSettings = createAndroidDemoSharingSettingsStore(applicationContext),
sharingSettings = sharingSettings,
)
proximityController = WalletDemoProximityController(
wallet = wallet,
profileProvider = sharingSettings::proximityTransportProfile,
)
proximityController = WalletDemoProximityController(wallet)
WalletDemoCredentialStoreNotifier.addListener(onCredentialStoreChanged)
handleIntent(intent)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@
<string name="app_name">walt.id Wallet</string>
<string name="wallet_hce_service_description">walt.id Wallet</string>
<string name="wallet_hce_aid_group_description">walt.id digital wallet</string>
<string name="mdoc_hce_service_description">In-person credential presentation</string>
<string name="mdoc_hce_aid_group_description">ISO mdoc presentation</string>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/mdoc_hce_service_description"
android:requireDeviceUnlock="true">
<aid-group
android:description="@string/mdoc_hce_aid_group_description"
android:category="other">
<aid-filter android:name="D2760000850101" />
<aid-filter android:name="A0000002480400" />
<aid-filter android:name="A0000002480401" />
</aid-group>
</host-apdu-service>
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
B33B17EA2C2C394F001A32F7 /* ComposeWalletDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33B17E92C2C394F001A32F7 /* ComposeWalletDemoApp.swift */; };
B33B17EC2C2C394F001A32F7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33B17EB2C2C394F001A32F7 /* ContentView.swift */; };
B33B17EE2C2C3957001A32F7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B33B17ED2C2C3957001A32F7 /* Assets.xcassets */; };
C0DE00000000000000001201 /* ComposeNfcHostPlatformAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000001202 /* ComposeNfcHostPlatformAdapter.swift */; };
C0DE00000000000000000102 /* PublicDemoBackendE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000002 /* PublicDemoBackendE2ETests.swift */; };
C0DE00000000000000000103 /* WalletE2EHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000003 /* WalletE2EHelpers.swift */; };
C0DE00000000000000000105 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000006 /* TestHelpers.swift */; };
Expand Down Expand Up @@ -72,6 +73,7 @@
B33B17E92C2C394F001A32F7 /* ComposeWalletDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeWalletDemoApp.swift; sourceTree = "<group>"; };
B33B17EB2C2C394F001A32F7 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
B33B17ED2C2C3957001A32F7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
C0DE00000000000000001202 /* ComposeNfcHostPlatformAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeNfcHostPlatformAdapter.swift; sourceTree = "<group>"; };
C0DE00000000000000000002 /* PublicDemoBackendE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicDemoBackendE2ETests.swift; sourceTree = "<group>"; };
C0DE00000000000000000003 /* WalletE2EHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletE2EHelpers.swift; sourceTree = "<group>"; };
C0DE00000000000000000004 /* iosAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iosAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -156,6 +158,7 @@
B30E22802C2D8AD700D4781C /* Info.plist */,
B33B17E92C2C394F001A32F7 /* ComposeWalletDemoApp.swift */,
B33B17EB2C2C394F001A32F7 /* ContentView.swift */,
C0DE00000000000000001202 /* ComposeNfcHostPlatformAdapter.swift */,
B33B17ED2C2C3957001A32F7 /* Assets.xcassets */,
C0DE00000000000000001031 /* iosApp.entitlements */,
);
Expand Down Expand Up @@ -385,6 +388,7 @@
buildActionMask = 2147483647;
files = (
B33B17EC2C2C394F001A32F7 /* ContentView.swift in Sources */,
C0DE00000000000000001201 /* ComposeNfcHostPlatformAdapter.swift in Sources */,
B33B17EA2C2C394F001A32F7 /* ComposeWalletDemoApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import Foundation
@_spi(KmpHostBridge) import WalletSDK
@preconcurrency import sharedUI

/// Adapts the shared Swift CardSession implementation to Compose's independently linked KMP types.
///
/// Kotlin/Native exports a distinct Swift protocol identity in each framework. This class owns no
/// NFC state or behavior: it only converts generated `sharedUI` values at that nominal boundary.
final class ComposeNfcHostPlatformAdapter:
sharedUI.Waltid_mdoc_proximity_mobileNfcHostPlatformAdapter,
@unchecked Sendable {
private let bridge = IOSNfcHostBridge()

func capability() async throws -> any sharedUI.Waltid_mdoc_proximity_mobileNfcHostAvailability {
switch await bridge.capability() {
case .available:
return sharedUI.WalletDemoIosKt.composeNfcHostAvailable()
case let .unavailable(reason):
return unavailable(reason)
}
}

func prepare(
router: sharedUI.Waltid_mdoc_proximity_mobileNfcHostApduRouter,
sessionScope _: any sharedUI.Kotlinx_coroutines_coreCoroutineScope
) async throws -> any sharedUI.Waltid_mdoc_proximity_mobileNfcHostPreparation {
let routerBridge = ComposeNfcHostRouter(router: router)
switch await bridge.prepare(
process: { command in
try await routerBridge.process(command)
},
deactivate: { reason in
await routerBridge.deactivate(reason)
}
) {
case let .ready(session):
return sharedUI.WalletDemoIosKt.composeNfcHostReady(
session: ComposePreparedNfcHostSession(bridgeSession: session)
)
case let .unavailable(reason):
return sharedUI.WalletDemoIosKt.composeNfcHostUnavailablePreparation(
code: reason.code,
message: reason.message
)
}
}

private func unavailable(
_ reason: IOSNfcHostBridgeUnavailable
) -> any sharedUI.Waltid_mdoc_proximity_mobileNfcHostAvailability {
sharedUI.WalletDemoIosKt.composeNfcHostUnavailable(
code: reason.code,
message: reason.message
)
}
}

/// Serializes access to the generated Kotlin router at the Swift concurrency boundary.
private actor ComposeNfcHostRouter {
private let router: sharedUI.Waltid_mdoc_proximity_mobileNfcHostApduRouter

init(router: sharedUI.Waltid_mdoc_proximity_mobileNfcHostApduRouter) {
self.router = router
}

func process(_ command: Data) async throws -> Data {
try await router.process(
encodedCommand: command.composeKotlinByteArray()
).doCopy().composeData()
}

func deactivate(_ reason: IOSNfcHostBridgeCloseReason) async {
try? await router.deactivate(reason: reason.sharedUICloseReason)
}
}

private final class ComposePreparedNfcHostSession:
sharedUI.Waltid_mdoc_proximity_mobilePreparedNfcHostSession,
@unchecked Sendable {
private let bridgeSession: IOSNfcHostBridgeSession

init(bridgeSession: IOSNfcHostBridgeSession) {
self.bridgeSession = bridgeSession
}

func close(reason: sharedUI.Waltid_mdoc_proximityProximityCloseReason) async throws {
await bridgeSession.close(reason: reason.nfcHostBridgeReason)
}
}

private extension sharedUI.Waltid_mdoc_proximityProximityCloseReason {
var nfcHostBridgeReason: IOSNfcHostBridgeCloseReason {
switch self {
case .completed:
return .completed
case .handoverCompleted:
return .handoverCompleted
case .cancelled:
return .cancelled
case .lostRace:
return .lostRace
case .timeout:
return .timeout
case .peerDisconnected:
return .peerDisconnected
case .protocolError:
return .protocolError
case .platformUnavailable:
return .platformUnavailable
default:
return .protocolError
}
}
}

private extension IOSNfcHostBridgeCloseReason {
var sharedUICloseReason: sharedUI.Waltid_mdoc_proximityProximityCloseReason {
switch self {
case .completed:
return .completed
case .handoverCompleted:
return .handoverCompleted
case .cancelled:
return .cancelled
case .lostRace:
return .lostRace
case .timeout:
return .timeout
case .peerDisconnected:
return .peerDisconnected
case .protocolError:
return .protocolError
case .platformUnavailable:
return .platformUnavailable
}
}
}

private extension Data {
func composeKotlinByteArray() -> sharedUI.KotlinByteArray {
let result = sharedUI.KotlinByteArray(size: Int32(count))
for (index, byte) in enumerated() {
result.set(index: Int32(index), value: Int8(bitPattern: byte))
}
return result
}
}

private extension sharedUI.KotlinByteArray {
func composeData() -> Data {
Data((0..<Int(size)).map { UInt8(bitPattern: get(index: Int32($0))) })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ struct ContentView: UIViewControllerRepresentable {
sharedUI.WalletDemoIosKt.walletDemoViewController(
appGroupIdentifier: appGroupIdentifier,
keychainAccessGroup: keychainAccessGroup,
nfcHostPlatformAdapter: ComposeNfcHostPlatformAdapter(),
onDigitalCredentialRegistryChanged: onDigitalCredentialRegistryChanged,
walletId: walletId,
attestationBaseUrl: attestationBaseUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,21 @@ package id.walt.walletdemo.compose.logic
import id.walt.wallet2.mobile.MobileWalletProximityAction
import id.walt.wallet2.mobile.MobileWalletProximityActionResult
import id.walt.wallet2.mobile.MobileWalletProximityCapabilities
import id.walt.wallet2.mobile.MobileWalletProximityBleBearerPolicy
import id.walt.wallet2.mobile.MobileWalletProximityBleRoles
import id.walt.wallet2.mobile.MobileWalletProximityConfiguration
import id.walt.wallet2.mobile.MobileWalletProximityCredentialOption
import id.walt.wallet2.mobile.MobileWalletProximityDeviceAuthenticationMethod
import id.walt.wallet2.mobile.MobileWalletProximityDocumentReview
import id.walt.wallet2.mobile.MobileWalletProximityElementReference
import id.walt.wallet2.mobile.MobileWalletProximityError
import id.walt.wallet2.mobile.MobileWalletProximityErrorCategory
import id.walt.wallet2.mobile.MobileWalletProximityEngagementConfiguration
import id.walt.wallet2.mobile.MobileWalletProximityHostActionResult
import id.walt.wallet2.mobile.MobileWalletProximityNfcEngagementMode
import id.walt.wallet2.mobile.MobileWalletProximityProfile
import id.walt.wallet2.mobile.MobileWalletProximityRemediationAction
import id.walt.wallet2.mobile.MobileWalletProximityRetrievalConfiguration
import id.walt.wallet2.mobile.MobileWalletProximityRequestedElement
import id.walt.wallet2.mobile.MobileWalletProximityReview
import id.walt.wallet2.mobile.MobileWalletProximitySession
Expand All @@ -23,6 +28,8 @@ import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.time.Instant
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
Expand All @@ -47,12 +54,74 @@ class WalletDemoProximityControllerTest {
advanceUntilIdle()

assertEquals(1, backend.startCalls)
val configuration = requireNotNull(backend.lastConfiguration)
val engagement = assertIs<MobileWalletProximityEngagementConfiguration.QrAndNfc>(
configuration.engagement,
)
assertIs<MobileWalletProximityNfcEngagementMode.Negotiated>(engagement.mode)
val retrieval = assertIs<MobileWalletProximityRetrievalConfiguration.Conventional>(
configuration.retrieval,
)
assertNotNull(retrieval.bluetoothLowEnergy)
assertNotNull(retrieval.nfc)
assertEquals(session.state.value, controller.state.value.sessionState)
assertTrue(controller.state.value.active)
controller.dismiss()
advanceUntilIdle()
}

@Test
fun `provisional NFCv2 hybrid profile selects only NFC engagement with alternate GATT BLE`() {
val configuration = WalletDemoProximityTransportProfile.ProvisionalNfcV2Hybrid.configuration()

val engagement = assertIs<MobileWalletProximityEngagementConfiguration.NfcOnly>(
configuration.engagement,
)
assertIs<MobileWalletProximityNfcEngagementMode.ProvisionalV2>(engagement.mode)
val retrieval = assertIs<MobileWalletProximityRetrievalConfiguration.ProvisionalNfcV2>(
configuration.retrieval,
)
val bluetooth = assertNotNull(retrieval.bluetoothLowEnergy)
assertEquals(MobileWalletProximityBleRoles.CentralClient, bluetooth.roles)
assertEquals(MobileWalletProximityBleBearerPolicy.GattOnly, bluetooth.bearerPolicy)
assertNull(retrieval.qrNfc)
}

@Test
fun `provisional NFCv2 direct profile has no fallback bearer`() {
val configuration = WalletDemoProximityTransportProfile.ProvisionalNfcV2Direct.configuration()

val engagement = assertIs<MobileWalletProximityEngagementConfiguration.NfcOnly>(
configuration.engagement,
)
assertIs<MobileWalletProximityNfcEngagementMode.ProvisionalV2>(engagement.mode)
val retrieval = assertIs<MobileWalletProximityRetrievalConfiguration.ProvisionalNfcV2>(
configuration.retrieval,
)
assertNull(retrieval.bluetoothLowEnergy)
assertNull(retrieval.qrNfc)
}

@Test
fun `start snapshots the selected profile before launching the session`() = runTest {
var selected = WalletDemoProximityTransportProfile.Default
val session = FakeSession(
MobileWalletProximityState.Preparing(MobileWalletProximityProfile.Iso180135Edition2Dis2026)
)
val backend = FakeBackend(session = session)
val controller = controller(backend) { selected }

controller.start()
selected = WalletDemoProximityTransportProfile.ProvisionalNfcV2Direct
advanceUntilIdle()

assertIs<MobileWalletProximityEngagementConfiguration.QrAndNfc>(
requireNotNull(backend.lastConfiguration).engagement,
)
controller.dismiss()
advanceUntilIdle()
}

@Test
fun `review defaults are complete and approval contains only the current holder choices`() = runTest {
val session = FakeSession(MobileWalletProximityState.ReviewRequired(review()))
Expand Down Expand Up @@ -253,8 +322,12 @@ class WalletDemoProximityControllerTest {

private fun kotlinx.coroutines.test.TestScope.controller(
backend: ProximityPresentationBackend,
profileProvider: () -> WalletDemoProximityTransportProfile = {
WalletDemoProximityTransportProfile.Default
},
): WalletDemoProximityController = WalletDemoProximityController(
wallet = backend,
profileProvider = profileProvider,
scope = this,
dispatcher = StandardTestDispatcher(testScheduler),
)
Expand All @@ -266,11 +339,14 @@ private class FakeBackend(
) : ProximityPresentationBackend {
var startCalls: Int = 0
private set
var lastConfiguration: MobileWalletProximityConfiguration? = null
private set

override suspend fun startProximityPresentation(
configuration: MobileWalletProximityConfiguration,
): MobileWalletProximitySession {
startCalls += 1
lastConfiguration = configuration
startGate?.let { withContext(NonCancellable) { it.await() } }
return session
}
Expand Down Expand Up @@ -359,6 +435,7 @@ private val readyCapabilities = MobileWalletProximityCapabilities(
nfcEngagement = availableUnselected,
bluetoothLowEnergy = availableSelected,
nfcRetrieval = availableUnselected,
nfcV2Retrieval = availableUnselected,
wifiAwareRetrieval = availableUnselected,
)

Expand Down
Loading
Loading