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(