Skip to content

Commit c553518

Browse files
authored
Add feature: iOS w3c DC #openwallet-foundation/multipaz#1603 (#79)
What does this PR do? Add features : iOS Support w3c DC Why are we making this change? Currently android support w3c DC, we should let iOS support that How was this tested? - This project may need to upgrade iOS, Xcode and macOS: currently I am using iOS 26.3.2, xcode:26.3, macOS:15.7.4 - Build on Xcode: You iOS account need to be Apple Developer account You need to add App Groups in Xcode and https://developer.apple.com/account/resources/identifiers/list - About add groups from https://developer.apple.com/account/resources/identifiers/list, first step login your account - Click identifiers find your identifiers for example my identifiers is:org.multipaz.samples.wallet.cmp.UtopiaSample , then click the identifier - In capabilities, find App Groups, click Edit ,select your Group then select save Screenshots/Videos (if applicable) This is the video:ScreenRecording_03-09-2026 16-47-30_1.MP4 Checklist - [x] Code follows project style guidelines - [x] Documentation updated (if needed) - [x] No breaking changes (or breaking changes are documented) Made-with: Cursor
1 parent 6bd98eb commit c553518

File tree

23 files changed

+673
-23
lines changed

23 files changed

+673
-23
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package org.multipaz.samples.wallet.cmp.util
2+
3+
actual fun shouldRegisterDigitalCredentialsInCommonModule(): Boolean = true
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package org.multipaz.samples.wallet.cmp.util
2+
3+
import org.multipaz.storage.Storage
4+
import org.multipaz.util.Platform
5+
6+
actual fun createWalletStorage(): Storage = Platform.nonBackedUpStorage

MultipazCodelab/Holder/composeApp/src/commonMain/kotlin/org/multipaz/samples/wallet/cmp/di/MultipazModule.kt

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@file:OptIn(kotlin.time.ExperimentalTime::class)
2+
13
package org.multipaz.samples.wallet.cmp.di
24

35
import io.ktor.client.HttpClient
@@ -17,6 +19,8 @@ import org.multipaz.presentment.model.PresentmentModel
1719
import org.multipaz.presentment.model.PresentmentSource
1820
import org.multipaz.presentment.model.SimplePresentmentSource
1921
import org.multipaz.prompt.PromptModel
22+
import org.multipaz.prompt.promptModelRequestConsent
23+
import org.multipaz.prompt.promptModelSilentConsent
2024
import org.multipaz.provisioning.DocumentProvisioningHandler
2125
import org.multipaz.provisioning.ProvisioningModel
2226
import org.multipaz.provisioning.openid4vci.OpenID4VCIBackend
@@ -27,6 +31,8 @@ import org.multipaz.samples.wallet.cmp.util.OpenID4VCILocalBackend
2731
import org.multipaz.samples.wallet.cmp.util.ProvisioningSupport
2832
import org.multipaz.samples.wallet.cmp.util.ProvisioningSupport.Companion.APP_LINK_BASE_URL
2933
import org.multipaz.samples.wallet.cmp.util.TestAppUtils
34+
import org.multipaz.samples.wallet.cmp.util.createWalletStorage
35+
import org.multipaz.samples.wallet.cmp.util.shouldRegisterDigitalCredentialsInCommonModule
3036
import org.multipaz.securearea.SecureArea
3137
import org.multipaz.securearea.SecureAreaRepository
3238
import org.multipaz.storage.Storage
@@ -40,7 +46,7 @@ import utopiasample.composeapp.generated.resources.Res
4046

4147
val multipazModule =
4248
module {
43-
single<Storage> { Platform.nonBackedUpStorage }
49+
single<Storage> { createWalletStorage() }
4450
single<SecureArea> { runBlocking { Platform.getSecureArea() } }
4551
single<SecureAreaRepository> {
4652
val secureArea: SecureArea = get()
@@ -155,26 +161,69 @@ val multipazModule =
155161
}
156162

157163
single<PresentmentSource> {
158-
runBlocking {
159-
val digitalCredentials = DigitalCredentials.getDefault()
160-
if (digitalCredentials.registerAvailable) {
161-
digitalCredentials.register(
162-
documentStore = get(),
163-
documentTypeRepository = get(),
164-
)
165-
}
164+
val settingsModel: AppSettingsModel = get()
165+
val requireAuthentication = settingsModel.presentmentRequireAuthentication.value
166+
val documentStore: DocumentStore = get()
167+
val documentTypeRepository: DocumentTypeRepository = get()
166168

167-
SimplePresentmentSource(
168-
documentStore = get(),
169-
documentTypeRepository = get(),
170-
preferSignatureToKeyAgreement = true,
171-
// Match domains used when storing credentials via OpenID4VCI
172-
domainMdocSignature = TestAppUtils.CREDENTIAL_DOMAIN_MDOC_USER_AUTH,
173-
domainMdocKeyAgreement = TestAppUtils.CREDENTIAL_DOMAIN_MDOC_MAC_USER_AUTH,
174-
domainKeylessSdJwt = TestAppUtils.CREDENTIAL_DOMAIN_SDJWT_KEYLESS,
175-
domainKeyBoundSdJwt = TestAppUtils.CREDENTIAL_DOMAIN_SDJWT_USER_AUTH,
176-
)
169+
// Android registers here; iOS uses IosDocumentProviderBridge with entitlement-filtered types.
170+
if (shouldRegisterDigitalCredentialsInCommonModule()) {
171+
runBlocking {
172+
val digitalCredentials = DigitalCredentials.getDefault()
173+
if (digitalCredentials.registerAvailable) {
174+
try {
175+
digitalCredentials.register(
176+
documentStore = documentStore,
177+
documentTypeRepository = documentTypeRepository,
178+
selectedProtocols = settingsModel.dcApiProtocols.value,
179+
)
180+
} catch (t: Throwable) {
181+
Logger.w("DigitalCredentials", "Initial DC API registration failed", t)
182+
}
183+
}
184+
}
177185
}
186+
187+
SimplePresentmentSource(
188+
documentStore = documentStore,
189+
documentTypeRepository = documentTypeRepository,
190+
showConsentPromptFn =
191+
if (settingsModel.presentmentShowConsentPrompt.value) {
192+
::promptModelRequestConsent
193+
} else {
194+
::promptModelSilentConsent
195+
},
196+
resolveTrustFn = { requester ->
197+
requester.certChain?.let { certChain ->
198+
val trustResult = get<TrustManager>().verify(certChain.certificates)
199+
if (trustResult.isTrusted) {
200+
trustResult.trustPoints.firstOrNull()?.metadata
201+
} else {
202+
null
203+
}
204+
}
205+
},
206+
preferSignatureToKeyAgreement = settingsModel.presentmentPreferSignatureToKeyAgreement.value,
207+
domainMdocSignature =
208+
if (requireAuthentication) {
209+
TestAppUtils.CREDENTIAL_DOMAIN_MDOC_USER_AUTH
210+
} else {
211+
TestAppUtils.CREDENTIAL_DOMAIN_MDOC_NO_USER_AUTH
212+
},
213+
domainMdocKeyAgreement =
214+
if (requireAuthentication) {
215+
TestAppUtils.CREDENTIAL_DOMAIN_MDOC_MAC_USER_AUTH
216+
} else {
217+
TestAppUtils.CREDENTIAL_DOMAIN_MDOC_MAC_NO_USER_AUTH
218+
},
219+
domainKeylessSdJwt = TestAppUtils.CREDENTIAL_DOMAIN_SDJWT_KEYLESS,
220+
domainKeyBoundSdJwt =
221+
if (requireAuthentication) {
222+
TestAppUtils.CREDENTIAL_DOMAIN_SDJWT_USER_AUTH
223+
} else {
224+
TestAppUtils.CREDENTIAL_DOMAIN_SDJWT_NO_USER_AUTH
225+
},
226+
)
178227
}
179228

180229
single<ProvisioningSupport> {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package org.multipaz.samples.wallet.cmp.util
2+
3+
expect fun shouldRegisterDigitalCredentialsInCommonModule(): Boolean
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package org.multipaz.samples.wallet.cmp.util
2+
3+
import org.multipaz.storage.Storage
4+
5+
expect fun createWalletStorage(): Storage
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package org.multipaz.samples.wallet.cmp
2+
3+
import kotlinx.coroutines.CoroutineScope
4+
import kotlinx.coroutines.Dispatchers
5+
import kotlinx.coroutines.flow.drop
6+
import kotlinx.coroutines.flow.launchIn
7+
import kotlinx.coroutines.flow.onEach
8+
import kotlinx.coroutines.launch
9+
import org.koin.core.component.KoinComponent
10+
import org.koin.core.component.get
11+
import org.multipaz.digitalcredentials.DigitalCredentials
12+
import org.multipaz.digitalcredentials.getDefault
13+
import org.multipaz.document.DocumentStore
14+
import org.multipaz.documenttype.DocumentTypeRepository
15+
import org.multipaz.documenttype.knowntypes.DrivingLicense
16+
import org.multipaz.presentment.model.PresentmentSource
17+
import org.multipaz.presentment.model.digitalCredentialsPresentment
18+
import org.multipaz.samples.wallet.cmp.di.initKoin
19+
import org.multipaz.samples.wallet.cmp.util.AppSettingsModel
20+
import org.multipaz.util.Logger
21+
22+
private const val TAG = "IosDocumentProvider"
23+
24+
private object IosDocumentProviderComponent : KoinComponent
25+
26+
private var koinInitialized = false
27+
private var registrationStarted = false
28+
29+
@Suppress("FunctionName")
30+
fun EnsureIosDocumentProviderInitialized() {
31+
if (!koinInitialized) {
32+
initKoin()
33+
koinInitialized = true
34+
}
35+
}
36+
37+
@Suppress("FunctionName")
38+
suspend fun GetIosDocumentProviderPresentmentSource(): PresentmentSource {
39+
EnsureIosDocumentProviderInitialized()
40+
return IosDocumentProviderComponent.get()
41+
}
42+
43+
@Suppress("FunctionName")
44+
fun StartIosDigitalCredentialsRegistration() {
45+
EnsureIosDocumentProviderInitialized()
46+
if (registrationStarted) {
47+
return
48+
}
49+
registrationStarted = true
50+
51+
CoroutineScope(Dispatchers.Default).launch {
52+
registerIosDigitalCredentials()
53+
54+
val documentStore: DocumentStore = IosDocumentProviderComponent.get()
55+
val settingsModel: AppSettingsModel = IosDocumentProviderComponent.get()
56+
57+
documentStore.eventFlow
58+
.onEach {
59+
Logger.i(TAG, "DocumentStore updated, refreshing iOS W3C DC registrations")
60+
registerIosDigitalCredentials()
61+
}.launchIn(this)
62+
63+
settingsModel.dcApiProtocols
64+
.drop(1)
65+
.onEach {
66+
Logger.i(TAG, "DC API protocols changed, refreshing iOS W3C DC registrations")
67+
registerIosDigitalCredentials()
68+
}.launchIn(this)
69+
}
70+
}
71+
72+
@Suppress("FunctionName")
73+
suspend fun UpdateIosDocumentProviderRegistrations() {
74+
EnsureIosDocumentProviderInitialized()
75+
registerIosDigitalCredentials()
76+
}
77+
78+
@Suppress("FunctionName")
79+
suspend fun ProcessIosDocumentRequest(
80+
requestData: String,
81+
origin: String?,
82+
): String {
83+
EnsureIosDocumentProviderInitialized()
84+
val source: PresentmentSource = IosDocumentProviderComponent.get()
85+
return digitalCredentialsPresentment(
86+
protocol = "org-iso-mdoc",
87+
data = requestData,
88+
appId = null,
89+
origin = origin ?: "",
90+
preselectedDocuments = emptyList(),
91+
source = source,
92+
)
93+
}
94+
95+
private suspend fun registerIosDigitalCredentials() {
96+
val digitalCredentials = DigitalCredentials.getDefault()
97+
if (!digitalCredentials.registerAvailable) {
98+
return
99+
}
100+
101+
val documentStore: DocumentStore = IosDocumentProviderComponent.get()
102+
val settingsModel: AppSettingsModel = IosDocumentProviderComponent.get()
103+
val entitledRepository = createEntitledIosDocumentTypeRepository()
104+
val selectedProtocols =
105+
settingsModel.dcApiProtocols.value
106+
.intersect(setOf("org-iso-mdoc"))
107+
.ifEmpty { setOf("org-iso-mdoc") }
108+
109+
try {
110+
val authState = digitalCredentials.authorizationState.value
111+
Logger.i(
112+
TAG,
113+
"Registering iOS DC credentials with protocols=$selectedProtocols authState=$authState",
114+
)
115+
digitalCredentials.register(
116+
documentStore = documentStore,
117+
documentTypeRepository = entitledRepository,
118+
selectedProtocols = selectedProtocols,
119+
)
120+
} catch (t: Throwable) {
121+
Logger.w(TAG, "Error registering with iOS W3C DC API", t)
122+
}
123+
}
124+
125+
private fun createEntitledIosDocumentTypeRepository(): DocumentTypeRepository =
126+
DocumentTypeRepository().apply {
127+
// Keep in sync with iosApp entitlements mobile-document-types.
128+
addDocumentType(DrivingLicense.getDocumentType())
129+
}

MultipazCodelab/Holder/composeApp/src/iosMain/kotlin/org/multipaz/samples/wallet/cmp/MainViewController.kt

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,27 @@ import androidx.compose.runtime.setValue
1212
import androidx.compose.ui.Alignment
1313
import androidx.compose.ui.Modifier
1414
import androidx.compose.ui.window.ComposeUIViewController
15+
import io.ktor.client.engine.darwin.Darwin
16+
import io.ktor.http.Url
17+
import io.ktor.http.protocolWithAuthority
18+
import kotlinx.coroutines.Dispatchers
1519
import kotlinx.coroutines.channels.Channel
20+
import kotlinx.coroutines.withContext
1621
import org.koin.core.component.KoinComponent
1722
import org.koin.core.component.get
1823
import org.multipaz.document.DocumentStore
1924
import org.multipaz.presentment.model.PresentmentModel
2025
import org.multipaz.presentment.model.PresentmentSource
26+
import org.multipaz.presentment.model.uriSchemePresentment
27+
import org.multipaz.prompt.PromptModel
2128
import org.multipaz.provisioning.ProvisioningModel
22-
import org.multipaz.samples.wallet.cmp.di.initKoin
2329
import org.multipaz.samples.wallet.cmp.util.ProvisioningSupport
2430
import org.multipaz.trustmanagement.TrustManager
2531
import org.multipaz.util.Logger
2632

2733
private const val TAG = "MainViewController"
34+
private const val OPENID4VP_URL_SCHEME = "openid4vp://"
35+
private const val HAIP_VP_URL_SCHEME = "haip-vp://"
2836

2937
// Store credentialOffers channel globally so HandleUrl can access it
3038
private var globalCredentialOffers: Channel<String>? = null
@@ -33,7 +41,7 @@ private var globalCredentialOffers: Channel<String>? = null
3341
fun MainViewController() =
3442
ComposeUIViewController(
3543
configure = {
36-
initKoin()
44+
EnsureIosDocumentProviderInitialized()
3745
},
3846
) {
3947
var isInitialized by remember { mutableStateOf(false) }
@@ -56,6 +64,7 @@ fun MainViewController() =
5664
koinHelper.get<ProvisioningSupport>()
5765
koinHelper.get<PresentmentModel>()
5866
koinHelper.get<PresentmentSource>()
67+
StartIosDigitalCredentialsRegistration()
5968
Logger.i(TAG, "iOS: All Koin dependencies initialized successfully")
6069
isInitialized = true
6170
} catch (e: Exception) {
@@ -106,3 +115,25 @@ fun HandleUrl(url: String) {
106115
Logger.e(TAG, "Error in HandleUrl: ${e.message}", e)
107116
}
108117
}
118+
119+
@Suppress("FunctionName") // Swift interop naming
120+
suspend fun ProcessIosUriSchemeRequest(requestUrl: String): String {
121+
EnsureIosDocumentProviderInitialized()
122+
val koinHelper = object : KoinComponent { }
123+
val source = koinHelper.get<PresentmentSource>()
124+
val promptModel = koinHelper.get<PromptModel>()
125+
val origin = Url(requestUrl).protocolWithAuthority
126+
127+
return withContext(Dispatchers.Main + promptModel) {
128+
uriSchemePresentment(
129+
source = source,
130+
uri = requestUrl,
131+
origin = origin,
132+
httpClientEngineFactory = Darwin,
133+
)
134+
}
135+
}
136+
137+
@Suppress("FunctionName") // Swift interop naming
138+
fun IsIosUriSchemePresentmentUrl(url: String): Boolean =
139+
url.startsWith(OPENID4VP_URL_SCHEME) || url.startsWith(HAIP_VP_URL_SCHEME)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package org.multipaz.samples.wallet.cmp.util
2+
3+
actual fun shouldRegisterDigitalCredentialsInCommonModule(): Boolean = false
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package org.multipaz.samples.wallet.cmp.util
2+
3+
import org.multipaz.storage.Storage
4+
import org.multipaz.storage.ios.IosStorage
5+
import platform.Foundation.NSFileManager
6+
7+
private const val IOS_APP_GROUP_IDENTIFIER = "group.org.multipaz.hanlu.testapp.sharedgroup"
8+
9+
actual fun createWalletStorage(): Storage =
10+
IosStorage(
11+
storageFileUrl =
12+
NSFileManager.defaultManager
13+
.containerURLForSecurityApplicationGroupIdentifier(
14+
groupIdentifier = IOS_APP_GROUP_IDENTIFIER,
15+
)!!
16+
.URLByAppendingPathComponent("storageNoBackup.db")!!,
17+
excludeFromBackup = true,
18+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import Foundation

0 commit comments

Comments
 (0)