From d8222f5444e03607a3e9d81e1bb13a6bfa6d9ee4 Mon Sep 17 00:00:00 2001 From: David Zeuthen Date: Sat, 17 Aug 2024 06:36:02 +0900 Subject: [PATCH] wallet: Add functionality to request identity documents. (#704) Also add "sample requests" to our repository for well-known doctypes in the identity-doctypes library. Also upgrade to the latest version of https://github.com/yuriy-budiyev/code-scanner Test: Manually tested Test: ./gradlew check Test: ./gradlew connectedCheck Signed-off-by: David Zeuthen --- gradle/libs.versions.toml | 4 +- .../documenttype/knowntypes/DrivingLicense.kt | 53 ++ .../documenttype/knowntypes/EUPersonalID.kt | 30 + .../identity/documenttype/DocumentType.kt | 45 +- .../documenttype/DocumentTypeRepository.kt | 20 + .../documenttype/DocumentWellKnownRequest.kt | 13 + .../documenttype/MdocNamespaceRequest.kt | 12 + .../identity/documenttype/MdocRequest.kt | 13 + settings.gradle.kts | 6 +- wallet/build.gradle.kts | 3 +- wallet/src/customized/assets/webview/about.md | 16 +- wallet/src/main/assets/webview/about.md | 14 +- .../wallet/MainActivity.kt | 3 +- .../wallet/PresentationActivity.kt | 2 +- .../wallet/ReaderDataElement.kt | 14 + .../wallet/ReaderDocument.kt | 14 + .../identity_credential/wallet/ReaderModel.kt | 353 ++++++++ .../wallet/ReaderNamespace.kt | 6 + .../wallet/ReaderResponse.kt | 5 + .../wallet/WalletApplication.kt | 18 +- .../wallet/navigation/WalletDestination.kt | 2 + .../wallet/navigation/WalletNavigation.kt | 15 +- .../OpenID4VPPresentationActivity.kt | 4 +- .../identity_credential/wallet/ui/CommonUI.kt | 6 +- .../wallet/ui/destination/main/MainScreen.kt | 13 + .../ui/destination/reader/ReaderResult.kt | 178 ++++ .../ui/destination/reader/ReaderScreen.kt | 799 ++++++++++++++++++ .../src/main/res/raw/owf_wallet_iaca_root.pem | 15 + wallet/src/main/res/values/strings.xml | 42 + 29 files changed, 1684 insertions(+), 34 deletions(-) create mode 100644 identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentWellKnownRequest.kt create mode 100644 identity/src/commonMain/kotlin/com/android/identity/documenttype/MdocNamespaceRequest.kt create mode 100644 identity/src/commonMain/kotlin/com/android/identity/documenttype/MdocRequest.kt create mode 100644 wallet/src/main/java/com/android/identity_credential/wallet/ReaderDataElement.kt create mode 100644 wallet/src/main/java/com/android/identity_credential/wallet/ReaderDocument.kt create mode 100644 wallet/src/main/java/com/android/identity_credential/wallet/ReaderModel.kt create mode 100644 wallet/src/main/java/com/android/identity_credential/wallet/ReaderNamespace.kt create mode 100644 wallet/src/main/java/com/android/identity_credential/wallet/ReaderResponse.kt create mode 100644 wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderResult.kt create mode 100644 wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderScreen.kt create mode 100644 wallet/src/main/res/raw/owf_wallet_iaca_root.pem diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2052ce942..b327e8568 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ camera = "1.3.3" compose-material3 = "1.2.1" compose-material-icons-extended = "1.6.7" androidx-navigation = "2.7.7" -code-scanner = "2.1.0" +code-scanner = "2.3.2" ktor = "2.3.10" javax-servlet-api = "4.0.1" androidx-lifecycle = "2.2.0" @@ -95,7 +95,7 @@ compose-material3 = { module = "androidx.compose.material3:material3", version.r compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose-material-icons-extended" } androidx-navigation-runtime = { group = "androidx.navigation", name = "navigation-runtime-ktx", version.ref = "androidx-navigation" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidx-navigation" } -code-scanner = { module = " com.budiyev.android:code-scanner", version.ref = "code-scanner" } +code-scanner = { module = "com.github.yuriy-budiyev:code-scanner", version.ref = "code-scanner" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } diff --git a/identity-doctypes/src/main/java/com/android/identity/documenttype/knowntypes/DrivingLicense.kt b/identity-doctypes/src/main/java/com/android/identity/documenttype/knowntypes/DrivingLicense.kt index 31be07bb9..119f399c1 100644 --- a/identity-doctypes/src/main/java/com/android/identity/documenttype/knowntypes/DrivingLicense.kt +++ b/identity-doctypes/src/main/java/com/android/identity/documenttype/knowntypes/DrivingLicense.kt @@ -758,6 +758,59 @@ object DrivingLicense { AAMVA_NAMESPACE, null ) + .addSampleRequest( + "US Transportation", + 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" + )) + ), + ) + .addSampleRequest( + "Age Over 21 + Portrait", + mapOf( + Pair(MDL_NAMESPACE, listOf( + "age_over_21", + "portrait" + )) + ), + ) + .addSampleRequest( + "Mandatory Data Elements", + 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", + )) + ) + ) + .addSampleRequest( + "All Data Elements", + mapOf( + Pair(MDL_NAMESPACE, listOf()), + Pair(AAMVA_NAMESPACE, listOf()) + ) + ) .build() } } diff --git a/identity-doctypes/src/main/java/com/android/identity/documenttype/knowntypes/EUPersonalID.kt b/identity-doctypes/src/main/java/com/android/identity/documenttype/knowntypes/EUPersonalID.kt index 4931b3482..9946a0b23 100644 --- a/identity-doctypes/src/main/java/com/android/identity/documenttype/knowntypes/EUPersonalID.kt +++ b/identity-doctypes/src/main/java/com/android/identity/documenttype/knowntypes/EUPersonalID.kt @@ -302,6 +302,36 @@ object EUPersonalID { EUPID_NAMESPACE, SampleData.ISSUING_COUNTRY.toDataItem() ) + .addSampleRequest( + displayName = "Age Over 18", + mdocDataElements = mapOf( + Pair(EUPID_NAMESPACE, listOf( + "age_over_18", + )) + ), + ) + .addSampleRequest( + 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" + )) + ) + ) + .addSampleRequest( + displayName = "All Data Elements", + mdocDataElements = mapOf( + Pair(EUPID_NAMESPACE, listOf( + )) + ) + ) .build() } } \ No newline at end of file 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 2f46f3e3c..84d37be0b 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentType.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentType.kt @@ -32,12 +32,14 @@ import com.android.identity.cbor.DataItem * no more than one paragraph. * * @param displayName the name suitable for display of the Document Type. + * @param sampleRequests sample [DocumentWellKnownRequest] for the Document Type. * @param mdocDocumentType metadata of an mDoc Document Type (optional). * @param vcDocumentType metadata of a W3C VC Document Type (optional). * */ class DocumentType private constructor( val displayName: String, + val sampleRequests: List, val mdocDocumentType: MdocDocumentType?, val vcDocumentType: VcDocumentType? ) { @@ -54,6 +56,8 @@ class DocumentType private constructor( var mdocBuilder: MdocDocumentType.Builder? = null, var vcBuilder: VcDocumentType.Builder? = null ) { + private val sampleRequests = mutableListOf() + /** * Initialize the [mdocBuilder]. * @@ -182,9 +186,48 @@ class DocumentType private constructor( ?: throw Exception("The VC Document Type was not initialized") } + /** + * Adds a sample request to the document. + * + * TODO: Add support for VC claims as well. + * + * @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. + */ + fun addSampleRequest( + displayName: String, + mdocDataElements: Map>? + ) = apply { + val mdocRequest = if (mdocDataElements == null) { + null + } else { + val nsRequests = mutableListOf() + for ((namespace, dataElements) in mdocDataElements) { + val mdocNsBuilder = mdocBuilder!!.namespaces[namespace]!! + val deList = if (dataElements.isEmpty()) { + mdocNsBuilder.dataElements.values.toList() + } else { + val list = mutableListOf() + for (dataElement in dataElements) { + list.add(mdocNsBuilder.dataElements[dataElement]!!) + } + list + } + nsRequests.add(MdocNamespaceRequest(namespace, deList)) + } + MdocRequest(mdocBuilder!!.docType, nsRequests) + } + sampleRequests.add(DocumentWellKnownRequest(displayName, mdocRequest)) + } + /** * Build the [DocumentType]. */ - fun build() = DocumentType(displayName, mdocBuilder?.build(), vcBuilder?.build()) + fun build() = DocumentType( + displayName, + sampleRequests, + mdocBuilder?.build(), + vcBuilder?.build()) } } \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentTypeRepository.kt b/identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentTypeRepository.kt index 4140c46b5..dca8e7f6a 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentTypeRepository.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentTypeRepository.kt @@ -53,4 +53,24 @@ class DocumentTypeRepository { _documentTypes.find { it.mdocDocumentType?.docType?.equals(mdocDocType) ?: false } + + /** + * Gets the first [DocumentType] in [documentTypes] with a given mdoc namespace. + * + * @param mdocNamespace the mdoc namespace name. + * @return the [DocumentType] or null if not found. + */ + fun getDocumentTypeForMdocNamespace(mdocNamespace: String): DocumentType? { + for (documentType in _documentTypes) { + if (documentType.mdocDocumentType == null) { + continue + } + for ((nsName, _) in documentType.mdocDocumentType.namespaces) { + if (nsName == mdocNamespace) { + return documentType + } + } + } + return null + } } \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentWellKnownRequest.kt b/identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentWellKnownRequest.kt new file mode 100644 index 000000000..bee2745a4 --- /dev/null +++ b/identity/src/commonMain/kotlin/com/android/identity/documenttype/DocumentWellKnownRequest.kt @@ -0,0 +1,13 @@ +package com.android.identity.documenttype + + +/** + * Class representing a well-known document request. + * + * @param displayName a short string with the name of the request, short enough to be used + * for a button. For example "Age Over 21 and Portrait" or "Full mDL". + */ +data class DocumentWellKnownRequest( + val displayName: String, + val mdocRequest: MdocRequest?, +) \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/documenttype/MdocNamespaceRequest.kt b/identity/src/commonMain/kotlin/com/android/identity/documenttype/MdocNamespaceRequest.kt new file mode 100644 index 000000000..909115bc3 --- /dev/null +++ b/identity/src/commonMain/kotlin/com/android/identity/documenttype/MdocNamespaceRequest.kt @@ -0,0 +1,12 @@ +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. + */ +data class MdocNamespaceRequest( + val namespace: String, + val dataElementsToRequest: List +) diff --git a/identity/src/commonMain/kotlin/com/android/identity/documenttype/MdocRequest.kt b/identity/src/commonMain/kotlin/com/android/identity/documenttype/MdocRequest.kt new file mode 100644 index 000000000..7dfbf5f3c --- /dev/null +++ b/identity/src/commonMain/kotlin/com/android/identity/documenttype/MdocRequest.kt @@ -0,0 +1,13 @@ +package com.android.identity.documenttype + +/** + * A class representing a request for a particular set of namespaces and data elements + * for a particular document type. + * + * @param docType the mdoc doctype. + * @param namespacesToRequest the namespaces to request. + */ +data class MdocRequest( + val docType: String, + val namespacesToRequest: List +) \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 57bcff02a..6dd11b9f9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,9 +25,9 @@ dependencyResolutionManagement { } } mavenCentral() - jcenter() { - content { - includeGroup("com.budiyev.android") + maven("https://jitpack.io") { + mavenContent { + includeGroup("com.github.yuriy-budiyev") } } } diff --git a/wallet/build.gradle.kts b/wallet/build.gradle.kts index 9d3f21267..a536c31c9 100644 --- a/wallet/build.gradle.kts +++ b/wallet/build.gradle.kts @@ -26,7 +26,7 @@ android { defaultConfig { applicationId = "com.android.identity_credential.wallet" - minSdk = 27 + minSdk = 29 targetSdk = libs.versions.android.targetSdk.get().toInt() versionCode = projectVersionCode versionName = projectVersionName @@ -131,6 +131,7 @@ dependencies { implementation(libs.androidx.material) implementation(libs.face.detection) implementation(libs.zxing.core) + implementation(libs.code.scanner) implementation(files("../third-party/play-services-identity-credentials-0.0.1-eap01.aar")) implementation(libs.bundles.google.play.services) diff --git a/wallet/src/customized/assets/webview/about.md b/wallet/src/customized/assets/webview/about.md index c2be32def..b5bc044cd 100644 --- a/wallet/src/customized/assets/webview/about.md +++ b/wallet/src/customized/assets/webview/about.md @@ -1,14 +1,18 @@ This is a customized flavor of the OWF Identity Credential Wallet. -This application supports provisioning and presentation of real-world -identity using the ISO/IEC 18013-5:2021 mdoc credential format. +Wallet version __placeholder__{appinfo=version} -Identity data in this application can be shared in-person to any +This application supports provisioning, presentation, and reading +of real-world identity using the ISO/IEC 18013-5:2021 mdoc and +IETF SD-JWT credential formats. + +Identity data in this application can shared in-person to any ISO/IEC 18013-5:2021 compliant mdoc/mDL reader using NFC or QR -engagement. +engagement. The application also supports reader functionality +for mdoc/mDLs. Identity data can also be shared to other Android applications or -websites using a preview version of the +websites using OpenID4VP and a preview version of the [Digital Identities API](https://wicg.github.io/digital-identities/) API being worked on in the [W3C WICG Digital Identities group](https://github.com/WICG/digital-identities). @@ -17,5 +21,3 @@ API being worked on in the * [OWF Identity Credential Home Page](https://github.com/openwallet-foundation-labs/identity-credential) * [IACA certificate](https://github.com/openwallet-foundation-labs/identity-credential/blob/main/wallet/src/main/res/raw/iaca_certificate.pem) - - diff --git a/wallet/src/main/assets/webview/about.md b/wallet/src/main/assets/webview/about.md index 94cd43b8b..e383e7dad 100644 --- a/wallet/src/main/assets/webview/about.md +++ b/wallet/src/main/assets/webview/about.md @@ -1,14 +1,16 @@ Wallet version __placeholder__{appinfo=version} -This application supports provisioning and presentation of real-world -identity using the ISO/IEC 18013-5:2021 mdoc credential format. +This application supports provisioning, presentation, and reading +of real-world identity using the ISO/IEC 18013-5:2021 mdoc and +IETF SD-JWT credential formats. -Identity data in this application can be shared in-person to any +Identity data in this application can shared in-person to any ISO/IEC 18013-5:2021 compliant mdoc/mDL reader using NFC or QR -engagement. +engagement. The application also supports reader functionality +for mdoc/mDLs. Identity data can also be shared to other Android applications or -websites using a preview version of the +websites using OpenID4VP and a preview version of the [Digital Identities API](https://wicg.github.io/digital-identities/) API being worked on in the [W3C WICG Digital Identities group](https://github.com/WICG/digital-identities). @@ -17,5 +19,3 @@ API being worked on in the * [OWF Identity Credential Home Page](https://github.com/openwallet-foundation-labs/identity-credential) * [IACA certificate](https://github.com/openwallet-foundation-labs/identity-credential/blob/main/wallet/src/main/res/raw/iaca_certificate.pem) - - diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/MainActivity.kt b/wallet/src/main/java/com/android/identity_credential/wallet/MainActivity.kt index 0b05365a4..3b727239b 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/MainActivity.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/MainActivity.kt @@ -90,7 +90,8 @@ class MainActivity : FragmentActivity() { permissionTracker = permissionTracker, sharedPreferences = application.sharedPreferences, qrEngagementViewModel = qrEngagementViewModel, - documentModel = application.documentModel + documentModel = application.documentModel, + readerModel = application.readerModel, ) } } 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 09cc89af1..b7a03dcfc 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 @@ -344,7 +344,7 @@ class PresentationActivity : FragmentActivity() { // See if we recognize the reader/verifier var trustPoint: TrustPoint? = null if (docRequest.readerAuthenticated) { - val result = walletApp.trustManager.verify( + val result = walletApp.readerTrustManager.verify( docRequest.readerCertificateChain!!.javaX509Certificates, customValidators = emptyList() // not needed for reader auth ) diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ReaderDataElement.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ReaderDataElement.kt new file mode 100644 index 000000000..69d6d950c --- /dev/null +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ReaderDataElement.kt @@ -0,0 +1,14 @@ +package com.android.identity_credential.wallet + +import android.graphics.Bitmap +import com.android.identity.documenttype.MdocDataElement + +data class ReaderDataElement( + // Null if the data element isn't known + val mdocDataElement: MdocDataElement?, + + val value: ByteArray, + + // Only set DocumentAttributeType.Picture + val bitmap: Bitmap?, +) diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ReaderDocument.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ReaderDocument.kt new file mode 100644 index 000000000..628b21b49 --- /dev/null +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ReaderDocument.kt @@ -0,0 +1,14 @@ +package com.android.identity_credential.wallet + +import kotlinx.datetime.Instant + +data class ReaderDocument( + val docType: String, + val msoValidFrom: Instant, + val msoValidUntil: Instant, + val msoSigned: Instant, + val msoExpectedUpdate: Instant?, + val namespaces: List, + val infoTexts: List, + val warningTexts: List, +) 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 new file mode 100644 index 000000000..6a2c06864 --- /dev/null +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ReaderModel.kt @@ -0,0 +1,353 @@ +package com.android.identity_credential.wallet + +import android.app.Activity +import android.content.Context +import android.content.res.Resources +import android.graphics.BitmapFactory +import android.nfc.NfcAdapter +import android.os.VibrationEffect +import android.os.Vibrator +import androidx.compose.runtime.mutableStateOf +import com.android.identity.android.mdoc.deviceretrieval.VerificationHelper +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.javaX509Certificates +import com.android.identity.documenttype.DocumentAttributeType +import com.android.identity.documenttype.DocumentTypeRepository +import com.android.identity.documenttype.DocumentWellKnownRequest +import com.android.identity.documenttype.MdocNamespace +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.mdoc.request.DeviceRequestGenerator +import com.android.identity.mdoc.response.DeviceResponseParser +import com.android.identity.trustmanagement.TrustManager +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import kotlinx.datetime.Clock + +class ReaderModel( + val context: Context, + val documentTypeRepository: DocumentTypeRepository, +) { + companion object { + private const val TAG = "ReaderModel" + } + + var phase = mutableStateOf(Phase.IDLE) + var response: ReaderResponse? = null + var error: Throwable? = null + + enum class Phase { + IDLE, + WAITING_FOR_ENGAGEMENT, + WAITING_FOR_CONNECTION, + WAITING_FOR_RESPONSE, + COMPLETE, + } + + private val nfcAdapter = NfcAdapter.getDefaultAdapter(context) + private val vibrator = context.getSystemService(Vibrator::class.java) + + private var activityForNfcReaderMode: Activity? = null + private var requestToUse: DocumentWellKnownRequest? = null + private var activityToUse: Activity? = null + private var trustManagerToUse: TrustManager? = null + + private var verificationHelper: VerificationHelper? = null + + private val nfcReaderModeListener = NfcAdapter.ReaderCallback { tag -> + verificationHelper?.nfcProcessOnTagDiscovered(tag) + } + + private fun disconnectVerificationHelper() { + if (verificationHelper != null) { + Logger.i(TAG, "Stopping VerificationHelper instance") + verificationHelper?.disconnect() + verificationHelper = null + } + } + + private fun disableNfcReaderMode() { + if (activityForNfcReaderMode != null) { + Logger.i(TAG, "Disabling reader mode on NfcAdapter") + nfcAdapter.disableReaderMode(activityForNfcReaderMode) + activityForNfcReaderMode = null + } + } + + private fun releaseResources() { + if (verificationHelper != null) { + Logger.i(TAG, "Stopping VerificationHelper instance") + verificationHelper?.disconnect() + verificationHelper = null + } + if (activityForNfcReaderMode != null) { + Logger.i(TAG, "Disabling reader mode on NfcAdapter") + nfcAdapter.disableReaderMode(activityForNfcReaderMode) + activityForNfcReaderMode = null + } + } + + // Should be called when getting ready to use the reader. + // + // Transitions to Phase.IDLE. + // + fun cancel() { + Logger.i(TAG, "Canceled") + releaseResources() + response = null + error = null + phase.value = Phase.IDLE + } + + fun restart() { + Logger.i(TAG, "Restart") + releaseResources() + response = null + error = null + startRequest(activityToUse!!, requestToUse!!, trustManagerToUse!!) + } + + private fun reportError(e: Throwable) { + Logger.i(TAG, "Completed with error", e) + releaseResources() + error = e + response = null + phase.value = Phase.COMPLETE + } + + private fun reportResponse(response: ReaderResponse) { + Logger.i(TAG, "Completed with response") + releaseResources() + this.response = response + error = null + phase.value = Phase.COMPLETE + } + + fun setQrCode(qrCode: String) { + verificationHelper?.setDeviceEngagementFromQrCode(qrCode) + phase.value = Phase.WAITING_FOR_CONNECTION + } + + fun updateRequest(request: DocumentWellKnownRequest) { + requestToUse = request + } + + // Should be called to start reading + fun startRequest( + activity: Activity, + request: DocumentWellKnownRequest, + trustManager: TrustManager + ) { + releaseResources() + response = null + error = null + activityToUse = activity + requestToUse = request + trustManagerToUse = trustManager + phase.value = Phase.WAITING_FOR_ENGAGEMENT + + val connectionMethods = mutableListOf() + val bleUuid = UUID.randomUUID() + connectionMethods.add( + ConnectionMethodBle( + false, + true, + null, + bleUuid) + ) + + val listener = object : VerificationHelper.Listener { + override fun onReaderEngagementReady(readerEngagement: ByteArray) { + } + + override fun onDeviceEngagementReceived(connectionMethods: List) { + Logger.i(TAG, "onDeviceEngagementReceived") + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK)) + if (connectionMethods.isEmpty()) { + reportError(Error("List of connectionMethods is empty")) + } else { + // For now, just connect to the first method... might have UI in the future to ask + // the user which one to connect to... + verificationHelper!!.connect(connectionMethods.first()) + } + phase.value = Phase.WAITING_FOR_CONNECTION + } + + override fun onMoveIntoNfcField() { + // TODO + } + + override fun onDeviceConnected() { + sendRequest() + } + + override fun onDeviceDisconnected(transportSpecificTermination: Boolean) { + Logger.i(TAG, "onDeviceDisconnected transportSpecificTermination=$transportSpecificTermination") + } + + override fun onResponseReceived(deviceResponseBytes: ByteArray) { + Logger.i(TAG, "onResponseReceived") + try { + reportResponse( + processResponse( + deviceResponseBytes, + trustManager, + activity.resources + ) + ) + } catch (e: Throwable) { + reportError(e) + } + } + + override fun onError(e: Throwable) { + Logger.i(TAG, "onError", e) + reportError(e) + } + } + + verificationHelper = VerificationHelper.Builder(context, listener, context.mainExecutor) + .setDataTransportOptions(DataTransportOptions.Builder().build()) + .setNegotiatedHandoverConnectionMethods(connectionMethods) + .build() + activityForNfcReaderMode = activity + nfcAdapter.enableReaderMode( + activityForNfcReaderMode, nfcReaderModeListener, + NfcAdapter.FLAG_READER_NFC_A + NfcAdapter.FLAG_READER_NFC_B + + NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK + NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS, + null) + + Logger.i(TAG, "Enabling reader mode on NfcAdapter") + phase.value = Phase.WAITING_FOR_ENGAGEMENT + } + + private fun sendRequest() { + val mdocRequest = requestToUse!!.mdocRequest!! + + val namespacesToRequest = mutableMapOf>() + for (ns in mdocRequest.namespacesToRequest) { + val dataElementsToRequest = mutableMapOf() + for (de in ns.dataElementsToRequest) { + dataElementsToRequest[de.attribute.identifier] = false + } + namespacesToRequest[ns.namespace] = dataElementsToRequest + } + + // TODO: add reader auth + val deviceRequestGenerator = DeviceRequestGenerator(verificationHelper!!.sessionTranscript) + .addDocumentRequest( + mdocRequest.docType, + namespacesToRequest, + null, + null, + Algorithm.UNSET, + null + ) + verificationHelper!!.sendRequest(deviceRequestGenerator.generate()) + phase.value = Phase.WAITING_FOR_RESPONSE + } + + private fun processResponse( + deviceResponseBytes: ByteArray, + trustManager: TrustManager, + res: Resources + ): ReaderResponse { + val parser = DeviceResponseParser(deviceResponseBytes, verificationHelper!!.sessionTranscript) + val deviceResponse = parser.parse() + + val readerDocuments = mutableListOf() + for (document in deviceResponse.documents) { + val infoTexts = mutableListOf() + val warningTexts = mutableListOf() + + if (document.issuerSignedAuthenticated) { + val readerAuthChain = document.issuerCertificateChain.javaX509Certificates + val trustResult = trustManager.verify( + readerAuthChain, + emptyList() // TODO: use mDL specific validators, if applicable + ) + if (trustResult.isTrusted) { + val trustPoint = trustResult.trustPoints[0] + infoTexts.add(res.getString(R.string.reader_model_info_in_trust_list, trustPoint.displayName)) + } else { + val dsCert = readerAuthChain[0] + val displayName = dsCert.issuerX500Principal.name + warningTexts.add(res.getString(R.string.reader_model_warning_not_in_trust_list, displayName)) + } + } + if (!document.deviceSignedAuthenticated) { + warningTexts.add(res.getString(R.string.reader_model_warning_device_auth)) + } + if (!document.issuerSignedAuthenticated) { + warningTexts.add(res.getString(R.string.reader_model_warning_issuer_auth)) + } + if (document.numIssuerEntryDigestMatchFailures > 0) { + warningTexts.add(res.getString(R.string.reader_model_warning_data_elem_auth)) + } + val now = Clock.System.now() + if (now < document.validityInfoValidFrom || now > document.validityInfoValidUntil) { + warningTexts.add(res.getString(R.string.reader_model_warning_validity_period)) + } + + val mdocType = documentTypeRepository.getDocumentTypeForMdoc(document.docType)?.mdocDocumentType + val resultNs = mutableListOf() + for (namespace in document.issuerNamespaces) { + val resultDataElements = mutableMapOf() + + val mdocNamespace = if (mdocType !=null) { + mdocType.namespaces.get(namespace) + } else { + // Some DocTypes not known by [documentTypeRepository] - could be they are + // private or was just never added - may use namespaces from existing + // DocTypes... support that as well. + // + documentTypeRepository.getDocumentTypeForMdocNamespace(namespace) + ?.mdocDocumentType?.namespaces?.get(namespace) + } + + for (dataElement in document.getIssuerEntryNames(namespace)) { + val value = document.getIssuerEntryData(namespace, dataElement) + val cborValue = Cbor.decode(value) + val mdocDataElement = mdocNamespace?.dataElements?.get(dataElement) + val bitmap = if (cborValue is Bstr && + mdocDataElement?.attribute?.type == DocumentAttributeType.Picture) { + val bitmapData = cborValue.asBstr + val options = BitmapFactory.Options() + options.inMutable = true + BitmapFactory.decodeByteArray( + bitmapData, + 0, + bitmapData.size, + options + ) + } else { + null + } + resultDataElements[dataElement] = ReaderDataElement( + mdocDataElement, + value, + bitmap + ) + } + resultNs.add(ReaderNamespace(namespace, resultDataElements)) + } + readerDocuments.add( + ReaderDocument( + document.docType, + document.validityInfoValidFrom, + document.validityInfoValidUntil, + document.validityInfoSigned, + document.validityInfoExpectedUpdate, + resultNs, + infoTexts, + warningTexts + ) + ) + } + return ReaderResponse(readerDocuments) + } + +} \ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ReaderNamespace.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ReaderNamespace.kt new file mode 100644 index 000000000..2b3f3ee79 --- /dev/null +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ReaderNamespace.kt @@ -0,0 +1,6 @@ +package com.android.identity_credential.wallet + +data class ReaderNamespace( + val name: String, + val dataElements: Map +) diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ReaderResponse.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ReaderResponse.kt new file mode 100644 index 000000000..e4f42cadc --- /dev/null +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ReaderResponse.kt @@ -0,0 +1,5 @@ +package com.android.identity_credential.wallet + +data class ReaderResponse( + val documents: List +) 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 c798a5c1a..86c5115a4 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 @@ -41,7 +41,6 @@ import com.android.identity.issuance.WalletApplicationCapabilities import com.android.identity.issuance.remote.WalletServerProvider import com.android.identity.mdoc.credential.MdocCredential import com.android.identity.sdjwt.credential.SdJwtVcCredential -import com.android.identity.securearea.PassphraseConstraints import com.android.identity.securearea.SecureAreaRepository import com.android.identity.securearea.software.SoftwareSecureArea import com.android.identity.storage.StorageEngine @@ -55,7 +54,6 @@ import kotlinx.coroutines.launch import kotlinx.datetime.Clock import kotlinx.io.files.Path import org.bouncycastle.jce.provider.BouncyCastleProvider -import java.io.File import java.net.URLDecoder import java.security.Security import java.util.concurrent.TimeUnit @@ -91,7 +89,8 @@ class WalletApplication : Application() { // immediate instantiations - val trustManager = TrustManager() + val readerTrustManager = TrustManager() + val issuerTrustManager = TrustManager() // lazy instantiations val sharedPreferences: SharedPreferences by lazy { @@ -106,6 +105,7 @@ class WalletApplication : Application() { lateinit var documentStore: DocumentStore lateinit var settingsModel: SettingsModel lateinit var documentModel: DocumentModel + lateinit var readerModel: ReaderModel private lateinit var androidKeystoreSecureArea: AndroidKeystoreSecureArea private lateinit var softwareSecureArea: SoftwareSecureArea lateinit var walletServerProvider: WalletServerProvider @@ -193,16 +193,20 @@ class WalletApplication : Application() { { getWalletApplicationInformation() } ) - // init TrustManager - trustManager.addTrustPoint( + // init TrustManagers + readerTrustManager.addTrustPoint( displayName = "OWF Identity Credential Reader", certificateResourceId = R.raw.owf_identity_credential_reader_cert, displayIconResourceId = R.drawable.owf_identity_credential_reader_display_icon ) + issuerTrustManager.addTrustPoint( + displayName = "OWF Identity Credential TEST IACA", + certificateResourceId = R.raw.owf_wallet_iaca_root, + displayIconResourceId = R.drawable.owf_identity_credential_reader_display_icon + ) documentModel = DocumentModel( applicationContext, - settingsModel, documentStore, secureAreaRepository, @@ -211,6 +215,8 @@ class WalletApplication : Application() { this ) + readerModel = ReaderModel(applicationContext, documentTypeRepository) + val notificationChannel = NotificationChannel( NOTIFICATION_CHANNEL_ID, resources.getString(R.string.app_name), diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletDestination.kt b/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletDestination.kt index 7ef2cb2a9..781b599cd 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletDestination.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletDestination.kt @@ -21,6 +21,7 @@ sealed class WalletDestination(val routeEnum: Route) : DestinationArguments() { object QrEngagement : WalletDestination(Route.QR_ENGAGEMENT) + object Reader : WalletDestination(Route.READER) // Screens with arguments object DocumentInfo : WalletDestination(Route.DOCUMENT_INFO) { @@ -177,6 +178,7 @@ enum class Route(val routeName: String, val argumentsStr: String = "") { "documentId={documentId}§ion={section}&auth_required={auth_required}"), PROVISION_DOCUMENT("provision_document"), QR_ENGAGEMENT("qr_engagement"), + READER("reader_select_request"), // a Route for popping the back stack showing a different Screen POP_BACK_STACK("pop_back_stack"), diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletNavigation.kt b/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletNavigation.kt index d39f50579..b1546aa65 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletNavigation.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletNavigation.kt @@ -9,6 +9,7 @@ import com.android.identity_credential.wallet.DocumentModel import com.android.identity_credential.wallet.PermissionTracker import com.android.identity_credential.wallet.ProvisioningViewModel import com.android.identity_credential.wallet.QrEngagementViewModel +import com.android.identity_credential.wallet.ReaderModel import com.android.identity_credential.wallet.WalletApplication import com.android.identity_credential.wallet.ui.destination.about.AboutScreen import com.android.identity_credential.wallet.ui.destination.addtowallet.AddToWalletScreen @@ -18,6 +19,7 @@ import com.android.identity_credential.wallet.ui.destination.document.Credential import com.android.identity_credential.wallet.ui.destination.main.MainScreen import com.android.identity_credential.wallet.ui.destination.provisioncredential.ProvisionDocumentScreen import com.android.identity_credential.wallet.ui.destination.qrengagement.QrEngagementScreen +import com.android.identity_credential.wallet.ui.destination.reader.ReaderScreen import com.android.identity_credential.wallet.ui.destination.settings.SettingsScreen /** @@ -31,7 +33,8 @@ fun WalletNavigation( permissionTracker: PermissionTracker, sharedPreferences: SharedPreferences, qrEngagementViewModel: QrEngagementViewModel, - documentModel: DocumentModel + documentModel: DocumentModel, + readerModel: ReaderModel, ) { // lambda navigateTo takes in a route string and navigates to the corresponding Screen @@ -173,5 +176,15 @@ fun WalletNavigation( onNavigate = navigateTo ) } + + composable(WalletDestination.Reader.route) { + ReaderScreen( + model = readerModel, + docTypeRepo = application.documentTypeRepository, + settingsModel = application.settingsModel, + issuerTrustManager = application.issuerTrustManager, + onNavigate = navigateTo, + ) + } } } \ No newline at end of file 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 33a3683fe..892a90313 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 @@ -3,7 +3,6 @@ package com.android.identity_credential.wallet.presentation import android.content.Intent import android.net.Uri import android.os.Bundle -import android.widget.Toast import androidx.activity.compose.setContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -45,7 +44,6 @@ import com.android.identity.issuance.DocumentExtensions.documentConfiguration import com.android.identity.issuance.DocumentExtensions.issuingAuthorityIdentifier import com.android.identity.mdoc.credential.MdocCredential import com.android.identity.mdoc.response.DeviceResponseGenerator -import com.android.identity.trustmanagement.TrustManager import com.android.identity.trustmanagement.TrustPoint import com.android.identity.util.Constants import com.android.identity.util.Logger @@ -432,7 +430,7 @@ class OpenID4VPPresentationActivity : FragmentActivity() { var trustPoint: TrustPoint? = null if (authorizationRequest.certificateChain != null) { - val trustResult = walletApp.trustManager.verify(authorizationRequest.certificateChain!!) + val trustResult = walletApp.readerTrustManager.verify(authorizationRequest.certificateChain!!) if (!trustResult.isTrusted) { Logger.w(TAG, "Reader root not trusted") if (trustResult.error != null) { diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/CommonUI.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/CommonUI.kt index 6d03b8f89..f9ea51e2b 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/CommonUI.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/CommonUI.kt @@ -90,9 +90,13 @@ fun ScreenWithAppBarAndBackButton( onBackButtonClick: () -> Unit, scrollable: Boolean = true, actions: @Composable() (RowScope.() -> Unit) = {}, + snackbarHost: @Composable () -> Unit = {}, body: @Composable ColumnScope.() -> Unit, ) { - ScreenWithAppBar(title, navigationIcon = { + ScreenWithAppBar( + title, + snackbarHost = snackbarHost, + navigationIcon = { IconButton(onClick = { onBackButtonClick() }) { Icon( imageVector = Icons.Filled.ArrowBack, diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/main/MainScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/main/MainScreen.kt index 05e50b95c..7eb47631e 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/main/MainScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/main/MainScreen.kt @@ -25,8 +25,10 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Nfc import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -119,6 +121,17 @@ fun MainScreen( } } ) + NavigationDrawerItem( + icon = { Icon(imageVector = Icons.Filled.Fingerprint, contentDescription = null) }, + label = { Text(text = stringResource(R.string.wallet_drawer_identity_reader)) }, + selected = false, + onClick = { + scope.launch { + drawerState.close() + onNavigate(WalletDestination.Reader.route) + } + } + ) NavigationDrawerItem( icon = { Icon(imageVector = Icons.Filled.Info, contentDescription = null) }, label = { Text(text = stringResource(R.string.wallet_drawer_about)) }, diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderResult.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderResult.kt new file mode 100644 index 000000000..913671d0d --- /dev/null +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderResult.kt @@ -0,0 +1,178 @@ +package com.android.identity_credential.wallet.ui.destination.reader + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.fillMaxHeight +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.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.DiagnosticOption +import com.android.identity.documenttype.DocumentTypeRepository +import com.android.identity_credential.wallet.R +import com.android.identity_credential.wallet.ReaderDocument +import com.android.identity_credential.wallet.ReaderModel +import com.android.identity_credential.wallet.navigation.WalletDestination +import com.android.identity_credential.wallet.ui.KeyValuePairText +import com.android.identity_credential.wallet.ui.ScreenWithAppBarAndBackButton + +private const val TAG = "ReaderShowResponse" + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ReaderResult( + model: ReaderModel, + documentTypeRepository: DocumentTypeRepository, + onNavigate: (String) -> Unit, +) { + val pagerState = rememberPagerState(pageCount = { model.response?.documents?.size ?: 0 }) + + Box( + modifier = Modifier.fillMaxHeight() + ) { + + ScreenWithAppBarAndBackButton( + title = stringResource(R.string.reader_result_screen_title), + onBackButtonClick = { onNavigate(WalletDestination.PopBackStack.route) }, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + if (model.error != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("An error occurred: ${model.error?.message}") + } + } + } else if (model.response != null) { + val response = model.response!! + if (response.documents.isEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.reader_result_screen_no_documents_returned), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + } + } else { + Column { + HorizontalPager( + state = pagerState, + ) { page -> + ShowResultDocument( + response.documents[page], + page, + response.documents.size + ) + } + } + } + } + } + } + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .align(Alignment.BottomEnd) + .wrapContentHeight() + .fillMaxWidth() + .height(30.dp) + .padding(8.dp), + ) { + repeat(pagerState.pageCount) { iteration -> + val color = + if (pagerState.currentPage == iteration) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.secondary + } + Box( + modifier = Modifier + .padding(2.dp) + .clip(CircleShape) + .background(color) + .size(8.dp) + ) + } + } + } +} + +@Composable +private fun ShowResultDocument(document: ReaderDocument, + documentIndex: Int, + numDocuments: Int) { + Column(Modifier.padding(8.dp)) { + KeyValuePairText("Document Number", "${documentIndex + 1} of ${numDocuments}") + KeyValuePairText(keyText = "DocType", valueText = document.docType) + for (namespace in document.namespaces) { + KeyValuePairText("Namespace", namespace.name) + for ((dataElementName, dataElement) in namespace.dataElements) { + val cborValue = Cbor.decode(dataElement.value) + val (key, value) = if (dataElement.mdocDataElement != null) { + Pair( + dataElement.mdocDataElement.attribute.displayName, + dataElement.mdocDataElement.renderValue(cborValue) + ) + } else { + Pair( + dataElementName, + Cbor.toDiagnostics(cborValue, setOf( + DiagnosticOption.PRETTY_PRINT, + DiagnosticOption.EMBEDDED_CBOR, + DiagnosticOption.BSTR_PRINT_LENGTH, + )) + ) + } + KeyValuePairText(key, value) + + if (dataElement.bitmap != null) { + Row( + horizontalArrangement = Arrangement.Center + ) { + Image( + bitmap = dataElement.bitmap.asImageBitmap(), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .size(200.dp), + contentDescription = dataElement.mdocDataElement?.attribute?.description + ?: "Unknown Data Element" + ) + } + } + } + } + } +} diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderScreen.kt new file mode 100644 index 000000000..c97dfdb6a --- /dev/null +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderScreen.kt @@ -0,0 +1,799 @@ +package com.android.identity_credential.wallet.ui.destination.reader + +import android.Manifest +import android.app.Activity +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.fillMaxSize +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.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +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.em +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.DiagnosticOption +import com.android.identity.documenttype.DocumentTypeRepository +import com.android.identity.documenttype.DocumentWellKnownRequest +import com.android.identity.documenttype.knowntypes.DrivingLicense +import com.android.identity.documenttype.knowntypes.EUPersonalID +import com.android.identity.trustmanagement.TrustManager +import com.android.identity.util.Logger +import com.android.identity_credential.wallet.R +import com.android.identity_credential.wallet.ReaderDocument +import com.android.identity_credential.wallet.ReaderModel +import com.android.identity_credential.wallet.SettingsModel +import com.android.identity_credential.wallet.WalletApplication +import com.android.identity_credential.wallet.navigation.WalletDestination +import com.android.identity_credential.wallet.ui.KeyValuePairText +import com.android.identity_credential.wallet.ui.ScreenWithAppBarAndBackButton +import com.budiyev.android.codescanner.CodeScanner +import com.budiyev.android.codescanner.CodeScannerView +import com.budiyev.android.codescanner.DecodeCallback +import com.budiyev.android.codescanner.ErrorCallback +import com.budiyev.android.codescanner.ScanMode +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.accompanist.permissions.rememberPermissionState +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.toLocalDateTime + +private const val TAG = "ReaderScreen" + +@Composable +fun ReaderScreen( + model: ReaderModel, + docTypeRepo: DocumentTypeRepository, + settingsModel: SettingsModel, + issuerTrustManager: TrustManager, + onNavigate: (String) -> Unit, +) { + val availableRequests = mutableListOf>() + for (req in docTypeRepo.getDocumentTypeForMdoc(DrivingLicense.MDL_DOCTYPE)?.sampleRequests!!) { + availableRequests.add(Pair("mDL: ${req.displayName}", req)) + } + for (req in docTypeRepo.getDocumentTypeForMdoc(EUPersonalID.EUPID_DOCTYPE)?.sampleRequests!!) { + availableRequests.add(Pair("EU PID: ${req.displayName}", req)) + } + + // Make sure we start scanning when entering this screen and stop scanning when + // we leave the screen... + // + val activity = LocalContext.current as Activity + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START) { + model.startRequest( + activity, + availableRequests[0].second, + issuerTrustManager, + ) + } else if (event == Lifecycle.Event.ON_STOP) { + model.cancel() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + when (model.phase.value) { + ReaderModel.Phase.IDLE, + ReaderModel.Phase.WAITING_FOR_ENGAGEMENT -> { + WaitForEngagement( + model, + settingsModel, + availableRequests, + onNavigate + ) + } + ReaderModel.Phase.WAITING_FOR_CONNECTION -> { + WaitingForConnection(model) + } + ReaderModel.Phase.WAITING_FOR_RESPONSE -> { + WaitingForResponse(model) + } + ReaderModel.Phase.COMPLETE -> { + if (model.response != null) { + ShowResponse(model) + } else { + ShowError(model) + } + } + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun WaitForEngagement( + model: ReaderModel, + settingsModel: SettingsModel, + availableRequests: List>, + onNavigate: (String) -> Unit, +) { + val showQrScannerDialog = remember { mutableStateOf(false) } + var dropdownExpanded = remember { mutableStateOf(false) } + var dropdownSelected = remember { mutableStateOf(availableRequests[0]) } + + if (showQrScannerDialog.value) { + ScanQrDialog(model, showQrScannerDialog) + } + + val hasProximityPresentationPermissions = rememberMultiplePermissionsState( + WalletApplication.MDOC_PROXIMITY_PERMISSIONS + ) + + val snackbarHostState = remember { SnackbarHostState() } + ScreenWithAppBarAndBackButton( + title = stringResource(R.string.reader_screen_title), + onBackButtonClick = { + onNavigate(WalletDestination.PopBackStack.route) + }, + scrollable = true, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { + if (!hasProximityPresentationPermissions.allPermissionsGranted && + !settingsModel.hideMissingProximityPermissionsWarning.value!! + ) { + LaunchedEffect(snackbarHostState) { + when (snackbarHostState.showSnackbar( + message = model.context.getString(R.string.proximity_permissions_snackbar_text), + actionLabel = model.context.getString(R.string.proximity_permissions_snackbar_action_label), + duration = SnackbarDuration.Indefinite, + withDismissAction = true + )) { + SnackbarResult.Dismissed -> { + settingsModel.hideMissingProximityPermissionsWarning.value = true + } + + SnackbarResult.ActionPerformed -> { + hasProximityPresentationPermissions.launchMultiplePermissionRequest() + } + } + } + } + + Row( + modifier = Modifier.weight(1.0f), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(0.1f)) + RequestPicker( + availableRequests, + dropdownSelected, + dropdownExpanded, + onRequestSelected = { request -> + model.updateRequest(request) + } + ) + Spacer(modifier = Modifier.weight(0.2f)) + NfcIconAndText() + Spacer(modifier = Modifier.weight(0.5f)) + QrScannerButton(showQrScannerDialog) + Spacer(modifier = Modifier.weight(0.1f)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RequestPicker( + availableRequests: List>, + comboBoxSelected: MutableState>, + comboBoxExpanded: MutableState, + onRequestSelected: (request: DocumentWellKnownRequest) -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Text( + modifier = Modifier.padding(end = 16.dp), + text = stringResource(R.string.reader_screen_identity_data_to_request) + ) + + ExposedDropdownMenuBox( + expanded = comboBoxExpanded.value, + onExpandedChange = { + comboBoxExpanded.value = !comboBoxExpanded.value + } + ) { + TextField( + value = comboBoxSelected.value.first, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = comboBoxExpanded.value) }, + modifier = Modifier.menuAnchor() + ) + + ExposedDropdownMenu( + expanded = comboBoxExpanded.value, + onDismissRequest = { comboBoxExpanded.value = false } + ) { + availableRequests.forEach { item -> + DropdownMenuItem( + text = { Text(text = item.first) }, + onClick = { + comboBoxSelected.value = item + comboBoxExpanded.value = false + onRequestSelected(comboBoxSelected.value.second) + } + ) + } + } + } + } + } +} + +@Composable +private fun NfcIconAndText( +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = R.drawable.nfc_icon), + contentDescription = stringResource(R.string.reader_screen_nfc_icon_content_description), + modifier = Modifier.size(96.dp), + ) + Text( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.reader_screen_nfc_presentation_instructions) + ) + } + } +} + +@Composable +private fun QrScannerButton( + showQrScannerDialog: MutableState +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 20.dp, top = 8.dp), + horizontalArrangement = Arrangement.Center + ) { + OutlinedButton(onClick = { showQrScannerDialog.value = true }) { + Icon( + painter = painterResource(id = R.drawable.qr_icon), + contentDescription = stringResource(R.string.reader_screen_qr_icon_content_description), + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.reader_screen_scan_qr_button_text) + ) + } + } + +} + +@Composable +private fun ScanQrDialog( + model: ReaderModel, + showQrScannerDialog: MutableState +) { + AlertDialog( + icon = { + Icon( + Icons.Filled.QrCode, + contentDescription = stringResource(R.string.reader_screen_qr_icon_content_description) + ) + }, + title = { + Text(text = stringResource(R.string.reader_screen_scan_qr_dialog_title)) + }, + text = { + QrScanner(model) + }, + onDismissRequest = { + showQrScannerDialog.value = false + }, + confirmButton = {}, + dismissButton = { + TextButton( + onClick = { + showQrScannerDialog.value = false + } + ) { + Text(stringResource(R.string.reader_screen_scan_qr_dialog_dismiss_button)) + } + } + ) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun QrScanner( + model: ReaderModel +) { + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + if (!cameraPermissionState.status.isGranted) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier.padding(20.dp), + text = stringResource(R.string.reader_screen_scan_qr_dialog_missing_permission_text) + ) + Button( + onClick = { + cameraPermissionState.launchPermissionRequest() + } + ) { + Text(stringResource(R.string.reader_screen_scan_qr_dialog_request_permission_button)) + } + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.reader_screen_scan_qr_dialog_text) + ) + Row( + modifier = Modifier + .width(300.dp) + .height(300.dp), + horizontalArrangement = Arrangement.Center + ) { + AndroidView( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + factory = { context -> + CodeScannerView(context).apply { + val codeScanner = CodeScanner(context, this).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + isAutoFocusEnabled = true + isAutoFocusButtonVisible = false + scanMode = ScanMode.SINGLE + decodeCallback = DecodeCallback { result -> + model.setQrCode(result.text) + releaseResources() + } + errorCallback = ErrorCallback { error -> + Logger.w(TAG, "Error scanning QR", error) + releaseResources() + } + camera = CodeScanner.CAMERA_BACK + isFlashEnabled = false + } + codeScanner.startPreview() + } + }, + ) + } + } + } + } +} + +@Composable +private fun WaitingForConnection( + model: ReaderModel +) { + ScreenWithAppBarAndBackButton( + title = stringResource(R.string.reader_screen_waiting_for_connection_title), + onBackButtonClick = { model.restart() }, + scrollable = true, + ) { + Row( + modifier = Modifier.weight(1.0f), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(R.string.reader_screen_waiting_for_connection_text)) + } + } + } + } +} + +@Composable +private fun WaitingForResponse( + model: ReaderModel +) { + ScreenWithAppBarAndBackButton( + title = stringResource(R.string.reader_screen_waiting_for_response_title), + onBackButtonClick = { model.restart() }, + scrollable = true, + ) { + Row( + modifier = Modifier.weight(1.0f), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(R.string.reader_screen_waiting_for_response_text)) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ShowResponse(model: ReaderModel) { + val response = model.response!! + val pagerState = rememberPagerState(pageCount = { response.documents.size }) + + Box( + modifier = Modifier.fillMaxHeight() + ) { + ScreenWithAppBarAndBackButton( + title = stringResource(R.string.reader_result_screen_title), + onBackButtonClick = { model.restart() }, + ) { + if (response.documents.isEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.reader_result_screen_no_documents_returned), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + } + } else { + Column { + HorizontalPager( + state = pagerState, + ) { page -> + ShowResultDocument( + response.documents[page], + page, + response.documents.size + ) + } + } + } + } + + if (pagerState.pageCount > 1) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .align(Alignment.BottomEnd) + .wrapContentHeight() + .fillMaxWidth() + .height(30.dp) + .padding(8.dp), + ) { + repeat(pagerState.pageCount) { iteration -> + val color = + if (pagerState.currentPage == iteration) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.secondary + } + Box( + modifier = Modifier + .padding(2.dp) + .clip(CircleShape) + .background(color) + .size(8.dp) + ) + } + } + } + + var showWarning = false + response.documents.forEach { + if (!it.warningTexts.isEmpty()) { + showWarning = true + } + } + if (showWarning) { + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier.align(Alignment.Center) + ) { + Text( + text = stringResource(id = R.string.reader_result_screen_unverified_data_warning), + textAlign = TextAlign.Center, + lineHeight = 1.25.em, + color = Color(red = 255, green = 128, blue = 128, alpha = 192), + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + style = TextStyle( + fontSize = 30.sp, + shadow = Shadow( + color = Color.Black, + offset = Offset(0f, 0f), + blurRadius = 2f + ), + ), + modifier = Modifier.rotate(-30f) + ) + } + } + } +} + +@Composable +private fun WarningCard(text: String) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.errorContainer), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.padding(16.dp), + ) { + Icon( + modifier = Modifier.padding(end = 16.dp), + imageVector = Icons.Filled.Warning, + contentDescription = "An error icon", + tint = MaterialTheme.colorScheme.onErrorContainer + ) + + Text( + text = text, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} + +@Composable +private fun InfoCard(text: String) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.padding(16.dp), + ) { + Icon( + modifier = Modifier.padding(end = 16.dp), + imageVector = Icons.Filled.Info, + contentDescription = "An info icon", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Text( + text = text, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } +} + +@Composable +private fun ShowResultDocument( + document: ReaderDocument, + documentIndex: Int, + numDocuments: Int +) { + Column(Modifier.padding(8.dp)) { + + for (text in document.infoTexts) { + InfoCard(text) + } + for (text in document.warningTexts) { + WarningCard(text) + } + + if (numDocuments > 1) { + KeyValuePairText( + stringResource(R.string.reader_result_screen_document_number), + stringResource(R.string.reader_result_screen_document_n_of_m, documentIndex + 1, numDocuments) + ) + } + KeyValuePairText(stringResource(R.string.reader_result_screen_doctype), document.docType) + KeyValuePairText(stringResource(R.string.reader_result_screen_valid_from), formatTime(document.msoValidFrom)) + KeyValuePairText(stringResource(R.string.reader_result_screen_valid_until), formatTime(document.msoValidUntil)) + KeyValuePairText(stringResource(R.string.reader_result_screen_signed_at), formatTime(document.msoSigned)) + KeyValuePairText( + stringResource(R.string.reader_result_screen_expected_update), + document.msoExpectedUpdate?.let { formatTime(it) } ?: stringResource(R.string.reader_result_screen_expected_update_not_set) + ) + + for (namespace in document.namespaces) { + KeyValuePairText(stringResource(R.string.reader_result_screen_namespace), namespace.name) + for ((dataElementName, dataElement) in namespace.dataElements) { + val cborValue = Cbor.decode(dataElement.value) + val (key, value) = if (dataElement.mdocDataElement != null) { + Pair( + dataElement.mdocDataElement.attribute.displayName, + dataElement.mdocDataElement.renderValue(cborValue) + ) + } else { + Pair( + dataElementName, + Cbor.toDiagnostics( + cborValue, setOf( + DiagnosticOption.PRETTY_PRINT, + DiagnosticOption.EMBEDDED_CBOR, + DiagnosticOption.BSTR_PRINT_LENGTH, + ) + ) + ) + } + KeyValuePairText(key, value) + + if (dataElement.bitmap != null) { + Row( + horizontalArrangement = Arrangement.Center + ) { + Image( + bitmap = dataElement.bitmap.asImageBitmap(), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .size(200.dp), + contentDescription = dataElement.mdocDataElement?.attribute?.description + ?: stringResource(R.string.reader_result_screen_bitmap_missing_description) + ) + } + } + } + } + } +} + +private fun formatTime(instant: Instant): String { + val tz = TimeZone.currentSystemDefault() + val isoStr = instant.toLocalDateTime(tz).format(LocalDateTime.Formats.ISO) + // Get rid of the middle 'T' + return isoStr.substring(0, 10) + " " + isoStr.substring(11) +} + +@Composable +private fun ShowError( + model: ReaderModel +) { + ScreenWithAppBarAndBackButton( + title = stringResource(R.string.reader_screen_error_title), + onBackButtonClick = { model.restart() }, + scrollable = true, + ) { + Row( + modifier = Modifier.weight(1.0f), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource( + R.string.reader_screen_error_prefix, + model.error!!.message ?: "" + )) + } + } + } + } +} diff --git a/wallet/src/main/res/raw/owf_wallet_iaca_root.pem b/wallet/src/main/res/raw/owf_wallet_iaca_root.pem new file mode 100644 index 000000000..f3f783b81 --- /dev/null +++ b/wallet/src/main/res/raw/owf_wallet_iaca_root.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICuDCCAj6gAwIBAgIRAJEOunFvFq1NePcgLOKRxfMwCgYIKoZIzj0EAwMwOTEqMCgGA1UEAwwh +T1dGIElkZW50aXR5IENyZWRlbnRpYWwgVEVTVCBJQUNBMQswCQYDVQQGEwJVVDAeFw0yNDAyMjcx +NTI1MjBaFw0yOTAyMjcxNTI1MjBaMDkxKjAoBgNVBAMMIU9XRiBJZGVudGl0eSBDcmVkZW50aWFs +IFRFU1QgSUFDQTELMAkGA1UEBhMCVVQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASq3ADsMCEnquP0 +VNuO7lGdNY1c6QUAvlFlSsvA5nP/rbq1IBVdrEYTKGboObQY+zq9dm7HF2RQ5MX0hs1NqH8JRSdb +DmHnOQAXKHytdqSwz5SZn+MIb4OEUKCBfmRJ4FOjggEIMIIBBDAdBgNVHQ4EFgQULGUFh51NiW+i +A6YxFHqypstAzc8wHwYDVR0jBBgwFoAULGUFh51NiW+iA6YxFHqypstAzc8wDgYDVR0PAQH/BAQD +AgEGMEoGA1UdEgRDhkFodHRwczovL2dpdGh1Yi5jb20vb3BlbndhbGxldC1mb3VuZGF0aW9uLWxh +YnMvaWRlbnRpdHktY3JlZGVudGlhbDASBgNVHRMBAf8ECDAGAQH/AgEAMFIGA1UdHwRLMEkwR6BF +oEOGQWh0dHBzOi8vZ2l0aHViLmNvbS9vcGVud2FsbGV0LWZvdW5kYXRpb24tbGFicy9pZGVudGl0 +eS1jcmVkZW50aWFsMAoGCCqGSM49BAMDA2gAMGUCMGrMjBwszeaA4fNrQ0xQoeI8bESnrVN6LmqU +7+dfc/XvzgJ+XLPNlqzM5grFPlW85gIxAMqJlVgV0Z8QWxNKe6Jp4DEOSy7qU8uZnElpmtnBXu7M +w/PVHBnQ/wIBq+oWxLxwKQ== +-----END CERTIFICATE----- diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml index a500c853e..909283491 100644 --- a/wallet/src/main/res/values/strings.xml +++ b/wallet/src/main/res/values/strings.xml @@ -62,6 +62,7 @@ Wallet Add to Wallet Settings + Identity Reader About Wallet @@ -84,6 +85,47 @@ About Wallet + + Identity Reader + NFC Icon + Tap to read or scan code + QR Icon + Scan code + Identity Data to Request: + Scan QR Code + Scan QR code from identity holder\'s device + Close + Camera permission is needed to scan QR code. + Request permission + Requesting Identity + Waiting for connection to be established + Requesting Identity + Waiting for response + Identity Documents Received + No Documents Returned + Error Requesting Identity + An error occurred: %1$s + UNVERIFIED DATA\nDO NOT USE + Document Number + %1$d of %2$d + DocType + Valid From + Valid Until + Signed At + Expected Update + Not Set + Namespace + Unknown Data Element + Document Issuer \'%1$s\' is in trust list. + Document Issuer \'%1$s\' is not in trust list. + Device Authentication failed. + Issuer Authentication failed. + One or more issuer provided data elements failed to authenticate. + Document information is not valid at this point in time. + + + Error Requesting + Settings Developer Mode