Skip to content

Commit 6acdcd5

Browse files
add issuer metadata UI to stored credentials in example apps
1 parent 0554a19 commit 6acdcd5

16 files changed

Lines changed: 440 additions & 59 deletions

File tree

waltid-applications/waltid-wallet-demo-compose/sharedLogic/src/commonMain/kotlin/id/walt/walletdemo/compose/logic/CredentialCardDisplayData.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ fun CredentialDetails.toCardDisplayData(): CredentialCardDisplayData {
2727
title = summary.label,
2828
credentialType = allItems.firstCredentialTypeText(),
2929
format = summary.format,
30-
issuer = summary.issuer?.takeIf { it.isNotBlank() } ?: CredentialDisplayText.Unknown,
30+
issuer = issuerDisplay?.name?.takeIf { it.isNotBlank() }
31+
?: summary.issuer?.takeIf { it.isNotBlank() }
32+
?: CredentialDisplayText.Unknown,
3133
holderName = holderName,
3234
validity = expiryDate?.let { CredentialDisplayText.expires(it) }
3335
?: fallbackAddedDate?.let { CredentialDisplayText.added(it) },

waltid-applications/waltid-wallet-demo-compose/sharedLogic/src/commonMain/kotlin/id/walt/walletdemo/compose/logic/CredentialDisplayModels.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package id.walt.walletdemo.compose.logic
33
data class CredentialDetails(
44
val summary: CredentialSummary,
55
val groups: List<ClaimGroup>,
6+
/** Issuer display from stored sidecar metadata (`issuerDisplay`), when available. */
7+
val issuerDisplay: WalletDemoMetadataDisplay? = null,
68
)
79

810
data class ClaimGroup(

waltid-applications/waltid-wallet-demo-compose/sharedLogic/src/commonMain/kotlin/id/walt/walletdemo/compose/logic/CredentialDisplayNormalizer.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,17 @@ object CredentialDisplayNormalizer {
2323
private val valueDecoder = CredentialDisplayValueDecoder(json) { element, path -> element.toDisplayValue(path) }
2424

2525
fun toDetails(summary: CredentialSummary): CredentialDetails {
26+
val issuerDisplay = StoredCredentialMetadataParser.issuerDisplay(summary.metadataJson)
2627
val rawJson = summary.credentialDataJson?.trim().orEmpty()
2728
if (rawJson.isBlank()) {
28-
return CredentialDetails(summary = summary, groups = emptyList())
29+
return CredentialDetails(summary = summary, groups = emptyList(), issuerDisplay = issuerDisplay)
2930
}
3031

3132
val parsed = runCatching { json.parseToJsonElement(rawJson).jsonObject }.getOrNull()
3233
?: return CredentialDetails(
3334
summary = summary,
3435
groups = emptyList(),
36+
issuerDisplay = issuerDisplay,
3537
)
3638
val displayData = if (summary.format == MdocFormat) parsed.withoutNullObjectMembers() else parsed
3739

@@ -60,6 +62,7 @@ object CredentialDisplayNormalizer {
6062
return CredentialDetails(
6163
summary = summary,
6264
groups = groupedItems,
65+
issuerDisplay = issuerDisplay,
6366
)
6467
}
6568

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package id.walt.walletdemo.compose.logic
2+
3+
import kotlinx.serialization.json.Json
4+
import kotlinx.serialization.json.JsonArray
5+
import kotlinx.serialization.json.JsonObject
6+
import kotlinx.serialization.json.contentOrNull
7+
import kotlinx.serialization.json.jsonObject
8+
import kotlinx.serialization.json.jsonPrimitive
9+
10+
/**
11+
* Parses sidecar [CredentialSummary.metadataJson] written by the wallet on receive.
12+
*
13+
* Today the wallet stores OpenID4VCI issuer display under `issuerDisplay` as an array of
14+
* `{ name, locale, logo: { uri, alt_text } }` entries.
15+
*/
16+
object StoredCredentialMetadataParser {
17+
private val json = Json {
18+
ignoreUnknownKeys = true
19+
isLenient = true
20+
}
21+
22+
fun issuerDisplay(
23+
metadataJson: String?,
24+
preferredLocales: List<String> = emptyList(),
25+
): WalletDemoMetadataDisplay? {
26+
val raw = metadataJson?.trim().orEmpty()
27+
if (raw.isEmpty()) return null
28+
29+
val root = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return null
30+
val displays = root["issuerDisplay"]?.let { element ->
31+
when (element) {
32+
is JsonArray -> element.mapNotNull { it as? JsonObject }
33+
is JsonObject -> listOf(element)
34+
else -> emptyList()
35+
}
36+
}.orEmpty()
37+
if (displays.isEmpty()) return null
38+
39+
val selected = selectPreferredDisplay(displays, preferredLocales) ?: return null
40+
val logo = selected["logo"]?.jsonObject
41+
val name = selected["name"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
42+
val logoUri = logo?.get("uri")?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
43+
val logoAltText = logo?.get("alt_text")?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
44+
?: logo?.get("altText")?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
45+
46+
if (name == null && logoUri == null) return null
47+
return WalletDemoMetadataDisplay(
48+
name = name,
49+
logoUri = logoUri,
50+
logoAltText = logoAltText,
51+
)
52+
}
53+
54+
private fun selectPreferredDisplay(
55+
displays: List<JsonObject>,
56+
preferredLocales: List<String>,
57+
): JsonObject? {
58+
val preferences = preferredLocales.mapNotNull(::normalizeLocale).distinct()
59+
preferences.forEach { preferred ->
60+
localeLookupTags(preferred).forEach { candidate ->
61+
displays.firstOrNull { normalizeLocale(it.locale()) == candidate }?.let { return it }
62+
}
63+
}
64+
return displays.firstOrNull { it.locale().isNullOrBlank() } ?: displays.firstOrNull()
65+
}
66+
67+
private fun JsonObject.locale(): String? =
68+
this["locale"]?.jsonPrimitive?.contentOrNull
69+
70+
private fun normalizeLocale(locale: String?): String? =
71+
locale
72+
?.trim()
73+
?.replace('_', '-')
74+
?.lowercase()
75+
?.takeIf { it.isNotEmpty() }
76+
77+
private fun localeLookupTags(locale: String): List<String> = buildList {
78+
val subtags = locale.split('-').filter(String::isNotEmpty).toMutableList()
79+
while (subtags.isNotEmpty()) {
80+
add(subtags.joinToString("-"))
81+
subtags.removeAt(subtags.lastIndex)
82+
if (subtags.lastOrNull()?.length == 1) subtags.removeAt(subtags.lastIndex)
83+
}
84+
}
85+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package id.walt.walletdemo.compose.logic
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertNotNull
6+
import kotlin.test.assertNull
7+
8+
class StoredCredentialMetadataParserTest {
9+
10+
@Test
11+
fun parsesIssuerDisplayNameAndLogo() {
12+
val display = StoredCredentialMetadataParser.issuerDisplay(
13+
"""
14+
{
15+
"issuerDisplay": [
16+
{
17+
"name": "Government Issuer",
18+
"locale": "en-US",
19+
"logo": { "uri": "https://issuer.example/logo.png", "alt_text": "Gov logo" }
20+
}
21+
]
22+
}
23+
""".trimIndent()
24+
)
25+
26+
assertNotNull(display)
27+
assertEquals("Government Issuer", display.name)
28+
assertEquals("https://issuer.example/logo.png", display.logoUri)
29+
assertEquals("Gov logo", display.logoAltText)
30+
}
31+
32+
@Test
33+
fun selectsPreferredLocale() {
34+
val display = StoredCredentialMetadataParser.issuerDisplay(
35+
metadataJson = """
36+
{
37+
"issuerDisplay": [
38+
{ "name": "English Issuer", "locale": "en" },
39+
{ "name": "German Issuer", "locale": "de" }
40+
]
41+
}
42+
""".trimIndent(),
43+
preferredLocales = listOf("de-AT"),
44+
)
45+
46+
assertEquals("German Issuer", display?.name)
47+
}
48+
49+
@Test
50+
fun returnsNullWhenMetadataMissing() {
51+
assertNull(StoredCredentialMetadataParser.issuerDisplay(null))
52+
assertNull(StoredCredentialMetadataParser.issuerDisplay("{}"))
53+
assertNull(StoredCredentialMetadataParser.issuerDisplay("""{"issuerDisplay":[]}"""))
54+
}
55+
56+
@Test
57+
fun toDetailsSurfacesIssuerDisplayOnCredentialDetails() {
58+
val details = CredentialDisplayNormalizer.toDetails(
59+
CredentialSummary(
60+
id = "cred-1",
61+
format = "vc+sd-jwt",
62+
issuer = "https://issuer.example",
63+
label = "PID",
64+
credentialDataJson = """{"given_name":"Ada"}""",
65+
metadataJson = """
66+
{
67+
"issuerDisplay": [
68+
{ "name": "Demo Issuer", "logo": { "uri": "https://issuer.example/logo.png" } }
69+
]
70+
}
71+
""".trimIndent(),
72+
)
73+
)
74+
75+
assertEquals("Demo Issuer", details.issuerDisplay?.name)
76+
assertEquals("https://issuer.example/logo.png", details.issuerDisplay?.logoUri)
77+
assertEquals("Demo Issuer", details.toCardDisplayData().issuer)
78+
}
79+
}

waltid-applications/waltid-wallet-demo-compose/sharedUI/src/commonMain/kotlin/id/walt/walletdemo/compose/ui/components/CredentialOverviewSection.kt

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,42 +24,62 @@ import id.walt.walletdemo.compose.ui.WalletUiTestTags
2424
@Composable
2525
internal fun CredentialOverviewSection(details: CredentialDetails, modifier: Modifier = Modifier) {
2626
val display = details.toCardDisplayData()
27+
val issuerFallback = details.summary.issuer?.takeIf { it.isNotBlank() } ?: display.issuer
2728

28-
Row(
29+
Column(
2930
modifier = modifier
3031
.fillMaxWidth()
3132
.clip(RoundedCornerShape(8.dp))
3233
.background(MaterialTheme.colorScheme.surfaceVariant)
3334
.testTag(WalletUiTestTags.credentialOverview(display.id))
3435
.padding(14.dp),
35-
horizontalArrangement = Arrangement.spacedBy(12.dp),
36-
verticalAlignment = Alignment.Top,
36+
verticalArrangement = Arrangement.spacedBy(12.dp),
3737
) {
38-
CredentialPortrait(portrait = display.portrait, size = 72.dp)
39-
Column(
40-
modifier = Modifier.weight(1f),
41-
verticalArrangement = Arrangement.spacedBy(4.dp),
38+
Row(
39+
horizontalArrangement = Arrangement.spacedBy(12.dp),
40+
verticalAlignment = Alignment.Top,
4241
) {
43-
Text(
44-
display.title,
45-
style = MaterialTheme.typography.titleSmall,
46-
fontWeight = FontWeight.SemiBold,
47-
maxLines = 2,
48-
overflow = TextOverflow.Ellipsis,
49-
)
50-
display.credentialType?.let {
51-
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
52-
}
53-
display.holderName?.let {
54-
Text(it, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
55-
}
56-
Text("Issuer: ${display.issuer}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
57-
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
58-
Text(display.format, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
59-
display.validity?.let {
60-
Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
42+
CredentialPortrait(portrait = display.portrait, size = 72.dp)
43+
Column(
44+
modifier = Modifier.weight(1f),
45+
verticalArrangement = Arrangement.spacedBy(4.dp),
46+
) {
47+
Text(
48+
display.title,
49+
style = MaterialTheme.typography.titleSmall,
50+
fontWeight = FontWeight.SemiBold,
51+
maxLines = 2,
52+
overflow = TextOverflow.Ellipsis,
53+
)
54+
display.credentialType?.let {
55+
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
56+
}
57+
display.holderName?.let {
58+
Text(it, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
59+
}
60+
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
61+
Text(display.format, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
62+
display.validity?.let {
63+
Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
64+
}
6165
}
6266
}
6367
}
68+
69+
val issuerDisplay = details.issuerDisplay
70+
if (issuerDisplay != null) {
71+
MetadataIdentityRow(
72+
display = issuerDisplay,
73+
fallbackName = issuerFallback,
74+
supportingText = details.summary.issuer
75+
?.takeIf { it.isNotBlank() && it != issuerDisplay.name },
76+
)
77+
} else {
78+
Text(
79+
"Issuer: $issuerFallback",
80+
style = MaterialTheme.typography.bodySmall,
81+
color = MaterialTheme.colorScheme.onSurfaceVariant,
82+
)
83+
}
6484
}
6585
}

waltid-applications/waltid-wallet-demo-ios/iosApp/iosApp.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
A1000310AAAA000100000010 /* CredentialDetailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000310AAAA000000000010 /* CredentialDetailsScreen.swift */; };
4343
A1000311AAAA000100000011 /* WalletAccessibilityID.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000311AAAA000000000011 /* WalletAccessibilityID.swift */; };
4444
A1000312AAAA000100000012 /* CredentialSystemInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000312AAAA000000000012 /* CredentialSystemInfo.swift */; };
45+
A1000313AAAA000100000013 /* StoredCredentialMetadataParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000313AAAA000000000013 /* StoredCredentialMetadataParser.swift */; };
4546
B33B17EA2C2C394F001A32F7 /* WalletDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33B17E92C2C394F001A32F7 /* WalletDemoApp.swift */; };
4647
B33B17EC2C2C394F001A32F7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33B17EB2C2C394F001A32F7 /* ContentView.swift */; };
4748
B33B17EE2C2C3957001A32F7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B33B17ED2C2C3957001A32F7 /* Assets.xcassets */; };
@@ -107,6 +108,7 @@
107108
A1000310AAAA000000000010 /* CredentialDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialDetailsScreen.swift; sourceTree = "<group>"; };
108109
A1000311AAAA000000000011 /* WalletAccessibilityID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletAccessibilityID.swift; sourceTree = "<group>"; };
109110
A1000312AAAA000000000012 /* CredentialSystemInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialSystemInfo.swift; sourceTree = "<group>"; };
111+
A1000313AAAA000000000013 /* StoredCredentialMetadataParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCredentialMetadataParser.swift; sourceTree = "<group>"; };
110112
B30E22802C2D8AD700D4781C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
111113
B33B17E62C2C394F001A32F7 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
112114
B33B17E92C2C394F001A32F7 /* WalletDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletDemoApp.swift; sourceTree = "<group>"; };
@@ -270,6 +272,7 @@
270272
A1000307AAAA000000000007 /* CredentialDisplayVocabulary.swift */,
271273
A1000308AAAA000000000008 /* CredentialTypeIdentifier.swift */,
272274
A1000312AAAA000000000012 /* CredentialSystemInfo.swift */,
275+
A1000313AAAA000000000013 /* StoredCredentialMetadataParser.swift */,
273276
);
274277
path = Display;
275278
sourceTree = "<group>";
@@ -571,6 +574,7 @@
571574
A1000310AAAA000100000010 /* CredentialDetailsScreen.swift in Sources */,
572575
A1000311AAAA000100000011 /* WalletAccessibilityID.swift in Sources */,
573576
A1000312AAAA000100000012 /* CredentialSystemInfo.swift in Sources */,
577+
A1000313AAAA000100000013 /* StoredCredentialMetadataParser.swift in Sources */,
574578
);
575579
runOnlyForDeploymentPostprocessing = 0;
576580
};

waltid-applications/waltid-wallet-demo-ios/iosApp/iosApp/Components/CredentialCardView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ struct CredentialCardView: View {
3131
.font(.caption)
3232
.foregroundColor(.primary)
3333
}
34-
Text("Issuer: \(details.issuer ?? CredentialDisplayText.unknown)")
34+
Text("Issuer: \(details.cardSummary.issuer)")
3535
.font(.caption)
3636
.foregroundColor(.secondary)
3737
.lineLimit(1)

0 commit comments

Comments
 (0)