Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f620ba5
feat(mobile): expose typed wallet review metadata
szijpeter Jul 20, 2026
052769e
feat(demo): render wallet review metadata in Compose
szijpeter Jul 20, 2026
68af430
feat(ios): render wallet review metadata
szijpeter Jul 20, 2026
c2b59f5
fix(mobile): harden wallet review metadata
szijpeter Jul 20, 2026
d1ad273
fix(mobile): remove synthesized verifier identity
szijpeter Jul 20, 2026
a74394f
fix(mobile): unify review metadata sections
szijpeter Jul 20, 2026
3437089
test(mobile): make transaction code review scroll aware
szijpeter Jul 20, 2026
31a9763
fix(demo): polish wallet review metadata
szijpeter Jul 20, 2026
3b4e3e3
fix(demo): use device locales on Compose iOS
szijpeter Jul 20, 2026
f3d09e4
fix(demo): compact technical details disclosure
szijpeter Jul 20, 2026
833a32f
fix(mobile): polish metadata review surfaces
szijpeter Jul 20, 2026
46861bc
test(mobile): stabilize Compose iOS tx-code retry
szijpeter Jul 20, 2026
80d8e37
fix(mobile): render mdoc claims human-readably
szijpeter Jul 21, 2026
2f288b2
fix(mobile): align metadata review with presentation outcomes
szijpeter Jul 21, 2026
0592c4b
test(mobile): scroll clipped claim values into view
szijpeter Jul 21, 2026
ca46c97
fix(mobile): preserve credential metadata parity
szijpeter Jul 21, 2026
cd8b291
Merge pull request #1926 from walt-id/feature/mobile-wallet-metadata-…
szijpeter Jul 22, 2026
92b8bd4
Merge branch 'main' into review/wal-896-mobile
szijpeter Jul 22, 2026
74cd9d8
fix(mobile): align verifier fixtures with strict request validation
szijpeter Jul 22, 2026
ba90be7
fix(wallet): propagate unsigned request policy
szijpeter Jul 22, 2026
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
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ compose-material-icons-core = { module = "org.jetbrains.compose.material:materia
easyqrscan = { module = "io.github.kalinjul.easyqrscan:scanner", version.ref = "easyqrscan" }
compose-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "compose-navigation3" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" }
androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,7 @@ public final class DemoBackend {
public func createResponseBoundVerifierSession(scenario: DemoCredentialScenario) async throws -> DemoVerifierSession {
try await createVerifierSession(
scenario: scenario,
transactionData: [],
bindClientIDToResponseURI: true
transactionData: []
)
}

Expand Down Expand Up @@ -186,26 +185,23 @@ public final class DemoBackend {
private func createVerifierSession(
scenario: DemoCredentialScenario,
transactionData: [[String: Any]],
encryptedResponse: Bool = false,
bindClientIDToResponseURI: Bool = false
encryptedResponse: Bool = false
) async throws -> DemoVerifierSession {
let endpoint = Self.verifierBaseURL
.appendingPathComponent("verification-session")
.appendingPathComponent("create")
let requestedSessionID = bindClientIDToResponseURI ? UUID().uuidString.lowercased() : nil
let requestedSessionID = UUID().uuidString.lowercased()
var coreFlow: [String: Any] = [
"dcql_query": [
"credentials": [scenario.verifierCredentialQuery],
],
]
if let requestedSessionID {
let responseURI = Self.verifierBaseURL
.appendingPathComponent("verification-session")
.appendingPathComponent(requestedSessionID)
.appendingPathComponent("response")
coreFlow["sessionId"] = requestedSessionID
coreFlow["clientId"] = "redirect_uri:\(responseURI.absoluteString)"
}
let responseURI = Self.verifierBaseURL
.appendingPathComponent("verification-session")
.appendingPathComponent(requestedSessionID)
.appendingPathComponent("response")
coreFlow["sessionId"] = requestedSessionID
coreFlow["clientId"] = "redirect_uri:\(responseURI.absoluteString)"
if encryptedResponse {
coreFlow["encrypted_response"] = true
}
Expand All @@ -231,21 +227,19 @@ public final class DemoBackend {
userInfo: [NSLocalizedDescriptionKey: "Missing sessionId in public demo verifier2 response: \(response)"]
)
}
guard requestedSessionID == nil || requestedSessionID == responseSessionID else {
guard requestedSessionID == responseSessionID else {
throw NSError(
domain: "WalletE2E",
code: 308,
userInfo: [NSLocalizedDescriptionKey: "Public demo verifier2 did not preserve the requested session ID"]
)
}
let requestURL = response["bootstrapAuthorizationRequestUrl"] as? String
?? response["authorizationRequestUrl"] as? String
?? response["fullAuthorizationRequestUrl"] as? String
let requestURL = response["fullAuthorizationRequestUrl"] as? String ?? response["authorizationRequestUrl"] as? String
guard let requestURL, !requestURL.isEmpty else {
throw NSError(
domain: "WalletE2E",
code: 302,
userInfo: [NSLocalizedDescriptionKey: "Missing inline authorization request URL in public demo verifier2 response: \(response)"]
userInfo: [NSLocalizedDescriptionKey: "Missing authorization request URL in public demo verifier2 response: \(response)"]
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.launchAndUnlock
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.latestStatus
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.sendDeepLink
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.setTextByTag
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.waitForResource
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.waitForResourceEnabled
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.waitForStatus
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
Expand Down Expand Up @@ -51,15 +49,16 @@ class PublicDemoBackendE2ETest {

clickByTag(device, "wallet.receiveButton")
assertTrue(
"Transaction-code input did not appear in offer review. Latest status: ${latestStatus(device)}",
waitForResource(device, "wallet.txCodeInput", CREDENTIAL_OPERATION_TIMEOUT) != null,
"Offer preview did not appear. Latest status: ${latestStatus(device)}",
waitForStatus(
device = device,
timeoutMs = CREDENTIAL_OPERATION_TIMEOUT,
matcher = { it.startsWith("Review credential offer") },
failurePrefixes = listOf("Receive failed", "Bootstrap failed"),
),
)

setTextByTag(device, "wallet.txCodeInput", incorrectCodeFor(transactionCode))
assertTrue(
"Accept button did not enable after entering a transaction code",
waitForResourceEnabled(device, "wallet.offerAcceptButton", UI_ELEMENT_TIMEOUT),
)
clickByTag(device, "wallet.offerAcceptButton")
assertTrue(
"Incorrect transaction code was not rejected. Latest status: ${latestStatus(device)}",
Expand All @@ -73,10 +72,6 @@ class PublicDemoBackendE2ETest {

// The reviewed offer remains active so the corrected code can be retried directly.
setTextByTag(device, "wallet.txCodeInput", transactionCode)
assertTrue(
"Accept button did not re-enable after correcting the transaction code",
waitForResourceEnabled(device, "wallet.offerAcceptButton", UI_ELEMENT_TIMEOUT),
)
clickByTag(device, "wallet.offerAcceptButton")
assertTrue(
"Receive did not succeed after correcting the transaction code. Latest status: ${latestStatus(device)}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,21 +130,28 @@ internal object WalletComposeE2EHelper {
}

fun clickByTag(device: UiDevice, tag: String) {
val node = waitForResource(device, tag, CLICK_VISIBLE_TIMEOUT)
?: findVisibleResource(device, tag)
val initialNode = waitForVisibleResource(device, tag, CLICK_VISIBLE_TIMEOUT)
?: findResourceAfterScrolling(device, tag)
if (node == null) {
if (initialNode == null) {
fail("$tag not found.\n${visibleUiSnapshot(device)}")
return
}
var node: UiObject2 = initialNode
val deadline = System.currentTimeMillis() + CLICK_VISIBLE_TIMEOUT
while (!node.isEnabled && System.currentTimeMillis() < deadline) {
Thread.sleep(200)
node = findVisibleResource(device, tag) ?: node
}
if (!node.isEnabled) {
fail("$tag is disabled.\n${visibleUiSnapshot(device)}")
}
assertTrue("$tag is disabled", node!!.isEnabled)
device.waitForIdle()
node.clickableAncestorOrSelf()?.click() ?: node.click()
device.waitForIdle()
}

fun setTextByTag(device: UiDevice, tag: String, value: String) {
val node = waitForResource(device, tag, CLICK_VISIBLE_TIMEOUT)
?: findVisibleResource(device, tag)
val node = waitForVisibleResource(device, tag, CLICK_VISIBLE_TIMEOUT)
?: findResourceAfterScrolling(device, tag)
if (node == null) {
fail("$tag not found.\n${visibleUiSnapshot(device)}")
Expand Down Expand Up @@ -178,21 +185,32 @@ internal object WalletComposeE2EHelper {
fail("$message. Claim row $tag not found.\n${visibleUiSnapshot(device)}")
return
}
val visibleTexts = node.flatten()
.mapNotNull { it.text?.trim()?.takeIf(String::isNotEmpty) }
val missingValues = expectedValues.filter { expected -> expected !in visibleTexts }
if (label !in visibleTexts || missingValues.isNotEmpty()) {
fail(
"""
$message.
claim=$tag
expectedLabel=$label
expectedValues=$expectedValues
visibleTexts=$visibleTexts
${visibleUiSnapshot(device)}
""".trimIndent()
)
var visibleTexts = node.visibleTexts()
fun expectedContentIsVisible(): Boolean =
label in visibleTexts && expectedValues.all { expected -> expected in visibleTexts }

if (expectedContentIsVisible()) return
repeat(6) {
device.scrollDown()
findVisibleResource(device, tag)?.let { visibleTexts = it.visibleTexts() }
if (expectedContentIsVisible()) return
}
repeat(12) {
device.scrollUp()
findVisibleResource(device, tag)?.let { visibleTexts = it.visibleTexts() }
if (expectedContentIsVisible()) return
}

fail(
"""
$message.
claim=$tag
expectedLabel=$label
expectedValues=$expectedValues
visibleTexts=$visibleTexts
${visibleUiSnapshot(device)}
""".trimIndent()
)
}

fun waitForResource(device: UiDevice, tag: String, timeoutMs: Long): UiObject2? {
Expand All @@ -206,14 +224,13 @@ internal object WalletComposeE2EHelper {
return null
}

fun waitForResourceEnabled(device: UiDevice, tag: String, timeoutMs: Long): Boolean {
private fun waitForVisibleResource(device: UiDevice, tag: String, timeoutMs: Long): UiObject2? {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val node = device.findObject(By.res(tag)) ?: findVisibleResource(device, tag)
if (node?.isEnabled == true) return true
findVisibleResource(device, tag)?.let { return it }
Thread.sleep(200)
}
return false
return null
}

private fun findTextAfterScrolling(device: UiDevice, texts: List<String>): UiObject2? {
Expand All @@ -233,30 +250,39 @@ internal object WalletComposeE2EHelper {
device.findObjects(By.pkg("id.walt.walletdemo.compose"))
.flatMap { it.flatten() }
.firstOrNull { node ->
runCatching { node.text?.trim() in texts }.getOrDefault(false)
node.isVisibleOn(device) && runCatching { node.text?.trim() in texts }.getOrDefault(false)
}

private fun findVisibleResource(device: UiDevice, tag: String): UiObject2? =
device.findObjects(By.pkg("id.walt.walletdemo.compose"))
.flatMap { it.flatten() }
.firstOrNull { node ->
runCatching { node.resourceName == tag }.getOrDefault(false)
node.isVisibleOn(device) && runCatching { node.resourceName == tag }.getOrDefault(false)
}

private fun findResourceAfterScrolling(device: UiDevice, tag: String): UiObject2? {
findVisibleResource(device, tag)?.let { return it }
repeat(6) {
device.scrollDown()
waitForResource(device, tag, 1_000L)?.let { return it }
findVisibleResource(device, tag)?.let { return it }
}
repeat(12) {
device.scrollUp()
waitForResource(device, tag, 1_000L)?.let { return it }
findVisibleResource(device, tag)?.let { return it }
}
return null
}

private fun UiObject2.isVisibleOn(device: UiDevice): Boolean = runCatching {
val bounds = visibleBounds
bounds.width() > 0 &&
bounds.height() > 0 &&
bounds.right > 0 &&
bounds.bottom > 0 &&
bounds.left < device.displayWidth &&
bounds.top < device.displayHeight
}.getOrDefault(false)

private fun claimTag(path: String): String =
"wallet.claim.${path.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("")}"

Expand Down Expand Up @@ -340,6 +366,9 @@ internal object WalletComposeE2EHelper {
private fun UiObject2.flatten(): List<UiObject2> =
listOf(this) + runCatching { children.flatMap { it.flatten() } }.getOrDefault(emptyList())

private fun UiObject2.visibleTexts(): List<String> =
flatten().mapNotNull { it.text?.trim()?.takeIf(String::isNotEmpty) }

private fun UiObject2.snapshotIdentity(): String =
runCatching {
listOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package id.walt.walletdemo.compose.android

import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import id.walt.walletdemo.compose.logic.DemoWalletConfig
Expand All @@ -16,7 +18,10 @@ class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT),
)

val config = DemoWalletConfig(
attestationBaseUrl = BuildConfig.ATTESTATION_BASE_URL,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ final class PublicDemoBackendE2ETests: XCTestCase {

ui.replaceText(in: txCodeInput, value: incorrectCode(for: transactionCode))
ui.tapButton(identifier: "wallet.offerAcceptButton", fallbackLabel: "Accept")
try await Task.sleep(for: .seconds(1))
if ui.button(identifier: "wallet.offerAcceptButton", fallbackLabel: "Accept").isEnabled {
// Compose iOS can consume the first activation after auto-dismissing a text input.
ui.tapButton(identifier: "wallet.offerAcceptButton", fallbackLabel: "Accept")
}
let rejectedStatus = ui.waitForStatus(prefixes: ["Receive failed"], timeout: credentialOperationTimeout)
guard rejectedStatus?.starts(with: "Receive failed") == true else {
XCTFail("Incorrect transaction code was not rejected, status: \(rejectedStatus ?? "nil")")
Expand Down Expand Up @@ -262,7 +267,7 @@ final class PublicDemoBackendE2ETests: XCTestCase {
)
XCTAssertEqual(previewStatus, "Review presentation request", "Presentation preview did not load, status: \(previewStatus ?? "nil")")

XCTAssertTrue(app.staticTexts["PAYMENT AUTHORIZATION"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["Payment Authorization"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["42.00"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["EUR"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["ACME Corp"].waitForExistence(timeout: 10))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ final class WalletE2EUI {
let doneButton = app.toolbars.buttons["Done"]
if doneButton.exists && doneButton.isHittable {
doneButton.tap()
} else if let focusedElement {
} else if let focusedElement, hasKeyboardFocus(in: focusedElement) {
focusedElement.typeText(XCUIKeyboardKey.return.rawValue)
} else {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package id.walt.walletdemo.compose.logic

import android.content.Context
import android.os.LocaleList
import id.walt.wallet2.mobile.MobileWalletConfig
import id.walt.wallet2.mobile.MobileWalletFactory

Expand All @@ -17,6 +18,9 @@ fun createAndroidDemoWallet(
walletId = config.walletId,
attestationConfig = config.toWalletAttestationConfig(),
transactionDataProfiles = transactionDataProfiles.profiles,
preferredLocales = LocaleList.getDefault().let { locales ->
List(locales.size()) { index -> locales[index].toLanguageTag() }
},
)
),
warning = transactionDataProfiles.warning,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ data class CredentialCardDisplayData(
val title: String,
val credentialType: String?,
val format: String,
val issuer: String?,
val issuer: String,
val holderName: String?,
val validity: String?,
val portrait: DisplayValue.Image?,
Expand All @@ -27,7 +27,7 @@ fun CredentialDetails.toCardDisplayData(): CredentialCardDisplayData {
title = summary.label,
credentialType = allItems.firstCredentialTypeText(),
format = summary.format,
issuer = summary.issuer,
issuer = summary.issuer?.takeIf { it.isNotBlank() } ?: CredentialDisplayText.Unknown,
holderName = holderName,
validity = expiryDate?.let { CredentialDisplayText.expires(it) }
?: fallbackAddedDate?.let { CredentialDisplayText.added(it) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,6 @@ package id.walt.walletdemo.compose.logic
fun CredentialSummary.toCredentialDetails(): CredentialDetails =
CredentialDisplayNormalizer.toDetails(this)

fun WalletDemoPresentationPreview.toVerifierDetails(): VerifierDetails =
VerifierDetails(
name = verifierName,
clientId = clientId,
responseUri = responseUri,
state = state,
nonce = nonce,
transactionData = transactionData,
)

fun WalletDemoPresentationCredentialOption.toCredentialDetails(): CredentialDetails {
val summary = CredentialSummary(
id = selection.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ data class CredentialDetails(
data class ClaimGroup(
val title: String,
val items: List<ClaimItem>,
val initiallyExpanded: Boolean = true,
)

class ClaimItemPath private constructor(
Expand Down
Loading
Loading