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 @@ + + + +