Skip to content

Commit 6bbbf23

Browse files
authored
Merge pull request #1893 from walt-id/feature/wal-1077-transaction-data-support
[WAL-1077] feat(mobile): add transaction data support
2 parents be65ea0 + 87fe367 commit 6bbbf23

70 files changed

Lines changed: 1748 additions & 79 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/scripts/mobile-ci/run-android-compose-demo-tests.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ identity_dir="$(cd "$script_dir/../../.." && pwd -P)"
66

77
"$identity_dir/gradlew" -p "$identity_dir" \
88
:waltid-applications:waltid-wallet-demo-compose:androidApp:connectedDebugAndroidTest \
9+
-PtransactionDataProfiles.url=https://wallet.demo.walt.id/wallet-api/transaction-data-profiles \
910
--info

waltid-applications/mobile-e2e-fixtures/ios/TestHelpers/DemoBackend.swift

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ public struct DemoVerifierSession {
2121
public final class DemoBackend {
2222
public static let shared = DemoBackend()
2323
private static let eudiPidSdJwtVct = "https://issuer2.demo.walt.id/openid4vci/urn:eudi:pid:1"
24+
public static let transactionDataProfilesURL = URL(string: "https://wallet.demo.walt.id/wallet-api/transaction-data-profiles")!
25+
private static let paymentAuthorizationType = "org.waltid.transaction-data.payment-authorization"
26+
private static let requiredPaymentAuthorizationFields: Set<String> = ["amount", "currency", "payee"]
2427

2528
public static let scenarios: [DemoCredentialScenario] = [
2629
DemoCredentialScenario(
@@ -64,6 +67,8 @@ public final class DemoBackend {
6467

6568
public static let presentationScenarios = scenarios
6669

70+
public static let transactionDataPresentationScenario = scenarios.first { $0.id == "eudi-pid-sdjwt" }!
71+
6772
public static let persistenceScenario = scenarios.first { $0.id == "eudi-pid-mdoc" }!
6873

6974
private static let issuerBaseURL = URL(string: "https://issuer2.demo.walt.id")!
@@ -102,17 +107,66 @@ public final class DemoBackend {
102107
}
103108

104109
public func createVerifierSession(scenario: DemoCredentialScenario) async throws -> DemoVerifierSession {
110+
try await createVerifierSession(
111+
scenario: scenario,
112+
transactionData: []
113+
)
114+
}
115+
116+
public func createTransactionDataVerifierSession(
117+
scenario: DemoCredentialScenario = DemoBackend.transactionDataPresentationScenario
118+
) async throws -> DemoVerifierSession {
119+
let fields = try await transactionDataProfileFields(type: Self.paymentAuthorizationType)
120+
let missingFields = Self.requiredPaymentAuthorizationFields.subtracting(fields)
121+
guard missingFields.isEmpty else {
122+
throw NSError(
123+
domain: "WalletE2E",
124+
code: 305,
125+
userInfo: [NSLocalizedDescriptionKey: "Public demo transaction data profile '\(Self.paymentAuthorizationType)' is missing required fields: \(missingFields.sorted().joined(separator: ", "))"]
126+
)
127+
}
128+
return try await createVerifierSession(
129+
scenario: scenario,
130+
transactionData: [Self.paymentAuthorizationTransactionData(credentialID: "pid", fields: fields)]
131+
)
132+
}
133+
134+
public func transactionDataProfileFields(type: String) async throws -> Set<String> {
135+
let response = try await client.textRequest(
136+
url: Self.transactionDataProfilesURL,
137+
retryTransientFailures: true
138+
)
139+
let json = try JSONSerialization.jsonObject(with: Data(response.body.utf8), options: [])
140+
guard let profiles = json as? [[String: Any]],
141+
let profile = profiles.first(where: { $0["type"] as? String == type }),
142+
let fields = profile["fields"] as? [String] else {
143+
throw NSError(
144+
domain: "WalletE2E",
145+
code: 306,
146+
userInfo: [NSLocalizedDescriptionKey: "Missing public demo transaction data profile: \(type)"]
147+
)
148+
}
149+
return Set(fields)
150+
}
151+
152+
private func createVerifierSession(
153+
scenario: DemoCredentialScenario,
154+
transactionData: [[String: Any]]
155+
) async throws -> DemoVerifierSession {
105156
let endpoint = Self.verifierBaseURL
106157
.appendingPathComponent("verification-session")
107158
.appendingPathComponent("create")
108-
let payload: [String: Any] = [
159+
var payload: [String: Any] = [
109160
"flow_type": "cross_device",
110161
"core_flow": [
111162
"dcql_query": [
112163
"credentials": [scenario.verifierCredentialQuery],
113164
],
114165
],
115166
]
167+
if !transactionData.isEmpty {
168+
payload["openid"] = ["transactionData": transactionData]
169+
}
116170
let response = try await client.jsonRequest(
117171
url: endpoint,
118172
method: "POST",
@@ -204,4 +258,25 @@ public final class DemoBackend {
204258
"claims": claims.map { ["path": [namespace, $0]] },
205259
]
206260
}
261+
262+
private static func paymentAuthorizationTransactionData(credentialID: String, fields: Set<String>) -> [String: Any] {
263+
var payload: [String: Any] = [
264+
"type": paymentAuthorizationType,
265+
"credential_ids": [credentialID],
266+
"require_cryptographic_holder_binding": true,
267+
"transaction_data_hashes_alg": ["sha-256"],
268+
]
269+
payload.putProfileField(fields: fields, key: "amount", value: "42.00")
270+
payload.putProfileField(fields: fields, key: "currency", value: "EUR")
271+
payload.putProfileField(fields: fields, key: "payee", value: "ACME Corp")
272+
return payload
273+
}
274+
}
275+
276+
private extension Dictionary where Key == String, Value == Any {
277+
mutating func putProfileField(fields: Set<String>, key: String, value: String) {
278+
if fields.contains(key) {
279+
self[key] = value
280+
}
281+
}
207282
}

waltid-applications/waltid-wallet-demo-compose/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ Android and iOS demo targets use the default managed encrypted local persistence
3232

3333
The UI stays focused on the production default. Non-default persistence options, including provided database keys and custom stores, are documented and tested at the SDK layer.
3434

35+
## Public demo backend defaults
36+
37+
Clean demo installs use the public walt.id demo profile endpoint for OpenID4VP transaction-data support:
38+
39+
```text
40+
https://wallet.demo.walt.id/wallet-api/transaction-data-profiles
41+
```
42+
43+
Android builds can override it with `-PtransactionDataProfiles.url=...`. Compose iOS can override it with the `TRANSACTION_DATA_PROFILES_URL` launch environment variable or `UserDefaults` key. Wallet attestation values remain explicit overrides through `attestation.*` Gradle properties on Android and `ATTESTATION_*` environment/UserDefaults values on iOS; no bearer token is defaulted.
44+
3545
## Target status
3646

3747
- Android and iOS are the supported mobile demo targets for wallet SDK issuance, presentation, platform-backed keys, and persistence.

waltid-applications/waltid-wallet-demo-compose/androidApp/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ plugins {
66
}
77

88
val javaVersion = identityLibs.versions.java.library.get().toInt()
9+
val publicDemoTransactionDataProfilesUrl = "https://wallet.demo.walt.id/wallet-api/transaction-data-profiles"
910

1011
android {
1112
namespace = "id.walt.walletdemo.compose.android"
@@ -23,6 +24,7 @@ android {
2324
buildConfigField("String", "ATTESTATION_ATTESTER_PATH", "\"${findProperty("attestation.attesterPath") ?: ""}\"")
2425
buildConfigField("String", "ATTESTATION_BEARER_TOKEN", "\"${findProperty("attestation.bearerToken") ?: ""}\"")
2526
buildConfigField("String", "ATTESTATION_HOST_HEADER", "\"${findProperty("attestation.hostHeader") ?: ""}\"")
27+
buildConfigField("String", "TRANSACTION_DATA_PROFILES_URL", "\"${findProperty("transactionDataProfiles.url") ?: publicDemoTransactionDataProfilesUrl}\"")
2628
}
2729

2830
buildFeatures {

waltid-applications/waltid-wallet-demo-compose/androidApp/src/androidTest/java/id/walt/walletdemo/compose/android/PublicDemoBackendE2ETest.kt

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import id.walt.mobile.test.backend.DemoTestBackend
88
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.CREDENTIAL_OPERATION_TIMEOUT
99
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.UI_ELEMENT_TIMEOUT
1010
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.VERIFIER_POLLING_TIMEOUT
11+
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.assertClaimValueVisibleAfterScrolling
1112
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.assertResourceTextEquals
13+
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.assertTextVisibleAfterScrolling
1214
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.clickByTag
1315
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.launchAndUnlock
1416
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.latestStatus
@@ -18,6 +20,7 @@ import kotlinx.coroutines.runBlocking
1820
import org.junit.Assert.assertTrue
1921
import org.junit.Test
2022
import org.junit.runner.RunWith
23+
import java.io.File
2124

2225
@RunWith(AndroidJUnit4::class)
2326
class PublicDemoBackendE2ETest {
@@ -94,4 +97,87 @@ class PublicDemoBackendE2ETest {
9497

9598
DemoTestBackend.waitForVerifierSuccess(session.sessionId, timeoutMs = VERIFIER_POLLING_TIMEOUT)
9699
}
100+
101+
@Test
102+
fun transactionDataPreviewAgainstPublicDemoIssuer2Verifier2() = runBlocking {
103+
val scenario = DemoTestBackend.transactionDataPresentationScenario
104+
val offer = DemoTestBackend.createOffer(scenario)
105+
106+
val instrumentation = InstrumentationRegistry.getInstrumentation()
107+
val context = instrumentation.targetContext
108+
val device = UiDevice.getInstance(instrumentation)
109+
110+
launchAndUnlock(context, device)
111+
112+
sendDeepLink(context, offer.offerUrl)
113+
assertResourceTextEquals(
114+
device = device,
115+
tag = "wallet.offerInput",
116+
expected = offer.offerUrl,
117+
timeoutMs = UI_ELEMENT_TIMEOUT,
118+
message = "Offer URL did not appear in UI after deep link",
119+
)
120+
121+
clickByTag(device, "wallet.receiveButton")
122+
val receiveSuccess = waitForStatus(
123+
device = device,
124+
timeoutMs = CREDENTIAL_OPERATION_TIMEOUT,
125+
matcher = { it.startsWith("Received") },
126+
failurePrefixes = listOf("Receive failed", "Bootstrap failed", "Present failed")
127+
)
128+
assertTrue("Receive did not complete successfully. Latest status: ${latestStatus(device)}", receiveSuccess)
129+
130+
val session = DemoTestBackend.createTransactionDataVerifierSession(scenario)
131+
sendDeepLink(context, session.authorizationRequestUri)
132+
assertResourceTextEquals(
133+
device = device,
134+
tag = "wallet.presentationInput",
135+
expected = session.authorizationRequestUri,
136+
timeoutMs = UI_ELEMENT_TIMEOUT,
137+
message = "Presentation request URL did not appear in UI after deep link",
138+
)
139+
140+
clickByTag(device, "wallet.presentButton")
141+
val previewReady = waitForStatus(
142+
device = device,
143+
timeoutMs = CREDENTIAL_OPERATION_TIMEOUT,
144+
matcher = { it == "Review presentation request" },
145+
failurePrefixes = listOf("Preview failed", "Present failed", "Receive failed", "Bootstrap failed")
146+
)
147+
assertTrue("Transaction-data preview did not load. Latest status: ${latestStatus(device)}", previewReady)
148+
149+
val screenshot = File("/sdcard/Download/wal1077-compose-android-transaction-data.png")
150+
if (device.takeScreenshot(screenshot)) {
151+
println("WAL1077_SCREENSHOT=${screenshot.absolutePath}")
152+
} else {
153+
println("WAL1077_SCREENSHOT_CAPTURE_FAILED=${screenshot.absolutePath}")
154+
}
155+
156+
assertTextVisibleAfterScrolling(
157+
device,
158+
listOf("PAYMENT AUTHORIZATION", "Payment Authorization"),
159+
"Payment profile title missing",
160+
)
161+
assertClaimValueVisibleAfterScrolling(
162+
device = device,
163+
path = "transactionData[0].details.amount",
164+
label = "Amount",
165+
expectedValues = listOf("42.00"),
166+
message = "Payment amount missing",
167+
)
168+
assertClaimValueVisibleAfterScrolling(
169+
device = device,
170+
path = "transactionData[0].details.currency",
171+
label = "Currency",
172+
expectedValues = listOf("EUR"),
173+
message = "Payment currency missing",
174+
)
175+
assertClaimValueVisibleAfterScrolling(
176+
device = device,
177+
path = "transactionData[0].details.payee",
178+
label = "Payee",
179+
expectedValues = listOf("ACME Corp"),
180+
message = "Payment payee missing",
181+
)
182+
}
97183
}

waltid-applications/waltid-wallet-demo-compose/androidApp/src/androidTest/java/id/walt/walletdemo/compose/android/WalletComposeE2EHelper.kt

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,47 @@ internal object WalletComposeE2EHelper {
115115
device.waitForIdle()
116116
}
117117

118+
fun assertTextVisibleAfterScrolling(
119+
device: UiDevice,
120+
texts: List<String>,
121+
message: String,
122+
) {
123+
if (findTextAfterScrolling(device, texts) != null) return
124+
fail("$message. Expected one of $texts.\n${visibleUiSnapshot(device)}")
125+
}
126+
127+
fun assertClaimValueVisibleAfterScrolling(
128+
device: UiDevice,
129+
path: String,
130+
label: String,
131+
expectedValues: List<String>,
132+
message: String,
133+
) {
134+
val tag = claimTag(path)
135+
val node = waitForResource(device, tag, CLICK_VISIBLE_TIMEOUT)
136+
?: findVisibleResource(device, tag)
137+
?: findResourceAfterScrolling(device, tag)
138+
if (node == null) {
139+
fail("$message. Claim row $tag not found.\n${visibleUiSnapshot(device)}")
140+
return
141+
}
142+
val visibleTexts = node.flatten()
143+
.mapNotNull { it.text?.trim()?.takeIf(String::isNotEmpty) }
144+
val missingValues = expectedValues.filter { expected -> expected !in visibleTexts }
145+
if (label !in visibleTexts || missingValues.isNotEmpty()) {
146+
fail(
147+
"""
148+
$message.
149+
claim=$tag
150+
expectedLabel=$label
151+
expectedValues=$expectedValues
152+
visibleTexts=$visibleTexts
153+
${visibleUiSnapshot(device)}
154+
""".trimIndent()
155+
)
156+
}
157+
}
158+
118159
fun waitForResource(device: UiDevice, tag: String, timeoutMs: Long): UiObject2? {
119160
val deadline = System.currentTimeMillis() + timeoutMs
120161
while (System.currentTimeMillis() < deadline) {
@@ -126,6 +167,26 @@ internal object WalletComposeE2EHelper {
126167
return null
127168
}
128169

170+
private fun findTextAfterScrolling(device: UiDevice, texts: List<String>): UiObject2? {
171+
findVisibleText(device, texts)?.let { return it }
172+
repeat(6) {
173+
device.scrollDown()
174+
findVisibleText(device, texts)?.let { return it }
175+
}
176+
repeat(12) {
177+
device.scrollUp()
178+
findVisibleText(device, texts)?.let { return it }
179+
}
180+
return null
181+
}
182+
183+
private fun findVisibleText(device: UiDevice, texts: List<String>): UiObject2? =
184+
device.findObjects(By.pkg("id.walt.walletdemo.compose"))
185+
.flatMap { it.flatten() }
186+
.firstOrNull { node ->
187+
runCatching { node.text?.trim() in texts }.getOrDefault(false)
188+
}
189+
129190
private fun findVisibleResource(device: UiDevice, tag: String): UiObject2? =
130191
device.findObjects(By.pkg("id.walt.walletdemo.compose"))
131192
.flatMap { it.flatten() }
@@ -135,20 +196,43 @@ internal object WalletComposeE2EHelper {
135196

136197
private fun findResourceAfterScrolling(device: UiDevice, tag: String): UiObject2? {
137198
repeat(6) {
138-
device.swipe(
139-
device.displayWidth / 2,
140-
(device.displayHeight * 0.72).toInt(),
141-
device.displayWidth / 2,
142-
(device.displayHeight * 0.36).toInt(),
143-
24,
144-
)
145-
device.waitForIdle()
199+
device.scrollDown()
200+
waitForResource(device, tag, 1_000L)?.let { return it }
201+
findVisibleResource(device, tag)?.let { return it }
202+
}
203+
repeat(12) {
204+
device.scrollUp()
146205
waitForResource(device, tag, 1_000L)?.let { return it }
147206
findVisibleResource(device, tag)?.let { return it }
148207
}
149208
return null
150209
}
151210

211+
private fun claimTag(path: String): String =
212+
"wallet.claim.${path.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("")}"
213+
214+
private fun UiDevice.scrollDown() {
215+
swipe(
216+
displayWidth / 2,
217+
(displayHeight * 0.72).toInt(),
218+
displayWidth / 2,
219+
(displayHeight * 0.36).toInt(),
220+
24,
221+
)
222+
waitForIdle()
223+
}
224+
225+
private fun UiDevice.scrollUp() {
226+
swipe(
227+
displayWidth / 2,
228+
(displayHeight * 0.36).toInt(),
229+
displayWidth / 2,
230+
(displayHeight * 0.72).toInt(),
231+
24,
232+
)
233+
waitForIdle()
234+
}
235+
152236
fun latestStatus(device: UiDevice): String {
153237
val tagged = device.findObject(By.res("wallet.status"))
154238
if (tagged?.text != null) return tagged.text

waltid-applications/waltid-wallet-demo-compose/androidApp/src/main/java/id/walt/walletdemo/compose/android/MainActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class MainActivity : ComponentActivity() {
2525
attestationAttesterPath = BuildConfig.ATTESTATION_ATTESTER_PATH,
2626
attestationBearerToken = BuildConfig.ATTESTATION_BEARER_TOKEN,
2727
attestationHostHeader = BuildConfig.ATTESTATION_HOST_HEADER,
28+
transactionDataProfilesUrl = BuildConfig.TRANSACTION_DATA_PROFILES_URL,
2829
)
2930
)
3031
)

0 commit comments

Comments
 (0)