diff --git a/.gitignore b/.gitignore index 07cd930..9a80a98 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ proguard-project.txt # Android Studio/IDEA *.iml .idea +.kotlin \ No newline at end of file diff --git a/CredentialProvider/MyVault/gradle/libs.versions.toml b/CredentialProvider/MyVault/gradle/libs.versions.toml index f597b3f..d368d6b 100644 --- a/CredentialProvider/MyVault/gradle/libs.versions.toml +++ b/CredentialProvider/MyVault/gradle/libs.versions.toml @@ -9,7 +9,7 @@ espressoCore = "3.6.1" lifecycleRuntime = "2.8.7" activityCompose = "1.10.0" composeBom = "2025.02.00" -credentials = "1.6.0" +credentials = "1.7.0-alpha02" room = "2.7.2" biometrics = "1.2.0-alpha05" accompanist = "0.28.0" diff --git a/DigitalCredentialsApp/README.md b/DigitalCredentialsApp/README.md new file mode 100644 index 0000000..63939a5 --- /dev/null +++ b/DigitalCredentialsApp/README.md @@ -0,0 +1,58 @@ +# Digital Credentials Sample App + +This is a sample repository for the **Digital Credentials API** integration in Android, showcasing how to request and parse verifiable credentials using the Credential Manager. + +The Digital Credentials Sample App is a functional Android app built with Kotlin and Jetpack Compose. It is designed to help developers understand the workflow for retrieving and presenting digital credentials, such as Mobile Driver's Licenses (mDL) and Verified Emails, using the OpenID4VP and DCQL standards. + +## Features + +This sample app implements the following use cases: + +* **Get Digital Credential (mdoc)**: Request a Mobile Driver's License (mDL) or other ISO 18013-5 compliant credentials. +* **Get Verified Email**: Request a verified email address using the SD-JWT (Selective Disclosure JWT) format. +* **OpenID4VP & DCQL Support**: Demonstrates how to construct modern, simplified Digital Credential Query Language (DCQL) requests. +* **CBOR & SD-JWT Parsing**: Includes a reference implementation for decoding CBOR-based mDoc data and parsing selectively disclosed claims from SD-JWTs. + +## Requirements + +* Latest release of [Android Studio](https://developer.android.com/studio) +* Java 17 or higher +* A physical device or emulator running Android 9 (API level 28) or higher. +* A digital wallet app (like [CMWallet](https://github.com/digitalcredentialsdev/CMWallet)) installed and registered on the device to test the generic retrieval flow. This is not needed to test email verification functionality. + +## Typical Workflow + +* **Launch the app**: The main screen presents two primary actions for credential retrieval. +* **Request a Credential**: Tap on "Get Digital Credential (mdoc)" or "Get Verified Email". +* **Credential Selection**: The Credential Manager bottom sheet will appear, listing available credentials from registered wallets. +* **User Consent**: Once a credential is selected, the wallet app will handle user consent and authentication. +* **Result Display**: The app receives the response, validates it locally (simulated), parses the individual claims (e.g., Given Name, Email), and displays them in the UI. + +## Architecture & Design + +### Credential Manager Integration + +Credential Manager calls are centralized in `CredentialManagerUtil.kt`. The app uses `GetDigitalCredentialOption` to pass JSON requests formatted according to the OpenID4VP specification. + +### Data Requests + +The `Requests.kt` file contains the logic for generating dynamic JSON requests. It supports: +- **DCQL Query Structure**: Simplified claim requests using paths. +- **Client Metadata**: Specifies supported algorithms for secure credential exchange. + +### Parsing Logic + +Since digital credentials come in various formats, the app includes robust parsing utilities: +- **CBOR Decoding**: A dedicated `Cbor.kt` utility handles the binary mDoc format, including manual unwrapping of Tag 24 items. +- **SD-JWT Parsing**: Logic to extract claims from Selective Disclosure JWTs by decoding individual disclosures. + +### UI Layer + +Built with **Jetpack Compose**, the UI follows a simple MVI-like pattern: +- `MainScreen.kt`: Handles the UI layout and interaction events. +- `MainViewModel.kt`: Manages the state of the credential request (Initial, Loading, Success, or Error). +- `MainUiState.kt`: Defines the data model for the UI states and extracted claims. + +## License + +This sample is distributed under the terms of the Apache License (Version 2.0). See the [LICENSE](LICENSE) file for more information. diff --git a/DigitalCredentialsApp/app/build.gradle b/DigitalCredentialsApp/app/build.gradle new file mode 100644 index 0000000..103f626 --- /dev/null +++ b/DigitalCredentialsApp/app/build.gradle @@ -0,0 +1,62 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'org.jetbrains.kotlin.plugin.compose' +} + +android { + namespace 'com.example.digitalcredentialsapp' + compileSdk 35 + + defaultConfig { + applicationId "com.example.digitalcredentialsapp" + minSdk 26 + targetSdk 35 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + buildFeatures { + compose true + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0' + + // Compose + implementation platform('androidx.compose:compose-bom:2024.02.00') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.activity:activity-compose:1.8.2' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0' + + // Credential Manager + implementation("androidx.credentials:credentials:1.6.0-beta01") + implementation("androidx.credentials:credentials-play-services-auth:1.6.0-beta01") + + // Coroutines + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' +} diff --git a/DigitalCredentialsApp/app/src/main/AndroidManifest.xml b/DigitalCredentialsApp/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fe99027 --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt new file mode 100644 index 0000000..eabe492 --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt @@ -0,0 +1,348 @@ +package com.example.digitalcredentialsapp + +import android.app.Activity +import android.util.Base64 +import android.util.Log +import androidx.credentials.CredentialManager +import androidx.credentials.DigitalCredential +import androidx.credentials.ExperimentalDigitalCredentialApi +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetDigitalCredentialOption +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialCustomException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialInterruptedException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.exceptions.NoCredentialException +import com.example.digitalcredentialsapp.data.CborTag +import com.example.digitalcredentialsapp.data.cborDecode +import com.example.digitalcredentialsapp.data.RequestedClaim +import com.example.digitalcredentialsapp.data.Requests +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.security.SecureRandom +import kotlin.collections.get + +/** + * Utility object for interacting with the Android Credential Manager API. + * + * This object handles the creation of credential requests and the parsing of responses + * for both Mobile Driver's Licenses (mDL) and Verified Emails. + */ +object CredentialManagerUtil { + + /** + * Initiates a request for a Digital Credential (e.g., mDL). + * + * @param activity The activity context used to display the Credential Manager UI. + * @return The resulting [MainUiState]. + */ + suspend fun getDigitalCredential(activity: Activity): MainUiState { + val requestJson = simulateMdlServerResponse() + return requestCredential(activity, requestJson) + } + + /** + * Initiates a request for a Verified Email credential. + * + * @param activity The activity context used to display the Credential Manager UI. + * @return The resulting [MainUiState]. + */ + suspend fun getVerifiedEmailCredential(activity: Activity): MainUiState { + val requestJson = simulateEmailServerResponse() + return requestCredential(activity, requestJson) + } + + /** + * Simulates a server-side request generation for an mDL. + */ + private suspend fun simulateMdlServerResponse(): String = withContext(Dispatchers.IO) { + Requests.getOpenId4VpDigitalCredentialRequest( + nonce = generateSecureRandomNonce(), + protocol = "openid4vp-v1-unsigned", + clientId = null, + clientMetadata = """ + { + "vp_formats_supported": { + "mso_mdoc": { + "deviceauth_alg_values": [-7], + "issuerauth_alg_values": [-7] + } + } + } + """.trimIndent(), + format = "mso_mdoc", + meta = """{"doctype_value": "org.iso.18013.5.1.mDL"}""", + requestedClaims = listOf( + RequestedClaim("", listOf("org.iso.18013.5.1", "family_name")), + RequestedClaim("", listOf("org.iso.18013.5.1", "given_name")), + RequestedClaim("", listOf("org.iso.18013.5.1", "age_over_21")) + ) + ) + } + + /** + * Simulates a server-side request generation for a Verified Email. + */ + private suspend fun simulateEmailServerResponse(): String = withContext(Dispatchers.IO) { + Requests.getOpenId4VpDigitalCredentialRequest( + nonce = generateSecureRandomNonce(), + protocol = "openid4vp-v1-unsigned", + clientId = null, + clientMetadata = null, + format = "dc+sd-jwt", + meta = """{"vct_values": ["UserInfoCredential"]}""", + requestedClaims = listOf( + RequestedClaim("email", listOf("email")), + RequestedClaim("email_verified", listOf("email_verified")) + ) + ) + } + + /** + * Internal helper to execute a credential request and handle common error states. + */ + @OptIn(ExperimentalDigitalCredentialApi::class) + private suspend fun requestCredential( + activity: Activity, + requestJson: String + ): MainUiState = withContext(Dispatchers.IO) { + try { + val credentialManager = CredentialManager.create(activity) + + val getDigitalCredentialOption = GetDigitalCredentialOption(requestJson = requestJson) + val request = GetCredentialRequest(listOf(getDigitalCredentialOption)) + + val result = credentialManager.getCredential(activity, request) + verifyResult(result) + } catch (e: GetCredentialException) { + handleFailure(e) + } catch (e: Exception) { + Log.e("CredentialManagerUtil", "Unexpected error", e) + MainUiState.Error(e.message ?: "An unknown error occurred") + } + } + + /** + * Handles the successfully returned credential. + */ + @OptIn(ExperimentalDigitalCredentialApi::class) + private fun verifyResult(result: GetCredentialResponse): MainUiState { + return when (val credential = result.credential) { + is DigitalCredential -> { + val responseJson = credential.credentialJson + validateResponseOnServer(responseJson) + val claims = parseClaims(responseJson) + MainUiState.Success( + title = "Credential Received", + claims = claims + ) + } + else -> { + Log.e("CredentialManagerUtil", "Unexpected type of credential ${credential.type}") + MainUiState.Error("Unexpected credential type: ${credential.type}") + } + } + } + + /** + * Handles credential request failures. + */ + private fun handleFailure(e: GetCredentialException): MainUiState { + return when (e) { + is GetCredentialCancellationException -> { + // The user intentionally canceled the operation and chose not + // to share the credential. + MainUiState.Error("Request was cancelled") + } + is GetCredentialInterruptedException -> { + // Retry-able error. Consider retrying the call. + MainUiState.Error("Request was interrupted. Please try again.") + } + is NoCredentialException -> { + // No credential was available. + MainUiState.Error("No matching credentials found") + } + is GetCredentialUnknownException -> { + // An unknown, usually unexpected, error has occurred. Check the + // message error for any additional debugging information. + MainUiState.Error("An unknown error occurred: ${e.message}") + } + is GetCredentialCustomException -> { + // You have encountered a custom error thrown by the wallet. + MainUiState.Error("A custom error occurred: ${e.type}") + } + else -> { + Log.w("CredentialManagerUtil", "Unexpected exception type ${e::class.java}") + MainUiState.Error(e.message ?: "An unknown error occurred") + } + } + } + + /** + * Simulates sending the response to a backend server for validation. + * + * @param responseJson The raw JSON response from the Digital Credentials API. + */ + private fun validateResponseOnServer(responseJson: String) { + Log.d("CredentialManagerUtil", "Response validated on (simulated) server") + } + + /** + * Generates a cryptographically secure nonce for the request. + */ + private fun generateSecureRandomNonce(): String { + val sr = SecureRandom() + val nonceBytes = ByteArray(32) + sr.nextBytes(nonceBytes) + return Base64.encodeToString(nonceBytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + } + + /** + * Parses the raw JSON response from the Digital Credentials API into a list of [CredentialClaim]s. + * + * In DCQL (Digital Credential Query Language), the response contains a 'vp_token' + * which can be a single token string or a map of credential IDs to tokens. + */ + private fun parseClaims(responseJsonString: String): List { + val claims = mutableListOf() + try { + val responseJson = JSONObject(responseJsonString) + val data = responseJson.optJSONObject("data") ?: responseJson + val vpToken = data.opt("vp_token") ?: return emptyList() + + when (vpToken) { + is JSONObject -> { + // Handle map of IDs to tokens/arrays + for (key in vpToken.keys()) { + val token = when (val value = vpToken.get(key)) { + is JSONArray -> if (value.length() > 0) value.getString(0) else null + is String -> value + else -> null + } + token?.let { claims.addAll(parseToken(it)) } + } + } + is JSONArray -> { + // Handle array of tokens + for (i in 0 until vpToken.length()) { + val token = vpToken.optString(i) + if (token.isNotEmpty()) claims.addAll(parseToken(token)) + } + } + is String -> { + // Handle single token string + claims.addAll(parseToken(vpToken)) + } + } + } catch (e: Exception) { + Log.e("CredentialManagerUtil", "Failed to parse claims", e) + } + return claims + } + + /** + * Identifies the format of a raw token and parses its claims. + */ + private fun parseToken(rawToken: String): List { + return if (rawToken.contains("~")) { + parseSdJwtClaims(rawToken) + } else { + parseMdocClaims(rawToken) + } + } + + /** + * Specifically parses claims from an mDoc (ISO 18013-5) binary response. + */ + private fun parseMdocClaims(base64Mdoc: String): List { + val claims = mutableListOf() + try { + val bytes = Base64.decode(base64Mdoc, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + val decoded = cborDecode(bytes) as? Map<*, *> ?: return emptyList() + + val documents = decoded["documents"] as? List<*> ?: return emptyList() + for (doc in documents) { + val docMap = doc as? Map<*, *> ?: continue + val issuerSigned = docMap["issuerSigned"] as? Map<*, *> ?: continue + val nameSpaces = issuerSigned["nameSpaces"] as? Map<*, *> ?: continue + + val mdlNamespace = nameSpaces["org.iso.18013.5.1"] + + if (mdlNamespace is List<*>) { + for (item in mdlNamespace) { + val decodedItem = if (item is CborTag && item.tag == 24L) { + cborDecode(item.item as ByteArray) + } else { + item + } + (decodedItem as? Map<*, *>)?.let { extractClaim(it, claims) } + } + } else if (mdlNamespace is Map<*, *>) { + mdlNamespace.forEach { (k, v) -> + val value = if (v is CborTag && v.tag == 24L) { + cborDecode(v.item as ByteArray) + } else { + v + } + claims.add(CredentialClaim(formatLabel(k.toString()), value.toString())) + } + } + } + } catch (e: Exception) { + Log.e("CredentialManagerUtil", "Failed to parse mDoc", e) + } + return claims + } + + /** + * Extracts an individual claim from an IssuerSignedItem map. + */ + private fun extractClaim(itemMap: Map<*, *>, claims: MutableList) { + val key = itemMap["elementIdentifier"] as? String ?: return + val value = itemMap["elementValue"] ?: return + + val formattedValue = when (value) { + is Boolean -> if (value) "Yes" else "No" + else -> value.toString() + } + claims.add(CredentialClaim(formatLabel(key), formattedValue)) + } + + /** + * Parses claims from an SD-JWT (Selective Disclosure JWT) format. + */ + private fun parseSdJwtClaims(sdJwt: String): List { + val claims = mutableListOf() + val parts = sdJwt.split("~") + for (i in 1 until parts.size) { + val part = parts[i] + if (part.isNotEmpty()) { + try { + val decodedBytes = Base64.decode(part, Base64.URL_SAFE or Base64.NO_WRAP) + val jsonArray = JSONArray(String(decodedBytes)) + if (jsonArray.length() == 3) { + val key = jsonArray.getString(1) + val value = jsonArray.get(2).toString() + claims.add(CredentialClaim(formatLabel(key), value)) + } + } catch (e: Exception) { + // Skip invalid parts + } + } + } + return claims + } + + /** + * Formats a raw identifier key into a human-readable label. + */ + private fun formatLabel(key: String): String { + return key.replace("_", " ") + .split(" ") + .joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } } + } +} \ No newline at end of file diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/Extensions.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/Extensions.kt new file mode 100644 index 0000000..6f0c101 --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/Extensions.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Google LLC + * + * 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. + */ + +package com.example.digitalcredentialsapp + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} \ No newline at end of file diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainActivity.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainActivity.kt new file mode 100644 index 0000000..782f045 --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainActivity.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Google LLC + * + * 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. + */ + +package com.example.digitalcredentialsapp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme { + MainScreen() + } + } + } +} diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainScreen.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainScreen.kt new file mode 100644 index 0000000..a1e9262 --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainScreen.kt @@ -0,0 +1,284 @@ +/* + * Copyright 2026 Google LLC + * + * 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. + */ + +package com.example.digitalcredentialsapp + +import android.app.Activity +import androidx.compose.foundation.layout.Column +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + +/** + * Stateful entry point for the Main screen. + * + * This composable initializes the [MainViewModel] and provides credential retrieval + * logic to the ViewModel via suspend lambdas. + * + * @param modifier The modifier to be applied to the screen. + */ +@Composable +fun MainScreen( + modifier: Modifier = Modifier +) { + val viewModel: MainViewModel = viewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + MainContent( + uiState = uiState, + onGetDigitalCredentialClick = { + val activity = context.findActivity() + if (activity != null) { + viewModel.getDigitalCredential { + CredentialManagerUtil.getDigitalCredential(activity) + } + } + }, + onGetVerifiedEmailClick = { + val activity = context.findActivity() + if (activity != null) { + viewModel.getVerifiedEmailCredential { + CredentialManagerUtil.getVerifiedEmailCredential(activity) + } + } + }, + modifier = modifier + ) +} + +/** + * Stateless UI content for the Main screen. + * + * Displays the credential request actions and the resulting claims in a clean layout. + * + * @param uiState The current state of the UI. + * @param onGetDigitalCredentialClick Callback triggered when the Digital Credential button is clicked. + * @param onGetVerifiedEmailClick Callback triggered when the Verified Email button is clicked. + * @param modifier The modifier to be applied to the layout. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainContent( + uiState: MainUiState, + onGetDigitalCredentialClick: () -> Unit, + onGetVerifiedEmailClick: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(R.string.header_title), + style = MaterialTheme.typography.titleLarge + ) + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + }, + modifier = modifier.fillMaxSize() + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onGetDigitalCredentialClick, + enabled = uiState !is MainUiState.Loading, + modifier = Modifier + .fillMaxWidth(0.8f) + .padding(bottom = 8.dp) + ) { + Text(text = stringResource(R.string.get_digital_credential)) + } + + Button( + onClick = onGetVerifiedEmailClick, + enabled = uiState !is MainUiState.Loading, + modifier = Modifier.fillMaxWidth(0.8f) + ) { + Text(text = stringResource(R.string.get_verified_email)) + } + + Spacer(modifier = Modifier.height(32.dp)) + + ResultSection(uiState) + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +/** + * Renders the results of a credential request based on the current [MainUiState]. + * + * @param uiState The state to render. + */ +@Composable +fun ResultSection(uiState: MainUiState) { + when (uiState) { + is MainUiState.Initial -> { + Text( + text = stringResource(R.string.results_placeholder), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } + is MainUiState.Loading -> { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.requesting_credential), + style = MaterialTheme.typography.bodyMedium + ) + } + } + is MainUiState.Success -> { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = uiState.title, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp), + color = MaterialTheme.colorScheme.primary + ) + uiState.claims.forEach { claim -> + ClaimCard(claim) + Spacer(modifier = Modifier.height(8.dp)) + } + if (uiState.claims.isEmpty()) { + Text( + text = stringResource(R.string.no_claims_extracted), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + is MainUiState.Error -> { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = uiState.message, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } +} + +/** + * Displays an individual [CredentialClaim] in a card. + * + * @param claim The claim to display. + */ +@Composable +fun ClaimCard(claim: CredentialClaim) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = claim.label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + Text( + text = claim.value, + style = MaterialTheme.typography.bodyLarge + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun MainScreenPreview() { + MaterialTheme { + MainContent( + uiState = MainUiState.Success( + title = "Driver's License", + claims = listOf( + CredentialClaim("Given Name", "John"), + CredentialClaim("Family Name", "Doe"), + CredentialClaim("Age Over 21", "Yes") + ) + ), + onGetDigitalCredentialClick = {}, + onGetVerifiedEmailClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun MainScreenLoadingPreview() { + MaterialTheme { + MainContent( + uiState = MainUiState.Loading, + onGetDigitalCredentialClick = {}, + onGetVerifiedEmailClick = {} + ) + } +} diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainUiState.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainUiState.kt new file mode 100644 index 0000000..63c22e2 --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainUiState.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Google LLC + * + * 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. + */ + +package com.example.digitalcredentialsapp + +/** + * Represents a single field or "claim" extracted from a digital credential. + * + * @property label The human-readable name of the field (e.g., "Given Name"). + * @property value The retrieved data for that field (e.g., "John"). + */ +data class CredentialClaim( + val label: String, + val value: String +) + +/** + * Sealed interface representing the various states of the Main screen UI. + */ +sealed interface MainUiState { + /** + * The initial state before any request has been made. + */ + data object Initial : MainUiState + + /** + * Represents an active credential retrieval request in progress. + */ + data object Loading : MainUiState + + /** + * Represents a successful credential retrieval. + * + * @property title The type of credential received (e.g., "Driver's License"). + * @property claims The list of individual fields extracted from the response. + */ + data class Success( + val title: String, + val claims: List + ) : MainUiState + + /** + * Represents a failure in the credential retrieval flow. + * + * @property message A human-readable error or status message (e.g., "Request cancelled"). + */ + data class Error(val message: String) : MainUiState +} diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainViewModel.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainViewModel.kt new file mode 100644 index 0000000..288c9de --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainViewModel.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Google LLC + * + * 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. + */ + +package com.example.digitalcredentialsapp + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class MainViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(MainUiState.Initial) + val uiState: StateFlow = _uiState.asStateFlow() + + /** + * Orchestrates a Digital Credential (e.g., mDL) request. + * + * @param requestBlock A suspend lambda that performs the retrieval and returns a [MainUiState]. + */ + fun getDigitalCredential(requestBlock: suspend () -> MainUiState) { + viewModelScope.launch { + _uiState.value = MainUiState.Loading + _uiState.value = requestBlock() + } + } + + /** + * Orchestrates a Verified Email credential request. + * + * @param requestBlock A suspend lambda that performs the retrieval and returns a [MainUiState]. + */ + fun getVerifiedEmailCredential(requestBlock: suspend () -> MainUiState) { + viewModelScope.launch { + _uiState.value = MainUiState.Loading + _uiState.value = requestBlock() + } + } +} diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt new file mode 100644 index 0000000..85a5580 --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt @@ -0,0 +1,369 @@ +/* + * Copyright 2026 Google LLC + * + * 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. + */ + +package com.example.digitalcredentialsapp.data + +import java.io.ByteArrayOutputStream + +/** + * Represents a CBOR tag and its associated item. + * + * @property tag The CBOR tag value. + * @property item The item associated with the tag. + */ +data class CborTag( + val tag: Long, + val item: Any? +) + +/** + * Decodes a CBOR-encoded byte array into its corresponding Kotlin object representation. + * + * @param data The CBOR-encoded byte array. + * @return The decoded object (e.g., Long, String, ByteArray, List, Map, CborTag, or null). + */ +fun cborDecode(data: ByteArray): Any? { + return Cbor().decode(data) +} + +/** + * Encodes a Kotlin object into a CBOR byte array. + * + * @param data The object to encode. Supports Number, String, ByteArray, List, Map, CborTag, and null. + * @return The CBOR-encoded byte array. + */ +fun cborEncode(data: Any?): ByteArray { + return Cbor().encode(data) +} + +/** + * A lightweight CBOR (Concise Binary Object Representation) encoder and decoder implementation. + * Supports basic types, collections, and tags with security depth limits and bounds checking. + */ +class Cbor { + /** + * Internal representation of a decoded CBOR item. + * + * @property item The decoded object. + * @property len The number of bytes consumed for this item. + * @property type The CBOR major type. + */ + data class Item(val item: Any?, val len: Int, val type: Int) + + /** + * Internal representation of a CBOR argument (value/length) and its header length. + * + * @property arg The value or length parsed from the header. + * @property len The length of the header in bytes. + */ + data class Arg(val arg: Long, val len: Int) + + private val MAX_RECURSION_DEPTH = 32 + private val MAX_ITEM_SIZE = 1024 * 1024 // 1MB limit for individual strings/byte arrays + + /** Major type 0: Unsigned integer. */ + val TYPE_UNSIGNED_INT = 0x00 + /** Major type 1: Negative integer. */ + val TYPE_NEGATIVE_INT = 0x01 + /** Major type 2: Byte string. */ + val TYPE_BYTE_STRING = 0x02 + /** Major type 3: Text string. */ + val TYPE_TEXT_STRING = 0x03 + /** Major type 4: Array of items. */ + val TYPE_ARRAY = 0x04 + /** Major type 5: Map of pairs. */ + val TYPE_MAP = 0x05 + /** Major type 6: Optional semantic tag. */ + val TYPE_TAG = 0x06 + /** Major type 7: Floating-point, simple values, and null/bool. */ + val TYPE_FLOAT = 0x07 + + /** + * Decodes the provided CBOR byte array. + * + * @param data The byte array to decode. + * @return The decoded object. + * @throws IllegalArgumentException if the data is malformed or exceeds safety limits. + */ + fun decode(data: ByteArray): Any? { + val ret = parseItem(data, 0, 0) + return ret.item + } + + /** + * Encodes the provided object into CBOR format. + * + * @param data The object to encode. + * @return The encoded byte array. + * @throws IllegalArgumentException if the type is unsupported or recursion depth is exceeded. + */ + fun encode(data: Any?): ByteArray { + val out = ByteArrayOutputStream() + encodeInternal(data, out, 0) + return out.toByteArray() + } + + /** + * Internal recursive encoding function. + * + * @param data The object to encode. + * @param out The stream to write encoded bytes to. + * @param depth Current recursion depth. + */ + private fun encodeInternal(data: Any?, out: ByteArrayOutputStream, depth: Int) { + if (depth > MAX_RECURSION_DEPTH) { + throw IllegalArgumentException("Max recursion depth exceeded during encoding") + } + if (data == null) { + out.write(createArg(TYPE_FLOAT, 22)) + return + } + if (data is Number) { + if (data is Double) { + throw IllegalArgumentException("Don't support doubles yet") + } else { + val value = data.toLong() + if (value >= 0) { + out.write(createArg(TYPE_UNSIGNED_INT, value)) + return + } else { + out.write(createArg(TYPE_NEGATIVE_INT, -1 - value)) + return + } + } + } + if (data is ByteArray) { + out.write(createArg(TYPE_BYTE_STRING, data.size.toLong())) + out.write(data) + return + } + if (data is String) { + val bytes = data.encodeToByteArray() + out.write(createArg(TYPE_TEXT_STRING, bytes.size.toLong())) + out.write(bytes) + return + } + if (data is List<*>) { + out.write(createArg(TYPE_ARRAY, data.size.toLong())) + for (i in data) { + encodeInternal(i, out, depth + 1) + } + return + } + if (data is Map<*, *>) { + out.write(createArg(TYPE_MAP, data.size.toLong())) + for (i in data) { + encodeInternal(i.key!!, out, depth + 1) + encodeInternal(i.value!!, out, depth + 1) + } + return + } + if (data is CborTag) { + out.write(createArg(TYPE_TAG, data.tag)) + encodeInternal(data.item, out, depth + 1) + return + } + throw IllegalArgumentException("Bad type") + } + + /** + * Extracts the major type from the CBOR header at the specified offset. + */ + private fun getType(data: ByteArray, offset: Int): Int { + val d = data[offset].toInt() + return (d and 0xFF) shr 5 + } + + /** + * Parses the argument (value or length) from the CBOR header. + * + * @param data The byte array being parsed. + * @param offset The starting position of the header. + * @return An [Arg] containing the parsed value and the header length. + * @throws IllegalArgumentException if the header is truncated or uses unsupported 64-bit sizes. + */ + private fun getArg(data: ByteArray, offset: Int): Arg { + if (offset >= data.size) { + throw IllegalArgumentException("Offset out of bounds") + } + val arg = data[offset].toLong() and 0x1F + if (arg < 24) { + return Arg(arg, 1) + } + if (arg == 24L) { + if (offset + 1 >= data.size) throw IllegalArgumentException("Unexpected end of data") + return Arg(data[offset + 1].toLong() and 0xFF, 2) + } + if (arg == 25L) { + if (offset + 2 >= data.size) throw IllegalArgumentException("Unexpected end of data") + var ret = (data[offset + 1].toLong() and 0xFF) shl 8 + ret = ret or (data[offset + 2].toLong() and 0xFF) + return Arg(ret, 3) + } + if (arg == 26L) { + if (offset + 4 >= data.size) throw IllegalArgumentException("Unexpected end of data") + var ret = (data[offset + 1].toLong() and 0xFF) shl 24 + ret = ret or ((data[offset + 2].toLong() and 0xFF) shl 16) + ret = ret or ((data[offset + 3].toLong() and 0xFF) shl 8) + ret = ret or (data[offset + 4].toLong() and 0xFF) + return Arg(ret, 5) + } + if (arg == 27L) { + if (offset + 8 >= data.size) throw IllegalArgumentException("Unexpected end of data") + var ret = (data[offset + 1].toLong() and 0xFF) shl 56 + ret = ret or ((data[offset + 2].toLong() and 0xFF) shl 48) + ret = ret or ((data[offset + 3].toLong() and 0xFF) shl 40) + ret = ret or ((data[offset + 4].toLong() and 0xFF) shl 32) + ret = ret or ((data[offset + 5].toLong() and 0xFF) shl 24) + ret = ret or ((data[offset + 6].toLong() and 0xFF) shl 16) + ret = ret or ((data[offset + 7].toLong() and 0xFF) shl 8) + ret = ret or (data[offset + 8].toLong() and 0xFF) + if (ret < 0) throw IllegalArgumentException("Unsupported 64-bit arg size") + return Arg(ret, 9) + } + throw IllegalArgumentException("Bad arg $arg") + } + + /** + * Recursively parses a CBOR item from the byte array. + * + * @param data The byte array to parse from. + * @param offset Current position in the byte array. + * @param depth Current recursion depth. + * @return The parsed [Item]. + */ + private fun parseItem(data: ByteArray, offset: Int, depth: Int): Item { + if (depth > MAX_RECURSION_DEPTH) { + throw IllegalArgumentException("Max recursion depth exceeded") + } + val itemType = getType(data, offset) + val arg = getArg(data, offset) + + when (itemType) { + TYPE_UNSIGNED_INT -> { + return Item(arg.arg, arg.len, TYPE_UNSIGNED_INT) + } + + TYPE_NEGATIVE_INT -> { + return Item(-1 - arg.arg, arg.len, TYPE_NEGATIVE_INT) + } + + TYPE_BYTE_STRING -> { + if (arg.arg > MAX_ITEM_SIZE) throw IllegalArgumentException("Byte string too large") + val end = offset + arg.len + arg.arg.toInt() + if (end > data.size) throw IllegalArgumentException("Unexpected end of data for byte string") + val ret = + data.sliceArray(offset + arg.len until end) + return Item(ret, arg.len + arg.arg.toInt(), TYPE_BYTE_STRING) + } + + TYPE_TEXT_STRING -> { + if (arg.arg > MAX_ITEM_SIZE) throw IllegalArgumentException("Text string too large") + val end = offset + arg.len + arg.arg.toInt() + if (end > data.size) throw IllegalArgumentException("Unexpected end of data for text string") + val ret = + data.sliceArray(offset + arg.len until end) + return Item( + ret.toString(Charsets.UTF_8), arg.len + arg.arg.toInt(), + TYPE_TEXT_STRING + ) + } + + TYPE_ARRAY -> { + val ret = mutableListOf() + var consumed = arg.len + for (i in 0 until arg.arg.toInt()) { + if (offset + consumed >= data.size) throw IllegalArgumentException("Unexpected end of data for array") + val item = parseItem(data, offset + consumed, depth + 1) + ret.add(item.item) + consumed += item.len + } + return Item(ret.toList(), consumed, TYPE_ARRAY) + } + + TYPE_MAP -> { + val ret = mutableMapOf() + var consumed = arg.len + for (i in 0 until arg.arg.toInt()) { + if (offset + consumed >= data.size) throw IllegalArgumentException("Unexpected end of data for map key") + val key = parseItem(data, offset + consumed, depth + 1) + consumed += key.len + if (offset + consumed >= data.size) throw IllegalArgumentException("Unexpected end of data for map value") + val value = parseItem(data, offset + consumed, depth + 1) + consumed += value.len + ret[key.item] = value.item + } + return Item(ret.toMap(), consumed, TYPE_MAP) + } + + TYPE_TAG -> { + if (offset + arg.len >= data.size) throw IllegalArgumentException("Unexpected end of data for tag") + val tagItem = parseItem(data, offset + arg.len, depth + 1) + return Item(CborTag(arg.arg, tagItem.item), arg.len + tagItem.len, TYPE_TAG) + } + + TYPE_FLOAT -> { + if (arg.arg.toInt() == 22) { + return Item(null, arg.len, TYPE_FLOAT) + } else if (arg.arg.toInt() == 20) { + return Item(false, arg.len, TYPE_FLOAT) + } else if (arg.arg.toInt() == 21) { + return Item(true, arg.len, TYPE_FLOAT) + } else { + throw IllegalArgumentException("Bad float $arg") + } + } + + else -> { + throw IllegalArgumentException("Bad type") + } + } + } + + /** + * Creates a CBOR header (major type + value/length argument). + */ + private fun createArg(type: Int, arg: Long): ByteArray { + val t = type shl 5 + val a = arg.toInt() + if (arg < 24) { + return byteArrayOf(((t or a) and 0xFF).toByte()) + } + if (arg <= 0xFF) { + return byteArrayOf( + ((t or 24) and 0xFF).toByte(), + (a and 0xFF).toByte() + ) + } + if (arg <= 0xFFFF) { + return byteArrayOf( + ((t or 25) and 0xFF).toByte(), + ((a shr 8) and 0xFF).toByte(), + (a and 0xFF).toByte() + ) + } + if (arg <= 0xFFFFFFFF) { + return byteArrayOf( + ((t or 26) and 0xFF).toByte(), + ((a shr 24) and 0xFF).toByte(), + ((a shr 16) and 0xFF).toByte(), + ((a shr 8) and 0xFF).toByte(), + (a and 0xFF).toByte() + ) + } + throw IllegalArgumentException("bad Arg") + } +} diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt new file mode 100644 index 0000000..0c4ed8e --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Google LLC + * + * 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. + */ + +package com.example.digitalcredentialsapp.data + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Represents a claim being requested in a Digital Credential query. + * + * @property id A unique identifier for this claim within the query. + * @property path The path to the claim in the credential's namespace (e.g., ["org.iso.18013.5.1", "family_name"]). + */ +data class RequestedClaim( + val id: String, + val path: List +) + +/** + * Represents JSON request strings for the Digital Credentials API. + * + * This object follows the OpenID4VP specifications to request credentials from digital wallets. + */ +object Requests { + + /** + * Generates a dynamic OpenID4VP JSON request for any digital credential. + * + * @param nonce A cryptographically secure random string to prevent replay attacks. + * @param protocol The protocol identifier (e.g., "openid4vp-v1" or "openid4vp-v1-unsigned"). + * @param clientId The unique identifier for the verifier (optional). + * @param clientMetadata Optional JSON string for client_metadata (e.g., supported algorithms). + * @param format The credential format (e.g., "mso_mdoc" or "dc+sd-jwt"). + * @param meta The metadata object for filtering (passed as a raw JSON string). + * @param requestedClaims A list of specific claims to request from the credential. + * @return The formatted OpenID4VP JSON request string. + */ + fun getOpenId4VpDigitalCredentialRequest( + nonce: String, + protocol: String, + clientId: String?, + clientMetadata: String?, + format: String, + meta: String, + requestedClaims: List + ): String { + val claimsJson = JSONObject().apply { + put("response_type", "vp_token") + put("response_mode", "dc_api") + clientId?.let { put("client_id", it) } + clientMetadata?.let { put("client_metadata", JSONObject(it)) } + put("nonce", nonce) + put("dcql_query", JSONObject().apply { + put("credentials", JSONArray().apply { + put(JSONObject().apply { + put("id", "cred1") + put("format", format) + put("meta", JSONObject(meta)) + put("claims", JSONArray().apply { + for (claim in requestedClaims) { + put(JSONObject().apply { + put("path", JSONArray(claim.path)) + }) + } + }) + }) + }) + }) + } + + val openId4VPJson = JSONObject().apply { + put("requests", JSONArray().apply { + put(JSONObject().apply { + put("protocol", protocol) + put("data", claimsJson) + }) + }) + } + + return openId4VPJson.toString(2) + } +} diff --git a/DigitalCredentialsApp/app/src/main/res/values/strings.xml b/DigitalCredentialsApp/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3bd67aa --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + Digital Credentials App + Digital Credentials API sample app + Get Digital Credential from Wallets + Get Verified Email from Device + Results will appear here + Requesting credential... + No claims were extracted from the response. This may happen if the credential format is unrecognized. + diff --git a/DigitalCredentialsApp/build.gradle b/DigitalCredentialsApp/build.gradle new file mode 100644 index 0000000..1bae234 --- /dev/null +++ b/DigitalCredentialsApp/build.gradle @@ -0,0 +1,22 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.6.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0" + classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.0" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/DigitalCredentialsApp/gradle.properties b/DigitalCredentialsApp/gradle.properties new file mode 100644 index 0000000..5bac8ac --- /dev/null +++ b/DigitalCredentialsApp/gradle.properties @@ -0,0 +1 @@ +android.useAndroidX=true diff --git a/DigitalCredentialsApp/gradle/wrapper/gradle-wrapper.jar b/DigitalCredentialsApp/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/DigitalCredentialsApp/gradle/wrapper/gradle-wrapper.jar differ diff --git a/DigitalCredentialsApp/gradle/wrapper/gradle-wrapper.properties b/DigitalCredentialsApp/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2a84e18 --- /dev/null +++ b/DigitalCredentialsApp/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/DigitalCredentialsApp/gradlew b/DigitalCredentialsApp/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/DigitalCredentialsApp/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# 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 ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# 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 + if ! command -v java >/dev/null 2>&1 + then + 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 +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# 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"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/DigitalCredentialsApp/gradlew.bat b/DigitalCredentialsApp/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/DigitalCredentialsApp/gradlew.bat @@ -0,0 +1,94 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@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=. +@rem This is normally unused +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% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 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! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/DigitalCredentialsApp/settings.gradle b/DigitalCredentialsApp/settings.gradle new file mode 100644 index 0000000..28ae021 --- /dev/null +++ b/DigitalCredentialsApp/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = "DigitalCredentialsApp" +include ':app'