Skip to content
Merged
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
@@ -0,0 +1,3 @@
package org.multipaz.samples.wallet.cmp.util

actual fun shouldRegisterDigitalCredentialsInCommonModule(): Boolean = true
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.multipaz.samples.wallet.cmp.util

import org.multipaz.storage.Storage
import org.multipaz.util.Platform

actual fun createWalletStorage(): Storage = Platform.nonBackedUpStorage
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:OptIn(kotlin.time.ExperimentalTime::class)

package org.multipaz.samples.wallet.cmp.di

import io.ktor.client.HttpClient
Expand All @@ -17,6 +19,8 @@ import org.multipaz.presentment.model.PresentmentModel
import org.multipaz.presentment.model.PresentmentSource
import org.multipaz.presentment.model.SimplePresentmentSource
import org.multipaz.prompt.PromptModel
import org.multipaz.prompt.promptModelRequestConsent
import org.multipaz.prompt.promptModelSilentConsent
import org.multipaz.provisioning.DocumentProvisioningHandler
import org.multipaz.provisioning.ProvisioningModel
import org.multipaz.provisioning.openid4vci.OpenID4VCIBackend
Expand All @@ -27,6 +31,8 @@ import org.multipaz.samples.wallet.cmp.util.OpenID4VCILocalBackend
import org.multipaz.samples.wallet.cmp.util.ProvisioningSupport
import org.multipaz.samples.wallet.cmp.util.ProvisioningSupport.Companion.APP_LINK_BASE_URL
import org.multipaz.samples.wallet.cmp.util.TestAppUtils
import org.multipaz.samples.wallet.cmp.util.createWalletStorage
import org.multipaz.samples.wallet.cmp.util.shouldRegisterDigitalCredentialsInCommonModule
import org.multipaz.securearea.SecureArea
import org.multipaz.securearea.SecureAreaRepository
import org.multipaz.storage.Storage
Expand All @@ -40,7 +46,7 @@ import utopiasample.composeapp.generated.resources.Res

val multipazModule =
module {
single<Storage> { Platform.nonBackedUpStorage }
single<Storage> { createWalletStorage() }
single<SecureArea> { runBlocking { Platform.getSecureArea() } }
single<SecureAreaRepository> {
val secureArea: SecureArea = get()
Expand Down Expand Up @@ -155,26 +161,69 @@ val multipazModule =
}

single<PresentmentSource> {
runBlocking {
val digitalCredentials = DigitalCredentials.getDefault()
if (digitalCredentials.registerAvailable) {
digitalCredentials.register(
documentStore = get(),
documentTypeRepository = get(),
)
}
val settingsModel: AppSettingsModel = get()
val requireAuthentication = settingsModel.presentmentRequireAuthentication.value
val documentStore: DocumentStore = get()
val documentTypeRepository: DocumentTypeRepository = get()

SimplePresentmentSource(
documentStore = get(),
documentTypeRepository = get(),
preferSignatureToKeyAgreement = true,
// Match domains used when storing credentials via OpenID4VCI
domainMdocSignature = TestAppUtils.CREDENTIAL_DOMAIN_MDOC_USER_AUTH,
domainMdocKeyAgreement = TestAppUtils.CREDENTIAL_DOMAIN_MDOC_MAC_USER_AUTH,
domainKeylessSdJwt = TestAppUtils.CREDENTIAL_DOMAIN_SDJWT_KEYLESS,
domainKeyBoundSdJwt = TestAppUtils.CREDENTIAL_DOMAIN_SDJWT_USER_AUTH,
)
// Android registers here; iOS uses IosDocumentProviderBridge with entitlement-filtered types.
if (shouldRegisterDigitalCredentialsInCommonModule()) {
runBlocking {
val digitalCredentials = DigitalCredentials.getDefault()
if (digitalCredentials.registerAvailable) {
try {
digitalCredentials.register(
documentStore = documentStore,
documentTypeRepository = documentTypeRepository,
selectedProtocols = settingsModel.dcApiProtocols.value,
)
} catch (t: Throwable) {
Logger.w("DigitalCredentials", "Initial DC API registration failed", t)
}
}
}
}

SimplePresentmentSource(
documentStore = documentStore,
documentTypeRepository = documentTypeRepository,
showConsentPromptFn =
if (settingsModel.presentmentShowConsentPrompt.value) {
::promptModelRequestConsent
} else {
::promptModelSilentConsent
},
resolveTrustFn = { requester ->
requester.certChain?.let { certChain ->
val trustResult = get<TrustManager>().verify(certChain.certificates)
if (trustResult.isTrusted) {
trustResult.trustPoints.firstOrNull()?.metadata
} else {
null
}
}
},
preferSignatureToKeyAgreement = settingsModel.presentmentPreferSignatureToKeyAgreement.value,
domainMdocSignature =
if (requireAuthentication) {
TestAppUtils.CREDENTIAL_DOMAIN_MDOC_USER_AUTH
} else {
TestAppUtils.CREDENTIAL_DOMAIN_MDOC_NO_USER_AUTH
},
domainMdocKeyAgreement =
if (requireAuthentication) {
TestAppUtils.CREDENTIAL_DOMAIN_MDOC_MAC_USER_AUTH
} else {
TestAppUtils.CREDENTIAL_DOMAIN_MDOC_MAC_NO_USER_AUTH
},
domainKeylessSdJwt = TestAppUtils.CREDENTIAL_DOMAIN_SDJWT_KEYLESS,
domainKeyBoundSdJwt =
if (requireAuthentication) {
TestAppUtils.CREDENTIAL_DOMAIN_SDJWT_USER_AUTH
} else {
TestAppUtils.CREDENTIAL_DOMAIN_SDJWT_NO_USER_AUTH
},
)
}

single<ProvisioningSupport> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.multipaz.samples.wallet.cmp.util

expect fun shouldRegisterDigitalCredentialsInCommonModule(): Boolean
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.multipaz.samples.wallet.cmp.util

import org.multipaz.storage.Storage

expect fun createWalletStorage(): Storage
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package org.multipaz.samples.wallet.cmp

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.multipaz.digitalcredentials.DigitalCredentials
import org.multipaz.digitalcredentials.getDefault
import org.multipaz.document.DocumentStore
import org.multipaz.documenttype.DocumentTypeRepository
import org.multipaz.documenttype.knowntypes.DrivingLicense
import org.multipaz.presentment.model.PresentmentSource
import org.multipaz.presentment.model.digitalCredentialsPresentment
import org.multipaz.samples.wallet.cmp.di.initKoin
import org.multipaz.samples.wallet.cmp.util.AppSettingsModel
import org.multipaz.util.Logger

private const val TAG = "IosDocumentProvider"

private object IosDocumentProviderComponent : KoinComponent

private var koinInitialized = false
private var registrationStarted = false

@Suppress("FunctionName")
fun EnsureIosDocumentProviderInitialized() {
if (!koinInitialized) {
initKoin()
koinInitialized = true
}
}

@Suppress("FunctionName")
suspend fun GetIosDocumentProviderPresentmentSource(): PresentmentSource {
EnsureIosDocumentProviderInitialized()
return IosDocumentProviderComponent.get()
}

@Suppress("FunctionName")
fun StartIosDigitalCredentialsRegistration() {
EnsureIosDocumentProviderInitialized()
if (registrationStarted) {
return
}
registrationStarted = true

CoroutineScope(Dispatchers.Default).launch {
registerIosDigitalCredentials()

val documentStore: DocumentStore = IosDocumentProviderComponent.get()
val settingsModel: AppSettingsModel = IosDocumentProviderComponent.get()

documentStore.eventFlow
.onEach {
Logger.i(TAG, "DocumentStore updated, refreshing iOS W3C DC registrations")
registerIosDigitalCredentials()
}.launchIn(this)

settingsModel.dcApiProtocols
.drop(1)
.onEach {
Logger.i(TAG, "DC API protocols changed, refreshing iOS W3C DC registrations")
registerIosDigitalCredentials()
}.launchIn(this)
}
}

@Suppress("FunctionName")
suspend fun UpdateIosDocumentProviderRegistrations() {
EnsureIosDocumentProviderInitialized()
registerIosDigitalCredentials()
}

@Suppress("FunctionName")
suspend fun ProcessIosDocumentRequest(
requestData: String,
origin: String?,
): String {
EnsureIosDocumentProviderInitialized()
val source: PresentmentSource = IosDocumentProviderComponent.get()
return digitalCredentialsPresentment(
protocol = "org-iso-mdoc",
data = requestData,
appId = null,
origin = origin ?: "",
preselectedDocuments = emptyList(),
source = source,
)
}

private suspend fun registerIosDigitalCredentials() {
val digitalCredentials = DigitalCredentials.getDefault()
if (!digitalCredentials.registerAvailable) {
return
}

val documentStore: DocumentStore = IosDocumentProviderComponent.get()
val settingsModel: AppSettingsModel = IosDocumentProviderComponent.get()
val entitledRepository = createEntitledIosDocumentTypeRepository()
val selectedProtocols =
settingsModel.dcApiProtocols.value
.intersect(setOf("org-iso-mdoc"))
.ifEmpty { setOf("org-iso-mdoc") }

try {
val authState = digitalCredentials.authorizationState.value
Logger.i(
TAG,
"Registering iOS DC credentials with protocols=$selectedProtocols authState=$authState",
)
digitalCredentials.register(
documentStore = documentStore,
documentTypeRepository = entitledRepository,
selectedProtocols = selectedProtocols,
)
} catch (t: Throwable) {
Logger.w(TAG, "Error registering with iOS W3C DC API", t)
}
}

private fun createEntitledIosDocumentTypeRepository(): DocumentTypeRepository =
DocumentTypeRepository().apply {
// Keep in sync with iosApp entitlements mobile-document-types.
addDocumentType(DrivingLicense.getDocumentType())
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,27 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.ComposeUIViewController
import io.ktor.client.engine.darwin.Darwin
import io.ktor.http.Url
import io.ktor.http.protocolWithAuthority
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.multipaz.document.DocumentStore
import org.multipaz.presentment.model.PresentmentModel
import org.multipaz.presentment.model.PresentmentSource
import org.multipaz.presentment.model.uriSchemePresentment
import org.multipaz.prompt.PromptModel
import org.multipaz.provisioning.ProvisioningModel
import org.multipaz.samples.wallet.cmp.di.initKoin
import org.multipaz.samples.wallet.cmp.util.ProvisioningSupport
import org.multipaz.trustmanagement.TrustManager
import org.multipaz.util.Logger

private const val TAG = "MainViewController"
private const val OPENID4VP_URL_SCHEME = "openid4vp://"
private const val HAIP_VP_URL_SCHEME = "haip-vp://"

// Store credentialOffers channel globally so HandleUrl can access it
private var globalCredentialOffers: Channel<String>? = null
Expand All @@ -33,7 +41,7 @@ private var globalCredentialOffers: Channel<String>? = null
fun MainViewController() =
ComposeUIViewController(
configure = {
initKoin()
EnsureIosDocumentProviderInitialized()
},
) {
var isInitialized by remember { mutableStateOf(false) }
Expand All @@ -56,6 +64,7 @@ fun MainViewController() =
koinHelper.get<ProvisioningSupport>()
koinHelper.get<PresentmentModel>()
koinHelper.get<PresentmentSource>()
StartIosDigitalCredentialsRegistration()
Logger.i(TAG, "iOS: All Koin dependencies initialized successfully")
isInitialized = true
} catch (e: Exception) {
Expand Down Expand Up @@ -106,3 +115,25 @@ fun HandleUrl(url: String) {
Logger.e(TAG, "Error in HandleUrl: ${e.message}", e)
}
}

@Suppress("FunctionName") // Swift interop naming
suspend fun ProcessIosUriSchemeRequest(requestUrl: String): String {
EnsureIosDocumentProviderInitialized()
val koinHelper = object : KoinComponent { }
val source = koinHelper.get<PresentmentSource>()
val promptModel = koinHelper.get<PromptModel>()
val origin = Url(requestUrl).protocolWithAuthority

return withContext(Dispatchers.Main + promptModel) {
uriSchemePresentment(
source = source,
uri = requestUrl,
origin = origin,
httpClientEngineFactory = Darwin,
)
}
}

@Suppress("FunctionName") // Swift interop naming
fun IsIosUriSchemePresentmentUrl(url: String): Boolean =
url.startsWith(OPENID4VP_URL_SCHEME) || url.startsWith(HAIP_VP_URL_SCHEME)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.multipaz.samples.wallet.cmp.util

actual fun shouldRegisterDigitalCredentialsInCommonModule(): Boolean = false
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package org.multipaz.samples.wallet.cmp.util

import org.multipaz.storage.Storage
import org.multipaz.storage.ios.IosStorage
import platform.Foundation.NSFileManager

private const val IOS_APP_GROUP_IDENTIFIER = "group.org.multipaz.hanlu.testapp.sharedgroup"

actual fun createWalletStorage(): Storage =
IosStorage(
storageFileUrl =
NSFileManager.defaultManager
.containerURLForSecurityApplicationGroupIdentifier(
groupIdentifier = IOS_APP_GROUP_IDENTIFIER,
)!!
.URLByAppendingPathComponent("storageNoBackup.db")!!,
excludeFromBackup = true,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import Foundation
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import Foundation
Loading
Loading