diff --git a/appholder/src/main/java/com/android/identity/wallet/HolderApp.kt b/appholder/src/main/java/com/android/identity/wallet/HolderApp.kt index 04c3a7614..5c0ba6e39 100644 --- a/appholder/src/main/java/com/android/identity/wallet/HolderApp.kt +++ b/appholder/src/main/java/com/android/identity/wallet/HolderApp.kt @@ -6,6 +6,7 @@ import com.android.identity.android.securearea.AndroidKeystoreSecureArea import com.android.identity.android.storage.AndroidStorageEngine import com.android.identity.android.util.AndroidLogPrinter import com.android.identity.credential.CredentialFactory +import com.android.identity.crypto.X509Cert import com.android.identity.document.DocumentStore import com.android.identity.documenttype.DocumentTypeRepository import com.android.identity.documenttype.knowntypes.DrivingLicense @@ -64,10 +65,10 @@ class HolderApp: Application() { certificateStorageEngineInstance = certificateStorageEngine certificateStorageEngineInstance.enumerate().forEach { val certificate = parseCertificate(certificateStorageEngineInstance.get(it)!!) - trustManagerInstance.addTrustPoint(TrustPoint(certificate)) + trustManagerInstance.addTrustPoint(TrustPoint(X509Cert(certificate.encoded))) } KeysAndCertificates.getTrustedReaderCertificates(this).forEach { - trustManagerInstance.addTrustPoint(TrustPoint(it)) + trustManagerInstance.addTrustPoint(TrustPoint(X509Cert(it.encoded))) } } diff --git a/appholder/src/main/java/com/android/identity/wallet/settings/CaCertificatesFragment.kt b/appholder/src/main/java/com/android/identity/wallet/settings/CaCertificatesFragment.kt index 72e9a3c1e..06b201530 100644 --- a/appholder/src/main/java/com/android/identity/wallet/settings/CaCertificatesFragment.kt +++ b/appholder/src/main/java/com/android/identity/wallet/settings/CaCertificatesFragment.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController +import com.android.identity.crypto.X509Cert import com.android.identity.trustmanagement.TrustPoint import com.android.identity.wallet.HolderApp import com.android.identity.wallet.theme.HolderAppTheme @@ -77,7 +78,7 @@ class CaCertificatesFragment : Fragment() { this.requireContext().contentResolver.openInputStream(uri).use { inputStream -> if (inputStream != null) { val certificate = parseCertificate(inputStream.readBytes()) - HolderApp.trustManagerInstance.addTrustPoint(TrustPoint(certificate)) + HolderApp.trustManagerInstance.addTrustPoint(TrustPoint(X509Cert(certificate.encoded))) HolderApp.certificateStorageEngineInstance.put( certificate.getSubjectKeyIdentifier(), certificate.encoded @@ -102,7 +103,7 @@ class CaCertificatesFragment : Fragment() { } val text = clipboard.primaryClip?.getItemAt(0)?.text!! val certificate = parseCertificate(text.toString().toByteArray()) - HolderApp.trustManagerInstance.addTrustPoint(TrustPoint(certificate)) + HolderApp.trustManagerInstance.addTrustPoint(TrustPoint(X509Cert(certificate.encoded))) HolderApp.certificateStorageEngineInstance.put( certificate.getSubjectKeyIdentifier(), certificate.encoded diff --git a/appholder/src/main/java/com/android/identity/wallet/settings/CaCertificatesViewModel.kt b/appholder/src/main/java/com/android/identity/wallet/settings/CaCertificatesViewModel.kt index bc2913fb6..abffacc97 100644 --- a/appholder/src/main/java/com/android/identity/wallet/settings/CaCertificatesViewModel.kt +++ b/appholder/src/main/java/com/android/identity/wallet/settings/CaCertificatesViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import com.android.identity.crypto.javaX509Certificate import com.android.identity.wallet.HolderApp import com.android.identity.wallet.trustmanagement.getSubjectKeyIdentifier import kotlinx.coroutines.flow.MutableStateFlow @@ -31,7 +32,9 @@ class CaCertificatesViewModel() : ViewModel() { fun deleteCertificate() { _currentCertificateItem.value?.trustPoint?.let { HolderApp.trustManagerInstance.removeTrustPoint(it) - HolderApp.certificateStorageEngineInstance.delete(it.certificate.getSubjectKeyIdentifier()) + HolderApp.certificateStorageEngineInstance.delete( + it.certificate.javaX509Certificate.getSubjectKeyIdentifier() + ) } } diff --git a/appholder/src/main/java/com/android/identity/wallet/settings/Mappers.kt b/appholder/src/main/java/com/android/identity/wallet/settings/Mappers.kt index ab2588655..ed12ad0b5 100644 --- a/appholder/src/main/java/com/android/identity/wallet/settings/Mappers.kt +++ b/appholder/src/main/java/com/android/identity/wallet/settings/Mappers.kt @@ -1,5 +1,6 @@ package com.android.identity.wallet.settings +import com.android.identity.crypto.javaX509Certificate import com.android.identity.trustmanagement.TrustPoint import com.android.identity.wallet.HolderApp import com.android.identity.wallet.trustmanagement.getCommonName @@ -10,16 +11,16 @@ import java.lang.StringBuilder import java.security.MessageDigest fun TrustPoint.toCertificateItem(docTypes: List = emptyList()): CertificateItem { - val subject = this.certificate.subjectX500Principal - val issuer = this.certificate.issuerX500Principal + val subject = this.certificate.javaX509Certificate.subjectX500Principal + val issuer = this.certificate.javaX509Certificate.issuerX500Principal val sha255Fingerprint = hexWithSpaces( MessageDigest.getInstance("SHA-256").digest( - this.certificate.encoded + this.certificate.encodedCertificate ) ) val sha1Fingerprint = hexWithSpaces( MessageDigest.getInstance("SHA-1").digest( - this.certificate.encoded + this.certificate.encodedCertificate ) ) val defaultValue = "" @@ -32,12 +33,14 @@ fun TrustPoint.toCertificateItem(docTypes: List = emptyList()): Certific commonNameIssuer = issuer.getCommonName(defaultValue), organisationIssuer = issuer.getOrganisation(defaultValue), organisationalUnitIssuer = issuer.organisationalUnit(defaultValue), - notBefore = this.certificate.notBefore, - notAfter = this.certificate.notAfter, + notBefore = this.certificate.javaX509Certificate.notBefore, + notAfter = this.certificate.javaX509Certificate.notAfter, sha255Fingerprint = sha255Fingerprint, sha1Fingerprint = sha1Fingerprint, docTypes = docTypes, - supportsDelete = HolderApp.certificateStorageEngineInstance.get(this.certificate.getSubjectKeyIdentifier()) != null , + supportsDelete = HolderApp.certificateStorageEngineInstance.get( + this.certificate.javaX509Certificate.getSubjectKeyIdentifier() + ) != null, trustPoint = this ) } diff --git a/appverifier/src/main/java/com/android/mdl/appreader/VerifierApp.kt b/appverifier/src/main/java/com/android/mdl/appreader/VerifierApp.kt index 8e80d4050..55765b2b9 100644 --- a/appverifier/src/main/java/com/android/mdl/appreader/VerifierApp.kt +++ b/appverifier/src/main/java/com/android/mdl/appreader/VerifierApp.kt @@ -59,10 +59,10 @@ class VerifierApp : Application() { certificateStorageEngineInstance = certificateStorageEngine certificateStorageEngineInstance.enumerate().forEach { val certificate = parseCertificate(certificateStorageEngineInstance.get(it)!!) - trustManagerInstance.addTrustPoint(TrustPoint(certificate)) + trustManagerInstance.addTrustPoint(TrustPoint(X509Cert(certificate.encoded))) } KeysAndCertificates.getTrustedIssuerCertificates(this).forEach { - trustManagerInstance.addTrustPoint(TrustPoint(it)) + trustManagerInstance.addTrustPoint(TrustPoint(X509Cert(it.encoded))) } val signedVical = SignedVical.parse( resources.openRawResource(R.raw.austroad_test_event_vical_20241002).readBytes() @@ -71,7 +71,7 @@ class VerifierApp : Application() { val cert = X509Cert(certInfo.certificate) trustManagerInstance.addTrustPoint( TrustPoint( - cert.javaX509Certificate, + cert, null, null ) diff --git a/appverifier/src/main/java/com/android/mdl/appreader/settings/CaCertificatesFragment.kt b/appverifier/src/main/java/com/android/mdl/appreader/settings/CaCertificatesFragment.kt index 8fb953074..1210f65fc 100644 --- a/appverifier/src/main/java/com/android/mdl/appreader/settings/CaCertificatesFragment.kt +++ b/appverifier/src/main/java/com/android/mdl/appreader/settings/CaCertificatesFragment.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController +import com.android.identity.crypto.X509Cert import com.android.identity.trustmanagement.TrustPoint import com.android.mdl.appreader.VerifierApp import com.android.mdl.appreader.theme.ReaderAppTheme @@ -77,7 +78,7 @@ class CaCertificatesFragment : Fragment() { this.requireContext().contentResolver.openInputStream(uri).use { inputStream -> if (inputStream != null) { val certificate = parseCertificate(inputStream.readBytes()) - VerifierApp.trustManagerInstance.addTrustPoint(TrustPoint(certificate)) + VerifierApp.trustManagerInstance.addTrustPoint(TrustPoint(X509Cert(certificate.encoded))) VerifierApp.certificateStorageEngineInstance.put( certificate.getSubjectKeyIdentifier(), certificate.encoded @@ -102,7 +103,7 @@ class CaCertificatesFragment : Fragment() { } val text = clipboard.primaryClip?.getItemAt(0)?.text!! val certificate = parseCertificate(text.toString().toByteArray()) - VerifierApp.trustManagerInstance.addTrustPoint(TrustPoint(certificate)) + VerifierApp.trustManagerInstance.addTrustPoint(TrustPoint(X509Cert(certificate.encoded))) VerifierApp.certificateStorageEngineInstance.put( certificate.getSubjectKeyIdentifier(), certificate.encoded diff --git a/appverifier/src/main/java/com/android/mdl/appreader/settings/CaCertificatesViewModel.kt b/appverifier/src/main/java/com/android/mdl/appreader/settings/CaCertificatesViewModel.kt index 88872c4b8..746c0eff2 100644 --- a/appverifier/src/main/java/com/android/mdl/appreader/settings/CaCertificatesViewModel.kt +++ b/appverifier/src/main/java/com/android/mdl/appreader/settings/CaCertificatesViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import com.android.identity.crypto.javaX509Certificate import com.android.mdl.appreader.VerifierApp import com.android.mdl.appreader.trustmanagement.getSubjectKeyIdentifier import kotlinx.coroutines.flow.MutableStateFlow @@ -31,7 +32,7 @@ class CaCertificatesViewModel() : ViewModel() { fun deleteCertificate() { _currentCertificateItem.value?.trustPoint?.let { VerifierApp.trustManagerInstance.removeTrustPoint(it) - VerifierApp.certificateStorageEngineInstance.delete(it.certificate.getSubjectKeyIdentifier()) + VerifierApp.certificateStorageEngineInstance.delete(it.certificate.javaX509Certificate.getSubjectKeyIdentifier()) } } diff --git a/appverifier/src/main/java/com/android/mdl/appreader/settings/Mappers.kt b/appverifier/src/main/java/com/android/mdl/appreader/settings/Mappers.kt index e0dd13874..05d99c00c 100644 --- a/appverifier/src/main/java/com/android/mdl/appreader/settings/Mappers.kt +++ b/appverifier/src/main/java/com/android/mdl/appreader/settings/Mappers.kt @@ -1,5 +1,6 @@ package com.android.mdl.appreader.settings +import com.android.identity.crypto.javaX509Certificate import com.android.identity.trustmanagement.TrustPoint import com.android.mdl.appreader.VerifierApp import com.android.mdl.appreader.trustmanagement.getCommonName @@ -10,16 +11,16 @@ import java.lang.StringBuilder import java.security.MessageDigest fun TrustPoint.toCertificateItem(docTypes: List = emptyList()): CertificateItem { - val subject = this.certificate.subjectX500Principal - val issuer = this.certificate.issuerX500Principal + val subject = this.certificate.javaX509Certificate.subjectX500Principal + val issuer = this.certificate.javaX509Certificate.issuerX500Principal val sha255Fingerprint = hexWithSpaces( MessageDigest.getInstance("SHA-256").digest( - this.certificate.encoded + this.certificate.encodedCertificate ) ) val sha1Fingerprint = hexWithSpaces( MessageDigest.getInstance("SHA-1").digest( - this.certificate.encoded + this.certificate.encodedCertificate ) ) val defaultValue = "" @@ -32,12 +33,14 @@ fun TrustPoint.toCertificateItem(docTypes: List = emptyList()): Certific commonNameIssuer = issuer.getCommonName(defaultValue), organisationIssuer = issuer.getOrganisation(defaultValue), organisationalUnitIssuer = issuer.organisationalUnit(defaultValue), - notBefore = this.certificate.notBefore, - notAfter = this.certificate.notAfter, + notBefore = this.certificate.javaX509Certificate.notBefore, + notAfter = this.certificate.javaX509Certificate.notAfter, sha255Fingerprint = sha255Fingerprint, sha1Fingerprint = sha1Fingerprint, docTypes = docTypes, - supportsDelete = VerifierApp.certificateStorageEngineInstance.get(this.certificate.getSubjectKeyIdentifier()) != null , + supportsDelete = VerifierApp.certificateStorageEngineInstance.get( + this.certificate.javaX509Certificate.getSubjectKeyIdentifier() + ) != null, trustPoint = this ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b327e8568..89f1742e5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ androidx-core-ktx = "1.13.1" androidx-espresso-core = "3.5.1" androidx-material = "1.12.0" androidx-test-junit = "1.1.5" -compose-plugin = "1.6.10" +compose-plugin = "1.7.0-rc01" faceDetection = "16.1.6" junit = "4.13.2" kotlin = "2.0.0" @@ -29,7 +29,7 @@ scuba = "0.0.26" jmrtd = "0.7.42" mlkit = "16.0.0" camera = "1.3.3" -compose-material3 = "1.2.1" +compose-material3 = "1.3.0" compose-material-icons-extended = "1.6.7" androidx-navigation = "2.7.7" code-scanner = "2.3.2" diff --git a/identity-appsupport/build.gradle.kts b/identity-appsupport/build.gradle.kts index d6547f769..eea68b2db 100644 --- a/identity-appsupport/build.gradle.kts +++ b/identity-appsupport/build.gradle.kts @@ -1,5 +1,9 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) alias(libs.plugins.jetbrainsCompose) alias(libs.plugins.compose.compiler) } @@ -9,6 +13,13 @@ kotlin { jvm() + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + listOf( iosX64(), iosArm64(), @@ -41,9 +52,44 @@ kotlin { implementation(libs.jetbrains.navigation.runtime) implementation(project(":identity")) + implementation(project(":identity-mdoc")) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.io.core) } } + + val jvmMain by getting { + dependencies { + implementation(libs.bouncy.castle.bcprov) + implementation(libs.bouncy.castle.bcpkix) + implementation(libs.tink) + } + } + + val androidMain by getting { + dependencies { + implementation(libs.bouncy.castle.bcprov) + implementation(libs.bouncy.castle.bcpkix) + implementation(libs.tink) + } + } + } +} + +android { + namespace = "com.android.identity.appsupport" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = 29 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + dependencies { + debugImplementation(compose.uiTooling) } } diff --git a/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/Util.android.kt b/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/Util.android.kt new file mode 100644 index 000000000..940cc9b82 --- /dev/null +++ b/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/Util.android.kt @@ -0,0 +1,34 @@ +package com.android.identity.appsupport.ui + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun AppTheme(content: @Composable () -> Unit) { + val darkScheme = isSystemInDarkTheme() + val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val context = LocalContext.current + if (darkScheme) { + dynamicDarkColorScheme(context) + } else { + dynamicLightColorScheme(context) + } + } else { + if (darkScheme) { + darkColorScheme() + } else { + lightColorScheme() + } + } + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} \ No newline at end of file diff --git a/identity-appsupport/src/commonMain/composeResources/values/strings.xml b/identity-appsupport/src/commonMain/composeResources/values/strings.xml index c955132fb..6397314e6 100644 --- a/identity-appsupport/src/commonMain/composeResources/values/strings.xml +++ b/identity-appsupport/src/commonMain/composeResources/values/strings.xml @@ -2,4 +2,22 @@ PIN is weak, please choose another Passphrase is weak, please choose another + + + Cancel + More + Share + Share with %1$s + Share with Unknown requester + The following information will be shared with %1$s: + The following information will be shared: + The following information will be shared with and stored by %1$s: + The following information will be shared with and stored by the requester: + The OWF Identity Credential Website describes how your data is being handled + The verifier requesting this data is not in a trust list. Make sure you are comfortable sharing this data with them. + Card Art + Verifier Icon + Data Element Icon + Warning Icon + \ No newline at end of file diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/Util.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/Util.kt new file mode 100644 index 000000000..a7c4aaabf --- /dev/null +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/Util.kt @@ -0,0 +1,18 @@ +package com.android.identity.appsupport.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +@Composable +expect fun AppTheme(content: @Composable () -> Unit) + +@Composable +fun AppThemeDefault(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme(), + content = content + ) +} diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/ConsentDocument.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/ConsentDocument.kt new file mode 100644 index 000000000..cb546dc7a --- /dev/null +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/ConsentDocument.kt @@ -0,0 +1,14 @@ +package com.android.identity.appsupport.ui.consent + +/** + * Details with the document that is being presented in the consent dialog. + * + * @property name the name of the document e.g. "Erika's Driving License" + * @property description the description e.g. "Driving License" or "Government-Issued ID" + * @property cardArt the card art for the document + */ +data class ConsentDocument( + val name: String, + val description: String, + val cardArt: ByteArray +) \ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/ConsentField.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/ConsentField.kt similarity index 86% rename from wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/ConsentField.kt rename to identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/ConsentField.kt index b1e5c160a..bb2ff0fec 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/ConsentField.kt +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/ConsentField.kt @@ -1,4 +1,4 @@ -package com.android.identity_credential.wallet.ui.prompt.consent +package com.android.identity.appsupport.ui.consent import com.android.identity.documenttype.DocumentAttribute diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/ConsentModalBottomSheet.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/ConsentModalBottomSheet.kt new file mode 100644 index 000000000..6bbcece1d --- /dev/null +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/ConsentModalBottomSheet.kt @@ -0,0 +1,465 @@ +package com.android.identity.appsupport.ui.consent + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.android.identity.appsupport.ui.getOutlinedImageVector +import identitycredential.identity_appsupport.generated.resources.Res +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_button_cancel +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_button_more +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_button_share +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_card_art_description +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_data_element_icon_description +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_headline_share_with_known_requester +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_headline_share_with_unknown_requester +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_share_and_stored_by_known_requester +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_share_and_stored_by_unknown_requester +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_share_with_known_requester +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_share_with_unknown_requester +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_verifier_icon_description +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_wallet_privacy_policy +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_warning_icon_description +import identitycredential.identity_appsupport.generated.resources.consent_modal_bottom_sheet_warning_verifier_not_in_trust_list +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.decodeToImageBitmap +import org.jetbrains.compose.resources.stringResource +import kotlin.math.min + +/** + * A [ModalBottomSheet] used for obtaining the user's consent when presenting credentials. + * + * @param sheetState a [SheetState] for state. + * @param consentFields the list of consent fields to show. + * @param document details about the document being presented. + * @param relyingParty a structure for conveying who is asking for the information. + * @param onConfirm called when the sheet is dismissed. + * @param onCancel called when the user presses the "Share" button. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConsentModalBottomSheet( + sheetState: SheetState, + consentFields: List, + document: ConsentDocument, + relyingParty: ConsentRelyingParty, + onConfirm: () -> Unit = {}, + onCancel: () -> Unit = {} +) { + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + + ModalBottomSheet( + onDismissRequest = { onCancel() }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + RelyingPartySection(relyingParty) + + DocumentSection(document) + + Column( + modifier = Modifier + .fillMaxWidth() + .focusGroup() + .verticalScroll(scrollState) + .weight(0.9f, false) + ) { + RequestSection( + consentFields = consentFields, + relyingParty = relyingParty + ) + } + + ButtonSection(scope, sheetState, onConfirm, onCancel, scrollState) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ButtonSection( + scope: CoroutineScope, + sheetState: SheetState, + onConfirm: () -> Unit, + onCancel: () -> Unit, + scrollState: ScrollState +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + TextButton(onClick = { + scope.launch { + sheetState.hide() + onCancel() + } + }) { + Text(text = stringResource(Res.string.consent_modal_bottom_sheet_button_cancel)) + } + + Button( + onClick = { + if (!scrollState.canScrollForward) { + onConfirm.invoke() + } else { + scope.launch { + val step = (scrollState.viewportSize * 0.9).toInt() + scrollState.animateScrollTo( + min( + scrollState.value + step, + scrollState.maxValue + ) + ) + } + } + } + ) { + if (scrollState.canScrollForward) { + Text(text = stringResource(Res.string.consent_modal_bottom_sheet_button_more)) + } else { + Text(text = stringResource(Res.string.consent_modal_bottom_sheet_button_share)) + } + } + } +} + +@OptIn(ExperimentalResourceApi::class) +@Composable +private fun RelyingPartySection(relyingParty: ConsentRelyingParty) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (relyingParty.trustPoint != null) { + if (relyingParty.trustPoint.displayIcon != null) { + val rpBitmap = remember { + relyingParty.trustPoint.displayIcon!!.decodeToImageBitmap() + } + Icon( + modifier = Modifier.size(80.dp).padding(bottom = 16.dp), + bitmap = rpBitmap, + contentDescription = stringResource(Res.string.consent_modal_bottom_sheet_verifier_icon_description), + tint = Color.Unspecified + ) + } + if (relyingParty.trustPoint.displayName != null) { + Text( + text = relyingParty.trustPoint.displayName!!, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + } else if (relyingParty.websiteOrigin != null) { + Text( + text = stringResource( + Res.string.consent_modal_bottom_sheet_headline_share_with_known_requester, + relyingParty.websiteOrigin + ), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } else { + Text( + text = stringResource(Res.string.consent_modal_bottom_sheet_headline_share_with_unknown_requester), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@OptIn(ExperimentalResourceApi::class) +@Composable +private fun DocumentSection(document: ConsentDocument) { + Column( + modifier = Modifier + .padding(vertical = 2.dp) + .fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(shape = RoundedCornerShape(16.dp, 16.dp, 0.dp, 0.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + horizontalAlignment = Alignment.Start, + ) { + Row( + modifier = Modifier.padding(16.dp) + ) { + Icon( + modifier = Modifier.size(50.dp), + bitmap = document.cardArt.decodeToImageBitmap(), + contentDescription = stringResource(Res.string.consent_modal_bottom_sheet_card_art_description), + tint = Color.Unspecified + ) + Column( + modifier = Modifier.padding(start = 16.dp) + ) { + Text( + text = document.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = document.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } + } + } +} + +@OptIn(ExperimentalTextApi::class) +@Composable +private fun RequestSection( + consentFields: List, + relyingParty: ConsentRelyingParty +) { + val dontUseColumns = consentFields.size <= 5 + val storedFields = consentFields.filter { it is MdocConsentField && it.intentToRetain == true } + val notStoredFields = consentFields.filter { !(it is MdocConsentField && it.intentToRetain == true) } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + if (notStoredFields.size > 0) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + Text( + text = if (relyingParty.trustPoint?.displayName != null) { + stringResource( + Res.string.consent_modal_bottom_sheet_share_with_known_requester, + relyingParty.trustPoint.displayName!! + ) + } else if (relyingParty.websiteOrigin != null) { + stringResource( + Res.string.consent_modal_bottom_sheet_share_with_known_requester, + relyingParty.websiteOrigin + ) + } else { + stringResource(Res.string.consent_modal_bottom_sheet_share_with_unknown_requester) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + } + DataElementGridView(notStoredFields, dontUseColumns) + } + if (storedFields.size > 0) { + if (notStoredFields.size > 0) { + HorizontalDivider(modifier = Modifier.padding(8.dp)) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + Text( + text = if (relyingParty.trustPoint?.displayName != null) { + stringResource( + Res.string.consent_modal_bottom_sheet_share_and_stored_by_known_requester, + relyingParty.trustPoint.displayName!! + ) + } else if (relyingParty.websiteOrigin != null) { + stringResource( + Res.string.consent_modal_bottom_sheet_share_and_stored_by_known_requester, + relyingParty.websiteOrigin + ) + } else { + stringResource(Res.string.consent_modal_bottom_sheet_share_and_stored_by_unknown_requester) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + } + DataElementGridView(storedFields, dontUseColumns) + } + } + } + Spacer(modifier = Modifier.height(2.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(shape = RoundedCornerShape(0.dp, 0.dp, 16.dp, 16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + // TODO: When we upgrade to a newer version of compose-ui we can use + // AnnotatedString.fromHtml() and clicking the links will also work. + // See b/139326648 for details. + // + val annotatedLinkString = buildAnnotatedString { + val str = stringResource(Res.string.consent_modal_bottom_sheet_wallet_privacy_policy) + val startIndex = 4 + val endIndex = startIndex + 31 + append(str) + addStyle( + style = SpanStyle( + color = Color(0xff64B5F6), + textDecoration = TextDecoration.Underline + ), start = startIndex, end = endIndex + ) + } + Text( + text = annotatedLinkString, + style = MaterialTheme.typography.bodySmall + ) + } + } + if (relyingParty.trustPoint == null) { + Box( + modifier = Modifier.padding(vertical = 8.dp) + ) { + WarningCard( + stringResource(Res.string.consent_modal_bottom_sheet_warning_verifier_not_in_trust_list) + ) + } + } +} + +@Composable +private fun DataElementGridView( + consentFields: List, + dontUseColumns: Boolean +) { + // If we have less than 5 data elements, don't use columns. + if (dontUseColumns) { + for (consentField in consentFields) { + Row(modifier = Modifier.fillMaxWidth()) { + DataElementView(consentField = consentField, modifier = Modifier.weight(1.0f)) + } + } + } else { + var n = 0 + while (n <= consentFields.size - 2) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + DataElementView(consentField = consentFields[n], modifier = Modifier.weight(1.0f)) + DataElementView( + consentField = consentFields[n + 1], + modifier = Modifier.weight(1.0f) + ) + } + n += 2 + } + if (n < consentFields.size) { + Row(modifier = Modifier.fillMaxWidth()) { + DataElementView(consentField = consentFields[n], modifier = Modifier.weight(1.0f)) + } + } + } +} + +/** + * Individual view for a DataElement. + */ +@Composable +private fun DataElementView( + modifier: Modifier, + consentField: ConsentField, +) { + Row( + horizontalArrangement = Arrangement.Start, + modifier = modifier.padding(8.dp), + ) { + if (consentField.attribute?.icon != null) { + Icon( + consentField.attribute!!.icon!!.getOutlinedImageVector(), + contentDescription = stringResource(Res.string.consent_modal_bottom_sheet_data_element_icon_description) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = consentField.displayName, + fontWeight = FontWeight.Normal, + style = MaterialTheme.typography.bodySmall + ) + } +} + +@Composable +private fun WarningCard(text: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(shape = RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.errorContainer), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.padding(16.dp), + ) { + Icon( + modifier = Modifier.padding(end = 16.dp), + imageVector = Icons.Outlined.Warning, + contentDescription = stringResource(Res.string.consent_modal_bottom_sheet_warning_icon_description), + tint = MaterialTheme.colorScheme.onErrorContainer + ) + + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/ConsentRelyingParty.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/ConsentRelyingParty.kt new file mode 100644 index 000000000..5824036d1 --- /dev/null +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/ConsentRelyingParty.kt @@ -0,0 +1,16 @@ +package com.android.identity.appsupport.ui.consent + +import com.android.identity.trustmanagement.TrustPoint + +/** + * Details about the Relying Party requesting data. + * + * TODO: also add appId. + * + * @property trustPoint if the verifier is in a trust-list, the [TrustPoint] indicating this + * @property websiteOrigin set if the verifier is a website, for example https://gov.example.com + */ +data class ConsentRelyingParty( + val trustPoint: TrustPoint?, + val websiteOrigin: String? = null, +) \ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/MdocConsentField.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/MdocConsentField.kt similarity index 98% rename from wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/MdocConsentField.kt rename to identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/MdocConsentField.kt index 927eeefcb..39657d0d2 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/MdocConsentField.kt +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/MdocConsentField.kt @@ -1,4 +1,4 @@ -package com.android.identity_credential.wallet.ui.prompt.consent +package com.android.identity.appsupport.ui.consent import com.android.identity.cbor.Cbor import com.android.identity.documenttype.DocumentAttribute diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/VcConsentField.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/VcConsentField.kt new file mode 100644 index 000000000..d83e1565d --- /dev/null +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/consent/VcConsentField.kt @@ -0,0 +1,20 @@ +package com.android.identity.appsupport.ui.consent + +import com.android.identity.documenttype.DocumentAttribute + +/** + * Consent field for VC credentials. + * + * @param displayName the name to display in the consent prompt. + * @param claimName the claim name. + * @param attribute a [DocumentAttribute], if the claim is well-known. + */ +data class VcConsentField( + override val displayName: String, + override val attribute: DocumentAttribute?, + val claimName: String +) : ConsentField(displayName, attribute) { + + companion object { + } +} \ No newline at end of file diff --git a/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/Util.ios.kt b/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/Util.ios.kt new file mode 100644 index 000000000..5718e8c9c --- /dev/null +++ b/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/Util.ios.kt @@ -0,0 +1,8 @@ +package com.android.identity.appsupport.ui + +import androidx.compose.runtime.Composable + +@Composable +actual fun AppTheme(content: @Composable () -> Unit) { + return AppThemeDefault(content) +} \ No newline at end of file diff --git a/identity-appsupport/src/jvmMain/kotlin/com/android/identity/appsupport/ui/Util.jvm.kt b/identity-appsupport/src/jvmMain/kotlin/com/android/identity/appsupport/ui/Util.jvm.kt new file mode 100644 index 000000000..5718e8c9c --- /dev/null +++ b/identity-appsupport/src/jvmMain/kotlin/com/android/identity/appsupport/ui/Util.jvm.kt @@ -0,0 +1,8 @@ +package com.android.identity.appsupport.ui + +import androidx.compose.runtime.Composable + +@Composable +actual fun AppTheme(content: @Composable () -> Unit) { + return AppThemeDefault(content) +} \ No newline at end of file diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/DrivingLicense.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/DrivingLicense.kt index 8a6ea2284..2ad7dce14 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/DrivingLicense.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/DrivingLicense.kt @@ -828,85 +828,125 @@ object DrivingLicense { id = "us-transportation", displayName = "US Transportation", mdocDataElements = mapOf( - Pair(MDL_NAMESPACE, listOf( - "sex", - "portrait", - "given_name", - "issue_date", - "expiry_date", - "family_name", - "document_number", - "issuing_authority", - )), - Pair(AAMVA_NAMESPACE, listOf( - "DHS_compliance", - "EDL_credential" - )) - ), + MDL_NAMESPACE to mapOf( + "sex" to false, + "portrait" to false, + "given_name" to false, + "issue_date" to false, + "expiry_date" to false, + "family_name" to false, + "document_number" to false, + "issuing_authority" to false + ), + AAMVA_NAMESPACE to mapOf( + "DHS_compliance" to false, + "EDL_credential" to false + ), + ) ) .addSampleRequest( id = "age_over_18", displayName ="Age Over 18", mdocDataElements = mapOf( - Pair(MDL_NAMESPACE, listOf( - "age_over_18", - )) + MDL_NAMESPACE to mapOf( + "age_over_18" to false, + ) ), ) .addSampleRequest( id = "age_over_21", displayName ="Age Over 21", mdocDataElements = mapOf( - Pair(MDL_NAMESPACE, listOf( - "age_over_21", - )) + MDL_NAMESPACE to mapOf( + "age_over_21" to false, + ) ), ) .addSampleRequest( id = "age_over_18_and_portrait", displayName ="Age Over 18 + Portrait", mdocDataElements = mapOf( - Pair(MDL_NAMESPACE, listOf( - "age_over_18", - "portrait" - )) + MDL_NAMESPACE to mapOf( + "age_over_18" to false, + "portrait" to false + ) ), ) .addSampleRequest( id = "age_over_21_and_portrait", displayName ="Age Over 21 + Portrait", mdocDataElements = mapOf( - Pair(MDL_NAMESPACE, listOf( - "age_over_21", - "portrait" - )) + MDL_NAMESPACE to mapOf( + "age_over_21" to false, + "portrait" to false + ) ), ) .addSampleRequest( id = "mandatory", displayName = "Mandatory Data Elements", mdocDataElements = mapOf( - Pair(MDL_NAMESPACE, listOf( - "family_name", - "given_name", - "birth_date", - "issue_date", - "expiry_date", - "issuing_country", - "issuing_authority", - "document_number", - "portrait", - "driving_privileges", - "un_distinguishing_sign", - )) + MDL_NAMESPACE to mapOf( + "family_name" to false, + "given_name" to false, + "birth_date" to false, + "issue_date" to false, + "expiry_date" to false, + "issuing_country" to false, + "issuing_authority" to false, + "document_number" to false, + "portrait" to false, + "driving_privileges" to false, + "un_distinguishing_sign" to false, + ) ) ) .addSampleRequest( id = "full", displayName ="All Data Elements", mdocDataElements = mapOf( - Pair(MDL_NAMESPACE, listOf()), - Pair(AAMVA_NAMESPACE, listOf()) + MDL_NAMESPACE to mapOf(), + AAMVA_NAMESPACE to mapOf() + ) + ) + .addSampleRequest( + id = "name-and-address-partially-stored", + displayName = "Name and Address (Partially Stored)", + mdocDataElements = mapOf( + MDL_NAMESPACE to mapOf( + "family_name" to true, + "given_name" to true, + "issuing_authority" to false, + "portrait" to false, + "resident_address" to true, + "resident_city" to true, + "resident_state" to true, + "resident_postal_code" to true, + "resident_country" to true, + ), + AAMVA_NAMESPACE to mapOf( + "resident_county" to true, + ) + ) + ) + .addSampleRequest( + id = "name-and-address-all-stored", + displayName = "Name and Address (All Stored)", + mdocDataElements = mapOf( + MDL_NAMESPACE to mapOf( + "family_name" to true, + "given_name" to true, + "issuing_authority" to true, + "portrait" to true, + "resident_address" to true, + "resident_city" to true, + "resident_state" to true, + "resident_postal_code" to true, + "resident_country" to true, + ), + AAMVA_NAMESPACE to mapOf( + "resident_county" to true, + ) ) ) .build() diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUPersonalID.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUPersonalID.kt index 88fb1a037..09c3027ec 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUPersonalID.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUPersonalID.kt @@ -338,9 +338,9 @@ object EUPersonalID { id = "age_over_18", displayName = "Age Over 18", mdocDataElements = mapOf( - Pair(EUPID_NAMESPACE, listOf( - "age_over_18", - )) + EUPID_NAMESPACE to mapOf( + "age_over_18" to false, + ) ), vcClaims = listOf("age_over_18") ) @@ -348,16 +348,16 @@ object EUPersonalID { id = "mandatory", displayName = "Mandatory Data Elements", mdocDataElements = mapOf( - Pair(EUPID_NAMESPACE, listOf( - "family_name", - "given_name", - "birth_date", - "age_over_18", - "issuance_date", - "expiry_date", - "issuing_authority", - "issuing_country" - )) + EUPID_NAMESPACE to mapOf( + "family_name" to false, + "given_name" to false, + "birth_date" to false, + "age_over_18" to false, + "issuance_date" to false, + "expiry_date" to false, + "issuing_authority" to false, + "issuing_country" to false + ) ), vcClaims = listOf( "family_name", @@ -374,8 +374,7 @@ object EUPersonalID { id = "full", displayName = "All Data Elements", mdocDataElements = mapOf( - Pair(EUPID_NAMESPACE, listOf( - )) + EUPID_NAMESPACE to mapOf() ), vcClaims = listOf() ) diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/PhotoID.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/PhotoID.kt index 6999af59b..91d5d4766 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/PhotoID.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/PhotoID.kt @@ -685,48 +685,45 @@ object PhotoID { id = "age_over_18", displayName ="Age Over 18", mdocDataElements = mapOf( - Pair( - ISO_23220_2_NAMESPACE, listOf( - "age_over_18", - )) + ISO_23220_2_NAMESPACE to mapOf( + "age_over_18" to false, + ) ), ) .addSampleRequest( id = "age_over_18_and_portrait", displayName ="Age Over 18 + Portrait", mdocDataElements = mapOf( - Pair( - ISO_23220_2_NAMESPACE, listOf( - "age_over_18", - "portrait" - )) + ISO_23220_2_NAMESPACE to mapOf( + "age_over_18" to false, + "portrait" to false + ) ), ) .addSampleRequest( id = "mandatory", displayName = "Mandatory Data Elements", mdocDataElements = mapOf( - Pair( - ISO_23220_2_NAMESPACE, listOf( - "family_name_unicode", - "given_name_unicode", - "birth_date", - "portrait", - "issue_date", - "expiry_date", - "issuing_authority_unicode", - "issuing_country", - "age_over_18", - )) + ISO_23220_2_NAMESPACE to mapOf( + "family_name_unicode" to false, + "given_name_unicode" to false, + "birth_date" to false, + "portrait" to false, + "issue_date" to false, + "expiry_date" to false, + "issuing_authority_unicode" to false, + "issuing_country" to false, + "age_over_18" to false, + ) ) ) .addSampleRequest( id = "full", displayName ="All Data Elements", mdocDataElements = mapOf( - Pair(ISO_23220_2_NAMESPACE, listOf()), - Pair(PHOTO_ID_NAMESPACE, listOf()), - Pair(DTC_NAMESPACE, listOf()) + ISO_23220_2_NAMESPACE to mapOf(), + PHOTO_ID_NAMESPACE to mapOf(), + DTC_NAMESPACE to mapOf() ) ) .build() diff --git a/identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentType.kt b/identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentType.kt index 4f5f8001d..5d19de146 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentType.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentType.kt @@ -201,15 +201,16 @@ class DocumentType private constructor( * * @param id an identifier for the request. * @param displayName a short name explaining the request. - * @param mdocDataElements the mdoc data elements in the request, per namespace. If - * the list of a namespace is empty, all defined data elements will be included. + * @param mdocDataElements the mdoc data elements in the request, per namespace, with + * the intent to retain value. If the list of a namespace is empty, all defined data + * elements will be included with intent to retain set to false. * @param vcClaims the VC claims in the request. If the list is empty, all defined * claims will be included. */ fun addSampleRequest( id: String, displayName: String, - mdocDataElements: Map>? = null, + mdocDataElements: Map>? = null, vcClaims: List? = null ) = apply { val mdocRequest = if (mdocDataElements == null) { @@ -218,16 +219,15 @@ class DocumentType private constructor( val nsRequests = mutableListOf() for ((namespace, dataElements) in mdocDataElements) { val mdocNsBuilder = mdocBuilder!!.namespaces[namespace]!! - val deList = if (dataElements.isEmpty()) { - mdocNsBuilder.dataElements.values.toList() + val map = mutableMapOf() + if (dataElements.isEmpty()) { + mdocNsBuilder.dataElements.values.map { map.put(it, false) } } else { - val list = mutableListOf() - for (dataElement in dataElements) { - list.add(mdocNsBuilder.dataElements[dataElement]!!) + for ((dataElement, intentToRetain) in dataElements) { + map.put(mdocNsBuilder.dataElements[dataElement]!!, intentToRetain) } - list } - nsRequests.add(MdocNamespaceRequest(namespace, deList)) + nsRequests.add(MdocNamespaceRequest(namespace, map)) } MdocRequest(mdocBuilder!!.docType, nsRequests) } diff --git a/identity/src/commonMain/kotlin/com/android/identity/documenttype/MdocNamespaceRequest.kt b/identity/src/commonMain/kotlin/com/android/identity/documenttype/MdocNamespaceRequest.kt index 909115bc3..d4a3ac6c1 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/documenttype/MdocNamespaceRequest.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/documenttype/MdocNamespaceRequest.kt @@ -4,9 +4,9 @@ package com.android.identity.documenttype * A class representing a request for data elements in a namespace. * * @param namespace the namespace. - * @param dataElementsToRequest the data elements to request. + * @param dataElementsToRequest the data elements to request, with intent to retain. */ data class MdocNamespaceRequest( val namespace: String, - val dataElementsToRequest: List + val dataElementsToRequest: Map ) diff --git a/identity/src/jvmMain/kotlin/com/android/identity/trustmanagement/TrustPoint.kt b/identity/src/commonMain/kotlin/com/android/identity/trustmanagement/TrustPoint.kt similarity index 59% rename from identity/src/jvmMain/kotlin/com/android/identity/trustmanagement/TrustPoint.kt rename to identity/src/commonMain/kotlin/com/android/identity/trustmanagement/TrustPoint.kt index c823c6e74..094bc1e0a 100644 --- a/identity/src/jvmMain/kotlin/com/android/identity/trustmanagement/TrustPoint.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/trustmanagement/TrustPoint.kt @@ -1,17 +1,17 @@ package com.android.identity.trustmanagement -import java.security.cert.X509Certificate +import com.android.identity.crypto.X509Cert /** - * Class used for the representation of a trusted CA [X509Certificate], a name - * suitable for display and an icon to display the certificate + * Class used for the representation of a trusted CA [X509Cert], a name + * suitable for display and an icon to display the certificate. * * @param certificate an X509 certificate * @param displayName a name suitable for display of the X509 certificate * @param displayIcon an icon that represents */ data class TrustPoint( - val certificate: X509Certificate, + val certificate: X509Cert, val displayName: String? = null, val displayIcon: ByteArray? = null ) diff --git a/identity/src/jvmMain/kotlin/com/android/identity/trustmanagement/TrustManager.kt b/identity/src/jvmMain/kotlin/com/android/identity/trustmanagement/TrustManager.kt index dd0f938f5..273bcfd04 100644 --- a/identity/src/jvmMain/kotlin/com/android/identity/trustmanagement/TrustManager.kt +++ b/identity/src/jvmMain/kotlin/com/android/identity/trustmanagement/TrustManager.kt @@ -15,6 +15,7 @@ */ package com.android.identity.trustmanagement +import com.android.identity.crypto.javaX509Certificate import java.security.cert.CertificateException import java.security.cert.PKIXCertPathChecker import java.security.cert.X509Certificate @@ -50,7 +51,7 @@ class TrustManager { * Add a [TrustPoint] to the [TrustManager]. */ fun addTrustPoint(trustPoint: TrustPoint) = - TrustManagerUtil.getSubjectKeyIdentifier(trustPoint.certificate).also { key -> + TrustManagerUtil.getSubjectKeyIdentifier(trustPoint.certificate.javaX509Certificate).also { key -> if (key.isNotEmpty()) { certificates[key] = trustPoint } @@ -66,7 +67,7 @@ class TrustManager { * Remove a [TrustPoint] from the [TrustManager]. */ fun removeTrustPoint(trustPoint: TrustPoint) = - TrustManagerUtil.getSubjectKeyIdentifier(trustPoint.certificate).also { key -> + TrustManagerUtil.getSubjectKeyIdentifier(trustPoint.certificate.javaX509Certificate).also { key -> certificates.remove(key) } @@ -92,7 +93,7 @@ class TrustManager { ): TrustResult { try { val trustPoints = getAllTrustPoints(chain) - val completeChain = chain.plus(trustPoints.map { it.certificate }) + val completeChain = chain.plus(trustPoints.map { it.certificate.javaX509Certificate }) try { validateCertificationTrustPath(completeChain, customValidators) return TrustResult( @@ -143,8 +144,8 @@ class TrustManager { var caCertificate: TrustPoint? = findCaCertificate(chain) ?: throw CertificateException("No trusted root certificate could not be found") result.add(caCertificate!!) - while (caCertificate != null && !TrustManagerUtil.isSelfSigned(caCertificate.certificate)) { - caCertificate = findCaCertificate(listOf(caCertificate.certificate)) + while (caCertificate != null && !TrustManagerUtil.isSelfSigned(caCertificate.certificate.javaX509Certificate)) { + caCertificate = findCaCertificate(listOf(caCertificate.certificate.javaX509Certificate)) if (caCertificate != null) { result.add(caCertificate) } diff --git a/identity/src/jvmTest/kotlin/com/android/identity/trustmanagement/TrustManagerTest.kt b/identity/src/jvmTest/kotlin/com/android/identity/trustmanagement/TrustManagerTest.kt index f3c0ae0af..6e1ec8d40 100644 --- a/identity/src/jvmTest/kotlin/com/android/identity/trustmanagement/TrustManagerTest.kt +++ b/identity/src/jvmTest/kotlin/com/android/identity/trustmanagement/TrustManagerTest.kt @@ -1,5 +1,6 @@ package com.android.identity.trustmanagement +import com.android.identity.crypto.X509Cert import org.bouncycastle.asn1.x500.X500Name import org.bouncycastle.asn1.x509.BasicConstraints import org.bouncycastle.asn1.x509.Extension @@ -168,7 +169,7 @@ class TrustManagerTest { val trustManager = TrustManager() // act (add certificate and verify chain) - trustManager.addTrustPoint(TrustPoint(mdlCaCertificate)) + trustManager.addTrustPoint(TrustPoint(X509Cert(mdlCaCertificate.encoded))) val result = trustManager.verify(listOf(mdlDsCertificate)) // assert @@ -183,8 +184,8 @@ class TrustManagerTest { val trustManager = TrustManager() // act (add intermediate and CA certificate and verify chain) - trustManager.addTrustPoint(TrustPoint(intermediateCertificate)) - trustManager.addTrustPoint(TrustPoint(caCertificate)) + trustManager.addTrustPoint(TrustPoint(X509Cert(intermediateCertificate.encoded))) + trustManager.addTrustPoint(TrustPoint(X509Cert(caCertificate.encoded))) val result = trustManager.verify(listOf(dsCertificate)) // assert @@ -199,7 +200,7 @@ class TrustManagerTest { val trustManager = TrustManager() // act (add intermediate certificate (without CA) and verify chain) - trustManager.addTrustPoint(TrustPoint(intermediateCertificate)) + trustManager.addTrustPoint(TrustPoint(X509Cert(intermediateCertificate.encoded))) val result = trustManager.verify(listOf(dsCertificate)) // assert @@ -236,7 +237,7 @@ class TrustManagerTest { val trustManager = TrustManager() // act (add certificate and verify chain) - trustManager.addTrustPoint(TrustPoint(mdlCaCertificate)) + trustManager.addTrustPoint(TrustPoint(X509Cert(mdlCaCertificate.encoded))) val result = trustManager.verify(listOf(mdlCaCertificate)) // assert diff --git a/samples/testapp/build.gradle.kts b/samples/testapp/build.gradle.kts index 49fb2b36f..5b469f652 100644 --- a/samples/testapp/build.gradle.kts +++ b/samples/testapp/build.gradle.kts @@ -62,6 +62,7 @@ kotlin { implementation(project(":identity")) implementation(project(":identity-mdoc")) implementation(project(":identity-appsupport")) + implementation(project(":identity-doctypes")) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.io.core) } diff --git a/samples/testapp/src/commonMain/composeResources/files/utopia-brewery.png b/samples/testapp/src/commonMain/composeResources/files/utopia-brewery.png new file mode 100644 index 000000000..3c149651f Binary files /dev/null and b/samples/testapp/src/commonMain/composeResources/files/utopia-brewery.png differ diff --git a/samples/testapp/src/commonMain/composeResources/files/utopia_driving_license_card_art.png b/samples/testapp/src/commonMain/composeResources/files/utopia_driving_license_card_art.png new file mode 100644 index 000000000..81ba85e77 Binary files /dev/null and b/samples/testapp/src/commonMain/composeResources/files/utopia_driving_license_card_art.png differ diff --git a/samples/testapp/src/commonMain/composeResources/values/strings.xml b/samples/testapp/src/commonMain/composeResources/values/strings.xml index cb770721b..1ae98f3a5 100644 --- a/samples/testapp/src/commonMain/composeResources/values/strings.xml +++ b/samples/testapp/src/commonMain/composeResources/values/strings.xml @@ -8,6 +8,9 @@ Software Secure Area Android Keystore Secure Area Secure Enclave Secure Area - PassphraseEntryField Cloud Secure Area + PassphraseEntryField use-cases + ConsentModalBottomSheet + ConsentModalBottomSheet use-cases + \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt index c3253635e..a4a461357 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt @@ -13,7 +13,7 @@ import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -25,39 +25,25 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.android.identity.appsupport.ui.AppTheme import com.android.identity.secure_area_test_app.ui.CloudSecureAreaScreen import com.android.identity.testapp.ui.AboutScreen import com.android.identity.testapp.ui.AndroidKeystoreSecureAreaScreen +import com.android.identity.testapp.ui.ConsentModalBottomSheetListScreen +import com.android.identity.testapp.ui.ConsentModalBottomSheetScreen import com.android.identity.testapp.ui.PassphraseEntryFieldScreen import com.android.identity.testapp.ui.SecureEnclaveSecureAreaScreen import com.android.identity.testapp.ui.SoftwareSecureAreaScreen import com.android.identity.testapp.ui.StartScreen +import com.android.identity.testapp.ui.VerifierType import identitycredential.samples.testapp.generated.resources.Res -import identitycredential.samples.testapp.generated.resources.about_screen_title -import identitycredential.samples.testapp.generated.resources.android_keystore_secure_area_screen_title import identitycredential.samples.testapp.generated.resources.back_button -import identitycredential.samples.testapp.generated.resources.passphrase_entry_field_screen_title -import identitycredential.samples.testapp.generated.resources.cloud_secure_area_screen_title -import identitycredential.samples.testapp.generated.resources.secure_enclave_secure_area_screen_title -import identitycredential.samples.testapp.generated.resources.software_secure_area_screen_title -import identitycredential.samples.testapp.generated.resources.start_screen_title import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview -enum class Screen(val title: StringResource) { - About(title = Res.string.about_screen_title), - Start(title = Res.string.start_screen_title), - SoftwareSecureArea(title = Res.string.software_secure_area_screen_title), - AndroidKeystoreSecureArea(title = Res.string.android_keystore_secure_area_screen_title), - SecureEnclaveSecureArea(title = Res.string.secure_enclave_secure_area_screen_title), - PassphraseEntryField(title = Res.string.passphrase_entry_field_screen_title), - CloudSecureArea(title = Res.string.cloud_secure_area_screen_title), -} - class App { companion object { @@ -69,20 +55,21 @@ class App { @Composable @Preview fun Content(navController: NavHostController = rememberNavController()) { + // Get current back stack entry val backStackEntry by navController.currentBackStackEntryAsState() // Get the name of the current screen - val currentScreen = Screen.valueOf( - backStackEntry?.destination?.route ?: Screen.Start.name - ) + val currentDestination = appDestinations.find { + it.route == backStackEntry?.destination?.route + } ?: StartDestination snackbarHostState = remember { SnackbarHostState() } - MaterialTheme { + AppTheme { // A surface container using the 'background' color from the theme Scaffold( topBar = { AppBar( - currentScreen = currentScreen, + currentDestination = currentDestination as Destination, canNavigateBack = navController.previousBackStackEntry != null, navigateUp = { navController.navigateUp() } ) @@ -90,35 +77,70 @@ class App { snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { innerPadding -> - NavHost( navController = navController, - startDestination = Screen.Start.name, + startDestination = StartDestination.route, modifier = Modifier .fillMaxSize() //.verticalScroll(rememberScrollState()) .padding(innerPadding) ) { - composable(route = Screen.About.name) { - AboutScreen() + composable(route = StartDestination.route) { + StartScreen( + onClickAbout = { navController.navigate(AboutDestination.route) }, + onClickSoftwareSecureArea = { navController.navigate(SoftwareSecureAreaDestination.route) }, + onClickAndroidKeystoreSecureArea = { navController.navigate(AndroidKeystoreSecureAreaDestination.route) }, + onClickCloudSecureArea = { navController.navigate(CloudSecureAreaDestination.route) }, + onClickSecureEnclaveSecureArea = { navController.navigate(SecureEnclaveSecureAreaDestination.route) }, + onClickPassphraseEntryField = { navController.navigate(PassphraseEntryFieldDestination.route) }, + onClickConsentSheetList = { navController.navigate(ConsentModalBottomSheetListDestination.route) }, + ) } - composable(route = Screen.Start.name) { - StartScreen(navController) + composable(route = AboutDestination.route) { + AboutScreen() } - composable(route = Screen.SoftwareSecureArea.name) { + composable(route = SoftwareSecureAreaDestination.route) { SoftwareSecureAreaScreen(showToast = { message -> showToast(message) }) } - composable(route = Screen.AndroidKeystoreSecureArea.name) { + composable(route = AndroidKeystoreSecureAreaDestination.route) { AndroidKeystoreSecureAreaScreen(showToast = { message -> showToast(message) }) } - composable(route = Screen.SecureEnclaveSecureArea.name) { + composable(route = SecureEnclaveSecureAreaDestination.route) { SecureEnclaveSecureAreaScreen(showToast = { message -> showToast(message) }) } - composable(route = Screen.PassphraseEntryField.name) { + composable(route = CloudSecureAreaDestination.route) { + CloudSecureAreaScreen(showToast = { message -> showToast(message) }) + } + composable(route = PassphraseEntryFieldDestination.route) { PassphraseEntryFieldScreen(showToast = { message -> showToast(message) }) } - composable(route = Screen.CloudSecureArea.name) { - CloudSecureAreaScreen(showToast = { message -> showToast(message) }) + composable(route = ConsentModalBottomSheetListDestination.route) { + ConsentModalBottomSheetListScreen( + onConsentModalBottomSheetClicked = + { mdlSampleRequest, verifierType -> + navController.navigate( + ConsentModalBottomSheetDestination.route + "/$mdlSampleRequest/$verifierType") + }, + showToast = { message -> showToast(message) } + ) + } + composable( + route = ConsentModalBottomSheetDestination.routeWithArgs, + arguments = ConsentModalBottomSheetDestination.arguments + ) { navBackStackEntry -> + val mdlSampleRequest = navBackStackEntry.arguments?.getString( + ConsentModalBottomSheetDestination.mdlSampleRequestArg + )!! + val verifierType = VerifierType.valueOf(navBackStackEntry.arguments?.getString( + ConsentModalBottomSheetDestination.verifierTypeArg + )!!) + ConsentModalBottomSheetScreen( + mdlSampleRequest = mdlSampleRequest, + verifierType = verifierType, + showToast = { message -> showToast(message) }, + onSheetConfirmed = { navController.popBackStack() }, + onSheetDismissed = { navController.popBackStack() }, + ) } } } @@ -148,13 +170,13 @@ class App { @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppBar( - currentScreen: Screen, + currentDestination: Destination, canNavigateBack: Boolean, navigateUp: () -> Unit, modifier: Modifier = Modifier ) { TopAppBar( - title = { Text(stringResource(currentScreen.title)) }, + title = { Text(stringResource(currentDestination.title)) }, colors = TopAppBarDefaults.mediumTopAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer ), @@ -163,7 +185,7 @@ fun AppBar( if (canNavigateBack) { IconButton(onClick = navigateUp) { Icon( - imageVector = Icons.Filled.ArrowBack, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back_button) ) } diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt new file mode 100644 index 000000000..976e9002b --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt @@ -0,0 +1,84 @@ +package com.android.identity.testapp + +import androidx.navigation.NavType +import androidx.navigation.navArgument +import identitycredential.samples.testapp.generated.resources.Res +import identitycredential.samples.testapp.generated.resources.about_screen_title +import identitycredential.samples.testapp.generated.resources.android_keystore_secure_area_screen_title +import identitycredential.samples.testapp.generated.resources.cloud_secure_area_screen_title +import identitycredential.samples.testapp.generated.resources.consent_modal_bottom_sheet_list_screen_title +import identitycredential.samples.testapp.generated.resources.consent_modal_bottom_sheet_screen_title +import identitycredential.samples.testapp.generated.resources.passphrase_entry_field_screen_title +import identitycredential.samples.testapp.generated.resources.secure_enclave_secure_area_screen_title +import identitycredential.samples.testapp.generated.resources.software_secure_area_screen_title +import identitycredential.samples.testapp.generated.resources.start_screen_title +import org.jetbrains.compose.resources.StringResource + +sealed interface Destination { + val route: String + val title: StringResource +} + +data object StartDestination : Destination { + override val route = "start" + override val title = Res.string.start_screen_title +} + +data object AboutDestination : Destination { + override val route = "about" + override val title = Res.string.about_screen_title +} + +data object SoftwareSecureAreaDestination : Destination { + override val route = "software_secure_area" + override val title = Res.string.software_secure_area_screen_title +} + +data object AndroidKeystoreSecureAreaDestination : Destination { + override val route = "android_keystore_secure_area" + override val title = Res.string.android_keystore_secure_area_screen_title +} + +data object SecureEnclaveSecureAreaDestination : Destination { + override val route = "secure_enclave_secure_area" + override val title = Res.string.secure_enclave_secure_area_screen_title +} + +data object CloudSecureAreaDestination : Destination { + override val route = "cloud_secure_area" + override val title = Res.string.cloud_secure_area_screen_title +} + +data object PassphraseEntryFieldDestination : Destination { + override val route = "passphrase_entry_field" + override val title = Res.string.passphrase_entry_field_screen_title +} + +data object ConsentModalBottomSheetListDestination : Destination { + override val route = "consent_modal_bottom_sheet_list" + override val title = Res.string.consent_modal_bottom_sheet_list_screen_title +} + +data object ConsentModalBottomSheetDestination : Destination { + override val route = "consent_modal_bottom_sheet" + override val title = Res.string.consent_modal_bottom_sheet_screen_title + const val mdlSampleRequestArg = "mdl_sample_request_arg" + const val verifierTypeArg = "verifier_type" + val routeWithArgs = "$route/{$mdlSampleRequestArg}/{$verifierTypeArg}" + val arguments = listOf( + navArgument(mdlSampleRequestArg) { type = NavType.StringType }, + navArgument(verifierTypeArg) { type = NavType.StringType }, + ) +} + +val appDestinations = listOf( + StartDestination, + AboutDestination, + SoftwareSecureAreaDestination, + AndroidKeystoreSecureAreaDestination, + SecureEnclaveSecureAreaDestination, + CloudSecureAreaDestination, + PassphraseEntryFieldDestination, + ConsentModalBottomSheetListDestination, + ConsentModalBottomSheetDestination +) \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/ConsentModalBottomSheetListScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/ConsentModalBottomSheetListScreen.kt new file mode 100644 index 000000000..fdf1418d1 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/ConsentModalBottomSheetListScreen.kt @@ -0,0 +1,54 @@ +package com.android.identity.testapp.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.android.identity.documenttype.knowntypes.DrivingLicense + +enum class VerifierType( + val description: String +) { + KNOWN_VERIFIER("Known Verifier"), + UNKNOWN_VERIFIER_PROXIMITY("Unknown Verifier (Proximity)"), + UNKNOWN_VERIFIER_WEBSITE("Unknown Verifier (Website)"), +} + +@Composable +fun ConsentModalBottomSheetListScreen( + onConsentModalBottomSheetClicked: (mdlSampleRequest: String, verifierType: VerifierType) -> Unit, + showToast: (message: String) -> Unit +) { + val mdlRequests = remember { + val documentType = DrivingLicense.getDocumentType() + documentType.sampleRequests + } + + LazyColumn( + modifier = Modifier.padding(8.dp) + ) { + + for (verifierType in VerifierType.values()) { + item { + Text( + text = verifierType.description, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + } + for (request in mdlRequests) { + item { + TextButton(onClick = { onConsentModalBottomSheetClicked(request.id, verifierType) }) { + Text("${request.displayName}") + } + } + } + } + } +} diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/ConsentModalBottomSheetScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/ConsentModalBottomSheetScreen.kt new file mode 100644 index 000000000..128f9fe48 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/ConsentModalBottomSheetScreen.kt @@ -0,0 +1,158 @@ +package com.android.identity.testapp.ui + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.android.identity.appsupport.ui.consent.ConsentModalBottomSheet +import com.android.identity.appsupport.ui.consent.ConsentDocument +import com.android.identity.appsupport.ui.consent.ConsentRelyingParty +import com.android.identity.appsupport.ui.consent.MdocConsentField +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.CborMap +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.X509Cert +import com.android.identity.documenttype.DocumentTypeRepository +import com.android.identity.documenttype.knowntypes.DrivingLicense +import com.android.identity.mdoc.request.DeviceRequestGenerator +import com.android.identity.mdoc.request.DeviceRequestParser +import com.android.identity.trustmanagement.TrustPoint +import identitycredential.samples.testapp.generated.resources.Res +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.ExperimentalResourceApi + +private const val IACA_CERT_PEM = + """ +-----BEGIN CERTIFICATE----- +MIICujCCAj+gAwIBAgIQWlUtc8+HqDS3PvCqXIlyYDAKBggqhkjOPQQDAzA5MSowKAYDVQQDDCFP +V0YgSWRlbnRpdHkgQ3JlZGVudGlhbCBURVNUIElBQ0ExCzAJBgNVBAYTAlpaMB4XDTI0MDkxNzE2 +NTEzN1oXDTI5MDkxNzE2NTEzN1owOTEqMCgGA1UEAwwhT1dGIElkZW50aXR5IENyZWRlbnRpYWwg +VEVTVCBJQUNBMQswCQYDVQQGEwJaWjB2MBAGByqGSM49AgEGBSuBBAAiA2IABJUHWyr1+ZlNvYEv +sR/1y2uYUkUczBqXTeTwiyRyiEGFFnZ0UR+gNKC4grdCP4F/dA+TWTduy2NlRmog5IByPSdwlvfW +B2f+Tf+MdbgZM+1+ukeaCgDhT/ZwgCoTNgvjyKOCAQowggEGMB0GA1UdDgQWBBQzCQV8RylodOk8 +Yq6AwLDQhC7fUDAfBgNVHSMEGDAWgBQzCQV8RylodOk8Yq6AwLDQhC7fUDAOBgNVHQ8BAf8EBAMC +AQYwTAYDVR0SBEUwQ4ZBaHR0cHM6Ly9naXRodWIuY29tL29wZW53YWxsZXQtZm91bmRhdGlvbi1s +YWJzL2lkZW50aXR5LWNyZWRlbnRpYWwwEgYDVR0TAQH/BAgwBgEB/wIBADBSBgNVHR8ESzBJMEeg +RaBDhkFodHRwczovL2dpdGh1Yi5jb20vb3BlbndhbGxldC1mb3VuZGF0aW9uLWxhYnMvaWRlbnRp +dHktY3JlZGVudGlhbDAKBggqhkjOPQQDAwNpADBmAjEAil9jZ+deFSg1/ESWDEuA3gSU43XCO2t4 +MirhUlQqSRYlOVBlD0sel7tyuiSPxEldAjEA1eTa/5yCZ67jjg6f2gbbJ8ZzMbff+DlHy77+wXIS +b35NiZ8FdVHgC2ut4fDQTRN4 +-----END CERTIFICATE----- + """ + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalResourceApi::class) +@Composable +fun ConsentModalBottomSheetScreen( + mdlSampleRequest: String, + verifierType: VerifierType, + showToast: (message: String) -> Unit, + onSheetConfirmed: () -> Unit, + onSheetDismissed: () -> Unit, +) { + val scope = rememberCoroutineScope() + + // TODO: use sheetGesturesEnabled=false when available - see b/288211587 for details + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + val consentFields = remember { + val request = DrivingLicense.getDocumentType().sampleRequests.find { it.id == mdlSampleRequest }!! + val namespacesToRequest = mutableMapOf>() + for (ns in request.mdocRequest!!.namespacesToRequest) { + val dataElementsToRequest = mutableMapOf() + for ((de, intentToRetain) in ns.dataElementsToRequest) { + dataElementsToRequest[de.attribute.identifier] = intentToRetain + } + namespacesToRequest[ns.namespace] = dataElementsToRequest + } + val encodedSessionTranscript = Cbor.encode(CborMap.builder().put("doesn't", "matter").end().build()) + val encodedDeviceRequest = DeviceRequestGenerator(encodedSessionTranscript) + .addDocumentRequest( + request.mdocRequest!!.docType, + namespacesToRequest, + null, + null, + Algorithm.UNSET, + null + ).generate() + val deviceRequest = DeviceRequestParser(encodedDeviceRequest, encodedSessionTranscript) + .parse() + + val docTypeRepo = DocumentTypeRepository() + docTypeRepo.addDocumentType(DrivingLicense.getDocumentType()) + MdocConsentField.generateConsentFields( + deviceRequest.docRequests[0], + docTypeRepo, + null + ) + } + + var cardArt by remember { + mutableStateOf(ByteArray(0)) + } + var relyingPartyDisplayIcon by remember { + mutableStateOf(ByteArray(0)) + } + LaunchedEffect(Unit) { + cardArt = Res.readBytes("files/utopia_driving_license_card_art.png") + relyingPartyDisplayIcon = Res.readBytes("files/utopia-brewery.png") + sheetState.show() + } + + val relyingParty = when (verifierType) { + VerifierType.KNOWN_VERIFIER -> { + ConsentRelyingParty( + trustPoint = TrustPoint( + certificate = X509Cert.fromPem(IACA_CERT_PEM), + displayName = "Utopia Brewery", + displayIcon = relyingPartyDisplayIcon + ), + websiteOrigin = null, + ) + } + VerifierType.UNKNOWN_VERIFIER_PROXIMITY -> { + ConsentRelyingParty( + trustPoint = null, + websiteOrigin = null, + ) + } + VerifierType.UNKNOWN_VERIFIER_WEBSITE -> { + ConsentRelyingParty( + trustPoint = null, + websiteOrigin = "https://www.example.com", + ) + } + } + + if (sheetState.isVisible && cardArt.size > 0) { + ConsentModalBottomSheet( + sheetState = sheetState, + consentFields = consentFields, + ConsentDocument( + name = "Erika's Driving License", + cardArt = cardArt, + description = "Driving License", + ), + relyingParty = relyingParty, + onConfirm = { + scope.launch { + sheetState.hide() + onSheetConfirmed() + } + }, + onCancel = { + scope.launch { + sheetState.hide() + showToast("The sheet was dismissed") + onSheetDismissed() + } + } + ) + } +} diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/PassphraseEntryFieldScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/PassphraseEntryFieldScreen.kt index 670d43416..4d7003a60 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/PassphraseEntryFieldScreen.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/PassphraseEntryFieldScreen.kt @@ -99,10 +99,10 @@ fun PassphraseEntryFieldScreen( } @Composable -fun ShowEntry(constraints: PassphraseConstraints, - checkWeakPassphrase: Boolean, - onDismissRequest: () -> Unit, - onPassphraseEntered: (passphrase: String) -> Unit) { +private fun ShowEntry(constraints: PassphraseConstraints, + checkWeakPassphrase: Boolean, + onDismissRequest: () -> Unit, + onPassphraseEntered: (passphrase: String) -> Unit) { // Is only non-null if the passphrase meets requirements. val curPassphrase = remember { mutableStateOf(null) } Dialog(onDismissRequest = { onDismissRequest() }) { diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt index 156eb4d37..9a71dbf2f 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt @@ -10,21 +10,28 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController import com.android.identity.testapp.Platform -import com.android.identity.testapp.Screen import com.android.identity.testapp.platform import identitycredential.samples.testapp.generated.resources.Res import identitycredential.samples.testapp.generated.resources.about_screen_title import identitycredential.samples.testapp.generated.resources.android_keystore_secure_area_screen_title import identitycredential.samples.testapp.generated.resources.passphrase_entry_field_screen_title import identitycredential.samples.testapp.generated.resources.cloud_secure_area_screen_title +import identitycredential.samples.testapp.generated.resources.consent_modal_bottom_sheet_list_screen_title import identitycredential.samples.testapp.generated.resources.secure_enclave_secure_area_screen_title import identitycredential.samples.testapp.generated.resources.software_secure_area_screen_title import org.jetbrains.compose.resources.stringResource @Composable -fun StartScreen(navController: NavHostController) { +fun StartScreen( + onClickAbout: () -> Unit = {}, + onClickSoftwareSecureArea: () -> Unit = {}, + onClickAndroidKeystoreSecureArea: () -> Unit = {}, + onClickCloudSecureArea: () -> Unit = {}, + onClickSecureEnclaveSecureArea: () -> Unit = {}, + onClickPassphraseEntryField: () -> Unit = {}, + onClickConsentSheetList: () -> Unit = {}, +) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background @@ -33,21 +40,13 @@ fun StartScreen(navController: NavHostController) { modifier = Modifier.padding(8.dp) ) { item { - TextButton( - onClick = { - navController.navigate(route = Screen.About.name) - } - ) { + TextButton(onClick = onClickAbout) { Text(stringResource(Res.string.about_screen_title)) } } item { - TextButton( - onClick = { - navController.navigate(route = Screen.SoftwareSecureArea.name) - } - ) { + TextButton(onClick = onClickSoftwareSecureArea) { Text(stringResource(Res.string.software_secure_area_screen_title)) } } @@ -55,20 +54,12 @@ fun StartScreen(navController: NavHostController) { when (platform) { Platform.ANDROID -> { item { - TextButton( - onClick = { - navController.navigate(route = Screen.AndroidKeystoreSecureArea.name) - } - ) { + TextButton(onClick = onClickAndroidKeystoreSecureArea) { Text(stringResource(Res.string.android_keystore_secure_area_screen_title)) } // Cloud Secure Area is Android-only for now. - TextButton( - onClick = { - navController.navigate(route = Screen.CloudSecureArea.name) - } - ) { + TextButton(onClick = onClickCloudSecureArea) { Text(stringResource(Res.string.cloud_secure_area_screen_title)) } } @@ -76,11 +67,7 @@ fun StartScreen(navController: NavHostController) { Platform.IOS -> { item { - TextButton( - onClick = { - navController.navigate(route = Screen.SecureEnclaveSecureArea.name) - } - ) { + TextButton(onClick = onClickSecureEnclaveSecureArea) { Text(stringResource(Res.string.secure_enclave_secure_area_screen_title)) } } @@ -88,15 +75,16 @@ fun StartScreen(navController: NavHostController) { } item { - TextButton( - onClick = { - navController.navigate(route = Screen.PassphraseEntryField.name) - } - ) { + TextButton(onClick = onClickPassphraseEntryField) { Text(stringResource(Res.string.passphrase_entry_field_screen_title)) } } + item { + TextButton(onClick = onClickConsentSheetList) { + Text(stringResource(Res.string.consent_modal_bottom_sheet_list_screen_title)) + } + } } } } diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialRequestServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialRequestServlet.kt index b57d03e60..965070f1e 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialRequestServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialRequestServlet.kt @@ -54,7 +54,7 @@ class CredentialRequestServlet : BaseServlet() { }) put("fields", buildJsonArray { for (namespace in fullPid.mdocRequest!!.namespacesToRequest) { - for (dataElement in namespace.dataElementsToRequest) { + for ((dataElement, _) in namespace.dataElementsToRequest) { add(buildJsonObject { put("intentToRetain", JsonPrimitive(false)) put("namespace", JsonPrimitive(namespace.namespace)) diff --git a/server/src/main/java/com/android/identity/wallet/server/VerifierServlet.kt b/server/src/main/java/com/android/identity/wallet/server/VerifierServlet.kt index ae9b13b13..9a8df11f4 100644 --- a/server/src/main/java/com/android/identity/wallet/server/VerifierServlet.kt +++ b/server/src/main/java/com/android/identity/wallet/server/VerifierServlet.kt @@ -1253,11 +1253,11 @@ private fun mdocCalcDcRequestStringPreview( val fields = JSONArray() for (ns in request.mdocRequest!!.namespacesToRequest) { - for (de in ns.dataElementsToRequest) { + for ((de, intentToRetain) in ns.dataElementsToRequest) { val field = JSONObject() field.put("namespace", ns.namespace) field.put("name", de.attribute.identifier) - field.put("intentToRetain", false) + field.put("intentToRetain", intentToRetain) fields.add(field) } } @@ -1306,9 +1306,9 @@ private fun mdocCalcDcRequestStringArf( val itemsToRequest = mutableMapOf>() for (ns in request.mdocRequest!!.namespacesToRequest) { - for (de in ns.dataElementsToRequest) { + for ((de, intentToRetain) in ns.dataElementsToRequest) { itemsToRequest.getOrPut(ns.namespace) { mutableMapOf() } - .put(de.attribute.identifier, false) + .put(de.attribute.identifier, intentToRetain) } } val generator = DeviceRequestGenerator(sessionTranscript) @@ -1342,12 +1342,12 @@ private fun mdocCalcPresentationDefinition( val fields = JSONArray() for (ns in request.mdocRequest!!.namespacesToRequest) { - for (de in ns.dataElementsToRequest) { + for ((de, intentToRetain) in ns.dataElementsToRequest) { var array = JSONArray() array.add("\$['${ns.namespace}']['${de.attribute.identifier}']") val field = JSONObject() field.put("path", array) - field.put("intent_to_retain", false) + field.put("intent_to_retain", intentToRetain) fields.add(field) } } diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/PresentationActivity.kt b/wallet/src/main/java/com/android/identity_credential/wallet/PresentationActivity.kt index cc49697b0..2da009130 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/PresentationActivity.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/PresentationActivity.kt @@ -52,6 +52,8 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.lifecycleScope import com.android.identity.android.mdoc.deviceretrieval.DeviceRetrievalHelper import com.android.identity.android.mdoc.transport.DataTransport +import com.android.identity.appsupport.ui.consent.ConsentDocument +import com.android.identity.appsupport.ui.consent.ConsentRelyingParty import com.android.identity.crypto.EcPrivateKey import com.android.identity.crypto.EcPublicKey import com.android.identity.crypto.javaX509Certificates @@ -66,7 +68,7 @@ import com.android.identity.util.Constants import com.android.identity.util.Logger import com.android.identity_credential.wallet.presentation.UserCanceledPromptException import com.android.identity_credential.wallet.presentation.showMdocPresentmentFlow -import com.android.identity_credential.wallet.ui.prompt.consent.MdocConsentField +import com.android.identity.appsupport.ui.consent.MdocConsentField import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.datetime.Clock @@ -380,8 +382,12 @@ class PresentationActivity : FragmentActivity() { val documentCborBytes = showMdocPresentmentFlow( activity = this@PresentationActivity, consentFields = consentFields, - documentName = mdocCredential.document.documentConfiguration.displayName, - trustPoint = trustPoint, + document = ConsentDocument( + name = mdocCredential.document.documentConfiguration.displayName, + description = mdocCredential.document.documentConfiguration.typeDisplayName, + cardArt = mdocCredential.document.documentConfiguration.cardArt, + ), + relyingParty = ConsentRelyingParty(trustPoint), credential = mdocCredential, encodedSessionTranscript = deviceRetrievalHelper!!.sessionTranscript ) diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ReaderModel.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ReaderModel.kt index 8c8fde764..b337c4ea0 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ReaderModel.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ReaderModel.kt @@ -13,6 +13,7 @@ import com.android.identity.android.mdoc.transport.DataTransportOptions import com.android.identity.cbor.Bstr import com.android.identity.cbor.Cbor import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.javaX509Certificate import com.android.identity.crypto.javaX509Certificates import com.android.identity.documenttype.DocumentAttributeType import com.android.identity.documenttype.DocumentTypeRepository @@ -220,8 +221,8 @@ class ReaderModel( val namespacesToRequest = mutableMapOf>() for (ns in mdocRequest.namespacesToRequest) { val dataElementsToRequest = mutableMapOf() - for (de in ns.dataElementsToRequest) { - dataElementsToRequest[de.attribute.identifier] = false + for ((de, intentToRetain) in ns.dataElementsToRequest) { + dataElementsToRequest[de.attribute.identifier] = intentToRetain } namespacesToRequest[ns.namespace] = dataElementsToRequest } @@ -262,7 +263,7 @@ class ReaderModel( if (trustResult.isTrusted) { val trustPoint = trustResult.trustPoints[0] val displayName = trustPoint.displayName - ?: trustPoint.certificate.subjectX500Principal.name + ?: trustPoint.certificate.javaX509Certificate.subjectX500Principal.name infoTexts.add(res.getString(R.string.reader_model_info_in_trust_list, displayName)) } else { val dsCert = readerAuthChain[0] diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt b/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt index fc08d0917..5c447b3a7 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt @@ -51,9 +51,6 @@ import com.android.identity.trustmanagement.TrustPoint import com.android.identity.util.Logger import com.android.identity_credential.wallet.logging.EventLogger import com.android.identity_credential.wallet.util.toByteArray -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.datetime.Clock import kotlinx.io.files.Path import org.bouncycastle.jce.provider.BouncyCastleProvider @@ -228,7 +225,7 @@ class WalletApplication : Application() { val cert = X509Cert(resources.openRawResource(certResourceId).readBytes()) readerTrustManager.addTrustPoint( TrustPoint( - cert.javaX509Certificate, + cert, null, null, ) @@ -244,7 +241,7 @@ class WalletApplication : Application() { val cert = X509Cert(certInfo.certificate) issuerTrustManager.addTrustPoint( TrustPoint( - cert.javaX509Certificate, + cert, null, null ) @@ -330,7 +327,7 @@ class WalletApplication : Application() { String( resources.openRawResource(certificateResourceId).readBytes() ) - ).javaX509Certificate, + ), displayName = displayName, displayIcon = displayIconResourceId?.let { iconId -> ResourcesCompat.getDrawable(resources, iconId, null)?.toByteArray() diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/credman/CredmanPresentationActivity.kt b/wallet/src/main/java/com/android/identity_credential/wallet/credman/CredmanPresentationActivity.kt index 63f5d87b2..b87124b7f 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/credman/CredmanPresentationActivity.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/credman/CredmanPresentationActivity.kt @@ -22,6 +22,7 @@ import android.util.Base64 import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import com.android.identity.android.mdoc.util.CredmanUtil +import com.android.identity.appsupport.ui.consent.ConsentDocument import com.android.identity.cbor.Cbor import com.android.identity.cbor.CborArray import com.android.identity.cbor.Simple @@ -41,8 +42,9 @@ import com.android.identity.util.Logger import com.android.identity.util.fromBase64Url import com.android.identity_credential.wallet.WalletApplication import com.android.identity_credential.wallet.presentation.showMdocPresentmentFlow -import com.android.identity_credential.wallet.ui.prompt.consent.ConsentField -import com.android.identity_credential.wallet.ui.prompt.consent.MdocConsentField +import com.android.identity.appsupport.ui.consent.ConsentField +import com.android.identity.appsupport.ui.consent.ConsentRelyingParty +import com.android.identity.appsupport.ui.consent.MdocConsentField import org.json.JSONObject import com.google.android.gms.identitycredentials.GetCredentialResponse @@ -150,6 +152,7 @@ class CredmanPresentationActivity : FragmentActivity() { mdocCredential, consentFields, null, + callingOrigin, encodedSessionTranscript ) @@ -250,6 +253,7 @@ class CredmanPresentationActivity : FragmentActivity() { mdocCredential, consentFields, trustPoint, + callingOrigin, encodedSessionTranscript, ) @@ -351,6 +355,7 @@ class CredmanPresentationActivity : FragmentActivity() { mdocCredential, consentFields, null, + callingOrigin, encodedSessionTranscript ) // Create the openid4vp response @@ -400,14 +405,18 @@ class CredmanPresentationActivity : FragmentActivity() { mdocCredential: MdocCredential, consentFields: List, trustPoint: TrustPoint?, + websiteOrigin: String?, encodedSessionTranscript: ByteArray, ): ByteArray { val documentCborBytes = showMdocPresentmentFlow( activity = this@CredmanPresentationActivity, consentFields = consentFields, - documentName = mdocCredential.document.documentConfiguration.displayName, - // TODO: Need to extend TrustManager with a verify() variants which takes a domain or appId - trustPoint = trustPoint, + document = ConsentDocument( + name = mdocCredential.document.documentConfiguration.displayName, + description = mdocCredential.document.documentConfiguration.typeDisplayName, + cardArt = mdocCredential.document.documentConfiguration.cardArt, + ), + relyingParty = ConsentRelyingParty(trustPoint, websiteOrigin), credential = mdocCredential, encodedSessionTranscript = encodedSessionTranscript, ) diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/presentation/OpenID4VPPresentationActivity.kt b/wallet/src/main/java/com/android/identity_credential/wallet/presentation/OpenID4VPPresentationActivity.kt index 62fc58d2b..ba0c3d866 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/presentation/OpenID4VPPresentationActivity.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/presentation/OpenID4VPPresentationActivity.kt @@ -31,6 +31,7 @@ import androidx.fragment.app.FragmentActivity import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.MutableLiveData import androidx.lifecycle.lifecycleScope +import com.android.identity.appsupport.ui.consent.ConsentDocument import com.android.identity.cbor.Cbor import com.android.identity.cbor.CborArray import com.android.identity.cbor.Simple @@ -40,20 +41,23 @@ import com.android.identity.crypto.Crypto import com.android.identity.crypto.X509Cert import com.android.identity.document.Document import com.android.identity.document.DocumentRequest +import com.android.identity.documenttype.DocumentTypeRepository import com.android.identity.documenttype.knowntypes.EUPersonalID import com.android.identity.issuance.CredentialFormat import com.android.identity.issuance.DocumentExtensions.documentConfiguration import com.android.identity.mdoc.credential.MdocCredential import com.android.identity.mdoc.response.DeviceResponseGenerator +import com.android.identity.sdjwt.SdJwtVerifiableCredential import com.android.identity.sdjwt.credential.SdJwtVcCredential import com.android.identity.trustmanagement.TrustPoint import com.android.identity.util.Constants import com.android.identity.util.Logger import com.android.identity_credential.wallet.R import com.android.identity_credential.wallet.WalletApplication -import com.android.identity_credential.wallet.ui.prompt.consent.ConsentField -import com.android.identity_credential.wallet.ui.prompt.consent.MdocConsentField -import com.android.identity_credential.wallet.ui.prompt.consent.VcConsentField +import com.android.identity.appsupport.ui.consent.ConsentField +import com.android.identity.appsupport.ui.consent.ConsentRelyingParty +import com.android.identity.appsupport.ui.consent.MdocConsentField +import com.android.identity.appsupport.ui.consent.VcConsentField import com.android.identity_credential.wallet.ui.theme.IdentityCredentialTheme // TODO: replace the nimbusds library usage with non-java-based alternative import com.nimbusds.jose.EncryptionMethod @@ -390,7 +394,7 @@ class OpenID4VPPresentationActivity : FragmentActivity() { val credential = document.findCredential(WalletApplication.CREDENTIAL_DOMAIN_SD_JWT_VC, now) ?: throw IllegalStateException("No credentials available") - val consentFields = VcConsentField.generateConsentFields( + val consentFields = VcConsentField.Companion.generateConsentFields( vct, requestedClaims, walletApp.documentTypeRepository, @@ -624,8 +628,12 @@ class OpenID4VPPresentationActivity : FragmentActivity() { val documentResponse = showMdocPresentmentFlow( activity = this, consentFields = consentFields, - documentName = credential.document.documentConfiguration.displayName, - trustPoint = trustPoint, + document = ConsentDocument( + name = credential.document.documentConfiguration.displayName, + description = credential.document.documentConfiguration.typeDisplayName, + cardArt = credential.document.documentConfiguration.cardArt, + ), + relyingParty = ConsentRelyingParty(trustPoint), credential = credential, encodedSessionTranscript = sessionTranscript, ) @@ -641,8 +649,12 @@ class OpenID4VPPresentationActivity : FragmentActivity() { showSdJwtPresentmentFlow( activity = this, consentFields = consentFields, - documentName = credential.document.documentConfiguration.displayName, - trustPoint = trustPoint, + document = ConsentDocument( + name = credential.document.documentConfiguration.displayName, + description = credential.document.documentConfiguration.typeDisplayName, + cardArt = credential.document.documentConfiguration.cardArt, + ), + relyingParty = ConsentRelyingParty(trustPoint), credential = credential, nonce = authorizationRequest.nonce, clientId = authorizationRequest.clientId @@ -941,4 +953,55 @@ internal fun formatAsDocumentRequest(inputDescriptor: JsonObject): DocumentReque )) } return DocumentRequest(requestedDataElements) -} \ No newline at end of file +} + +/** + * Helper function to generate a list of entries for the consent prompt for VCs. + * + * TODO: Move to VcConsentField when making identity-sdjwt a Kotlin Multiplatform library. + * + * @param vct the Verifiable Credential Type. + * @param claims the list of claims. + * @param documentTypeRepository a [DocumentTypeRepository] used to determine the display name. + * @param vcCredential if set, the returned list is filtered so it only references claims + * available in the credential. + */ +private fun VcConsentField.Companion.generateConsentFields( + vct: String, + claims: List, + documentTypeRepository: DocumentTypeRepository, + vcCredential: SdJwtVcCredential?, +): List { + val vcType = documentTypeRepository.getDocumentTypeForVc(vct)?.vcDocumentType + val ret = mutableListOf() + for (claimName in claims) { + val attribute = vcType?.claims?.get(claimName) + ret.add( + VcConsentField( + attribute?.displayName ?: claimName, + attribute, + claimName + ) + ) + } + return filterConsentFields(ret, vcCredential) +} + +private fun filterConsentFields( + list: List, + credential: SdJwtVcCredential? +): List { + if (credential == null) { + return list + } + val sdJwt = SdJwtVerifiableCredential.fromString( + String(credential.issuerProvidedData, Charsets.US_ASCII)) + + val availableClaims = mutableSetOf() + for (disclosure in sdJwt.disclosures) { + availableClaims.add(disclosure.key) + } + return list.filter { vcConsentField -> + availableClaims.contains(vcConsentField.claimName) + } +} diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/presentation/PresentmentFlow.kt b/wallet/src/main/java/com/android/identity_credential/wallet/presentation/PresentmentFlow.kt index ee829609b..1efa3d2d7 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/presentation/PresentmentFlow.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/presentation/PresentmentFlow.kt @@ -7,6 +7,7 @@ import com.android.identity.android.securearea.UserAuthenticationType import com.android.identity.android.securearea.cloud.CloudKeyLockedException import com.android.identity.android.securearea.cloud.CloudKeyUnlockData import com.android.identity.android.securearea.cloud.CloudSecureArea +import com.android.identity.appsupport.ui.consent.ConsentDocument import com.android.identity.cbor.Cbor import com.android.identity.credential.Credential import com.android.identity.credential.SecureAreaBoundCredential @@ -29,9 +30,10 @@ import com.android.identity.trustmanagement.TrustPoint import com.android.identity.util.Logger import com.android.identity_credential.wallet.R import com.android.identity_credential.wallet.ui.prompt.biometric.showBiometricPrompt -import com.android.identity_credential.wallet.ui.prompt.consent.ConsentField -import com.android.identity_credential.wallet.ui.prompt.consent.MdocConsentField -import com.android.identity_credential.wallet.ui.prompt.consent.VcConsentField +import com.android.identity.appsupport.ui.consent.ConsentField +import com.android.identity.appsupport.ui.consent.ConsentRelyingParty +import com.android.identity.appsupport.ui.consent.MdocConsentField +import com.android.identity.appsupport.ui.consent.VcConsentField import com.android.identity_credential.wallet.ui.prompt.consent.showConsentPrompt import com.android.identity_credential.wallet.ui.prompt.passphrase.showPassphrasePrompt @@ -41,17 +43,17 @@ const val MAX_PASSPHRASE_ATTEMPTS = 3 private suspend fun showPresentmentFlowImpl( activity: FragmentActivity, consentFields: List, - documentName: String, - trustPoint: TrustPoint?, + document: ConsentDocument, + relyingParty: ConsentRelyingParty, credential: SecureAreaBoundCredential, signAndGenerate: (KeyUnlockData?) -> ByteArray ): ByteArray { // always show the Consent Prompt first showConsentPrompt( activity = activity, - documentName = documentName, + document = document, + relyingParty = relyingParty, consentFields = consentFields, - trustPoint = trustPoint ).let { resultSuccess -> // throw exception if user canceled the Prompt if (!resultSuccess){ @@ -210,16 +212,16 @@ private suspend fun showPresentmentFlowImpl( suspend fun showMdocPresentmentFlow( activity: FragmentActivity, consentFields: List, - documentName: String, - trustPoint: TrustPoint?, + document: ConsentDocument, + relyingParty: ConsentRelyingParty, credential: MdocCredential, encodedSessionTranscript: ByteArray, ): ByteArray { return showPresentmentFlowImpl( activity, consentFields, - documentName, - trustPoint, + document, + relyingParty, credential ) { keyUnlockData: KeyUnlockData? -> mdocSignAndGenerate(consentFields, credential, encodedSessionTranscript!!, keyUnlockData) @@ -229,17 +231,17 @@ suspend fun showMdocPresentmentFlow( suspend fun showSdJwtPresentmentFlow( activity: FragmentActivity, consentFields: List, - documentName: String, + document: ConsentDocument, + relyingParty: ConsentRelyingParty, credential: SecureAreaBoundCredential, - trustPoint: TrustPoint?, nonce: String, clientId: String, ): ByteArray { return showPresentmentFlowImpl( activity, consentFields, - documentName, - trustPoint, + document, + relyingParty, credential ) { keyUnlockData: KeyUnlockData? -> val sdJwt = SdJwtVerifiableCredential.fromString( diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/ConsentPrompt.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/ConsentPrompt.kt index c09d958f0..2fc0ffa69 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/ConsentPrompt.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/ConsentPrompt.kt @@ -4,9 +4,15 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.FragmentActivity -import com.android.identity.trustmanagement.TrustPoint +import com.android.identity.appsupport.ui.consent.ConsentField +import com.android.identity.appsupport.ui.consent.ConsentModalBottomSheet +import com.android.identity.appsupport.ui.consent.ConsentDocument +import com.android.identity.appsupport.ui.consent.ConsentRelyingParty import com.android.identity_credential.wallet.ui.theme.IdentityCredentialTheme import com.google.android.material.bottomsheet.BottomSheetDialogFragment import kotlinx.coroutines.suspendCancellableCoroutine @@ -20,16 +26,16 @@ import kotlin.coroutines.resume */ suspend fun showConsentPrompt( activity: FragmentActivity, - documentName: String, - consentFields: List, - trustPoint: TrustPoint? + document: ConsentDocument, + relyingParty: ConsentRelyingParty, + consentFields: List ): Boolean = suspendCancellableCoroutine { continuation -> // new instance of the ConsentPrompt bottom sheet dialog fragment but not shown yet val consentPrompt = ConsentPrompt( consentFields = consentFields, - documentName = documentName, - verifier = trustPoint, + document = document, + relyingParty = relyingParty, onConsentPromptResult = { promptWasSuccessful -> continuation.resume(promptWasSuccessful) } @@ -47,14 +53,15 @@ suspend fun showConsentPrompt( */ class ConsentPrompt( private val consentFields: List, - private val documentName: String, - private val verifier: TrustPoint?, + private val document: ConsentDocument, + private val relyingParty: ConsentRelyingParty, private val onConsentPromptResult: (Boolean) -> Unit, ) : BottomSheetDialogFragment() { /** - * Define the composable [ConsentPromptEntryField] and issue callbacks to [onConsentPromptResult] + * Define the composable [ConsentModalBottomSheet] and issue callbacks to [onConsentPromptResult] * based on which button is tapped. */ + @OptIn(ExperimentalMaterial3Api::class) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -63,11 +70,20 @@ class ConsentPrompt( ComposeView(requireContext()).apply { setContent { IdentityCredentialTheme { + // TODO: use sheetGesturesEnabled=false when available instead of confirmValueChanged + // hack - see b/288211587 for details + // + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden } + ) + // define the ConsentPromptComposable (and show) - ConsentPromptEntryField( + ConsentModalBottomSheet( + sheetState = sheetState, consentFields = consentFields, - documentName = documentName, - verifier = verifier, + document = document, + relyingParty = relyingParty, // user accepted to send requested credential data onConfirm = { // notify that the user tapped on the 'Confirm' button diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/ConsentPromptEntryField.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/ConsentPromptEntryField.kt deleted file mode 100644 index 8fe280e0c..000000000 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/ConsentPromptEntryField.kt +++ /dev/null @@ -1,372 +0,0 @@ -package com.android.identity_credential.wallet.ui.prompt.consent - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableIntState -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.android.identity.appsupport.ui.getOutlinedImageVector -import com.android.identity.trustmanagement.TrustPoint -import com.android.identity_credential.wallet.R -import com.android.identity_credential.wallet.util.toImageBitmap -import kotlinx.coroutines.launch -import kotlin.math.floor - -/** - * ConfirmButtonState defines the possible states of the Confirm button in the Consent Prompt. - * This state is referenced through [val confirmButtonState] in [ConsentPromptEntryField] - * - * If the Confirm button state is ENABLED then the Confirm button is enabled for the user to tap. - * This invokes the `onConfirm()` callback and closes Consent Prompt composable. - * - * If Confirm button state is DISABLED then user cannot tap on the Confirm button until the user has - * scrolled to the bottom of the list where the state is changed to ENABLED. - */ -private enum class ConfirmButtonState { - // User can confirm sending the requested credentials after scrolling to the bottom - ENABLED, - - // Confirm button cannot be tapped in this state - DISABLED, - - // For initializing the state flow - INIT -} - -/** - * ConsentPromptEntryField is responsible for showing a bottom sheet modal dialog prompting the user - * to consent to sending credential data to requesting party and user can cancel at any time. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ConsentPromptEntryField( - consentFields: List, - documentName: String, - verifier: TrustPoint?, - onConfirm: () -> Unit = {}, - onCancel: () -> Unit = {} -) { - // used for bottom sheet - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - - // determine whether user needs to scroll to tap on the Confirm button - val confirmButtonState = remember { mutableStateOf(ConfirmButtonState.INIT) } - val scrolledToBottom = remember { mutableStateOf(false) } // remember if user scrolled to bottom - - // the index of the last row that is currently visible - val lastVisibleRowIndexState = remember { mutableIntStateOf(0) } - - // total rows that contain data element texts, with at most 2 per row - val totalDataElementRows = remember { - floor(consentFields.size / 2.0).toInt().run { - if (consentFields.size % 2 != 0) { //odd number of elements - this + 1// 1 more row to represent the single element - } else { - this// even number of elements, row count remains unchanged - } - } - } - // the index of the last row that will be/is rendered in the LazyColumn - val lastRowIndex = remember { totalDataElementRows - 1 } - - // if user has not previously scrolled to bottom of list - if (!scrolledToBottom.value) { // else if user has already scrolled to bottom, don't change button state - - // set Confirm button state according to whether there are more rows to be shown to user than - // what the user is currently seeing - confirmButtonState.value = - if (lastRowIndex > lastVisibleRowIndexState.intValue) { - ConfirmButtonState.DISABLED // user needs to scroll to reach the bottom of the list - } else {// last visible row index has reached the LazyColumnI last row index - // remember that user already saw the bottom-most row even if they scroll back up - scrolledToBottom.value = true - ConfirmButtonState.ENABLED // user has the option to now share their sensitive data - } - } - - ModalBottomSheet( - modifier = Modifier.fillMaxHeight(0.6F), - onDismissRequest = { onCancel() }, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface - ) { - - ConsentPromptHeader( - documentName = documentName, - verifier = verifier - ) - - Box( - modifier = Modifier - .fillMaxHeight(0.8f) - .fillMaxWidth() - .padding(top = 16.dp, bottom = 8.dp) - ) { - DataElementsListView( - consentFields = consentFields, - lastVisibleRowIndexState = lastVisibleRowIndexState, - ) - } - - // show the 2 action button on the bottom of the dialog - ConsentPromptActions( - confirmButtonState = confirmButtonState, - onCancel = { - scope.launch { - sheetState.hide() - onCancel() - } - }, - onConfirm = { onConfirm.invoke() } - ) - } -} - -/** - * Show the title text according to whether there's a TrustPoint's available, and if present, show - * the icon too. - */ -@Composable -private fun ConsentPromptHeader( - documentName: String, - verifier: TrustPoint? -) { - // title of dialog, if verifier is null or verifier.displayName is null, use default text - val title = if (verifier == null) { - LocalContext.current.getString(R.string.consent_prompt_title, documentName) - } else { // title is based on TrustPoint's displayName, if available - val verifierDisplayName = verifier.displayName - ?: "Trusted Verifier (${verifier.certificate.subjectX500Principal.name})" - LocalContext.current.getString( - R.string.consent_prompt_title_verifier, verifierDisplayName, - documentName - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.Center - ) { - // show icon if icon bytes are present - verifier?.displayIcon?.let { iconBytes -> - Icon( - modifier = Modifier.size(50.dp), - // TODO: we're computing a bitmap every recomposition and this could be slow - bitmap = iconBytes.toImageBitmap(), - contentDescription = stringResource(id = R.string.consent_prompt_icon_description) - ) - } ?: Spacer(modifier = Modifier.width(24.dp)) - Text( - text = title, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - } -} - -/** - * List View showing 2 columns of data elements requested to be sent to requesting party. - * Shows the document name that is used for extracting requested data. - * - * Report back on param [lastVisibleRowIndexState] the index of the last row that is considered to - * be actively visible from Compose (as user scrolls and compose draws). - */ -@Composable -private fun DataElementsListView( - consentFields: List, - lastVisibleRowIndexState: MutableIntState, -) { - val groupedElements = consentFields.chunked(2).map { pair -> - if (pair.size == 1) Pair(pair.first(), null) - else Pair(pair.first(), pair.last()) - } - val lazyListState = rememberLazyListState() - val visibleRows = remember { derivedStateOf { lazyListState.layoutInfo.visibleItemsInfo } } - - if (visibleRows.value.isNotEmpty()) { - // notify of the last row's index that's considered visible - lastVisibleRowIndexState.intValue = visibleRows.value.last().index - } - LazyColumn( - state = lazyListState, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .focusGroup() - .padding(start=10.dp) - ) { - items(groupedElements.size) { index -> - val pair = groupedElements[index] - DataElementsRow( - left = pair.first, - right = pair.second, - ) - } - } -} - -/** - * A single row containing 2 columns of data elements to consent to sending to the Verifier. - */ -@Composable -private fun DataElementsRow( - left: ConsentField, - right: ConsentField?, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 1.dp), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - val chipModifier = if (right != null) Modifier.weight(1f) else Modifier - DataElementView( - modifier = chipModifier, - consentField = left, - ) - right?.let { - DataElementView( - modifier = chipModifier, - consentField = right, - ) - } - } -} - -/** - * Individual view for a DataElement. - */ -@Composable -private fun DataElementView( - modifier: Modifier = Modifier, - consentField: ConsentField, -) { - FilterChip( - modifier = modifier, - selected = true, - enabled = false, - colors = FilterChipDefaults.filterChipColors( - disabledContainerColor = Color.Transparent, - disabledTrailingIconColor = MaterialTheme.colorScheme.onSurface, - disabledLeadingIconColor = MaterialTheme.colorScheme.onSurface, - disabledLabelColor = MaterialTheme.colorScheme.onSurface, - disabledSelectedContainerColor = Color.Transparent, - - ), - - onClick = {}, - label = { - if (consentField.attribute?.icon != null) { - Icon( - consentField.attribute!!.icon!!.getOutlinedImageVector(), - contentDescription = "${consentField.attribute!!.icon!!.iconName} icon" - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Text( - text = "${consentField.displayName}", - fontWeight = FontWeight.Normal, - style = MaterialTheme.typography.bodySmall - ) - }, - ) -} - -/** - * Bottom actions containing 2 buttons: Cancel and Confirm - * Once user taps on Confirm, we disable buttons to prevent unintended taps. - */ -@Composable -private fun ConsentPromptActions( - confirmButtonState: MutableState, - onCancel: () -> Unit, - onConfirm: () -> Unit -) { - Column(modifier = Modifier.fillMaxHeight()) { - - Row( - horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier.padding(horizontal = 10.dp) - - ) { - // Cancel button - Button( - modifier = Modifier.weight(1f), - onClick = { onCancel.invoke() } - ) { - Text(text = stringResource(id = R.string.consent_prompt_button_cancel)) - } - - Spacer(modifier = Modifier.width(10.dp)) - - // Confirm button - Button( - modifier = Modifier.weight(1f), - // enabled when user scrolls to the bottom - enabled = confirmButtonState.value == ConfirmButtonState.ENABLED, - onClick = { onConfirm.invoke() } - ) { - Text(text = stringResource(id = R.string.consent_prompt_button_confirm)) - } - } - // fade out "scroll to bottom" when user reaches bottom of list (enabled via 'visible' param) - AnimatedVisibility( - visible = confirmButtonState.value == ConfirmButtonState.DISABLED, - enter = fadeIn(), - exit = fadeOut() - ) { - Text( - text = stringResource(id = R.string.consent_prompt_text_scroll_to_bottom), - fontSize = 12.sp, - style = TextStyle.Default.copy( - color = Color.Gray - ), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - } - } -} \ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/VcConsentField.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/VcConsentField.kt deleted file mode 100644 index 5750a119f..000000000 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/prompt/consent/VcConsentField.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.android.identity_credential.wallet.ui.prompt.consent - -import com.android.identity.documenttype.DocumentAttribute -import com.android.identity.documenttype.DocumentTypeRepository -import com.android.identity.sdjwt.SdJwtVerifiableCredential -import com.android.identity.sdjwt.credential.SdJwtVcCredential -import com.android.identity.util.Logger - -/** - * Consent field for VC credentials. - * - * @param displayName the name to display in the consent prompt. - * @param claimName the claim name. - * @param attribute a [DocumentAttribute], if the claim is well-known. - */ -data class VcConsentField( - override val displayName: String, - override val attribute: DocumentAttribute?, - val claimName: String -) : ConsentField(displayName, attribute) { - - companion object { - - /** - * Helper function to generate a list of entries for the consent prompt for VCs. - * - * @param vct the Verifiable Credential Type. - * @param claims the list of claims. - * @param documentTypeRepository a [DocumentTypeRepository] used to determine the display name. - * @param vcCredential if set, the returned list is filtered so it only references claims - * available in the credential. - */ - fun generateConsentFields( - vct: String, - claims: List, - documentTypeRepository: DocumentTypeRepository, - vcCredential: SdJwtVcCredential?, - ): List { - val vcType = documentTypeRepository.getDocumentTypeForVc(vct)?.vcDocumentType - val ret = mutableListOf() - for (claimName in claims) { - val attribute = vcType?.claims?.get(claimName) - ret.add( - VcConsentField( - attribute?.displayName ?: claimName, - attribute, - claimName - ) - ) - } - return filterConsentFields(ret, vcCredential) - } - - private fun filterConsentFields( - list: List, - credential: SdJwtVcCredential? - ): List { - if (credential == null) { - return list - } - val sdJwt = SdJwtVerifiableCredential.fromString( - String(credential.issuerProvidedData, Charsets.US_ASCII)) - - val availableClaims = mutableSetOf() - for (disclosure in sdJwt.disclosures) { - availableClaims.add(disclosure.key) - } - return list.filter { vcConsentField -> - availableClaims.contains(vcConsentField.claimName) - } - } - - } -} \ No newline at end of file diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml index b2959f2cd..6baea2b47 100644 --- a/wallet/src/main/res/values/strings.xml +++ b/wallet/src/main/res/values/strings.xml @@ -478,14 +478,6 @@ Scan Credential Offer QR code for OpenID for Verifiable Credential Issuance (draft #14) - - Cancel - Share - Scroll to bottom - The following information is being requested from \"%1$s\" - %1$s is requesting information from \"%2$s\" - Verifier Icon - Use PIN Cancel