diff --git a/android-holder-tutorial-sample-app/app/.gitignore b/android-holder-tutorial-sample-app/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/build.gradle.kts b/android-holder-tutorial-sample-app/app/build.gradle.kts
new file mode 100644
index 0000000..28d7a74
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/build.gradle.kts
@@ -0,0 +1,63 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "com.example.holdertutorial"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.example.holdertutorial"
+ minSdk = 24
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.graphics)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation(libs.androidx.compose.material3)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
+ implementation("global.mattr.mobilecredential:holder:6.1.2")
+ implementation("androidx.navigation:navigation-compose:2.9.0")
+ implementation("com.google.accompanist:accompanist-permissions:0.36.0")
+ implementation("com.journeyapps:zxing-android-embedded:4.3.0")
+}
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/proguard-rules.pro b/android-holder-tutorial-sample-app/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/androidTest/java/com/example/holdertutorial/ExampleInstrumentedTest.kt b/android-holder-tutorial-sample-app/app/src/androidTest/java/com/example/holdertutorial/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..8a9cd7f
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/androidTest/java/com/example/holdertutorial/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.example.holdertutorial
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.example.holdertutorial", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/AndroidManifest.xml b/android-holder-tutorial-sample-app/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..68e03eb
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/AndroidManifest.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/MainActivity.kt b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/MainActivity.kt
new file mode 100644
index 0000000..ac71b80
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/MainActivity.kt
@@ -0,0 +1,228 @@
+package com.example.holdertutorial
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.NavController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navDeepLink
+import com.example.holdertutorial.ui.theme.HolderTutorialTheme
+import global.mattr.mobilecredential.holder.dto.MobileCredential
+import global.mattr.mobilecredential.holder.MobileCredentialHolder
+import global.mattr.mobilecredential.holder.ProximityPresentationSession
+import global.mattr.mobilecredential.holder.issuance.CredentialIssuanceConfiguration
+import global.mattr.mobilecredential.holder.issuance.dto.DiscoveredCredentialOffer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ // Claim Credential - Step 1.2: Initialize the SDK
+ lifecycleScope.launch {
+ try {
+ // This function initializes storage and performs validations to ensure the SDK is ready for use.
+ // Application context is derived from the passed context and persisted.
+ // redirectUri must be registered during initialization and in the manifest file for the credential issuance workflow to be able to redirect the user back to the application.
+ // autoTrustMobileCredentialIaca is an optional parameter that, when set to true, will automatically trust any IACAs received during the credential issuance flow.
+ // This is not recommended for production applications, but can be useful for the quickstart.
+ MobileCredentialHolder.getInstance().initialize(
+ context = this@MainActivity,
+ // Step 4.1: Add credential issuance configuration
+ credentialIssuanceConfiguration = CredentialIssuanceConfiguration(
+ redirectUri = "io.mattrlabs.sample.mobilecredentialtutorialholderapp:" +
+ "//credentials/callback",
+ autoTrustMobileCredentialIaca = true
+ )
+ )
+ } catch (e: Exception) {
+ Log.e("MainActivity", "SDK initialization failed", e)
+ }
+ }
+
+ setContent {
+ HolderTutorialTheme {
+ val navController = rememberNavController()
+ Scaffold { innerPadding ->
+ NavHost(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(8.dp),
+ startDestination = "home",
+ navController = navController,
+ ) {
+ composable("home") {
+ HomeScreen(this@MainActivity, navController)
+ }
+ composable("scanOffer") {
+ // Claim Credential - Step 2.5: Add "Scan Offer" screen call
+ ScanOfferScreen(navController)
+ }
+ composable("retrievedCredential") {
+ // Claim Credential - Step 4.9: Add "Retrieved Credential" screen call
+ RetrievedCredentialsScreen()
+ }
+ composable("presentationQr") {
+ // Proximity Presentation - Step 1.2: Add "QR Presentation" screen call
+ PresentationQrScreen(this@MainActivity, navController)
+ }
+ composable("presentationSelectCredentials") {
+ // Proximity Presentation - Step 2.6: Add "Select Credential" screen call
+ PresentationSelectCredentialsScreen(this@MainActivity)
+ }
+ // Online Presentation - Step 2.2: Add "Online Presentation" screen call
+ composable(
+ "onlinePresentation",
+ deepLinks = listOf(
+ navDeepLink { uriPattern = "mdoc-openid4vp://{wildcard}" }
+ )
+ ) {
+ @Suppress("DEPRECATION")
+ val deepLink = it.arguments
+ ?.getParcelable(NavController.KEY_DEEP_LINK_INTENT)
+ ?.data
+ ?.toString() ?: ""
+
+ OnlinePresentationScreen(this@MainActivity, deepLink)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun HomeScreen(activity: Activity, navController: NavController) {
+ val coroutineScope = rememberCoroutineScope()
+ var transactionCode by remember { mutableStateOf("") }
+
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = { navController.navigate("scanOffer") }, Modifier.fillMaxWidth()) {
+ Text("Claim Credential")
+ }
+
+ // Proximity Presentation - Step 1.3: Add button for starting the credentials presentation workflow
+ Button(onClick = { navController.navigate("presentationQr") }, Modifier.fillMaxWidth()) {
+ Text("Present Credentials")
+ }
+
+ // Claim Credential - Step 3.3: Display discovered credential offer
+ SharedData.discoveredCredentialOffer?.let { discoveredOffer ->
+ Text("Received Credential Offer from ${discoveredOffer.issuer}")
+ LazyColumn(Modifier.fillMaxWidth()) {
+ items(discoveredOffer.credentials, key = { it.doctype }) { credential ->
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(4.dp)) {
+ Text("Name: ${credential.name ?: ""}")
+ Text("DocType: ${credential.doctype}")
+ }
+ }
+ }
+ }
+
+ // Claim Credential - Step 4.3: Add transaction code input
+ if (discoveredOffer.transactionCode != null) {
+ OutlinedTextField(
+ value = transactionCode,
+ onValueChange = { transactionCode = it },
+ label = { Text("Transaction Code") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ // Claim Credential - Step 4.5: Add consent button
+ Spacer(Modifier.weight(1f))
+ Button(
+ onClick = {
+ onRetrieveCredentials(coroutineScope, activity, navController, transactionCode)
+ },
+ Modifier.fillMaxWidth()
+ ) { Text("Consent and retrieve Credential(s)") }
+ }
+ }
+}
+
+// Claim Credential - Step 4.4: Create function to retrieve credentials
+private fun onRetrieveCredentials(
+ coroutineScope: CoroutineScope,
+ activity: Activity,
+ navController: NavController,
+ transactionCode: String
+) {
+ coroutineScope.launch {
+ try {
+ val mdocHolder = MobileCredentialHolder.getInstance()
+ // Each of the credential retrieval results contains:
+ // - Credential document type.
+ // - Either the credential ID, that can be now used to access the credential from the local storage (see code below), OR
+ // an error if the credential retrieval failed.
+ val retrieveCredentialResults = mdocHolder.retrieveCredentials(
+ activity,
+ SharedData.scannedOffer!!,
+ clientId = "android-mobile-credential-tutorial-holder-app",
+ transactionCode = transactionCode
+ )
+
+ // Claim Credential - Step 4.6: Display retrieved credentials
+ SharedData.retrievedCredentials = retrieveCredentialResults.mapNotNull {
+ try {
+ // The credential ID can be used to get the full credential from the SDK storage.
+ // fetchUpdatedStatusList - Whether to enforce the online revocation status check for the credential.
+ // Returned object contains all credential data, including the user's PII.
+ mdocHolder.getCredential(it.credentialId!!, fetchUpdatedStatusList = false)
+ } catch (e: Exception) {
+ val msg = "Failed to get credential from storage"
+ Toast.makeText(activity, msg, Toast.LENGTH_SHORT).show()
+ null
+ }
+ }
+
+ navController.navigate("retrievedCredential")
+ SharedData.discoveredCredentialOffer = null
+ } catch (e: Exception) {
+ Toast.makeText(activity, "Failed to retrieve credentials", Toast.LENGTH_SHORT).show()
+ }
+ }
+}
+
+object SharedData {
+ // Claim Credential - Step 3.1: Add discovered credential offer variables
+ var scannedOffer: String? = null
+ var discoveredCredentialOffer: DiscoveredCredentialOffer? = null
+ // Claim Credential - Step 4.2: Add retrieved credentials variable
+ var retrievedCredentials: List = emptyList()
+ // Proximity Presentation - Step 2.1: Add proximity presentation request variable
+ var proximityPresentationRequest: ProximityPresentationSession.CredentialRequestInfo? = null
+}
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/OnlinePresentationScreen.kt b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/OnlinePresentationScreen.kt
new file mode 100644
index 0000000..2769a48
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/OnlinePresentationScreen.kt
@@ -0,0 +1,139 @@
+package com.example.holdertutorial
+
+import android.app.Activity
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.example.holdertutorial.Document
+import com.example.holdertutorial.toUiString
+import global.mattr.mobilecredential.holder.deviceretrieval.devicerequest.DataElements
+import global.mattr.mobilecredential.holder.deviceretrieval.deviceresponse.NameSpace
+import global.mattr.mobilecredential.holder.dto.MobileCredential
+import global.mattr.mobilecredential.holder.dto.MobileCredentialMetaData
+import global.mattr.mobilecredential.holder.MobileCredentialHolder
+import global.mattr.mobilecredential.holder.onlinepresentation.OnlinePresentationSession
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+@Composable
+fun OnlinePresentationScreen(activity: Activity, requestUri: String) {
+ var session: OnlinePresentationSession? by remember { mutableStateOf(null) }
+
+ // Step 2.1: Create an online presentation session
+ LaunchedEffect(requestUri) {
+ withContext(Dispatchers.IO) {
+ // A simple quickstart code to wait until the SDK is initialized.
+ // Useful for cases when the application is started from the online presentation Intent.
+ val mdocHolder = MobileCredentialHolder.getInstance()
+ while (!mdocHolder.initialized) delay(100)
+
+ // Start an online presentation session.
+ // requireTrustedVerifier = false - Trust any verifier without validating against the trusted verifier list.
+ // Not recommended for production, but useful for quickstart.
+ session = mdocHolder
+ .createOnlinePresentationSession(requestUri, requireTrustedVerifier = false)
+ }
+ }
+
+ // session.matchedCredentials - Map that pairs credential requests to lists of the stored credentials that match those requests.
+ // Credentials in the list will only contain the requested claim names (e.g. "given_name", "family_name") without values.
+ // Values can be retrieved from storage by calling getCredential() with the credential id.
+ val (requested, matched) = session?.matchedCredentials?.entries?.firstOrNull() ?: return
+
+ var matchedCredentials by remember { mutableStateOf(matched) }
+ var selectedCredentialId by remember { mutableStateOf(matchedCredentials.first().id) }
+ val coroutineScope = rememberCoroutineScope()
+
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ Text("REQUESTED DATA", style = MaterialTheme.typography.titleLarge)
+ Card(Modifier.padding(vertical = 8.dp)) {
+ Document(requested.docType, requested.namespaces.value.toUi())
+ }
+ Spacer(Modifier.padding(12.dp))
+
+ Text("MATCHED CREDENTIALS", style = MaterialTheme.typography.titleLarge)
+ Spacer(Modifier.padding(6.dp))
+ matchedCredentials.forEach { matchedCredential ->
+ // Step 3.2: Display matching credentials and claims
+ val borderWidth = if (matchedCredential.id == selectedCredentialId) 4.dp else 0.dp
+ Column(
+ Modifier
+ .clickable { selectedCredentialId = matchedCredential.id }
+ .border(borderWidth, Color.Blue, RoundedCornerShape(16.dp))
+ .padding(8.dp)
+ ) {
+ Card(Modifier.fillMaxWidth()) {
+ Document(matchedCredential.docType, matchedCredential.claims)
+ }
+
+ Button(
+ onClick = {
+ // Get the credential with PII from the local storage.
+ val credentialWithValues = MobileCredentialHolder.getInstance()
+ .getCredential(matchedCredential.id, fetchUpdatedStatusList = false)
+
+ // Use PII values (e.g. "John", "Smith") to update the matched credentials list for display.
+ matchedCredentials =
+ matchedCredentials.withClaimValues(from = credentialWithValues)
+ },
+ Modifier.fillMaxWidth()
+ ) { Text("Show Values") }
+ }
+ Spacer(Modifier.padding(12.dp))
+ }
+
+ // Step 4.1: Send response
+ Button(
+ onClick = {
+ coroutineScope.launch {
+ session?.sendResponse(listOf(selectedCredentialId), activity)
+ }
+ },
+ Modifier.fillMaxWidth()
+ ) { Text("Send Response") }
+ }
+}
+
+// Step 3.1: Create function to add values to claims
+private fun List.withClaimValues(
+ from: MobileCredential
+): List = map { credential ->
+ if (credential.id != from.id) {
+ credential
+ } else {
+ credential.copy(
+ claims = credential.claims.mapValues { (namespace, claims) ->
+ claims.map { claim ->
+ val claimValue = from.claims[namespace]?.get(claim)
+ claimValue?.let { "$claim: ${it.toUiString()}" } ?: claim
+ }.toSet()
+ }
+ )
+ }
+}
+
+private fun Map.toUi() = mapValues { (_, dataElements) ->
+ dataElements.value.keys.toSet()
+}
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/PresentationQrScreen.kt b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/PresentationQrScreen.kt
new file mode 100644
index 0000000..2e7b2a2
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/PresentationQrScreen.kt
@@ -0,0 +1,97 @@
+package com.example.holdertutorial
+
+import android.app.Activity
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.widget.Toast
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.unit.IntSize
+import androidx.navigation.NavController
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.EncodeHintType
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
+import com.journeyapps.barcodescanner.BarcodeEncoder
+import global.mattr.mobilecredential.holder.MobileCredentialHolder
+import global.mattr.mobilecredential.holder.ProximityPresentationSession
+import global.mattr.mobilecredential.holder.datatransport.bluetooth.BleMode
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+@Composable
+fun PresentationQrScreen(activity: Activity, navController: NavController) {
+ var containerSize by remember { mutableStateOf(IntSize.Zero) }
+ var session: ProximityPresentationSession? by remember { mutableStateOf(null) }
+ var qrCode: Bitmap? by remember { mutableStateOf(null) }
+
+ LaunchedEffect(Unit) {
+ // Step 1.4: Create a Proximity presentation session
+ session = MobileCredentialHolder.getInstance().createProximityPresentationSession(
+ activity, // Not persisted. Used to request Bluetooth permissions if necessary.
+ bleMode = BleMode.MDocPeripheralServer,
+ onRequestReceived = { _, requests, e ->
+ // Step 2.2: Handle the presentation request
+ if (e == null && !requests.isNullOrEmpty()) {
+ // Using only the first request for simplicity
+ SharedData.proximityPresentationRequest = requests.first()
+ withContext(Dispatchers.Main) {
+ navController.navigate("presentationSelectCredentials")
+ }
+ } else {
+ val msg = "Error while retrieving the request"
+ withContext(Dispatchers.Main) {
+ Toast.makeText(activity, msg, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ )
+ }
+
+ LaunchedEffect(session, containerSize) {
+ // Step 1.6: Generate a QR code
+ qrCode = session?.deviceEngagement?.toQrCode(containerSize)
+ }
+
+ Box(
+ modifier = Modifier
+ .aspectRatio(1f)
+ .onSizeChanged { containerSize = it }
+ ) {
+ qrCode?.let {
+ Image(
+ bitmap = it.asImageBitmap(),
+ contentDescription = "A QR Code",
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+}
+
+// Step 1.5: Create function to generate QR Code from String
+private fun String.toQrCode(size: IntSize): Bitmap? {
+ if (this.isEmpty() || size == IntSize.Zero) return null
+
+ val (width, height) = size
+ return Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565).apply {
+ val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M)
+ val encoded = BarcodeEncoder()
+ .encode(this@toQrCode, BarcodeFormat.QR_CODE, width, height, hints)
+
+ for (x in 0 until width) {
+ for (y in 0 until height) {
+ setPixel(x, y, if (encoded[x, y]) Color.BLACK else Color.WHITE)
+ }
+ }
+ }
+}
diff --git a/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/PresentationSelectCredentialsScreen.kt b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/PresentationSelectCredentialsScreen.kt
new file mode 100644
index 0000000..a73e63e
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/PresentationSelectCredentialsScreen.kt
@@ -0,0 +1,119 @@
+package com.example.holdertutorial
+
+import android.app.Activity
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import global.mattr.mobilecredential.holder.deviceretrieval.devicerequest.DataElements
+import global.mattr.mobilecredential.holder.deviceretrieval.deviceresponse.NameSpace
+import global.mattr.mobilecredential.holder.dto.MobileCredential
+import global.mattr.mobilecredential.holder.dto.MobileCredentialMetaData
+import global.mattr.mobilecredential.holder.MobileCredentialHolder
+import kotlinx.coroutines.launch
+
+@Composable
+fun PresentationSelectCredentialsScreen(activity: Activity) {
+ val request = SharedData.proximityPresentationRequest ?: return
+
+ // Lists of the stored credentials that match the Verifier's request.
+ // Credentials in the list will only contain the requested claim names (e.g. "given_name", "family_name") without values.
+ // Values can be retrieved from storage by calling getCredential() with the credential id.
+ var matchedCredentials by remember { mutableStateOf(request.matchedCredentials) }
+ var selectedCredentialId by remember { mutableStateOf(matchedCredentials.first().id) }
+ val coroutineScope = rememberCoroutineScope()
+
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ Text("REQUESTED DATA", style = MaterialTheme.typography.titleLarge)
+ Card(Modifier.padding(vertical = 8.dp)) {
+ Document(request.request.docType, request.request.namespaces.value.toUi())
+ }
+ Spacer(Modifier.padding(12.dp))
+
+ Text("MATCHED CREDENTIALS", style = MaterialTheme.typography.titleLarge)
+ Spacer(Modifier.padding(6.dp))
+ matchedCredentials.forEach { matchedCredential ->
+ // Step 2.5: Display matching credentials and claims
+ val borderWidth = if (matchedCredential.id == selectedCredentialId) 4.dp else 0.dp
+ Column(
+ Modifier
+ .clickable { selectedCredentialId = matchedCredential.id }
+ .border(borderWidth, Color.Blue, RoundedCornerShape(16.dp))
+ .padding(8.dp)
+ ) {
+ Card(Modifier.fillMaxWidth()) {
+ Document(matchedCredential.docType, matchedCredential.claims)
+ }
+
+ Button(
+ onClick = {
+ // Get the credential with PII from the local storage.
+ val credentialWithValues = MobileCredentialHolder.getInstance()
+ .getCredential(matchedCredential.id, fetchUpdatedStatusList = false)
+
+ // Use PII values (e.g. "John", "Smith") to update the matched credentials list for display.
+ matchedCredentials =
+ matchedCredentials.withClaimValues(from = credentialWithValues)
+ },
+ Modifier.fillMaxWidth()
+ ) { Text("Show Values") }
+ }
+ Spacer(Modifier.padding(12.dp))
+ }
+
+ // Step 3.2: Send response
+ Button(
+ onClick = {
+ coroutineScope.launch { sendResponse(selectedCredentialId, activity) }
+ },
+ Modifier.fillMaxWidth()
+ ) { Text("Send Response") }
+ }
+}
+
+// Step 2.4: Create function to add values to claims
+private fun List.withClaimValues(
+ from: MobileCredential
+): List = map { credential ->
+ if (credential.id != from.id) {
+ credential
+ } else {
+ credential.copy(
+ claims = credential.claims.mapValues { (namespace, claims) ->
+ claims.map { claim ->
+ val claimValue = from.claims[namespace]?.get(claim)
+ claimValue?.let { "$claim: ${it.toUiString()}" } ?: claim
+ }.toSet()
+ }
+ )
+ }
+}
+
+private fun Map.toUi() = mapValues { (_, dataElements) ->
+ dataElements.value.keys.toSet()
+}
+
+// Step 3.1: Create function to send the credential response
+private suspend fun sendResponse(credentialId: String, activity: Activity) {
+ MobileCredentialHolder.getInstance()
+ .getCurrentProximityPresentationSession()
+ ?.sendResponse(listOf(credentialId), activity)
+}
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/RetrievedCredentialsScreen.kt b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/RetrievedCredentialsScreen.kt
new file mode 100644
index 0000000..2159b8b
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/RetrievedCredentialsScreen.kt
@@ -0,0 +1,68 @@
+package com.example.holdertutorial
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import global.mattr.mobilecredential.holder.deviceretrieval.deviceresponse.NameSpace
+import global.mattr.mobilecredential.holder.dto.MobileCredentialElement
+
+@Composable
+fun RetrievedCredentialsScreen() {
+ if (SharedData.retrievedCredentials.isEmpty()) {
+ Text("No credentials received")
+ } else {
+ Column {
+ Text(
+ "Retrieved Credentials",
+ modifier = Modifier.fillMaxWidth(),
+ style = MaterialTheme.typography.titleLarge
+ )
+
+ LazyColumn(Modifier.fillMaxWidth()) {
+ items(SharedData.retrievedCredentials, key = { it.id }) { credential ->
+ Document(
+ credential.docType,
+ credential.claims.mapValues { (_, claims) ->
+ claims.map { (name, value) -> "$name: ${value.toUiString()}" }.toSet()
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun Document(docType: String, namespacesAndClaims: Map>) {
+ Column(Modifier.fillMaxWidth().padding(6.dp)) {
+ Text(docType, Modifier.padding(6.dp), style = MaterialTheme.typography.titleMedium)
+ namespacesAndClaims.forEach { (namespace, claims) ->
+ Text(namespace, Modifier.padding(6.dp), style = MaterialTheme.typography.titleSmall)
+ Column(
+ Modifier
+ .padding(6.dp)
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.background, RoundedCornerShape(6.dp))
+ .padding(6.dp)
+ ) {
+ claims.forEach { claim -> Text(claim) }
+ }
+ }
+ }
+}
+
+fun MobileCredentialElement.toUiString() = when (this) {
+ is MobileCredentialElement.ArrayElement, is MobileCredentialElement.DataElement,
+ is MobileCredentialElement.MapElement -> this::class.simpleName ?: "Unknown element"
+
+ else -> value.toString()
+}
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/ScanOfferScreen.kt b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/ScanOfferScreen.kt
new file mode 100644
index 0000000..1d9f4cf
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/ScanOfferScreen.kt
@@ -0,0 +1,98 @@
+package com.example.holdertutorial
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.navigation.NavController
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.journeyapps.barcodescanner.BarcodeCallback
+import com.journeyapps.barcodescanner.DecoratedBarcodeView
+import global.mattr.mobilecredential.holder.MobileCredentialHolder
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+// Gets the permissions and shows the screen content, when the permissions are obtained
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun ScanOfferScreen(navController: NavController) {
+ val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
+
+ val requestPermissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
+
+ LaunchedEffect(cameraPermissionState) {
+ if (!cameraPermissionState.status.isGranted) {
+ requestPermissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+
+ if (cameraPermissionState.status.isGranted) Content(navController)
+}
+
+// Screen content
+@Composable
+private fun Content(navController: NavController) {
+ val context = LocalContext.current
+ val barcodeView = remember { DecoratedBarcodeView(context) }
+ val coroutineScope = rememberCoroutineScope()
+ var isQrScanned by remember { mutableStateOf(false) }
+
+ val barcodeCallback = remember {
+ BarcodeCallback { result ->
+ // Executed when the QR code was scanned
+ coroutineScope.launch { onQrScanned(context, result.text, navController) }
+ barcodeView.pause()
+ isQrScanned = true
+ }
+ }
+
+ // Setting up the QR scanner
+ DisposableEffect(Unit) {
+ barcodeView.decodeContinuous(barcodeCallback)
+ barcodeView.resume()
+ onDispose { barcodeView.pause() }
+ }
+
+ // Showing the scanner until the QR is scanned. Showing a progress bar after that
+ if (!isQrScanned) {
+ AndroidView(factory = { barcodeView }, modifier = Modifier.fillMaxSize())
+ } else {
+ Box(Modifier.fillMaxSize()) {
+ CircularProgressIndicator(Modifier.align(Alignment.Center))
+ }
+ }
+}
+
+private suspend fun onQrScanned(context: Context, offer: String, navController: NavController) {
+ // Step 3.2: Discover credential offer
+ try {
+ SharedData.scannedOffer = offer
+ SharedData.discoveredCredentialOffer =
+ MobileCredentialHolder.getInstance().discoverCredentialOffer(offer)
+ } catch (e: Exception) {
+ Toast.makeText(context, "Failed to discover offer", Toast.LENGTH_SHORT).show()
+ }
+
+ navController.navigateUp()
+}
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/ui/theme/Color.kt b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/ui/theme/Color.kt
new file mode 100644
index 0000000..dd8e689
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.example.holdertutorial.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/ui/theme/Theme.kt b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/ui/theme/Theme.kt
new file mode 100644
index 0000000..9c8ec92
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/ui/theme/Theme.kt
@@ -0,0 +1,58 @@
+package com.example.holdertutorial.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun HolderTutorialTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/ui/theme/Type.kt b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/ui/theme/Type.kt
new file mode 100644
index 0000000..2c6cac6
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/java/com/example/holdertutorial/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package com.example.holdertutorial.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/drawable/ic_launcher_background.xml b/android-holder-tutorial-sample-app/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/drawable/ic_launcher_foreground.xml b/android-holder-tutorial-sample-app/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/android-holder-tutorial-sample-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/values/colors.xml b/android-holder-tutorial-sample-app/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f8c6127
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/values/strings.xml b/android-holder-tutorial-sample-app/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..886111d
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Holder Tutorial
+
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/values/themes.xml b/android-holder-tutorial-sample-app/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..1470155
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/xml/backup_rules.xml b/android-holder-tutorial-sample-app/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..4df9255
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/main/res/xml/data_extraction_rules.xml b/android-holder-tutorial-sample-app/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/app/src/test/java/com/example/holdertutorial/ExampleUnitTest.kt b/android-holder-tutorial-sample-app/app/src/test/java/com/example/holdertutorial/ExampleUnitTest.kt
new file mode 100644
index 0000000..8f8a37a
--- /dev/null
+++ b/android-holder-tutorial-sample-app/app/src/test/java/com/example/holdertutorial/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.example.holdertutorial
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/build.gradle.kts b/android-holder-tutorial-sample-app/build.gradle.kts
new file mode 100644
index 0000000..952b930
--- /dev/null
+++ b/android-holder-tutorial-sample-app/build.gradle.kts
@@ -0,0 +1,6 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+}
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/gradle.properties b/android-holder-tutorial-sample-app/gradle.properties
new file mode 100644
index 0000000..20e2a01
--- /dev/null
+++ b/android-holder-tutorial-sample-app/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/android-holder-tutorial-sample-app/gradle/libs.versions.toml b/android-holder-tutorial-sample-app/gradle/libs.versions.toml
new file mode 100644
index 0000000..85f878f
--- /dev/null
+++ b/android-holder-tutorial-sample-app/gradle/libs.versions.toml
@@ -0,0 +1,32 @@
+[versions]
+agp = "8.13.2"
+kotlin = "2.0.21"
+coreKtx = "1.18.0"
+junit = "4.13.2"
+junitVersion = "1.3.0"
+espressoCore = "3.7.0"
+lifecycleRuntimeKtx = "2.10.0"
+activityCompose = "1.13.0"
+composeBom = "2024.09.00"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+
diff --git a/android-holder-tutorial-sample-app/gradle/wrapper/gradle-wrapper.jar b/android-holder-tutorial-sample-app/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/android-holder-tutorial-sample-app/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android-holder-tutorial-sample-app/gradle/wrapper/gradle-wrapper.properties b/android-holder-tutorial-sample-app/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..3065bd1
--- /dev/null
+++ b/android-holder-tutorial-sample-app/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Thu Apr 23 11:35:30 NZST 2026
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android-holder-tutorial-sample-app/gradlew b/android-holder-tutorial-sample-app/gradlew
new file mode 100755
index 0000000..4f906e0
--- /dev/null
+++ b/android-holder-tutorial-sample-app/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/android-holder-tutorial-sample-app/gradlew.bat b/android-holder-tutorial-sample-app/gradlew.bat
new file mode 100644
index 0000000..ac1b06f
--- /dev/null
+++ b/android-holder-tutorial-sample-app/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android-holder-tutorial-sample-app/local.properties b/android-holder-tutorial-sample-app/local.properties
new file mode 100644
index 0000000..23eeb91
--- /dev/null
+++ b/android-holder-tutorial-sample-app/local.properties
@@ -0,0 +1,8 @@
+## This file must *NOT* be checked into Version Control Systems,
+# as it contains information specific to your local configuration.
+#
+# Location of the SDK. This is only used by Gradle.
+# For customization when using a Version Control System, please read the
+# header note.
+#Fri Apr 24 11:26:59 NZST 2026
+sdk.dir=/Users/pavel.vorotylenko/Library/Android/sdk
diff --git a/android-holder-tutorial-sample-app/settings.gradle.kts b/android-holder-tutorial-sample-app/settings.gradle.kts
new file mode 100644
index 0000000..cc1e5b4
--- /dev/null
+++ b/android-holder-tutorial-sample-app/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven {
+ url = uri("repo")
+ }
+ }
+}
+
+rootProject.name = "Holder Tutorial"
+include(":app")
diff --git a/android-in-person-verifier-tutorial-sample-app/app/.gitignore b/android-in-person-verifier-tutorial-sample-app/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/build.gradle.kts b/android-in-person-verifier-tutorial-sample-app/app/build.gradle.kts
new file mode 100644
index 0000000..4cd523e
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/build.gradle.kts
@@ -0,0 +1,63 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "com.example.verifiertutorial"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.example.verifiertutorial"
+ minSdk = 24
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.graphics)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation(libs.androidx.compose.material3)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
+ implementation("global.mattr.mobilecredential:verifier:6.1.0")
+ implementation("androidx.navigation:navigation-compose:2.9.0")
+ implementation("com.google.accompanist:accompanist-permissions:0.36.0")
+ implementation("com.journeyapps:zxing-android-embedded:4.3.0")
+}
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/proguard-rules.pro b/android-in-person-verifier-tutorial-sample-app/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/androidTest/java/com/example/verifiertutorial/ExampleInstrumentedTest.kt b/android-in-person-verifier-tutorial-sample-app/app/src/androidTest/java/com/example/verifiertutorial/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..196f24f
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/androidTest/java/com/example/verifiertutorial/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.example.verifiertutorial
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.example.verifiertutorial", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/AndroidManifest.xml b/android-in-person-verifier-tutorial-sample-app/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..7a204cd
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/AndroidManifest.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/Iacas.kt b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/Iacas.kt
new file mode 100644
index 0000000..4cc0ec6
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/Iacas.kt
@@ -0,0 +1,22 @@
+package com.example.verifiertutorial
+
+object Iacas {
+
+ val mattrLabs = """
+ -----BEGIN CERTIFICATE-----
+ MIICYzCCAgmgAwIBAgIKXhjLoCkLWBxREDAKBggqhkjOPQQDAjA4MQswCQYDVQQG
+ EwJBVTEpMCcGA1UEAwwgbW9udGNsaWZmLWRtdi5tYXR0cmxhYnMuY29tIElBQ0Ew
+ HhcNMjQwMTE4MjMxNDE4WhcNMzQwMTE1MjMxNDE4WjA4MQswCQYDVQQGEwJBVTEp
+ MCcGA1UEAwwgbW9udGNsaWZmLWRtdi5tYXR0cmxhYnMuY29tIElBQ0EwWTATBgcq
+ hkjOPQIBBggqhkjOPQMBBwNCAASBnqobOh8baMW7mpSZaQMawj6wgM5e5nPd6HXp
+ dB8eUVPlCMKribQ7XiiLU96rib/yQLH2k1CUeZmEjxoEi42xo4H6MIH3MBIGA1Ud
+ EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRFZwEOI9yq
+ 232NG+OzNQzFKa/LxDAuBgNVHRIEJzAlhiNodHRwczovL21vbnRjbGlmZi1kbXYu
+ bWF0dHJsYWJzLmNvbTCBgQYDVR0fBHoweDB2oHSgcoZwaHR0cHM6Ly9tb250Y2xp
+ ZmYtZG12LnZpaS5hdTAxLm1hdHRyLmdsb2JhbC92Mi9jcmVkZW50aWFscy9tb2Jp
+ bGUvaWFjYXMvMjk0YmExYmMtOTFhMS00MjJmLThhMTctY2IwODU0NWY0ODYwL2Ny
+ bDAKBggqhkjOPQQDAgNIADBFAiAlZYQP95lGzVJfCykhcpCzpQ2LWE/AbjTGkcGI
+ SNsu7gIhAJfP54a2hXz4YiQN4qJERlORjyL1Ru9M0/dtQppohFm6
+ -----END CERTIFICATE-----
+ """.trimIndent()
+}
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/MainActivity.kt b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/MainActivity.kt
new file mode 100644
index 0000000..079c102
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/MainActivity.kt
@@ -0,0 +1,93 @@
+package com.example.verifiertutorial
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.NavController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.example.verifiertutorial.ui.theme.VerifierTutorialTheme
+import global.mattr.mobilecredential.verifier.dto.MobileCredentialResponse
+import global.mattr.mobilecredential.verifier.MobileCredentialVerifier
+import kotlinx.coroutines.launch
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // The SDK must be initialized before any other SDK functions are called.
+ // Initialize SDK - Step 1.1: Initialize the SDK
+ lifecycleScope.launch {
+ // This function initializes storage and performs validations to ensure the SDK is ready for use.
+ // Only the application context is persisted.
+ MobileCredentialVerifier.initialize(this@MainActivity)
+ // Setup certificates - Step 2.1: Add trusted issuer certificates
+ // The added certificates will be the trust chain roots used to verify signatures of the received credentials.
+ MobileCredentialVerifier.addTrustedIssuerCertificates(listOf(Iacas.mattrLabs))
+ }
+
+ enableEdgeToEdge()
+ setContent {
+ VerifierTutorialTheme {
+ val navController = rememberNavController()
+
+ NavHost(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(vertical = 72.dp, horizontal = 8.dp),
+ startDestination = "home",
+ navController = navController,
+ ) {
+ // Landing screen with navigation buttons
+ composable("home") {
+ HomeScreen(navController)
+ }
+
+ // Verify mDocs - Step 1.7: Add "Scan QR" screen call
+ // Scans a holder's QR code and initiates the proximity presentation workflow (Tutorial: "Verify mDocs")
+ composable("scanQr") {
+ ScanQrScreen(this@MainActivity, navController)
+ }
+
+ // Verify mDocs - Step 4.4: Add "View Response" screen call
+ // Displays the verification results after a credential is received (Tutorial: "Verify mDocs" Step 4)
+ composable("viewResponse") {
+ ViewResponseScreen()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun HomeScreen(navController: NavController) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = { navController.navigate("scanQr") }
+ ) {
+ Text("Scan QR Code")
+ }
+ }
+}
+
+// Verify mDocs - Step 2.2: Add shared data
+/** Holds state that needs to be shared across multiple screens. */
+object SharedData {
+
+ /** Stores the response received after the proximity presentation workflow completes. */
+ var credentialResponse: MobileCredentialResponse? = null
+}
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ScanQrScreen.kt b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ScanQrScreen.kt
new file mode 100644
index 0000000..6616d85
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ScanQrScreen.kt
@@ -0,0 +1,204 @@
+package com.example.verifiertutorial
+
+import android.app.Activity
+import android.Manifest
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.navigation.NavController
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.journeyapps.barcodescanner.BarcodeCallback
+import com.journeyapps.barcodescanner.DecoratedBarcodeView
+import global.mattr.mobilecredential.verifier.deviceretrieval.devicerequest.DataElements
+import global.mattr.mobilecredential.verifier.deviceretrieval.devicerequest.NameSpaces
+import global.mattr.mobilecredential.verifier.dto.MobileCredentialRequest
+import global.mattr.mobilecredential.verifier.dto.MobileCredentialResponse
+import global.mattr.mobilecredential.verifier.MobileCredentialVerifier
+import global.mattr.mobilecredential.verifier.ProximityPresentationSessionListener
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+/**
+ * ScanOfferScreen implements the "Verify mDocs" section of the tutorial.
+ */
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun ScanQrScreen(activity: Activity, navController: NavController) {
+ // Verify mDocs - Step 1.6: Add permission request logic
+ val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
+
+ val requestPermissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
+
+ LaunchedEffect(cameraPermissionState) {
+ if (!cameraPermissionState.status.isGranted) {
+ requestPermissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+
+ // Show the camera scanning content once permission is granted.
+ if (cameraPermissionState.status.isGranted) Content(activity, navController)
+}
+
+// Verify mDocs - Step 1.5: Add screen content
+/**
+ * Renders the QR code scanner UI.
+ *
+ * It displays a live camera preview using the ZXing barcode scanning library.
+ * When a QR code is detected, the view switches to a loading spinner while the proximity presentation session is being established with the holder's wallet.
+ */
+@Composable
+private fun Content(activity: Activity, navController: NavController) {
+ val context = LocalContext.current
+ val barcodeView = remember { DecoratedBarcodeView(context) }
+ val coroutineScope = rememberCoroutineScope()
+ var isQrScanned by remember { mutableStateOf(false) }
+
+ // Callback invoked when the ZXing library successfully decodes a barcode.
+ // It triggers the proximity presentation workflow and pauses scanning.
+ val barcodeCallback = remember {
+ BarcodeCallback { result ->
+ onQrScanned(activity, result.text, coroutineScope, navController)
+ barcodeView.pause()
+ isQrScanned = true
+ }
+ }
+
+ // Start continuous scanning when the composable enters composition, and pause the camera when it leaves (e.g. navigation away).
+ DisposableEffect(Unit) {
+ barcodeView.decodeContinuous(barcodeCallback)
+ barcodeView.resume()
+ onDispose { barcodeView.pause() }
+ }
+
+ if (!isQrScanned) {
+ // Show the live camera feed for QR code scanning.
+ AndroidView(factory = { barcodeView }, modifier = Modifier.fillMaxSize())
+ } else {
+ // After the QR is captured, show a progress spinner while the SDK establishes the BLE session with the holder's wallet and exchanges credentials.
+ Box(Modifier.fillMaxSize()) {
+ CircularProgressIndicator(Modifier.align(Alignment.Center))
+ }
+ }
+}
+
+// Verify mDocs - Step 1.4: Add QR scan callback
+/**
+ * The QR code encodes a device engagement string (as defined in ISO 18013-5) which the SDK uses to establish a BLE-based proximity presentation session with the holder's device.
+ */
+private fun onQrScanned(
+ activity: Activity,
+ deviceEngagement: String,
+ coroutineScope: CoroutineScope,
+ navController: NavController
+) {
+ coroutineScope.launch {
+ // Verify mDocs - Step 3.2: Create session
+ SharedData.credentialResponse = try {
+ suspendCancellableCoroutine { continuation: Continuation ->
+ val sessionListener = SessionListener(coroutineScope, continuation)
+
+ // createProximityPresentationSession takes:
+ // - activity: needed to grant Bluetooth permissions.
+ // - deviceEngagement: the string decoded from the holder's QR code.
+ // - sessionListener: receives onEstablished/onError/onTerminated callbacks.
+ MobileCredentialVerifier
+ .createProximityPresentationSession(activity, deviceEngagement, sessionListener)
+ }
+ } catch (_: Exception) {
+ Toast.makeText(activity, "Failed to request credentials", Toast.LENGTH_SHORT).show()
+ null
+ }
+
+ // Verify mDocs - Step 4.1: Handle response
+ // If the credential response was received successfully, navigate to the results screen.
+ SharedData.credentialResponse?.let {
+ navController.navigate("viewResponse") { popUpTo("home") }
+ }
+ }
+}
+
+// Verify mDocs - Step 3.1: Create session listener
+/**
+ * SessionListener implements [ProximityPresentationSessionListener] to react to the lifecycle events of the BLE proximity presentation session.
+ */
+private class SessionListener(
+ private val coroutineScope: CoroutineScope,
+ private val continuation: Continuation
+) : ProximityPresentationSessionListener {
+
+ // onEstablished is called when the BLE session is successfully established with the holder's device.
+ override fun onEstablished() {
+ coroutineScope.launch {
+ // Verify mDocs - Step 3.3: Request credentials
+ // Once the BLE session is established, send the presentation request to the holder's wallet.
+ // The wallet will prompt the holder for consent, then return matching credentials.
+ try {
+ val response = MobileCredentialVerifier.sendProximityPresentationRequest(
+ listOf(sampleMdocRequest)
+ )
+ // We can either send another credential request, or we should terminate the session to free up the resources.
+ MobileCredentialVerifier.terminateProximityPresentationSession()
+ continuation.resume(response)
+ } catch (e: Exception) {
+ continuation.resumeWithException(e)
+ }
+ }
+ }
+
+ override fun onTerminated(error: Throwable?) {
+ /* no-op */
+ }
+
+ // onError is called if there are critical errors during the session establishment (e.g. BLE interrupted or disabled).
+ override fun onError(error: Throwable?) {
+ error?.let { continuation.resumeWithException(it) }
+ }
+}
+
+// Verify mDocs - Step 2.1: Create a sample request
+/**
+ * The presentation request sent to the holder's wallet.
+ *
+ * For the verification to succeed, the holder's credential must contain these claims under the exact namespace specified.
+ * Claims under a different namespace (e.g. "org.iso.18013.5.1.US") will not match.
+ */
+private val sampleMdocRequest = MobileCredentialRequest(
+ // A mobile driver's license (mDL) document type defined in ISO 18013-5.
+ docType = "org.iso.18013.5.1.mDL",
+ namespaces = NameSpaces(
+ mapOf(
+ // "org.iso.18013.5.1" - The ISO standard namespace for mDL claims.
+ "org.iso.18013.5.1" to DataElements(
+ // "given_name", "family_name", "birth_date" — the specific claims requested.
+ // The intent to retain flags are false for each of the claims,
+ // indicating the verifier does NOT intend to store the claim values beyond the immediate verification.
+ //
+ // For the verification to succeed, the holder's credential must contain these claims under the exact namespace specified.
+ // Claims under a different namespace (e.g. "org.iso.18013.5.1.US") will not match.
+ listOf("given_name", "family_name", "birth_date").associateWith { false }
+ )
+ )
+ )
+)
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ViewResponseScreen.kt b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ViewResponseScreen.kt
new file mode 100644
index 0000000..8e15c75
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ViewResponseScreen.kt
@@ -0,0 +1,121 @@
+package com.example.verifiertutorial
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.example.verifiertutorial.SharedData
+import global.mattr.mobilecredential.verifier.deviceretrieval.deviceresponse.DataElementIdentifier
+import global.mattr.mobilecredential.verifier.deviceretrieval.deviceresponse.NameSpace
+import global.mattr.mobilecredential.verifier.dto.MobileCredentialElement
+
+/**
+ * ViewResponseScreen implements the "Verify mDocs — Step 4: Display verification results" section of the tutorial.
+ */
+@Composable
+fun ViewResponseScreen() {
+ // Verify mDocs - Step 4.5: Define content
+ // For tutorial simplicity, only the first credential in the response is displayed, since the sample request only asks for a single document type (mDL).
+ val credential = SharedData.credentialResponse?.credentials?.firstOrNull()
+ if (credential == null || SharedData.credentialResponse?.credentialErrors != null) {
+ // Verify mDocs - Step 4.6: Show error
+ // Show an error if something went wrong during the retrieval
+ Box(Modifier.fillMaxSize()) {
+ Text("There were errors while receiving the response", Modifier.align(Alignment.Center))
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ // Verify mDocs - Step 4.10: Show credential verification status
+ // The overall verification status (Verified / Not verified) — indicating whether the credential's chain of trust was validated against the stored IACA certificates.
+ val statusStyle = MaterialTheme.typography.titleLarge
+ if (credential.verificationResult.verified) {
+ Text("Verified", style = statusStyle, color = Color.Green)
+ } else {
+ Text("Not verified", style = statusStyle, color = Color.Red)
+ }
+
+ // Verify mDocs - Step 4.9: Show retrieved claims and errors
+ // credential.claims contains the data elements the holder consented to share (e.g. given_name, family_name, birth_date), grouped by namespace.
+ Claims("Received claims", credential.claims)
+ Spacer(Modifier.padding(8.dp))
+ // credential.claimErrors contains data elements that were requested but not provided (absent from credential or consent denied).
+ Claims("Failed claims", credential.claimErrors)
+ }
+ }
+}
+
+// Verify mDocs - Step 4.8: Display claims
+@Composable
+private fun ColumnScope.Claims(
+ title: String,
+ claims: Map>?
+) {
+ Text(
+ title,
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.CenterHorizontally),
+ style = MaterialTheme.typography.titleLarge
+ )
+ claims?.forEach { (namespace, claims) ->
+ Card {
+ Column(Modifier.padding(6.dp)) {
+ // Display the namespace (e.g. "org.iso.18013.5.1") as a section title within the card, followed by each claim as a name-value row.
+ Text(
+ namespace,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(vertical = 4.dp)
+ )
+ claims.forEach { (name, value) ->
+ Row {
+ // Claim identifier on the left (e.g. "family_name", "birth_date").
+ Text(
+ name,
+ Modifier
+ .weight(1f)
+ .padding(end = 4.dp)
+ )
+ // Claim value on the right, converted to a display string.
+ Text(value.claimToUiString(), overflow = TextOverflow.Ellipsis)
+ }
+ }
+ }
+ }
+ } ?: Text("Nothing here")
+}
+
+// Verify mDocs - Step 4.7: Map a claim or an error to string
+private fun Any.claimToUiString() = when (this) {
+ is MobileCredentialElement -> {
+ when (this) {
+ // Out of the tutorial scope.
+ is MobileCredentialElement.ArrayElement, is MobileCredentialElement.DataElement,
+ is MobileCredentialElement.MapElement -> this::class.simpleName ?: "Unknown element"
+
+ else -> value.toString()
+ }
+ }
+
+ else -> "Not returned"
+}
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ui/theme/Color.kt b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ui/theme/Color.kt
new file mode 100644
index 0000000..97e0c34
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.example.verifiertutorial.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ui/theme/Theme.kt b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ui/theme/Theme.kt
new file mode 100644
index 0000000..c8e81f1
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ui/theme/Theme.kt
@@ -0,0 +1,58 @@
+package com.example.verifiertutorial.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun VerifierTutorialTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ui/theme/Type.kt b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ui/theme/Type.kt
new file mode 100644
index 0000000..9e4d11b
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/java/com/example/verifiertutorial/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package com.example.verifiertutorial.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/drawable/ic_launcher_background.xml b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/drawable/ic_launcher_foreground.xml b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/values/colors.xml b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f8c6127
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/values/strings.xml b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..2501662
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Verifier Tutorial
+
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/values/themes.xml b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..efd7caf
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/xml/backup_rules.xml b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..4df9255
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/main/res/xml/data_extraction_rules.xml b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/app/src/test/java/com/example/verifiertutorial/ExampleUnitTest.kt b/android-in-person-verifier-tutorial-sample-app/app/src/test/java/com/example/verifiertutorial/ExampleUnitTest.kt
new file mode 100644
index 0000000..7ad7707
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/app/src/test/java/com/example/verifiertutorial/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.example.verifiertutorial
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/build.gradle.kts b/android-in-person-verifier-tutorial-sample-app/build.gradle.kts
new file mode 100644
index 0000000..952b930
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/build.gradle.kts
@@ -0,0 +1,6 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+}
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/gradle.properties b/android-in-person-verifier-tutorial-sample-app/gradle.properties
new file mode 100644
index 0000000..20e2a01
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/android-in-person-verifier-tutorial-sample-app/gradle/libs.versions.toml b/android-in-person-verifier-tutorial-sample-app/gradle/libs.versions.toml
new file mode 100644
index 0000000..85f878f
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/gradle/libs.versions.toml
@@ -0,0 +1,32 @@
+[versions]
+agp = "8.13.2"
+kotlin = "2.0.21"
+coreKtx = "1.18.0"
+junit = "4.13.2"
+junitVersion = "1.3.0"
+espressoCore = "3.7.0"
+lifecycleRuntimeKtx = "2.10.0"
+activityCompose = "1.13.0"
+composeBom = "2024.09.00"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+
diff --git a/android-in-person-verifier-tutorial-sample-app/gradle/wrapper/gradle-wrapper.jar b/android-in-person-verifier-tutorial-sample-app/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/android-in-person-verifier-tutorial-sample-app/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android-in-person-verifier-tutorial-sample-app/gradle/wrapper/gradle-wrapper.properties b/android-in-person-verifier-tutorial-sample-app/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..c353f55
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Thu Apr 16 13:39:54 NZST 2026
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android-in-person-verifier-tutorial-sample-app/gradlew b/android-in-person-verifier-tutorial-sample-app/gradlew
new file mode 100755
index 0000000..4f906e0
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/android-in-person-verifier-tutorial-sample-app/gradlew.bat b/android-in-person-verifier-tutorial-sample-app/gradlew.bat
new file mode 100644
index 0000000..ac1b06f
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android-in-person-verifier-tutorial-sample-app/local.properties b/android-in-person-verifier-tutorial-sample-app/local.properties
new file mode 100644
index 0000000..c7143e4
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/local.properties
@@ -0,0 +1,8 @@
+## This file must *NOT* be checked into Version Control Systems,
+# as it contains information specific to your local configuration.
+#
+# Location of the SDK. This is only used by Gradle.
+# For customization when using a Version Control System, please read the
+# header note.
+#Mon Apr 20 08:40:27 NZST 2026
+sdk.dir=/Users/pavel.vorotylenko/Library/Android/sdk
diff --git a/android-in-person-verifier-tutorial-sample-app/settings.gradle.kts b/android-in-person-verifier-tutorial-sample-app/settings.gradle.kts
new file mode 100644
index 0000000..57bdca2
--- /dev/null
+++ b/android-in-person-verifier-tutorial-sample-app/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven {
+ url = uri("repo")
+ }
+ }
+}
+
+rootProject.name = "Verifier Tutorial"
+include(":app")
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/project.pbxproj b/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..70c5f7c
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/project.pbxproj
@@ -0,0 +1,375 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 9FCF56F82F99B4480034BC9A /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 9FCF56F72F99B4480034BC9A /* CodeScanner */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 9FCF56C52F99A6750034BC9A /* iOS Holder Tutorial.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "iOS Holder Tutorial.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 9FCF56C72F99A6750034BC9A /* iOS Holder Tutorial */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = "iOS Holder Tutorial";
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 9FCF56C22F99A6750034BC9A /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 9FCF56F82F99B4480034BC9A /* CodeScanner in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 9FCF56BC2F99A6750034BC9A = {
+ isa = PBXGroup;
+ children = (
+ 9FCF56C72F99A6750034BC9A /* iOS Holder Tutorial */,
+ 9FCF56C62F99A6750034BC9A /* Products */,
+ );
+ sourceTree = "";
+ };
+ 9FCF56C62F99A6750034BC9A /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 9FCF56C52F99A6750034BC9A /* iOS Holder Tutorial.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 9FCF56C42F99A6750034BC9A /* iOS Holder Tutorial */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 9FCF56E62F99A6770034BC9A /* Build configuration list for PBXNativeTarget "iOS Holder Tutorial" */;
+ buildPhases = (
+ 9FCF56C12F99A6750034BC9A /* Sources */,
+ 9FCF56C22F99A6750034BC9A /* Frameworks */,
+ 9FCF56C32F99A6750034BC9A /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 9FCF56C72F99A6750034BC9A /* iOS Holder Tutorial */,
+ );
+ name = "iOS Holder Tutorial";
+ packageProductDependencies = (
+ 9FCF56F72F99B4480034BC9A /* CodeScanner */,
+ );
+ productName = "iOs Holder Tutorial";
+ productReference = 9FCF56C52F99A6750034BC9A /* iOS Holder Tutorial.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 9FCF56BD2F99A6750034BC9A /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2640;
+ LastUpgradeCheck = 2640;
+ TargetAttributes = {
+ 9FCF56C42F99A6750034BC9A = {
+ CreatedOnToolsVersion = 26.4.1;
+ };
+ };
+ };
+ buildConfigurationList = 9FCF56C02F99A6750034BC9A /* Build configuration list for PBXProject "iOS Holder Tutorial" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 9FCF56BC2F99A6750034BC9A;
+ minimizedProjectReferenceProxies = 1;
+ packageReferences = (
+ 9FCF56F62F99B4480034BC9A /* XCRemoteSwiftPackageReference "CodeScanner" */,
+ );
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 9FCF56C62F99A6750034BC9A /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 9FCF56C42F99A6750034BC9A /* iOS Holder Tutorial */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 9FCF56C32F99A6750034BC9A /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 9FCF56C12F99A6750034BC9A /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 9FCF56E42F99A6770034BC9A /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = 33LBXLB573;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.4;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 9FCF56E52F99A6770034BC9A /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = 33LBXLB573;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.4;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 9FCF56E72F99A6770034BC9A /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "iOs-Holder-Tutorial-Info.plist";
+ INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Use BLE";
+ INFOPLIST_KEY_NSCameraUsageDescription = Camera;
+ INFOPLIST_KEY_NSFaceIDUsageDescription = "Face ID";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "learn.mattr.iOS-Holder-Tutorial";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 9FCF56E82F99A6770034BC9A /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "iOs-Holder-Tutorial-Info.plist";
+ INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Use BLE";
+ INFOPLIST_KEY_NSCameraUsageDescription = Camera;
+ INFOPLIST_KEY_NSFaceIDUsageDescription = "Face ID";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "learn.mattr.iOS-Holder-Tutorial";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 9FCF56C02F99A6750034BC9A /* Build configuration list for PBXProject "iOS Holder Tutorial" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 9FCF56E42F99A6770034BC9A /* Debug */,
+ 9FCF56E52F99A6770034BC9A /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 9FCF56E62F99A6770034BC9A /* Build configuration list for PBXNativeTarget "iOS Holder Tutorial" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 9FCF56E72F99A6770034BC9A /* Debug */,
+ 9FCF56E82F99A6770034BC9A /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+ 9FCF56F62F99B4480034BC9A /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/twostraws/CodeScanner";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 2.5.2;
+ };
+ };
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 9FCF56F72F99B4480034BC9A /* CodeScanner */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 9FCF56F62F99B4480034BC9A /* XCRemoteSwiftPackageReference "CodeScanner" */;
+ productName = CodeScanner;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = 9FCF56BD2F99A6750034BC9A /* Project object */;
+}
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..f1b47dc
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000..998fa5d
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,15 @@
+{
+ "originHash" : "d59e09a8199b37d3d5a58522f728dec08e73dbe61a82a172d7e664c8318d995d",
+ "pins" : [
+ {
+ "identity" : "codescanner",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/twostraws/CodeScanner",
+ "state" : {
+ "revision" : "5e886430238944c7200fc9e10dbf2d9550dba865",
+ "version" : "2.5.2"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/project.xcworkspace/xcuserdata/avner.matan.xcuserdatad/UserInterfaceState.xcuserstate b/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/project.xcworkspace/xcuserdata/avner.matan.xcuserdatad/UserInterfaceState.xcuserstate
new file mode 100644
index 0000000..458f679
Binary files /dev/null and b/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/project.xcworkspace/xcuserdata/avner.matan.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/xcuserdata/avner.matan.xcuserdatad/xcschemes/xcschememanagement.plist b/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/xcuserdata/avner.matan.xcuserdatad/xcschemes/xcschememanagement.plist
new file mode 100644
index 0000000..1c14ed3
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial.xcodeproj/xcuserdata/avner.matan.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ SchemeUserState
+
+ iOs Holder Tutorial.xcscheme_^#shared#^_
+
+ orderHint
+ 0
+
+
+
+
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial/Assets.xcassets/AccentColor.colorset/Contents.json b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..2305880
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,35 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial/Assets.xcassets/Contents.json b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial/Constants.swift b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/Constants.swift
new file mode 100644
index 0000000..a152096
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/Constants.swift
@@ -0,0 +1,10 @@
+//
+// Constants.swift
+// iOS Holder Tutorial
+//
+
+
+enum Constants {
+ static let redirectUri: String = "io.mattrlabs.sample.mobilecredentialholderapp://credentials/callback"
+ static let clientId: String = "ios-sample-mobile-credential-holder-app"
+}
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial/ContentView.swift b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/ContentView.swift
new file mode 100644
index 0000000..9045649
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/ContentView.swift
@@ -0,0 +1,413 @@
+//
+// ContentView.swift
+// iOS Holder Tutorial
+//
+
+import SwiftUI
+import Combine
+// Claim Credential - Step 1.2: Import MobileCredentialHolderSDK
+import MobileCredentialHolderSDK
+
+struct ContentView: View {
+ @ObservedObject var viewModel: ViewModel = ViewModel()
+ var body: some View {
+ NavigationStack(path: $viewModel.navigationPath) {
+ VStack {
+ Button("Claim Credential") {
+ viewModel.navigationPath.append(NavigationState.qrScan)
+
+ }
+ .padding()
+ createQRCodeButton
+ if viewModel.shouldDisplayOnlinePresentation {
+ Button("View Online Presentation Session") {
+ viewModel.navigationPath.append(NavigationState.onlinePresentation)
+ }
+ .padding()
+
+ }
+ Spacer()
+ }
+ .padding()
+ .navigationDestination(for: NavigationState.self) { destination in
+ switch destination {
+ case .qrScan:
+ codeScannerView
+ case .credentialOffer:
+ credentialOfferView
+ case .transactionCodeInput:
+ transactionCodeInputView
+ case .retrievedCredentials:
+ retrievedCredentialsView
+ case .onlinePresentation:
+ // Online Presentation - Step 3.3: Display online presentation view
+ PresentCredentialsView(
+ viewModel: PresentCredentialsViewModel(
+ requestedDocuments: $viewModel.credentialRequest,
+ matchedCredentials: $viewModel.matchedCredentials,
+ matchedMetadata: $viewModel.matchedMetadata,
+ sendCredentialAction: viewModel.sendOnlinePresentationSessionResponse(id:),
+ getCredentialAction: viewModel.getCredential(id:)
+ )
+ )
+ case .presentCredentials:
+ qrCodeView
+ case .proximityPresentation:
+ // Proximity Presentation - Step 2.5: Display proximity presentation view
+ PresentCredentialsView(
+ viewModel: PresentCredentialsViewModel(
+ requestedDocuments: $viewModel.credentialRequest,
+ matchedCredentials: $viewModel.matchedCredentials,
+ matchedMetadata: $viewModel.matchedMetadata,
+ sendCredentialAction: viewModel.sendProximityPresentationResponse(id:),
+ getCredentialAction: viewModel.getCredential(id:)
+ )
+ )
+ }
+ }
+ // Online Presentation - Step 2.4: Create session from request URI
+ .onOpenURL { url in
+ Task {
+ await viewModel.createOnlinePresentationSession(authorizationRequestURI: url.absoluteString)
+ }
+ // Navigate to online presentation view
+ viewModel.navigationPath.append(NavigationState.onlinePresentation)
+ }
+ }
+ }
+
+ // MARK: - Credential Retrieval Views
+
+ var codeScannerView: some View {
+ // Claim Credential - Step 2.4 Create QRScannerView
+ QRScannerView(
+ completion: { credentialOffer in
+ viewModel.discoverCredentialOffer(credentialOffer)
+ }
+ )
+ }
+
+ var credentialOfferView: some View {
+ // Claim Credential - Step 3.5: Display Credential offer
+ VStack {
+ Text("Received \(viewModel.discoveredCredentialOffer?.credentials.count ?? 0) Credential Offer(s)")
+ .font(.headline)
+ Text("from \(viewModel.discoveredCredentialOffer?.issuer.absoluteString ?? "unknown issuer")")
+ .font(.subheadline)
+ List(viewModel.discoveredCredentialOffer?.credentials ?? [], id: \.docType) { credential in
+ Section {
+ HStack {
+ Text("Name:")
+ .bold()
+ Spacer()
+ Text("\(credential.name ?? "")")
+ }
+ HStack {
+ Text("Doctype:")
+ .bold()
+ Spacer()
+ Text("\(credential.docType)")
+ }
+ HStack {
+ Text("No. of claims:")
+ .bold()
+ Spacer()
+ Text("\(credential.claims.count)")
+ }
+ }
+ }
+ Button {
+ if viewModel.discoveredCredentialOffer?.transactionCode != nil {
+ viewModel.navigationPath.append(NavigationState.transactionCodeInput)
+ return
+ }
+ viewModel.retrieveCredential(transactionCode: nil)
+ } label: {
+ Text("Consent and retrieve Credential(s)")
+ .font(.title3)
+ }
+ .buttonStyle(.borderedProminent)
+ .clipShape(Capsule())
+ }
+ }
+
+ var transactionCodeInputView: some View {
+ // Claim Credential - Step: 3.4 Display transaction code input view.
+ TransactionCodeInputView(viewModel: viewModel)
+ }
+
+
+ var retrievedCredentialsView: some View {
+ // Claim Credential - Step 4.4: Display retrieved credentials
+ ScrollView {
+ VStack {
+ Text("Retrieved Credentials")
+ .font(.title)
+ ForEach(viewModel.retrievedCredentials, id: \.id) { credential in
+ DocumentView(viewModel: DocumentViewModel(from: credential))
+ }
+ }
+ }
+ }
+
+ // MARK: - Proximity Presentation Views
+
+ var createQRCodeButton: some View {
+ // Proximity Presentation - Step 1.5: Add button to generate QR code
+ Button {
+ viewModel.createDeviceEngagementString()
+ // Navigates user to presentCredentialsView, once the string has been created.
+ viewModel.navigationPath.append(NavigationState.presentCredentials)
+ } label: {
+ Text("Present Credentials")
+ }
+ }
+
+ func generateQRCode(data: Data) -> Data? {
+ // Proximity Presentation - Step 1.6: Generate QR code
+ guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
+ filter.setValue(data, forKey: "inputMessage")
+ guard let ciimage = filter.outputImage else { return nil }
+ let transform = CGAffineTransform(scaleX: 10, y: 10)
+ let scaledCIImage = ciimage.transformed(by: transform)
+ let uiimage = UIImage(ciImage: scaledCIImage)
+ return uiimage.pngData()
+ }
+
+ var qrCodeView: some View {
+ // Proximity Presentation - Step 1.7: Create QR code view
+ VStack {
+ Text("Scan to establish device engagement session")
+ .font(.title3)
+ Spacer()
+ if let imageData = generateQRCode(data: viewModel.deviceEngagementString?.data(using: .utf8) ?? Data()),
+ let image = UIImage(data: imageData) {
+ Image(uiImage: image)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ }
+ Spacer()
+ }
+ }
+}
+
+class ViewModel: ObservableObject {
+ @Published var navigationPath = NavigationPath()
+ // Claim Credential - Step 1.3: Add MobileCredentialHolder var
+ var mobileCredentialHolder: MobileCredentialHolder
+
+ // Claim Credential - Step 3.1: Add DiscoveredCredentialOffer and discoveredCredentialOfferURL vars
+ @Published var discoveredCredentialOffer: DiscoveredCredentialOffer?
+ var discoveredCredentialOfferURL = ""
+
+ // Claim Credential - Step 4.1: Add retrievedCredentials var
+ @Published var retrievedCredentials: [MobileCredential] = []
+
+
+ // Proximity Presentation - Step 1.2: Create deviceEngagementString and proximityPresentationSession variables
+ @Published var deviceEngagementString: String?
+ @Published var proximityPresentationSession: ProximityPresentationSession?
+
+
+ // Proximity and Online Presentation: Create variables for credential presentations
+ @Published var matchedCredentials: [MobileCredential] = []
+ @Published var matchedMetadata: [MobileCredentialMetadata] = []
+ @Published var credentialRequest: [MobileCredentialRequest] = []
+
+
+ // Online Presentation - Step 2.1: Create a variable to hold the online presentation session object
+ @Published var onlinePresentationSession: OnlinePresentationSession?
+
+
+ var shouldDisplayOnlinePresentation: Bool {
+ // Online Presentation - Step 3.4: View Online Presentation
+ onlinePresentationSession != nil
+ }
+
+ // Claim Credential - Step 1.4: Initialize MobileCredentialHolder SDK
+ init() {
+ do {
+ mobileCredentialHolder = MobileCredentialHolder.shared
+ try mobileCredentialHolder.initialize(
+ userAuthenticationConfiguration: UserAuthenticationConfiguration(userAuthenticationBehavior: .onDeviceKeyAccess),
+ credentialIssuanceConfiguration: CredentialIssuanceConfiguration(
+ redirectUri: Constants.redirectUri,
+ autoTrustMobileCredentialIaca: true
+ )
+ )
+ } catch {
+ print(error)
+ }
+ }
+
+ @MainActor
+ func getCredential(id: String) {
+ // Proximity and Online Presentation: Retrieve a credential from storage
+ Task {
+ do {
+ let credential = try await mobileCredentialHolder.getCredential(credentialId: id)
+ matchedCredentials.append(credential)
+ } catch {
+ print(error)
+ }
+ }
+ }
+}
+
+// MARK: - Credential Retrieval
+
+extension ViewModel {
+ @MainActor
+ func discoverCredentialOffer(_ offer: String) {
+ // Claim Credential - Step 3.2: Add discover credential offer logic
+ Task {
+ do {
+ discoveredCredentialOffer = try await mobileCredentialHolder.discoverCredentialOffer(offer)
+ // save the url to use for credential retrieval
+ discoveredCredentialOfferURL = offer
+ // present credential offer screen, as soon as credential offer is discovered
+ navigationPath.append(NavigationState.credentialOffer)
+ } catch {
+ print(error)
+ }
+ }
+ }
+
+ @MainActor
+ func retrieveCredential(transactionCode: String?) {
+ // Claim Credential - Step 4.2: Call retrieveCredential method
+ Task {
+ do {
+ let retrievedCredentialResults = try await mobileCredentialHolder.retrieveCredentials(
+ credentialOffer: discoveredCredentialOfferURL,
+ clientId: Constants.clientId,
+ transactionCode: transactionCode
+ )
+ Task {
+ var credentials: [MobileCredential] = []
+ for result in retrievedCredentialResults {
+ if let credentialId = result.credentialId {
+ if let credential = try? await mobileCredentialHolder.getCredential(credentialId: credentialId) {
+ credentials.append(credential)
+ }
+ }
+ }
+ self.retrievedCredentials = credentials
+ // Clear navigation stack and display retrievedCredentials view
+ navigationPath = NavigationPath()
+ navigationPath.append(NavigationState.retrievedCredentials)
+ }
+ } catch {
+ print(error.localizedDescription)
+ }
+ }
+ }
+}
+
+// MARK: - Online Presentation
+
+extension ViewModel {
+ @MainActor
+ func createOnlinePresentationSession(authorizationRequestURI: String) async {
+ // Online Presentation - Step 2.3: Create online presentation session
+ Task {
+ do {
+ onlinePresentationSession = try await mobileCredentialHolder.createOnlinePresentationSession(authorizationRequestUri: authorizationRequestURI, requireTrustedVerifier: false)
+ matchedMetadata = onlinePresentationSession?.matchedCredentials?
+ .flatMap { $0.matchedMobileCredentials }
+ .compactMap { $0 } ?? []
+
+ credentialRequest = onlinePresentationSession?.matchedCredentials?
+ .compactMap { $0.request }
+ .compactMap { $0 } ?? []
+ } catch {
+ print(error.localizedDescription)
+ }
+ }
+ }
+
+ @MainActor
+ func sendOnlinePresentationSessionResponse(id: String) {
+ // Online Presentation - Step 4.1: Send online presentation response
+ Task {
+ do {
+ _ = try await onlinePresentationSession?.sendResponse(credentialIds: [id])
+ // set presentation session to nil after sending a response
+ onlinePresentationSession = nil
+ // Return to root view after the response is sent
+ navigationPath = NavigationPath()
+ } catch {
+ print(error)
+ }
+ }
+ }
+}
+
+// MARK: - Proximity Presentation
+
+extension ViewModel {
+ func createDeviceEngagementString() {
+ // Proximity Presentation - Step 1.3: Create function to create a proximity presentation session and generate QR code
+ Task { @MainActor in
+ do {
+ proximityPresentationSession = try await mobileCredentialHolder.createProximityPresentationSession(
+ onRequestReceived: onRequestReceived(_:error:),
+ bleMode: .mDocPeripheralServer
+ )
+ deviceEngagementString = proximityPresentationSession?.deviceEngagement
+
+ } catch {
+ print(error)
+ }
+ }
+ }
+
+ // Proximity Presentation - Step 1.4: Update function signature
+ func onRequestReceived(_ mobileCredentialRequests: [(request: MobileCredentialRequest, matchedMobileCredentials: [MobileCredentialMetadata])]?, error: Error?) {
+ // Proximity Presentation - Step 2.2: Store credential requests and matched credentials
+ Task { @MainActor in
+ matchedMetadata = mobileCredentialRequests?
+ .flatMap { $0.matchedMobileCredentials }
+ .compactMap { $0 } ?? []
+
+ credentialRequest = mobileCredentialRequests?
+ .compactMap { $0.request }
+ .compactMap { $0 } ?? []
+ // Navigate to presentation view if there are no errors
+ if error == nil {
+ navigationPath.append(NavigationState.proximityPresentation)
+ } else {
+ print(error!)
+ }
+ }
+ }
+
+ @MainActor
+ func sendProximityPresentationResponse(id: String) {
+ // Proximity Presentation - Step 3.1: Send a credential response
+ Task {
+ do {
+ let _ = try await proximityPresentationSession?.sendResponse(credentialIds: [id])
+ // set presentation session to nil after sending a response
+ proximityPresentationSession = nil
+ // Return to root view after the response is sent
+ navigationPath = NavigationPath()
+ } catch {
+ print(error)
+ }
+ }
+
+ }
+}
+
+// MARK: - Navigation
+
+enum NavigationState: Hashable {
+ case qrScan
+ case credentialOffer
+ case transactionCodeInput
+ case retrievedCredentials
+ case onlinePresentation
+ case presentCredentials
+ case proximityPresentation
+}
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial/DocumentView.swift b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/DocumentView.swift
new file mode 100644
index 0000000..edf6282
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/DocumentView.swift
@@ -0,0 +1,133 @@
+//
+// DocumentView.swift
+// iOS Holder Tutorial
+//
+
+import MobileCredentialHolderSDK
+import SwiftUI
+import Combine
+
+ struct DocumentView: View {
+
+ var viewModel: DocumentViewModel
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(viewModel.docType)
+ .font(.title)
+ .fontWeight(.bold)
+ .padding(.bottom, 5)
+
+ ForEach(viewModel.namespacesAndClaims.keys.sorted(), id: \.self) { key in
+ VStack(alignment: .leading, spacing: 5) {
+ Text(key)
+ .font(.headline)
+ .padding(.vertical, 5)
+ .padding(.horizontal, 10)
+ .background(Color.gray.opacity(0.2))
+ .cornerRadius(5)
+
+ ForEach(viewModel.namespacesAndClaims[key]!.keys.sorted(), id: \.self) { claim in
+ HStack {
+ Text(claim)
+ .fontWeight(.semibold)
+ Spacer()
+ Text(viewModel.namespacesAndClaims[key]![claim]! ?? "")
+ .fontWeight(.regular)
+ }
+ .padding(.vertical, 5)
+ .padding(.horizontal, 10)
+ .background(Color.white)
+ .cornerRadius(5)
+ .shadow(radius: 1)
+ }
+ }
+ .padding(.vertical, 5)
+ }
+ }
+ .padding()
+ .background(RoundedRectangle(cornerRadius: 10).fill(Color.white).shadow(radius: 5))
+ .padding(.horizontal)
+ }
+ }
+
+ // MARK: DocumentViewModel
+
+ class DocumentViewModel: ObservableObject {
+ var docType: String
+
+ var namespacesAndClaims: [String: [String: String?]]
+
+ init(from credential: MobileCredential) {
+ self.docType = credential.docType
+ self.namespacesAndClaims = credential.claims?.reduce(into: [String: [String: String]]()) { result, outerElement in
+ let (outerKey, innerDict) = outerElement
+ result[outerKey] = innerDict.mapValues { $0.textRepresentation }
+ } ?? [:]
+ }
+
+ init(from credentialMetadata: MobileCredentialMetadata) {
+ self.docType = credentialMetadata.docType
+ var result: [String: [String: String?]] = [:]
+ credentialMetadata.claims?.forEach { namespace, claimIDs in
+ var transformedClaims: [String: String?] = [:]
+ claimIDs.forEach { claimID in
+ transformedClaims[claimID] = Optional.none
+ }
+ result[namespace] = transformedClaims
+ }
+ self.namespacesAndClaims = result
+ }
+
+ init(from request: MobileCredentialRequest) {
+ self.docType = request.docType
+ self.namespacesAndClaims = request.namespaces.reduce(into: [String: [String: String?]]()) { result, outerElement in
+ let (outerKey, innerDict) = outerElement
+ result[outerKey] = innerDict.mapValues { _ in nil }
+ }
+ }
+ }
+
+ // MARK: Helper
+ extension MobileCredentialElementValue {
+ var textRepresentation: String {
+ switch self {
+ case .bool(let bool):
+ return "\(bool)"
+ case .string(let string):
+ return string
+ case .int(let int):
+ return "\(int)"
+ case .unsigned(let uInt):
+ return "\(uInt)"
+ case .float(let float):
+ return "\(float)"
+ case .double(let double):
+ return "\(double)"
+ case let .date(date):
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateStyle = .short
+ dateFormatter.timeStyle = .none
+ return dateFormatter.string(from: date)
+ case let .dateTime(date):
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateStyle = .short
+ dateFormatter.timeStyle = .short
+ return dateFormatter.string(from: date)
+ case .data(let data):
+ return "Data \(data.count) bytes"
+ case .map(let dictionary):
+ let result = dictionary.mapValues { value in
+ value.textRepresentation
+ }
+ return "\(result)"
+ case .array(let array):
+ return array.reduce("") { partialResult, element in
+ partialResult + element.textRepresentation
+ }
+ .appending("")
+ @unknown default:
+ return "Unknown type"
+ }
+ }
+ }
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial/PresentCredentialsView.swift b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/PresentCredentialsView.swift
new file mode 100644
index 0000000..6d51cf1
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/PresentCredentialsView.swift
@@ -0,0 +1,109 @@
+//
+// PresentCredentialsView.swift
+// iOS Holder Tutorial
+//
+
+
+import MobileCredentialHolderSDK
+import SwiftUI
+import Combine
+
+struct PresentCredentialsView: View {
+ @ObservedObject var viewModel: PresentCredentialsViewModel
+ @State var selectedID: String?
+
+ init(viewModel: PresentCredentialsViewModel) {
+ self.viewModel = viewModel
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ Text("Requested Documents")
+ .font(.headline)
+ .padding(.leading)
+
+ ForEach(viewModel.requestedDocuments, id: \.docType) { requestedDocument in
+ DocumentView(viewModel: DocumentViewModel(from: requestedDocument))
+ }
+
+ Text("Matched Credentials")
+ .font(.headline)
+ .padding(.leading)
+
+ ForEach(viewModel.matchedMetadata, id: \.id) { matchedMetadata in
+ VStack(alignment: .leading, spacing: 10) {
+ if let matchedCredential = viewModel.matchedMobileCredential(id: matchedMetadata.id) {
+ DocumentView(viewModel: DocumentViewModel(from: matchedCredential))
+ .padding(.vertical)
+ .background(selectedID == matchedMetadata.id ? Color.blue.opacity(0.2) : Color.clear)
+ .onTapGesture {
+ guard selectedID != matchedMetadata.id else {
+ selectedID = nil
+ return
+ }
+ selectedID = matchedMetadata.id
+ }
+ Button("Hide claim values") {
+ viewModel.matchedCredentials.removeAll(where: { $0.id == matchedMetadata.id })
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ } else {
+ DocumentView(viewModel: DocumentViewModel(from: matchedMetadata))
+ .padding(.vertical)
+ .background(selectedID == matchedMetadata.id ? Color.blue.opacity(0.2) : Color.clear)
+ .onTapGesture {
+ guard selectedID != matchedMetadata.id else {
+ selectedID = nil
+ return
+ }
+ selectedID = matchedMetadata.id
+ }
+ Button("Show claim values") {
+ viewModel.getCredentialAction(matchedMetadata.id)
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ }
+ }
+ }
+ }
+ if selectedID != nil {
+ Button("Send Response") {
+ viewModel.sendCredentialAction(selectedID!)
+ }
+ .buttonStyle(.borderedProminent)
+ .clipShape(Capsule())
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ }
+}
+
+// MARK: PresentCredentialsViewModel
+
+class PresentCredentialsViewModel: ObservableObject {
+ @Binding var requestedDocuments: [MobileCredentialRequest]
+ @Binding var matchedCredentials: [MobileCredential]
+ @Binding var matchedMetadata: [MobileCredentialMetadata]
+
+ var getCredentialAction: (String) -> Void
+ var sendCredentialAction: (String) -> Void
+
+ init(
+ requestedDocuments: Binding<[MobileCredentialRequest]>,
+ matchedCredentials: Binding<[MobileCredential]>,
+ matchedMetadata: Binding<[MobileCredentialMetadata]>,
+ sendCredentialAction: @escaping (String) -> Void,
+ getCredentialAction: @escaping (String) -> Void
+ ) {
+ self._requestedDocuments = requestedDocuments
+ self._matchedCredentials = matchedCredentials
+ self._matchedMetadata = matchedMetadata
+ self.sendCredentialAction = sendCredentialAction
+ self.getCredentialAction = getCredentialAction
+ }
+
+ func matchedMobileCredential(id: String) -> MobileCredential? {
+ matchedCredentials.first(where: { $0.id == id })
+ }
+}
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial/QRScannerView.swift b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/QRScannerView.swift
new file mode 100644
index 0000000..e77af95
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/QRScannerView.swift
@@ -0,0 +1,29 @@
+//
+// QRScannerView.swift
+// iOS Holder Tutorial
+//
+
+ import SwiftUI
+ import CodeScanner
+ import AVFoundation
+
+ struct QRScannerView: View {
+
+ private let completionHandler: (String) -> Void
+
+ init(completion: @escaping (String) -> Void) {
+ completionHandler = completion
+ }
+
+ var body: some View {
+ CodeScannerView(codeTypes: [.qr]) { result in
+ switch result {
+ case .failure(let error):
+ print(error.localizedDescription)
+ case .success(let result):
+ print(result.string)
+ completionHandler(result.string)
+ }
+ }
+ }
+}
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial/TransactionCodeInputView.swift b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/TransactionCodeInputView.swift
new file mode 100644
index 0000000..ab15955
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/TransactionCodeInputView.swift
@@ -0,0 +1,46 @@
+//
+// TransactionCodeInputView.swift
+// iOS Holder Tutorial
+//
+
+import SwiftUI
+
+struct TransactionCodeInputView: View {
+ @ObservedObject var viewModel: ViewModel
+ @State private var transactionCode = ""
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Text("Transaction Code Required")
+ .font(.title2)
+ .fontWeight(.bold)
+
+ Text("Please enter the transaction code to proceed with credential retrieval.")
+ .multilineTextAlignment(.center)
+ .foregroundColor(.secondary)
+
+ TextField("Enter transaction code", text: $transactionCode)
+ .textFieldStyle(RoundedBorderTextFieldStyle())
+ .padding(.horizontal)
+
+ HStack(spacing: 20) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .buttonStyle(.bordered)
+
+ Button("Retrieve Credentials") {
+ viewModel.retrieveCredential(transactionCode: transactionCode)
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(transactionCode.isEmpty)
+ }
+
+ Spacer()
+ }
+ .padding()
+ .navigationTitle("Transaction Code")
+ .navigationBarBackButtonHidden(false)
+ }
+}
diff --git a/ios-holder-tutorial-sample-app/iOS Holder Tutorial/iOS_Holder_TutorialApp.swift b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/iOS_Holder_TutorialApp.swift
new file mode 100644
index 0000000..bd7a106
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS Holder Tutorial/iOS_Holder_TutorialApp.swift
@@ -0,0 +1,15 @@
+//
+// iOS_Holder_TutorialApp.swift
+// iOS Holder Tutorial
+//
+
+import SwiftUI
+
+@main
+struct iOS_Holder_TutorialApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/ios-holder-tutorial-sample-app/iOS-Holder-Tutorial-Info.plist b/ios-holder-tutorial-sample-app/iOS-Holder-Tutorial-Info.plist
new file mode 100644
index 0000000..54038f5
--- /dev/null
+++ b/ios-holder-tutorial-sample-app/iOS-Holder-Tutorial-Info.plist
@@ -0,0 +1,19 @@
+
+
+
+
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ mdoc-openid4vp
+ CFBundleURLSchemes
+
+ mdoc-openid4vp
+
+
+
+
+
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/project.pbxproj b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..454e7a2
--- /dev/null
+++ b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/project.pbxproj
@@ -0,0 +1,365 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ C718F87F2F8F084F00870BB9 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = C718F87E2F8F084F00870BB9 /* CodeScanner */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ C718F8652F8F07E000870BB9 /* In person verification tutorial.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "In person verification tutorial.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ C718F8672F8F07E000870BB9 /* In person verification tutorial */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = "In person verification tutorial";
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ C718F8622F8F07E000870BB9 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ C718F87F2F8F084F00870BB9 /* CodeScanner in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ C718F85C2F8F07E000870BB9 = {
+ isa = PBXGroup;
+ children = (
+ C718F8672F8F07E000870BB9 /* In person verification tutorial */,
+ C718F8662F8F07E000870BB9 /* Products */,
+ );
+ sourceTree = "";
+ };
+ C718F8662F8F07E000870BB9 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ C718F8652F8F07E000870BB9 /* In person verification tutorial.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ C718F8642F8F07E000870BB9 /* In person verification tutorial */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = C718F8702F8F07E100870BB9 /* Build configuration list for PBXNativeTarget "In person verification tutorial" */;
+ buildPhases = (
+ C718F8612F8F07E000870BB9 /* Sources */,
+ C718F8622F8F07E000870BB9 /* Frameworks */,
+ C718F8632F8F07E000870BB9 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ C718F8672F8F07E000870BB9 /* In person verification tutorial */,
+ );
+ name = "In person verification tutorial";
+ packageProductDependencies = (
+ C718F87E2F8F084F00870BB9 /* CodeScanner */,
+ );
+ productName = "In person verification tutorial";
+ productReference = C718F8652F8F07E000870BB9 /* In person verification tutorial.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ C718F85D2F8F07E000870BB9 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2640;
+ LastUpgradeCheck = 2640;
+ TargetAttributes = {
+ C718F8642F8F07E000870BB9 = {
+ CreatedOnToolsVersion = 26.4;
+ };
+ };
+ };
+ buildConfigurationList = C718F8602F8F07E000870BB9 /* Build configuration list for PBXProject "In person verification tutorial" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = C718F85C2F8F07E000870BB9;
+ minimizedProjectReferenceProxies = 1;
+ packageReferences = (
+ C718F87D2F8F084F00870BB9 /* XCRemoteSwiftPackageReference "CodeScanner" */,
+ );
+ preferredProjectObjectVersion = 77;
+ productRefGroup = C718F8662F8F07E000870BB9 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ C718F8642F8F07E000870BB9 /* In person verification tutorial */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ C718F8632F8F07E000870BB9 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ C718F8612F8F07E000870BB9 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ C718F86E2F8F07E100870BB9 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = 33LBXLB573;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.4;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ C718F86F2F8F07E100870BB9 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = 33LBXLB573;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.4;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ C718F8712F8F07E100870BB9 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ C718F8722F8F07E100870BB9 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ C718F8602F8F07E000870BB9 /* Build configuration list for PBXProject "In person verification tutorial" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ C718F86E2F8F07E100870BB9 /* Debug */,
+ C718F86F2F8F07E100870BB9 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ C718F8702F8F07E100870BB9 /* Build configuration list for PBXNativeTarget "In person verification tutorial" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ C718F8712F8F07E100870BB9 /* Debug */,
+ C718F8722F8F07E100870BB9 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+ C718F87D2F8F084F00870BB9 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/twostraws/CodeScanner.git";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 2.5.2;
+ };
+ };
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ C718F87E2F8F084F00870BB9 /* CodeScanner */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = C718F87D2F8F084F00870BB9 /* XCRemoteSwiftPackageReference "CodeScanner" */;
+ productName = CodeScanner;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = C718F85D2F8F07E000870BB9 /* Project object */;
+}
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000..cf9f16c
--- /dev/null
+++ b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,15 @@
+{
+ "originHash" : "e67e427de5a5557f1a0ae78fb064b547f287d965299898eeb6db9c4d0632ba8f",
+ "pins" : [
+ {
+ "identity" : "codescanner",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/twostraws/CodeScanner.git",
+ "state" : {
+ "revision" : "5e886430238944c7200fc9e10dbf2d9550dba865",
+ "version" : "2.5.2"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/project.xcworkspace/xcuserdata/nikita.shvadlenko.xcuserdatad/UserInterfaceState.xcuserstate b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/project.xcworkspace/xcuserdata/nikita.shvadlenko.xcuserdatad/UserInterfaceState.xcuserstate
new file mode 100644
index 0000000..89b9314
Binary files /dev/null and b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/project.xcworkspace/xcuserdata/nikita.shvadlenko.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/xcuserdata/nikita.shvadlenko.xcuserdatad/xcschemes/xcschememanagement.plist b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/xcuserdata/nikita.shvadlenko.xcuserdatad/xcschemes/xcschememanagement.plist
new file mode 100644
index 0000000..4cf33b4
--- /dev/null
+++ b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial.xcodeproj/xcuserdata/nikita.shvadlenko.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ SchemeUserState
+
+ In person verification tutorial.xcscheme_^#shared#^_
+
+ orderHint
+ 0
+
+
+
+
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/Assets.xcassets/AccentColor.colorset/Contents.json b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..2305880
--- /dev/null
+++ b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,35 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/Assets.xcassets/Contents.json b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/CertificateManagementView.swift b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/CertificateManagementView.swift
new file mode 100644
index 0000000..79000fe
--- /dev/null
+++ b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/CertificateManagementView.swift
@@ -0,0 +1,116 @@
+import MobileCredentialVerifierSDK
+import SwiftUI
+
+// MARK: - Section: Manage Certificates (Step 1)
+// This view provides the interface for managing trusted IACA (Issuing Authority Certificate
+// Authority) root certificates. Every mDoc is signed by a chain of trust - for the verifier
+// app to verify a presented mDoc, it must validate the mDoc was signed using a root certificate
+// associated with a trusted issuer. This screen lets the user add, view, and remove those
+// trusted certificates.
+struct CertificateManagementView: View {
+ @State var viewModel = CertificateManagementViewModel()
+ @State var certificateString = ""
+
+ var body: some View {
+ Form {
+ Section(
+ header: Text("IACA Certificate").font(.headline),
+ footer: HStack {
+ Spacer()
+ Button("Add") {
+ viewModel.addCertificate(certificateString)
+ }
+ Spacer().frame(width: 30)
+ Button("Clear") {
+ certificateString = ""
+ }
+ .foregroundColor(.red)
+ .contentShape(Rectangle())
+ .frame(alignment: .trailing)
+ }
+ ) {
+ TextField("IACA certificate string", text: $certificateString)
+ }
+
+ Section(
+ header: Text("Stored Certificates").font(.headline)
+ ) {
+ certificateListView
+ }
+ }
+ .navigationBarTitle("Certificate Setting")
+ .onAppear {
+ viewModel.getCertificates()
+ }
+ }
+
+ // MARK: Certificate Management Views
+ var certificateListView: some View {
+ // Manage Certificates - Step 2.3: Display retrieved certificates
+ // Iterates over stored certificates and displays each one with its PEM content.
+ // A swipe-to-delete action is available on each row to remove individual certificates.
+ ForEach(viewModel.certificates, id: \.id) { certificate in
+ Text("\(certificate.pem)")
+ .frame(maxHeight: 100)
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive) {
+ viewModel.removeCertificate(certificate.id)
+ } label: {
+ Image(systemName: "trash")
+ }
+ }
+ }
+ }
+}
+
+// MARK: - CertificateManagementViewModel
+// Contains the business logic for certificate management operations.
+// Uses a shared MobileCredentialVerifier instance to interact with the SDK's
+// certificate storage (add, retrieve, and delete trusted IACA certificates).
+@Observable
+final class CertificateManagementViewModel {
+ // Manage Certificates - Step 2.1: Add certificates and verifier variable
+ // certificates: Holds the list of currently stored trusted certificates for display.
+ // mobileCredentialVerifier: Shared SDK instance used to call certificate management functions.
+ var certificates: [TrustedCertificate] = []
+ let mobileCredentialVerifier = MobileCredentialVerifier.shared
+
+ func getCertificates() {
+ // Manage Certificates - Step 2.2: Create getCertificates function
+ // Calls the SDK's getTrustedIssuerCertificates() to retrieve all stored certificates
+ // and updates the published certificates array to refresh the UI.
+ do {
+ let fetchedCertificates = try mobileCredentialVerifier.getTrustedIssuerCertificates()
+ certificates = fetchedCertificates
+ } catch {
+ print(error.localizedDescription)
+ }
+ }
+
+ func addCertificate(_ certificate: String) {
+ // Manage Certificates - Step 2.4: Create addCertificate function
+ // Calls the SDK's addTrustedIssuerCertificates() to store a new IACA certificate
+ // from the provided PEM string, then refreshes the certificate list.
+ Task { @MainActor in
+ do {
+ _ = try await mobileCredentialVerifier.addTrustedIssuerCertificates(certificates: [certificate])
+ self.getCertificates()
+ } catch {
+ print(error.localizedDescription)
+ }
+ }
+ }
+
+ func removeCertificate(_ certificateID: String) {
+ // Manage Certificates - Step 2.5: Create removeCertificate function
+ // Calls the SDK's deleteTrustedIssuerCertificate() to remove a certificate by ID,
+ // then refreshes the list. Removing a certificate means the app will no longer
+ // trust mDocs issued under that certificate's chain of trust.
+ do {
+ try mobileCredentialVerifier.deleteTrustedIssuerCertificate(certificateId: certificateID)
+ self.getCertificates()
+ } catch {
+ print(error.localizedDescription)
+ }
+ }
+}
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/Constants.swift b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/Constants.swift
new file mode 100644
index 0000000..58f9c32
--- /dev/null
+++ b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/Constants.swift
@@ -0,0 +1,26 @@
+// MARK: - Constants
+// Contains the pre-configured IACA (Issuing Authority Certificate Authority) root certificate
+// for the Montcliff DMV test issuer (montcliff-dmv.mattrlabs.com).
+// This PEM-encoded certificate is used during SDK initialization to automatically trust mDocs
+// issued by this test issuer. Users can also add/remove certificates at runtime via the
+// Certificate Management screen.
+//
+// In a production app, you would manage these certificates dynamically rather than hardcoding them.
+enum Constants {
+ static let montcliffPEM =
+"""
+ MIICYzCCAgmgAwIBAgIKXhjLoCkLWBxREDAKBggqhkjOPQQDAjA4MQswCQYDVQQG
+ EwJBVTEpMCcGA1UEAwwgbW9udGNsaWZmLWRtdi5tYXR0cmxhYnMuY29tIElBQ0Ew
+ HhcNMjQwMTE4MjMxNDE4WhcNMzQwMTE1MjMxNDE4WjA4MQswCQYDVQQGEwJBVTEp
+ MCcGA1UEAwwgbW9udGNsaWZmLWRtdi5tYXR0cmxhYnMuY29tIElBQ0EwWTATBgcq
+ hkjOPQIBBggqhkjOPQMBBwNCAASBnqobOh8baMW7mpSZaQMawj6wgM5e5nPd6HXp
+ dB8eUVPlCMKribQ7XiiLU96rib/yQLH2k1CUeZmEjxoEi42xo4H6MIH3MBIGA1Ud
+ EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRFZwEOI9yq
+ 232NG+OzNQzFKa/LxDAuBgNVHRIEJzAlhiNodHRwczovL21vbnRjbGlmZi1kbXYu
+ bWF0dHJsYWJzLmNvbTCBgQYDVR0fBHoweDB2oHSgcoZwaHR0cHM6Ly9tb250Y2xp
+ ZmYtZG12LnZpaS5hdTAxLm1hdHRyLmdsb2JhbC92Mi9jcmVkZW50aWFscy9tb2Jp
+ bGUvaWFjYXMvMjk0YmExYmMtOTFhMS00MjJmLThhMTctY2IwODU0NWY0ODYwL2Ny
+ bDAKBggqhkjOPQQDAgNIADBFAiAlZYQP95lGzVJfCykhcpCzpQ2LWE/AbjTGkcGI
+ SNsu7gIhAJfP54a2hXz4YiQN4qJERlORjyL1Ru9M0/dtQppohFm6
+"""
+}
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/ContentView.swift b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/ContentView.swift
new file mode 100644
index 0000000..997a859
--- /dev/null
+++ b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/ContentView.swift
@@ -0,0 +1,228 @@
+import SwiftUI
+
+// MARK: - Section: Initialize the SDK (Step 2)
+// Import the MobileCredentialVerifierSDK framework to access the SDK's capabilities
+// for verifying mDocs presented via a proximity workflow (ISO 18013-5).
+// Initialize SDK - Step 2.1: import MobileCredentialVerifierSDK
+import MobileCredentialVerifierSDK
+
+// MARK: - Section: Create the Application Structure (Step 1)
+// ContentView serves as the main entry point for the verifier app UI.
+// It provides navigation to three key screens:
+// 1. Certificate Management - for managing trusted issuer certificates (IACA)
+// 2. Scan QR Code - for scanning the holder's QR code to initiate proximity verification
+// 3. View Response - for displaying the verification results
+struct ContentView: View {
+ @State var viewModel: VerifierViewModel = VerifierViewModel()
+
+ var body: some View {
+ NavigationStack(path: $viewModel.navigationPath) {
+ VStack {
+ Button("Certificate Management") {
+ viewModel.navigationPath.append(NavigationState.certificateManagement)
+ }
+ .padding()
+
+ Button("Scan QR Code") {
+ viewModel.navigationPath.append(NavigationState.scanQRCode)
+ }
+ .padding()
+
+ Button("View Response") {
+ viewModel.navigationPath.append(NavigationState.viewResponse)
+ }
+ .padding()
+ }
+ .navigationDestination(for: NavigationState.self) { destination in
+ switch destination {
+ case .certificateManagement:
+ certificateManagementView
+ case .scanQRCode:
+ codeScannerView
+ case .viewResponse:
+ presentationResponseView
+ }
+ }
+ }
+ }
+
+ // MARK: Verification Views
+
+ // Verify mDocs - Step 2: Scan and process a QR code
+ // As defined in ISO 18013-5, the proximity presentation workflow is initiated by the holder
+ // presenting a QR code. The verifier scans this QR code to retrieve the device engagement
+ // string, which is then used to establish a secure BLE connection with the holder's wallet.
+ var codeScannerView: some View {
+ // Verify mDocs - Step 2.4: Create QRScannerView
+ QRScannerView(
+ completion: { string in
+ viewModel.setupProximityPresentationSession(string)
+ }
+ )
+ }
+ // MARK: Section: Manage Certificates (Step 1.2)
+ // Navigate to the CertificateManagementView where the user can add, view,
+ // and remove trusted IACA root certificates. These certificates are needed
+ // to verify that a presented mDoc was issued by a trusted issuer.
+ // Manage Certificates - Step 1.2: Create CertificateManagementView
+ var certificateManagementView: some View {
+ CertificateManagementView()
+ }
+ // Verify mDocs - Step 4: Display verification results
+ // After the proximity presentation exchange completes, this view displays
+ // the verification results for each received mDoc. It shows a loading indicator
+ // while waiting for the holder's response, then renders a DocumentView for
+ // each credential once results are available.
+ var presentationResponseView: some View {
+ // Verify mDocs - Step 4.2: Create PresentationResponseView
+ ZStack {
+ if viewModel.receivedDocuments.isEmpty {
+ VStack(spacing: 40) {
+ Text("Waiting for response...")
+ .font(.title)
+ ProgressView()
+ .progressViewStyle(.circular)
+ .scaleEffect(2)
+ }
+ } else {
+ ScrollView {
+ ForEach(viewModel.receivedDocuments, id: \.docType) { doc in
+ DocumentView(viewModel: DocumentViewModel(from: doc))
+ .padding(10)
+ }
+ }
+ }
+ }
+ }
+}
+
+// MARK: - VerifierViewModel
+// The VerifierViewModel holds the core SDK instance, the credential request definition,
+// and the received verification results. It coordinates the entire proximity presentation
+// workflow: initialization, session creation, request sending, and response handling.
+@Observable
+final class VerifierViewModel {
+ var navigationPath = NavigationPath()
+
+ // Initialize SDK - Step 2.2: Add MobileCredentialVerifier var
+ // This variable holds a shared instance of MobileCredentialVerifier, which provides
+ // access to all SDK functions (certificate management, session creation, etc.).
+ var mobileCredentialVerifier: MobileCredentialVerifier
+
+ // MARK: Section: Verify mDocs - Step 1: Create a presentation request
+ // MobileCredentialRequest defines what information the verifier requests from the holder.
+ // - docType: The credential type to request (e.g. mobile driver's license).
+ // - namespaces: The specific claims to request under each namespace.
+ // Each claim maps to a boolean indicating whether the verifier intends to retain (persist) the value.
+ // For verification to succeed, the presented credential must include these claims
+ // under the exact namespace specified in the request.
+ // Verify mDocs - Step 1.1: Create MobileCredentialRequest instance
+ let mobileCredentialRequest = MobileCredentialRequest(
+ docType: "org.iso.18013.5.1.mDL",
+ namespaces: [
+ "org.iso.18013.5.1": [
+ "family_name": false,
+ "given_name": false,
+ "birth_date": false
+ ]
+ ]
+ )
+
+ // Verify mDocs - Step 1.2: Create receivedDocuments variable
+ // Holds the wallet's response after verification. Each MobileCredentialPresentation
+ // contains the verified claims, any claim errors, and the overall verification result.
+ var receivedDocuments: [MobileCredentialPresentation] = []
+
+ // Initialize SDK - Step 2.3: Initialize the SDK
+ // Initialization assigns the shared MobileCredentialVerifier instance and calls initialize()
+ // to prepare the SDK for use. A trusted IACA certificate is also pre-loaded so the app
+ // can verify mDocs issued by the Montcliff DMV test issuer out of the box.
+ init() {
+ do {
+ mobileCredentialVerifier = MobileCredentialVerifier.shared
+ try mobileCredentialVerifier.initialize()
+ Task {
+ try? await mobileCredentialVerifier.addTrustedIssuerCertificates(certificates: [Constants.montcliffPEM])
+ }
+ } catch {
+ print(error.localizedDescription)
+ }
+ }
+}
+
+// MARK: - Proximity Presentation
+// This extension handles the proximity presentation workflow (Verify mDocs - Step 3):
+// 1. The verifier scans a QR code to get the device engagement string.
+// 2. createProximityPresentationSession establishes a BLE connection with the holder's wallet.
+// 3. Once connected (onEstablished callback), sendDeviceRequest sends the credential request.
+// 4. The holder reviews and consents to sharing, then the SDK receives and verifies the response.
+// 5. Results are stored in receivedDocuments for display.
+extension VerifierViewModel {
+ func setupProximityPresentationSession(_ deviceEngagementString: String) {
+ // Verify mDocs - Step 3.2: Create setupProximityPresentationSession
+ // Takes the device engagement string from the scanned QR code and creates a proximity
+ // presentation session. The `listener` (self) receives session lifecycle callbacks.
+ mobileCredentialVerifier.createProximityPresentationSession(encodedDeviceEngagementString: deviceEngagementString, listener: self)
+ }
+
+ func sendDeviceRequest() {
+ // Verify mDocs - Step 3.3: Create sendDeviceRequest function
+ // This function is called when the proximity session is established (via onEstablished).
+ // It performs the following sequence:
+ // 1. Navigates to the response view (shows a loading indicator).
+ // 2. Sends the presentation request to the holder's wallet via sendProximityPresentationRequest.
+ // The SDK handles the BLE data exchange, receives the wallet's response, and verifies
+ // any mDocs included using the stored trusted IACA certificates.
+ // 3. Stores the verified credentials in receivedDocuments for the UI to display.
+ // 4. Terminates the proximity session to release BLE resources.
+ Task { @MainActor in
+ receivedDocuments = []
+ do {
+ navigationPath.append(NavigationState.viewResponse)
+ let deviceResponse = try await mobileCredentialVerifier.sendProximityPresentationRequest(
+ request: [mobileCredentialRequest]
+ )
+
+ receivedDocuments = deviceResponse.credentials ?? []
+ await mobileCredentialVerifier.terminateProximityPresentationSession()
+ } catch {
+ print(error)
+ receivedDocuments = []
+ }
+ }
+ }
+}
+
+// Verify mDocs - Step 3.1: Extend VerifierViewModel class
+// Conforming to ProximityPresentationSessionListener enables the ViewModel to react
+// to proximity session lifecycle events:
+// - onEstablished: Called when the BLE connection with the holder is ready.
+// This is where we trigger sendDeviceRequest() to send our credential request.
+// - onTerminated: Called when the session ends (either normally or due to an error).
+// - onError: Called if an error occurs during the session.
+extension VerifierViewModel: ProximityPresentationSessionListener {
+ public func onEstablished() {
+ sendDeviceRequest()
+ }
+
+ public func onTerminated(error: (any Error)?) {
+ print("Session Terminated")
+ }
+
+ func onError(error: (any Error)?) {
+ print("There was an error")
+ }
+}
+
+
+// MARK: - Navigation
+// NavigationState defines the three main screens of the verifier app,
+// corresponding to the tutorial's key capabilities:
+// - certificateManagement: Manage trusted issuer certificates (Section: Manage Certificates)
+// - scanQRCode: Scan holder's QR code to initiate verification (Section: Verify mDocs - Step 2)
+// - viewResponse: Display verification results (Section: Verify mDocs - Step 4)
+enum NavigationState: Hashable {
+ case certificateManagement
+ case scanQRCode
+ case viewResponse
+}
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/DocumentView.swift b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/DocumentView.swift
new file mode 100644
index 0000000..c388c84
--- /dev/null
+++ b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/DocumentView.swift
@@ -0,0 +1,178 @@
+import MobileCredentialVerifierSDK
+import SwiftUI
+
+// MARK: - Section: Verify mDocs - Step 4: Display verification results
+// After the proximity presentation exchange completes, this view displays the verification
+// results for a single received mDoc credential. It shows:
+// - The document type (e.g. org.iso.18013.5.1.mDL)
+// - The overall verification status (Verified/Invalid) with color coding
+// - Any verification failure reason
+// - All received claims organized by namespace (e.g. family_name, given_name, birth_date)
+// - Any failed claims (claims that were requested but not provided by the holder)
+struct DocumentView: View {
+ var viewModel: DocumentViewModel
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(viewModel.docType)
+ .font(.title)
+ .fontWeight(.bold)
+ .padding(.bottom, 5)
+
+ Text(viewModel.verificationResult)
+ .font(.title)
+ .fontWeight(.bold)
+ .foregroundStyle(viewModel.verificationFailedReason == nil ? .green : .red)
+ .padding(.bottom, 5)
+
+ if let verificationFailedReason = viewModel.verificationFailedReason {
+ Text(verificationFailedReason)
+ .font(.title3)
+ .fontWeight(.bold)
+ .foregroundStyle(.red)
+ .padding(.bottom, 5)
+ }
+
+ ForEach(viewModel.namespacesAndClaims.keys.sorted(), id: \.self) { key in
+ VStack(alignment: .leading, spacing: 5) {
+ Text(key)
+ .font(.headline)
+ .padding(.vertical, 5)
+ .padding(.horizontal, 10)
+ .background(Color.gray.opacity(0.2))
+ .cornerRadius(5)
+
+ ForEach(viewModel.namespacesAndClaims[key]!.keys.sorted(), id: \.self) { claim in
+ HStack {
+ Text(claim)
+ .fontWeight(.semibold)
+ Spacer()
+ Text(viewModel.namespacesAndClaims[key]![claim]! ?? "")
+ .fontWeight(.regular)
+ }
+ .padding(.vertical, 5)
+ .padding(.horizontal, 10)
+ .background(Color.white)
+ .cornerRadius(5)
+ .shadow(radius: 1)
+ }
+ }
+ .padding(.vertical, 5)
+ }
+
+ if !viewModel.claimErrors.isEmpty {
+ Text("Failed Claims:")
+ .font(.headline)
+ .padding(.vertical, 5)
+
+ ForEach(viewModel.claimErrors.keys.sorted(), id: \.self) { key in
+ VStack(alignment: .leading, spacing: 5) {
+ Text(key)
+ .font(.headline)
+ .padding(.vertical, 5)
+ .padding(.horizontal, 10)
+ .background(Color.gray.opacity(0.2))
+ .cornerRadius(5)
+
+ ForEach(viewModel.claimErrors[key]!.keys.sorted(), id: \.self) { claim in
+ HStack {
+ Text(claim)
+ .fontWeight(.semibold)
+ Spacer()
+ Text(viewModel.claimErrors[key]![claim]! ?? "")
+ .fontWeight(.regular)
+ }
+ .padding(.vertical, 5)
+ .padding(.horizontal, 10)
+ .background(Color.white)
+ .cornerRadius(5)
+ .shadow(radius: 1)
+ }
+ }
+ .padding(.vertical, 5)
+ }
+ }
+ }
+ .padding()
+ .background(RoundedRectangle(cornerRadius: 10).fill(Color.white).shadow(radius: 5))
+ .padding(.horizontal)
+ }
+}
+
+// MARK: - DocumentViewModel
+// Transforms a MobileCredentialPresentation (SDK response object) into display-friendly strings.
+// The MobileCredentialPresentation contains:
+// - docType: The credential type that was presented.
+// - verificationResult: Whether the credential passed verification and any failure reason.
+// - claims: Successfully received and verified claim values, organized by namespace.
+// - claimErrors: Claims that were requested but couldn't be provided.
+@Observable
+class DocumentViewModel {
+ let docType: String
+ let namespacesAndClaims: [String: [String: String?]]
+ let claimErrors: [String: [String: String?]]
+ let verificationResult: String
+ let verificationFailedReason: String?
+
+ init(from presentation: MobileCredentialPresentation) {
+ self.docType = presentation.docType
+ self.verificationResult = presentation.verificationResult.verified ? "Verified" : "Invalid"
+ self.verificationFailedReason = presentation.verificationResult.reason?.message
+
+ self.namespacesAndClaims = presentation.claims?.reduce(into: [String: [String: String]]()) { result, outerElement in
+ let (outerKey, innerDict) = outerElement
+ result[outerKey] = innerDict.mapValues { $0.textRepresentation }
+ } ?? [:]
+
+ self.claimErrors = presentation.claimErrors?.reduce(into: [String: [String: String]]()) { result, outerElement in
+ let (outerKey, innerDict) = outerElement
+ result[outerKey] = innerDict.mapValues { "\($0)" }
+ } ?? [:]
+ }
+}
+
+// MARK: - Helper: MobileCredentialElementValue text conversion
+// Extension that converts SDK claim values (MobileCredentialElementValue) into human-readable
+// strings for display. Claim values can be various types (bool, string, int, date, binary data,
+// nested maps, arrays, etc.) and each needs appropriate formatting.
+extension MobileCredentialElementValue {
+ var textRepresentation: String {
+ switch self {
+ case .bool(let bool):
+ return "\(bool)"
+ case .string(let string):
+ return string
+ case .int(let int):
+ return "\(int)"
+ case .unsigned(let uInt):
+ return "\(uInt)"
+ case .float(let float):
+ return "\(float)"
+ case .double(let double):
+ return "\(double)"
+ case let .date(date):
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateStyle = .short
+ dateFormatter.timeStyle = .none
+ return dateFormatter.string(from: date)
+ case let .dateTime(date):
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateStyle = .short
+ dateFormatter.timeStyle = .short
+ return dateFormatter.string(from: date)
+ case .data(let data):
+ return "Data \(data.count) bytes"
+ case .map(let dictionary):
+ let result = dictionary.mapValues { value in
+ value.textRepresentation
+ }
+ return "\(result)"
+ case .array(let array):
+ return array.reduce("") { partialResult, element in
+ partialResult + element.textRepresentation
+ }
+ .appending("")
+ @unknown default:
+ return "Unknown type"
+ }
+ }
+}
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/In_person_verification_tutorialApp.swift b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/In_person_verification_tutorialApp.swift
new file mode 100644
index 0000000..6e20644
--- /dev/null
+++ b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/In_person_verification_tutorialApp.swift
@@ -0,0 +1,17 @@
+import SwiftUI
+
+// MARK: - App Entry Point
+// This is the main entry point for the In-Person Verification tutorial app.
+// It launches ContentView, which provides the navigation structure for the three
+// key capabilities built in this tutorial:
+// 1. Initialize the SDK (automatic on launch)
+// 2. Manage trusted issuer certificates
+// 3. Verify mDocs via proximity presentation (scan QR, exchange credentials, view results)
+@main
+struct In_person_verification_tutorialApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/QRScannerView.swift b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/QRScannerView.swift
new file mode 100644
index 0000000..20dc058
--- /dev/null
+++ b/ios-in-person-verifier-tutorial-sample-app/In person verification tutorial/QRScannerView.swift
@@ -0,0 +1,35 @@
+import SwiftUI
+import CodeScanner
+import AVFoundation
+
+// MARK: - Section: Verify mDocs - Step 2: Scan and process a QR code
+// As defined in ISO 18013-5, a proximity presentation workflow is always initiated by the holder,
+// who presents a QR code containing a device engagement string. The verifier scans this QR code
+// to retrieve the engagement data and establish a secure BLE connection.
+//
+// This view uses the third-party CodeScanner library (added via Swift Package Manager) to provide
+// camera-based QR code scanning. When a QR code is successfully scanned, the result string
+// (device engagement) is passed to the completion handler, which triggers
+// setupProximityPresentationSession in the VerifierViewModel.
+//
+// Prerequisites: Camera usage permissions must be configured in the app's Info.plist.
+struct QRScannerView: View {
+ private let completionHandler: (String) -> Void
+
+ init(completion: @escaping (String) -> Void) {
+ completionHandler = completion
+ }
+
+ var body: some View {
+ CodeScannerView(codeTypes: [.qr]) { result in
+ switch result {
+ case .failure(let error):
+ print(error.localizedDescription)
+ case .success(let result):
+ print(result.string)
+ completionHandler(result.string)
+ }
+ }
+ }
+}
+