Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display Driving Privileges in a human-friendly format. #778

Merged
merged 1 commit into from
Nov 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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("<div>${vehicleIndent}Vehicle class: $categoryCode</div>")
val indent = "&nbsp;".repeat(8)
categoryMap.getOrNull("issue_date")?.asDateString?.let { append("<div>${indent}Issued: $it</div>") }
categoryMap.getOrNull("expiry_date")?.asDateString?.let { append("<div>${indent}Expires: $it</div>") }
}
}
return htmlDisplayValue
}

private fun isPortraitApplicable(docType: String, namespace: String?): Boolean {
val hasPortrait = docType == MDL_DOCTYPE
val namespaceContainsPortrait = namespace == MDL_NAMESPACE
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String, String>
val attributes: Map<String, AttributeDisplayInfo>
)

private data class VisitNamespaceResult(
val portrait: ByteArray?,
val signatureOrUsualMark: ByteArray?,
val keysAndValues: Map<String, String>
val keysAndValues: Map<String, AttributeDisplayInfo>
)

private fun visitNamespace(
Expand All @@ -52,9 +45,7 @@ private fun visitNamespace(
namespaceName: String,
listOfEncodedIssuerSignedItemBytes: List<ByteArray>,
): VisitNamespaceResult {
var portrait: ByteArray? = null
var signatureOrUsualMark: ByteArray? = null
val keysAndValues: MutableMap<String, String> = LinkedHashMap()
val keysAndValues: MutableMap<String, AttributeDisplayInfo> = LinkedHashMap()
for (encodedIssuerSignedItemBytes in listOfEncodedIssuerSignedItemBytes) {
val issuerSignedItemBytes = Cbor.decode(encodedIssuerSignedItemBytes)
val issuerSignedItem = issuerSignedItemBytes.asTaggedEncodedCbor
Expand All @@ -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 = "&nbsp;".repeat(4)
append("<div>${vehicleIndent}Vehicle class: $categoryCode</div>")
val indent = "&nbsp;".repeat(8)
categoryMap.getOrNull("issue_date")?.asDateString?.let { append("<div>${indent}Issued: $it</div>") }
categoryMap.getOrNull("expiry_date")?.asDateString?.let { append("<div>${indent}Expires: $it</div>") }
}
}
return htmlDisplayValue
}
kdeus marked this conversation as resolved.
Show resolved Hide resolved

fun Document.renderDocumentDetails(
Expand All @@ -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]

Expand All @@ -135,11 +161,7 @@ fun Document.renderDocumentDetails(
renderDocumentDetailsForSdJwt(documentTypeRepository, credential)
}
else -> {
DocumentDetails(
null,
null,
mapOf()
)
return DocumentDetails(mapOf())
}
}
}
Expand All @@ -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!!)
Expand All @@ -161,7 +180,7 @@ private fun Document.renderDocumentDetailsForMdoc(
val mso = MobileSecurityObjectParser(encodedMso).parse()

val documentType = documentTypeRepository.getDocumentTypeForMdoc(mso.docType)
val kvPairs = mutableMapOf<String, String>()
val kvPairs = mutableMapOf<String, AttributeDisplayInfo>()
for (namespaceName in mso.valueDigestNamespaces) {
val digestIdMapping = documentData.digestIdMapping[namespaceName] ?: listOf()
val result = visitNamespace(
Expand All @@ -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<String, String>()
val kvPairs = mutableMapOf<String, AttributeDisplayInfo>()

val vcType = documentTypeRepository.getDocumentTypeForVc(credential.vct)?.vcDocumentType

Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,

// 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<String, AttributeDisplayInfo>,

// A list of the underlying credentials
val credentialInfos: List<CredentialInfo>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,6 @@ class DocumentModel(
lastRefresh = document.state.timestamp,
status = statusString,
attributes = data.attributes,
attributePortrait = data.portrait,
attributeSignatureOrUsualMark = data.signatureOrUsualMark,
credentialInfos = keyInfos,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
kdeus marked this conversation as resolved.
Show resolved Hide resolved
style = MaterialTheme.typography.bodyMedium
)
}
}

@Composable
fun ColumnWithPortrait(
modifier: Modifier = Modifier,
Expand Down
Loading