diff --git a/appverifier/src/main/java/com/android/mdl/appreader/fragment/ShowDocumentFragment.kt b/appverifier/src/main/java/com/android/mdl/appreader/fragment/ShowDocumentFragment.kt index 656922381..230f8601c 100644 --- a/appverifier/src/main/java/com/android/mdl/appreader/fragment/ShowDocumentFragment.kt +++ b/appverifier/src/main/java/com/android/mdl/appreader/fragment/ShowDocumentFragment.kt @@ -281,6 +281,8 @@ class ShowDocumentFragment : Fragment() { } else if (doc.docType == MDL_DOCTYPE && ns == MDL_NAMESPACE && elem == "signature_usual_mark") { valueStr = String.format("(%d bytes, shown below)", value.size) signatureBytes = doc.getIssuerEntryByteString(ns, elem) + } else if (doc.docType == MDL_DOCTYPE && ns == MDL_NAMESPACE && elem == "driving_privileges") { + valueStr = createDrivingPrivilegesHtml(value) } else if (mdocDataElement != null) { valueStr = mdocDataElement.renderValue(Cbor.decode(value)) } else { @@ -303,6 +305,22 @@ class ShowDocumentFragment : Fragment() { return sb.toString() } + private fun createDrivingPrivilegesHtml(encodedElementValue: ByteArray): String { + val decodedValue = Cbor.decode(encodedElementValue).asArray + val htmlDisplayValue = buildString { + for (categoryMap in decodedValue) { + val categoryCode = + categoryMap.getOrNull("vehicle_category_code")?.asTstr ?: "Unspecified" + val vehicleIndent = " ".repeat(4) + append("
${vehicleIndent}Vehicle class: $categoryCode
") + val indent = " ".repeat(8) + categoryMap.getOrNull("issue_date")?.asDateString?.let { append("
${indent}Issued: $it
") } + categoryMap.getOrNull("expiry_date")?.asDateString?.let { append("
${indent}Expires: $it
") } + } + } + return htmlDisplayValue + } + private fun isPortraitApplicable(docType: String, namespace: String?): Boolean { val hasPortrait = docType == MDL_DOCTYPE val namespaceContainsPortrait = namespace == MDL_NAMESPACE diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/AttributeDisplayInfo.kt b/wallet/src/main/java/com/android/identity_credential/wallet/AttributeDisplayInfo.kt new file mode 100644 index 000000000..35589541a --- /dev/null +++ b/wallet/src/main/java/com/android/identity_credential/wallet/AttributeDisplayInfo.kt @@ -0,0 +1,9 @@ +package com.android.identity_credential.wallet + +import android.graphics.Bitmap + +sealed class AttributeDisplayInfo + +data class AttributeDisplayInfoPlainText(val name: String, val value: String) : AttributeDisplayInfo() +data class AttributeDisplayInfoHtml(val name: String, val value: String) : AttributeDisplayInfo() +data class AttributeDisplayInfoImage(val name: String, val image: Bitmap) : AttributeDisplayInfo() diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/DocumentDetails.kt b/wallet/src/main/java/com/android/identity_credential/wallet/DocumentDetails.kt index cb84b5b57..9bbc6c9b1 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/DocumentDetails.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/DocumentDetails.kt @@ -1,11 +1,10 @@ package com.android.identity_credential.wallet import android.content.Context -import android.graphics.Bitmap import com.android.identity.cbor.Cbor import com.android.identity.cbor.DiagnosticOption import com.android.identity.document.Document -import com.android.identity.documenttype.DocumentAttributeType +import com.android.identity.documenttype.DocumentAttribute import com.android.identity.documenttype.DocumentTypeRepository import com.android.identity.documenttype.MdocDocumentType import com.android.identity.documenttype.knowntypes.DrivingLicense @@ -30,20 +29,14 @@ private const val TAG = "ViewDocumentData" * or sent to external parties. * * @param typeName human readable type name of the document, e.g. "Driving License". - * @param portrait portrait of the holder, if available - * @param signatureOrUsualMark signature or usual mark of the holder, if available * @param attributes key/value pairs with data in the document */ data class DocumentDetails( - val portrait: Bitmap?, - val signatureOrUsualMark: Bitmap?, - val attributes: Map + val attributes: Map ) private data class VisitNamespaceResult( - val portrait: ByteArray?, - val signatureOrUsualMark: ByteArray?, - val keysAndValues: Map + val keysAndValues: Map ) private fun visitNamespace( @@ -52,9 +45,7 @@ private fun visitNamespace( namespaceName: String, listOfEncodedIssuerSignedItemBytes: List, ): VisitNamespaceResult { - var portrait: ByteArray? = null - var signatureOrUsualMark: ByteArray? = null - val keysAndValues: MutableMap = LinkedHashMap() + val keysAndValues: MutableMap = LinkedHashMap() for (encodedIssuerSignedItemBytes in listOfEncodedIssuerSignedItemBytes) { val issuerSignedItemBytes = Cbor.decode(encodedIssuerSignedItemBytes) val issuerSignedItem = issuerSignedItemBytes.asTaggedEncodedCbor @@ -64,53 +55,88 @@ private fun visitNamespace( val mdocDataElement = mdocDocumentType?.namespaces?.get(namespaceName)?.dataElements?.get(elementIdentifier) - var elementValueAsString: String? = null + val elementName = mdocDataElement?.attribute?.displayName ?: elementIdentifier + var attributeDisplayInfo: AttributeDisplayInfo? = null if (mdocDataElement != null) { - elementValueAsString = mdocDataElement.renderValue( - value = Cbor.decode(encodedElementValue), - trueFalseStrings = Pair( - context.resources.getString(R.string.document_details_boolean_false_value), - context.resources.getString(R.string.document_details_boolean_true_value), - ) - ) - - if (mdocDataElement.attribute.type == DocumentAttributeType.Picture && - namespaceName == DrivingLicense.MDL_NAMESPACE) { - when (mdocDataElement.attribute.identifier) { - "portrait" -> { - portrait = elementValue.asBstr - continue - } - - "signature_usual_mark" -> { - signatureOrUsualMark = elementValue.asBstr - continue - } - } - } - if (mdocDataElement.attribute.type == DocumentAttributeType.Picture && - namespaceName == PhotoID.PHOTO_ID_NAMESPACE) { - when (mdocDataElement.attribute.identifier) { - "portrait" -> { - portrait = elementValue.asBstr - continue - } + attributeDisplayInfo = if (isImageAttribute(namespaceName, mdocDataElement.attribute)) { + Jpeg2kConverter.decodeByteArray(context, elementValue.asBstr)?.let { + AttributeDisplayInfoImage(elementName, it) } + } else if (namespaceName == DrivingLicense.MDL_NAMESPACE && + mdocDataElement.attribute.identifier == "driving_privileges") { + val htmlDisplayValue = createDrivingPrivilegesHtml(encodedElementValue) + AttributeDisplayInfoHtml(elementName, htmlDisplayValue) + } else { + AttributeDisplayInfoPlainText( + elementName, + mdocDataElement.renderValue( + value = Cbor.decode(encodedElementValue), + trueFalseStrings = Pair( + context.resources.getString(R.string.document_details_boolean_false_value), + context.resources.getString(R.string.document_details_boolean_true_value), + ) + ) + ) } } - if (elementValueAsString == null) { - elementValueAsString = Cbor.toDiagnostics( - encodedElementValue, - setOf(DiagnosticOption.BSTR_PRINT_LENGTH) + if (attributeDisplayInfo == null) { + attributeDisplayInfo = AttributeDisplayInfoPlainText( + elementName, + Cbor.toDiagnostics( + encodedElementValue, + setOf(DiagnosticOption.BSTR_PRINT_LENGTH) + ) ) } - val elementName = mdocDataElement?.attribute?.displayName ?: elementIdentifier - keysAndValues[elementName] = elementValueAsString + keysAndValues[elementIdentifier] = attributeDisplayInfo } - return VisitNamespaceResult(portrait, signatureOrUsualMark, keysAndValues) + return VisitNamespaceResult(keysAndValues) +} + +private fun isImageAttribute(namespaceName: String, attribute: DocumentAttribute): Boolean { + if (namespaceName == DrivingLicense.MDL_NAMESPACE) { + return when (attribute.identifier) { + "portrait", "signature_usual_mark" -> true + else -> false + } + } + if (namespaceName == PhotoID.PHOTO_ID_NAMESPACE) { + return when (attribute.identifier) { + "portrait" -> true + else -> false + } + } + return false +} + +/** + * Creates a string with HTML that renders the Driving Privileges field in a more human-readable + * format. + * + * TODO: We should consider moving this to MdocDataElement.renderValue(), with a parameter to switch + * between text/plain and text/html. + * + * @param encodedElementValue The CBOR-encoded value of the driving_privileges element. + */ +fun createDrivingPrivilegesHtml(encodedElementValue: ByteArray): String { + val decodedValue = Cbor.decode(encodedElementValue).asArray + val htmlDisplayValue = buildString { + for (categoryMap in decodedValue) { + val categoryCode = + categoryMap.getOrNull("vehicle_category_code")?.asTstr ?: "Unspecified" + // The current HTML -> AnnotatedString parser only handles a subset of HTML. + // Because of that, we'll do indentation using spaces. + val vehicleIndent = " ".repeat(4) + append("
${vehicleIndent}Vehicle class: $categoryCode
") + val indent = " ".repeat(8) + categoryMap.getOrNull("issue_date")?.asDateString?.let { append("
${indent}Issued: $it
") } + categoryMap.getOrNull("expiry_date")?.asDateString?.let { append("
${indent}Expires: $it
") } + } + } + return htmlDisplayValue } fun Document.renderDocumentDetails( @@ -120,7 +146,7 @@ fun Document.renderDocumentDetails( // TODO: maybe use DocumentConfiguration instead of pulling data out of a certified credential. if (certifiedCredentials.size == 0) { - return DocumentDetails(null, null, mapOf()) + return DocumentDetails(mapOf()) } val credential = certifiedCredentials[0] @@ -135,11 +161,7 @@ fun Document.renderDocumentDetails( renderDocumentDetailsForSdJwt(documentTypeRepository, credential) } else -> { - DocumentDetails( - null, - null, - mapOf() - ) + return DocumentDetails(mapOf()) } } } @@ -150,9 +172,6 @@ private fun Document.renderDocumentDetailsForMdoc( credential: MdocCredential ): DocumentDetails { - var portrait: Bitmap? = null - var signatureOrUsualMark: Bitmap? = null - val documentData = StaticAuthDataParser(credential.issuerProvidedData).parse() val issuerAuthCoseSign1 = Cbor.decode(documentData.issuerAuth).asCoseSign1 val encodedMsoBytes = Cbor.decode(issuerAuthCoseSign1.payload!!) @@ -161,7 +180,7 @@ private fun Document.renderDocumentDetailsForMdoc( val mso = MobileSecurityObjectParser(encodedMso).parse() val documentType = documentTypeRepository.getDocumentTypeForMdoc(mso.docType) - val kvPairs = mutableMapOf() + val kvPairs = mutableMapOf() for (namespaceName in mso.valueDigestNamespaces) { val digestIdMapping = documentData.digestIdMapping[namespaceName] ?: listOf() val result = visitNamespace( @@ -170,24 +189,17 @@ private fun Document.renderDocumentDetailsForMdoc( namespaceName, digestIdMapping ) - if (result.portrait != null) { - portrait = Jpeg2kConverter.decodeByteArray(context, result.portrait) - } - if (result.signatureOrUsualMark != null) { - signatureOrUsualMark = Jpeg2kConverter.decodeByteArray( - context, result.signatureOrUsualMark) - } kvPairs += result.keysAndValues } - return DocumentDetails(portrait, signatureOrUsualMark, kvPairs) + return DocumentDetails(kvPairs) } private fun Document.renderDocumentDetailsForSdJwt( documentTypeRepository: DocumentTypeRepository, credential: SdJwtVcCredential ): DocumentDetails { - val kvPairs = mutableMapOf() + val kvPairs = mutableMapOf() val vcType = documentTypeRepository.getDocumentTypeForVc(credential.vct)?.vcDocumentType @@ -207,8 +219,8 @@ private fun Document.renderDocumentDetailsForSdJwt( ?.displayName ?: claimName - kvPairs[displayName] = content + kvPairs[claimName] = AttributeDisplayInfoPlainText(displayName, content) } - return DocumentDetails(null, null, kvPairs) + return DocumentDetails(kvPairs) } diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/DocumentInfo.kt b/wallet/src/main/java/com/android/identity_credential/wallet/DocumentInfo.kt index f12998368..d195fdf2b 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/DocumentInfo.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/DocumentInfo.kt @@ -40,14 +40,9 @@ data class DocumentInfo( // Human-readable string explaining the user what state the document is in. val status: String, - // Data attributes - val attributes: Map, - - // Data attribute: Portrait of the document holder, if available - val attributePortrait: Bitmap?, - - // Data attribute: Signature or usual mark of the holder, if available - val attributeSignatureOrUsualMark: Bitmap?, + // Data attributes (mapped from attribute identifier to information about how to display the + // name and value of the attribute). + val attributes: Map, // A list of the underlying credentials val credentialInfos: List diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/DocumentModel.kt b/wallet/src/main/java/com/android/identity_credential/wallet/DocumentModel.kt index 1ed02e383..faf67f3fa 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/DocumentModel.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/DocumentModel.kt @@ -303,8 +303,6 @@ class DocumentModel( lastRefresh = document.state.timestamp, status = statusString, attributes = data.attributes, - attributePortrait = data.portrait, - attributeSignatureOrUsualMark = data.signatureOrUsualMark, credentialInfos = keyInfos, ) } 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 359bb7c23..6426c1c86 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 @@ -30,7 +30,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.android.identity_credential.wallet.R @@ -127,6 +129,30 @@ fun KeyValuePairText( } } +@Composable +fun KeyValuePairHtml( + keyText: String, + html: String +) { + Column( + Modifier + .padding(8.dp) + .fillMaxWidth()) { + Text( + text = keyText, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + Text( + // TODO: Find a KMM-friendly alternative to fromHtml. This has been removed from common + // and is now Android-only (see + // https://android-review.googlesource.com/c/platform/frameworks/support/+/3150316). + text = AnnotatedString.Companion.fromHtml(html), + style = MaterialTheme.typography.bodyMedium + ) + } +} + @Composable fun ColumnWithPortrait( modifier: Modifier = Modifier, diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/document/DocumentDetailsScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/document/DocumentDetailsScreen.kt index d25b838c7..46a3beb01 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/document/DocumentDetailsScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/document/DocumentDetailsScreen.kt @@ -37,13 +37,17 @@ import androidx.compose.ui.unit.sp import androidx.fragment.app.FragmentActivity import com.android.identity.android.securearea.UserAuthenticationType import com.android.identity.util.Logger +import com.android.identity_credential.wallet.AttributeDisplayInfoHtml +import com.android.identity_credential.wallet.AttributeDisplayInfoImage +import com.android.identity_credential.wallet.AttributeDisplayInfoPlainText import com.android.identity_credential.wallet.DocumentInfo import com.android.identity_credential.wallet.DocumentModel import com.android.identity_credential.wallet.R import com.android.identity_credential.wallet.navigation.WalletDestination -import com.android.identity_credential.wallet.ui.prompt.biometric.showBiometricPrompt +import com.android.identity_credential.wallet.ui.KeyValuePairHtml import com.android.identity_credential.wallet.ui.KeyValuePairText import com.android.identity_credential.wallet.ui.ScreenWithAppBarAndBackButton +import com.android.identity_credential.wallet.ui.prompt.biometric.showBiometricPrompt import kotlinx.coroutines.launch @@ -161,37 +165,60 @@ private fun DocumentDetails( modifier = Modifier.padding(8.dp) ) - if (documentInfo.attributePortrait != null) { - Row( - horizontalArrangement = Arrangement.Center - ) { - Image( - bitmap = documentInfo.attributePortrait.asImageBitmap(), - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - .size(200.dp), - contentDescription = stringResource(R.string.accessibility_portrait), - ) + val topImages = listOf("portrait") + val bottomImages = listOf("signature_usual_mark") + for (attributeId in topImages) { + val attributeDisplayInfo = documentInfo.attributes[attributeId] + if (attributeDisplayInfo != null) { + val displayInfo = attributeDisplayInfo as AttributeDisplayInfoImage + Row( + horizontalArrangement = Arrangement.Center + ) { + Image( + bitmap = displayInfo.image.asImageBitmap(), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .size(200.dp), + contentDescription = displayInfo.name + ) + } } } - for ((key, value) in documentInfo.attributes) { - KeyValuePairText(key, value) + val centerAttributes = documentInfo.attributes.filter { + !topImages.contains(it.key) && !bottomImages.contains(it.key) + } + for ((attributeId, displayInfo) in centerAttributes) { + when (displayInfo) { + is AttributeDisplayInfoPlainText -> { + KeyValuePairText(displayInfo.name, displayInfo.value) + } + is AttributeDisplayInfoHtml -> { + KeyValuePairHtml(displayInfo.name, displayInfo.value) + } + else -> { + throw IllegalArgumentException("Unsupported attribute display info for $attributeId: $displayInfo") + } + } } - if (documentInfo.attributeSignatureOrUsualMark != null) { - Row( - horizontalArrangement = Arrangement.Center - ) { - Image( - bitmap = documentInfo.attributeSignatureOrUsualMark.asImageBitmap(), - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .size(75.dp), - contentDescription = stringResource(R.string.accessibility_signature), - ) + for (attributeId in bottomImages) { + val attributeDisplayInfo = documentInfo.attributes[attributeId] + if (attributeDisplayInfo != null) { + val displayInfo = attributeDisplayInfo as AttributeDisplayInfoImage + Row( + horizontalArrangement = Arrangement.Center + ) { + Image( + bitmap = displayInfo.image.asImageBitmap(), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .size(75.dp), + contentDescription = displayInfo.name + ) + } } } } 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 index 73f5239aa..9ded9e389 100644 --- 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 @@ -76,7 +76,9 @@ 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.createDrivingPrivilegesHtml import com.android.identity_credential.wallet.navigation.WalletDestination +import com.android.identity_credential.wallet.ui.KeyValuePairHtml import com.android.identity_credential.wallet.ui.KeyValuePairText import com.android.identity_credential.wallet.ui.ScreenWithAppBarAndBackButton import com.android.identity_credential.wallet.ui.qrscanner.ScanQrDialog @@ -615,7 +617,12 @@ private fun ShowResultDocument( ) ) } - KeyValuePairText(key, value) + if (dataElementName == "driving_privileges") { + val html = createDrivingPrivilegesHtml(dataElement.value) + KeyValuePairHtml(key, html) + } else { + KeyValuePairText(key, value) + } if (dataElement.bitmap != null) { Row(