diff --git a/README.md b/README.md
index 2e480ebf6..a5c99a0de 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,10 @@ reachable on the left side bar of the Android Studio, or by selecting: _View ->
Inside the `Build Variants` panel, at the `appholder` row, the desired flavor can be chosen. Once a
flavor is selected, by running the app it will install it on the target device/emulator.
+The `wallet` module is a rewrite of the `appholder` reference application
+with an eye towards a production-quality and easily rebrandable identity
+wallet application.
+
## ISO 18013-7 Reader Website
The `wwwverifier` module contains the source code for a website acting as an
diff --git a/gradle.properties b/gradle.properties
index c0c0b2d9d..b7cadebaf 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -15,3 +15,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
+# Workaround until we can upgrade to a newer Android Gradle Plugin
+android.suppressUnsupportedCompileSdk=34
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c6b26aa32..0cb5dee90 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -34,6 +34,9 @@
espresso-core = "3.5.1"
junit-jupiter = "5.10.0"
truth = "1.1.5"
+ junit = "4.13.2"
+ lifecycle-runtime-ktx = "2.6.2"
+ navigation-compose = "2.7.6"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
@@ -85,6 +88,12 @@
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit-jupiter" }
kotlinx-coroutine-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines-version" }
truth = { module = "com.google.truth:truth", version.ref = "truth" }
+ junit = { group = "junit", name = "junit", version.ref = "junit" }
+ lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" }
+ ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+ ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+ ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+ androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
[bundles]
androidx-core = ["androidx-core-ktx", "androidx-appcompat", "androidx-material", "androidx-contraint-layout", "androidx-fragment-ktx", "androidx-legacy-v4", "androidx-preference-ktx", "androidx-work"]
diff --git a/secure-area-test-app/build.gradle b/secure-area-test-app/build.gradle
index 8117689b8..d2439167e 100644
--- a/secure-area-test-app/build.gradle
+++ b/secure-area-test-app/build.gradle
@@ -37,7 +37,7 @@ android {
compose true
}
composeOptions {
- kotlinCompilerExtensionVersion '1.4.6'
+ kotlinCompilerExtensionVersion libs.versions.kotlin.compiler.extension.get()
}
packagingOptions {
resources {
diff --git a/settings.gradle b/settings.gradle
index b2713e15a..c4724ca97 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -24,5 +24,6 @@ dependencyResolutionManagement {
}
}
}
-include ':appholder', ':appverifier', ':identity', ':identity-android', ':secure-area-test-app', ':wwwverifier'
+include ':appholder', ':appverifier', ':identity', ':identity-android', ':secure-area-test-app', ':wwwverifier', ':wallet'
rootProject.name = 'Identity Credential'
+
diff --git a/wallet/.gitignore b/wallet/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/wallet/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/wallet/build.gradle b/wallet/build.gradle
new file mode 100644
index 000000000..0df86fb21
--- /dev/null
+++ b/wallet/build.gradle
@@ -0,0 +1,71 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+}
+
+android {
+ namespace 'com.android.identity_credential.wallet'
+ compileSdk 34
+
+ defaultConfig {
+ applicationId "com.android.identity_credential.wallet"
+ minSdk 27
+ targetSdk 34
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary true
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled 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
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion libs.versions.kotlin.compiler.extension.get()
+ }
+ packagingOptions {
+ resources {
+ excludes += '/META-INF/{AL2.0,LGPL2.1}'
+ }
+ }
+}
+
+dependencies {
+ implementation project(':identity')
+ implementation project(':identity-android')
+
+ implementation libs.cbor
+ implementation libs.androidx.core.ktx
+ implementation libs.lifecycle.runtime.ktx
+ implementation libs.androidx.activity.compose
+ implementation platform(libs.compose.bom)
+ implementation libs.compose.ui
+ implementation libs.ui.graphics
+ implementation libs.compose.ui.tooling
+ implementation libs.compose.material
+ implementation libs.bundles.bouncy.castle
+ implementation libs.androidx.navigation.compose
+ testImplementation libs.junit
+ androidTestImplementation libs.androidx.test.ext.junit
+ androidTestImplementation libs.androidx.test.espresso
+ androidTestImplementation platform(libs.compose.bom)
+ androidTestImplementation libs.ui.test.junit4
+ debugImplementation libs.compose.icons
+ debugImplementation libs.ui.test.manifest
+}
\ No newline at end of file
diff --git a/wallet/proguard-rules.pro b/wallet/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /dev/null
+++ b/wallet/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/wallet/src/androidTest/java/com/android/identity_credential/wallet/ExampleInstrumentedTest.kt b/wallet/src/androidTest/java/com/android/identity_credential/wallet/ExampleInstrumentedTest.kt
new file mode 100644
index 000000000..47b0b674d
--- /dev/null
+++ b/wallet/src/androidTest/java/com/android/identity_credential/wallet/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.android.identity_credential.wallet
+
+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.android.identity_credential.wallet", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/wallet/src/main/AndroidManifest.xml b/wallet/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..1876fb266
--- /dev/null
+++ b/wallet/src/main/AndroidManifest.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/MainActivity.kt b/wallet/src/main/java/com/android/identity_credential/wallet/MainActivity.kt
new file mode 100644
index 000000000..8021cc4d6
--- /dev/null
+++ b/wallet/src/main/java/com/android/identity_credential/wallet/MainActivity.kt
@@ -0,0 +1,477 @@
+@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+
+package com.android.identity_credential.wallet
+
+import android.graphics.BitmapFactory
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+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.layout.size
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material3.Button
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.Divider
+import androidx.compose.material3.DrawerState
+import androidx.compose.material3.DrawerValue
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalDrawerSheet
+import androidx.compose.material3.ModalNavigationDrawer
+import androidx.compose.material3.NavigationDrawerItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberDrawerState
+import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.android.identity.util.Logger
+import com.android.identity_credential.wallet.ui.theme.IdentityCredentialTheme
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+class MainActivity : ComponentActivity() {
+
+ companion object {
+ private const val TAG = "MainActivity"
+ }
+
+ private lateinit var application: WalletApplication
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ application = getApplication() as WalletApplication
+
+ setContent {
+ IdentityCredentialTheme {
+ // A surface container using the 'background' color from the theme
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ val navController = rememberNavController()
+ NavHost(navController = navController, startDestination = "MainScreen") {
+ composable("MainScreen") {
+ MainScreen(navController)
+ }
+ composable("AboutScreen") {
+ AboutScreen(navController)
+ }
+ composable("AddToWalletScreen") {
+ AddToWalletScreen(navController)
+ }
+ composable("CredentialInfo/{credentialId}") { backStackEntry ->
+ CredentialInfoScreen(navController,
+ backStackEntry.arguments?.getString("credentialId")!!)
+ }
+ }
+ }
+ }
+ }
+ }
+
+
+ @Composable
+ fun MainScreen(navigation: NavHostController) {
+ val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
+ val scope = rememberCoroutineScope()
+ ModalNavigationDrawer(
+ drawerState = drawerState,
+ drawerContent = {
+ ModalDrawerSheet {
+ Text("Wallet", modifier = Modifier.padding(16.dp))
+ Divider()
+ NavigationDrawerItem(
+ icon = { Icon(imageVector = Icons.Filled.Add, contentDescription = null) },
+ label = { Text(text = "Add to Wallet") },
+ selected = false,
+ onClick = {
+ scope.launch {
+ drawerState.close()
+ navigation.navigate("AddToWalletScreen")
+ }
+ }
+ )
+ NavigationDrawerItem(
+ icon = { Icon(imageVector = Icons.Filled.Info, contentDescription = null) },
+ label = { Text(text = "About Wallet") },
+ selected = false,
+ onClick = {
+ scope.launch {
+ drawerState.close()
+ navigation.navigate("AboutScreen")
+ }
+ }
+ )
+ }
+ },
+ ) {
+ MainScreenContent(navigation, scope, drawerState)
+ }
+ }
+
+ @Composable
+ fun MainScreenContent(navigation: NavHostController,
+ scope: CoroutineScope,
+ drawerState: DrawerState) {
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
+ Scaffold(
+ topBar = {
+ CenterAlignedTopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ titleContentColor = MaterialTheme.colorScheme.primary,
+ ),
+ title = {
+ Text(
+ "Wallet",
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ IconButton(
+ onClick = {
+ scope.launch {
+ drawerState.apply {
+ Logger.d(TAG, "isClosed = $isClosed")
+ if (isClosed) open() else close()
+ }
+ }
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Menu,
+ contentDescription = "Localized description"
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxHeight()
+ .padding(innerPadding),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ if (application.credentialStore.listCredentials().size == 0) {
+ MainScreenNoCredentialsAvailable(navigation)
+ } else {
+ MainScreenCredentialPager(navigation)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text(
+ modifier = Modifier.padding(8.dp),
+ text = "Hold to Reader"
+ )
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun MainScreenNoCredentialsAvailable(navigation: NavHostController) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text(
+ modifier = Modifier.padding(8.dp),
+ text = "No credentials in wallet, start by\n" +
+ "adding credentials.",
+ color = MaterialTheme.colorScheme.secondary,
+ textAlign = TextAlign.Center
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Button(onClick = {
+ navigation.navigate("AddToWalletScreen")
+ }) {
+ Text("Add to Wallet")
+ }
+ }
+ }
+
+ @Composable
+ fun MainScreenCredentialPager(navigation: NavHostController) {
+
+ Column() {
+
+ val credentialIds = application.credentialStore.listCredentials()
+ val pagerState = rememberPagerState(pageCount = {
+ credentialIds.size
+ })
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier.height(200.dp)
+ ) { page ->
+
+ val credentialId = credentialIds[page]
+ val credential = application.credentialStore.lookupCredential(credentialId)!!
+ val encodedArtwork = credential.applicationData.getData("artwork")
+ val options = BitmapFactory.Options()
+ options.inMutable = true
+ val credentialBitmap =
+ BitmapFactory.decodeByteArray(encodedArtwork, 0, encodedArtwork.size, options)
+ val credentialName = credential.applicationData.getString("displayName")
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Image(
+ bitmap = credentialBitmap.asImageBitmap(),
+ contentDescription = "Artwork for $credentialName",
+ modifier = Modifier.clickable(onClick = {
+ navigation.navigate("CredentialInfo/$credentialId")
+ })
+ )
+ }
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .wrapContentHeight()
+ .fillMaxWidth()
+ .height(30.dp)
+ .padding(8.dp),
+ ) {
+ repeat(pagerState.pageCount) { iteration ->
+ val color =
+ if (pagerState.currentPage == iteration) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.secondary
+ }
+ Box(
+ modifier = Modifier
+ .padding(2.dp)
+ .clip(CircleShape)
+ .background(color)
+ .size(8.dp)
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun AboutScreen(navigation: NavHostController) {
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
+ Scaffold(
+ topBar = {
+ CenterAlignedTopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ titleContentColor = MaterialTheme.colorScheme.primary,
+ ),
+ title = {
+ Text(
+ "About Wallet",
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ IconButton(
+ onClick = {
+ navigation.popBackStack()
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Filled.ArrowBack,
+ contentDescription = "Back Arrow"
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxHeight()
+ .padding(innerPadding),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text(
+ modifier = Modifier.padding(8.dp),
+ text = "TODO: About Screen"
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun AddToWalletScreen(navigation: NavHostController) {
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
+ Scaffold(
+ topBar = {
+ CenterAlignedTopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ titleContentColor = MaterialTheme.colorScheme.primary,
+ ),
+ title = {
+ Text(
+ "Add to Wallet",
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ IconButton(
+ onClick = {
+ navigation.popBackStack()
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Filled.ArrowBack,
+ contentDescription = "Back Arrow"
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxHeight()
+ .padding(innerPadding),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Button(onClick = {
+ if (application.addSelfsignedMdl()) {
+ navigation.popBackStack()
+ } else {
+ Toast.makeText(applicationContext,
+ "Already have two self-signed mDLs, not adding more",
+ Toast.LENGTH_SHORT).show()
+ }
+ }) {
+ Text("Add self-signed mDL")
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun CredentialInfoScreen(navigation: NavHostController,
+ credentialId: String) {
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
+ Scaffold(
+ topBar = {
+ CenterAlignedTopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ titleContentColor = MaterialTheme.colorScheme.primary,
+ ),
+ title = {
+ Text(
+ "Credential Information",
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ IconButton(
+ onClick = {
+ navigation.popBackStack()
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Filled.ArrowBack,
+ contentDescription = "Back Arrow"
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxHeight()
+ .padding(innerPadding),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text("TODO: show info for $credentialId")
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Button(onClick = {
+ application.credentialStore.deleteCredential(credentialId)
+ navigation.popBackStack()
+ }) {
+ Text("Delete")
+ }
+ }
+
+ }
+ }
+ }
+
+}
diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt b/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt
new file mode 100644
index 000000000..b2fabef78
--- /dev/null
+++ b/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt
@@ -0,0 +1,284 @@
+package com.android.identity_credential.wallet
+
+import android.app.Application
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.RadialGradient
+import android.graphics.Rect
+import android.graphics.Shader
+import android.widget.Toast
+import com.android.identity.android.securearea.AndroidKeystoreSecureArea
+import com.android.identity.android.storage.AndroidStorageEngine
+import com.android.identity.credential.CredentialStore
+import com.android.identity.credential.NameSpacedData
+import com.android.identity.internal.Util
+import com.android.identity.mdoc.mso.MobileSecurityObjectGenerator
+import com.android.identity.mdoc.mso.StaticAuthDataGenerator
+import com.android.identity.mdoc.util.MdocUtil
+import com.android.identity.securearea.SecureArea
+import com.android.identity.securearea.SecureAreaRepository
+import com.android.identity.util.Logger
+import com.android.identity.util.Timestamp
+import org.bouncycastle.asn1.x500.X500Name
+import org.bouncycastle.cert.X509CertificateHolder
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
+import org.bouncycastle.jce.provider.BouncyCastleProvider
+import org.bouncycastle.operator.ContentSigner
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.math.BigInteger
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+import java.security.SecureRandom
+import java.security.Security
+import java.security.cert.X509Certificate
+import java.security.spec.ECGenParameterSpec
+import java.util.Date
+import java.util.Random
+import kotlin.math.ceil
+
+class WalletApplication : Application() {
+ companion object {
+ private const val TAG = "WalletApplication"
+
+ val MDL_DOCTYPE = "org.iso.18013.5.1.mDL"
+ val MDL_NAMESPACE = "org.iso.18013.5.1"
+ val AAMVA_NAMESPACE = "org.iso.18013.5.1.aamva"
+ }
+
+ lateinit var secureAreaRepository: SecureAreaRepository
+ lateinit var credentialStore: CredentialStore
+
+ private lateinit var androidKeystoreSecureArea: AndroidKeystoreSecureArea
+
+ override fun onCreate() {
+ super.onCreate()
+ Logger.d(TAG, "onCreate")
+
+ // This is needed to prefer BouncyCastle bundled with the app instead of the Conscrypt
+ // based implementation included in the OS itself.
+ Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
+ Security.addProvider(BouncyCastleProvider())
+
+ // Setup singletons
+ val storageDir = File(applicationContext.noBackupFilesDir, "identity")
+ val storageEngine = AndroidStorageEngine.Builder(applicationContext, storageDir).build()
+ secureAreaRepository = SecureAreaRepository()
+ androidKeystoreSecureArea = AndroidKeystoreSecureArea(applicationContext, storageEngine)
+ secureAreaRepository.addImplementation(androidKeystoreSecureArea);
+ credentialStore = CredentialStore(storageEngine, secureAreaRepository)
+ }
+
+ private fun createArtwork(color1: Int,
+ color2: Int,
+ artworkText: String): ByteArray {
+ val width = 800
+ val height = ceil(width.toFloat() * 2.125 / 3.375).toInt()
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ val bgPaint = Paint()
+ bgPaint.setShader(
+ RadialGradient(
+ width / 2f, height / 2f,
+ height / 0.5f, color1, color2, Shader.TileMode.MIRROR
+ )
+ )
+ val round = bitmap.width / 25f
+ canvas.drawRoundRect(
+ 0f,
+ 0f,
+ bitmap.width.toFloat(),
+ bitmap.height.toFloat(),
+ round,
+ round,
+ bgPaint
+ )
+
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ paint.setColor(android.graphics.Color.WHITE)
+ paint.textSize = bitmap.width / 10.0f
+ paint.setShadowLayer(2.0f, 1.0f, 1.0f, android.graphics.Color.BLACK)
+ val bounds = Rect()
+ paint.getTextBounds(artworkText, 0, artworkText.length, bounds)
+ val textPadding = bitmap.width/25f
+ val x: Float = textPadding
+ val y: Float = bitmap.height - bounds.height() - textPadding + paint.textSize/2
+ canvas.drawText(artworkText, x, y, paint)
+
+ val baos = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
+ return baos.toByteArray()
+ }
+
+ // Returns true if the mDL was added, false otherwise
+ fun addSelfsignedMdl(): Boolean {
+ if (credentialStore.lookupCredential("mDL_Erika") == null) {
+ provisionCredential(
+ "mDL_Erika",
+ "Erika's Driving License",
+ android.graphics.Color.rgb(64, 255, 64),
+ android.graphics.Color.rgb(0, 96, 0),
+ "E MUS",
+ "Erika",
+ "Mustermann",
+ R.drawable.img_erika_portrait
+ )
+ return true
+ }
+ if (credentialStore.lookupCredential("mDL_Max") == null) {
+ provisionCredential(
+ "mDL_Max",
+ "Max's Driving License",
+ android.graphics.Color.rgb(64, 64, 255),
+ android.graphics.Color.rgb(0, 0, 96),
+ "M EXA",
+ "Max",
+ "Example-Person",
+ R.drawable.img_erika_portrait
+ )
+ return true
+ }
+ return false
+ }
+
+ private fun provisionCredential(
+ credentialId: String,
+ displayName: String,
+ color1: Int,
+ color2: Int,
+ artworkText: String,
+ givenName: String,
+ familyName: String,
+ portrait_id: Int
+ ) {
+ val credential = credentialStore.createCredential(credentialId)
+
+ credential.applicationData.setData("artwork", createArtwork(color1, color2, artworkText))
+ credential.applicationData.setString("displayName", displayName)
+ credential.applicationData.setString("docType", "org.iso.18013.5.1.mDL")
+
+ val baos = ByteArrayOutputStream()
+ BitmapFactory.decodeResource(
+ applicationContext.resources,
+ portrait_id
+ ).compress(Bitmap.CompressFormat.JPEG, 50, baos)
+ val portrait: ByteArray = baos.toByteArray()
+
+ val now = Timestamp.now()
+ val issueDate = now
+ val expiryDate = Timestamp.ofEpochMilli(issueDate.toEpochMilli() + 5*365*24*3600*1000L)
+
+ val credentialData = NameSpacedData.Builder()
+ .putEntryString(MDL_NAMESPACE, "given_name", givenName)
+ .putEntryString(MDL_NAMESPACE, "family_name", familyName)
+ .putEntryByteString(MDL_NAMESPACE, "portrait", portrait)
+ .putEntryNumber(MDL_NAMESPACE, "sex", 2)
+ .putEntry(MDL_NAMESPACE, "issue_date", Util.cborEncodeDateTime(issueDate))
+ .putEntry(MDL_NAMESPACE, "expiry_date", Util.cborEncodeDateTime(expiryDate))
+ .putEntryString(MDL_NAMESPACE, "document_number", "1234567890")
+ .putEntryString(MDL_NAMESPACE, "issuing_authority", "State of Utopia")
+ .putEntryString(AAMVA_NAMESPACE, "DHS_compliance", "F")
+ .putEntryNumber(AAMVA_NAMESPACE, "EDL_credential", 1)
+ .putEntryBoolean(MDL_NAMESPACE, "age_over_18", true)
+ .putEntryBoolean(MDL_NAMESPACE, "age_over_21", true)
+ .build()
+ credential.applicationData.setNameSpacedData("credentialData", credentialData)
+ credential.applicationData.setString("docType", MDL_DOCTYPE)
+
+ // Create AuthKeys and MSOs, make sure they're valid for a long time
+ val timeSigned = now
+ val validFrom = now
+ val validUntil = Timestamp.ofEpochMilli(validFrom.toEpochMilli() + 365*24*3600*1000L)
+
+ // Create three authentication keys and certify them
+ for (n in 0..2) {
+ val pendingAuthKey = credential.createPendingAuthenticationKey(
+ "mdoc",
+ androidKeystoreSecureArea,
+ SecureArea.CreateKeySettings("".toByteArray()),
+ null
+ )
+
+ // Generate an MSO and issuer-signed data for this authentication key.
+ val msoGenerator = MobileSecurityObjectGenerator(
+ "SHA-256",
+ MDL_DOCTYPE,
+ pendingAuthKey.attestation[0].publicKey
+ )
+ msoGenerator.setValidityInfo(timeSigned, validFrom, validUntil, null)
+ val deterministicRandomProvider = Random(42)
+ val issuerNameSpaces = MdocUtil.generateIssuerNameSpaces(
+ credentialData,
+ deterministicRandomProvider,
+ 16,
+ null
+ )
+ for (nameSpaceName in issuerNameSpaces.keys) {
+ val digests = MdocUtil.calculateDigestsForNameSpace(
+ nameSpaceName,
+ issuerNameSpaces,
+ "SHA-256"
+ )
+ msoGenerator.addDigestIdsForNamespace(nameSpaceName, digests)
+ }
+ val issuerKeyPair: KeyPair = generateIssuingAuthorityKeyPair()
+ val issuerCert = getSelfSignedIssuerAuthorityCertificate(issuerKeyPair)
+
+ val mso = msoGenerator.generate()
+ val taggedEncodedMso = Util.cborEncode(Util.cborBuildTaggedByteString(mso))
+ val issuerCertChain = listOf(issuerCert)
+ val encodedIssuerAuth = Util.cborEncode(
+ Util.coseSign1Sign(
+ issuerKeyPair.private,
+ "SHA256withECDSA", taggedEncodedMso,
+ null,
+ issuerCertChain
+ )
+ )
+
+ val issuerProvidedAuthenticationData = StaticAuthDataGenerator(
+ MdocUtil.stripIssuerNameSpaces(issuerNameSpaces, null),
+ encodedIssuerAuth
+ ).generate()
+
+ pendingAuthKey.certify(issuerProvidedAuthenticationData, validFrom, validUntil)
+ }
+ Logger.d(TAG, "Created credential with name ${credential.name}")
+ }
+
+ private fun generateIssuingAuthorityKeyPair(): KeyPair {
+ val kpg = KeyPairGenerator.getInstance("EC")
+ val ecSpec = ECGenParameterSpec("secp256r1")
+ kpg.initialize(ecSpec)
+ return kpg.generateKeyPair()
+ }
+
+ private fun getSelfSignedIssuerAuthorityCertificate(
+ issuerAuthorityKeyPair: KeyPair
+ ): X509Certificate {
+ val issuer: X500Name = X500Name("CN=State Of Utopia")
+ val subject: X500Name = X500Name("CN=State Of Utopia Issuing Authority Signing Key")
+
+ // Valid from now to five years from now.
+ val now = Date()
+ val kMilliSecsInOneYear = 365L * 24 * 60 * 60 * 1000
+ val expirationDate = Date(now.time + 5 * kMilliSecsInOneYear)
+ val serial = BigInteger("42")
+ val builder = JcaX509v3CertificateBuilder(
+ issuer,
+ serial,
+ now,
+ expirationDate,
+ subject,
+ issuerAuthorityKeyPair.public
+ )
+ val signer: ContentSigner = JcaContentSignerBuilder("SHA256withECDSA")
+ .build(issuerAuthorityKeyPair.private)
+ val certHolder: X509CertificateHolder = builder.build(signer)
+ return JcaX509CertificateConverter().getCertificate(certHolder)
+ }
+}
\ No newline at end of file
diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/theme/Color.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/theme/Color.kt
new file mode 100644
index 000000000..b83a4f6c3
--- /dev/null
+++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.android.identity_credential.wallet.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/wallet/src/main/java/com/android/identity_credential/wallet/ui/theme/Theme.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/theme/Theme.kt
new file mode 100644
index 000000000..a61ea6dd6
--- /dev/null
+++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/theme/Theme.kt
@@ -0,0 +1,70 @@
+package com.android.identity_credential.wallet.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.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+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 IdentityCredentialTheme(
+ 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
+ }
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = colorScheme.primary.toArgb()
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/theme/Type.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/theme/Type.kt
new file mode 100644
index 000000000..00d93dba4
--- /dev/null
+++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package com.android.identity_credential.wallet.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/wallet/src/main/res/drawable/ic_launcher_background.xml b/wallet/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 000000000..07d5da9cb
--- /dev/null
+++ b/wallet/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/wallet/src/main/res/drawable/ic_launcher_foreground.xml b/wallet/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 000000000..0f12c1d4f
--- /dev/null
+++ b/wallet/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/wallet/src/main/res/drawable/img_erika_portrait.jpg b/wallet/src/main/res/drawable/img_erika_portrait.jpg
new file mode 100644
index 000000000..31e356ddc
Binary files /dev/null and b/wallet/src/main/res/drawable/img_erika_portrait.jpg differ
diff --git a/wallet/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/wallet/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..7353dbd1f
--- /dev/null
+++ b/wallet/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/wallet/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/wallet/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000..7353dbd1f
--- /dev/null
+++ b/wallet/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/wallet/src/main/res/mipmap-hdpi/ic_launcher.webp b/wallet/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 000000000..d75e2c20a
Binary files /dev/null and b/wallet/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/wallet/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/wallet/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..9516b42fd
Binary files /dev/null and b/wallet/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/wallet/src/main/res/mipmap-mdpi/ic_launcher.webp b/wallet/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 000000000..f5f4dfd11
Binary files /dev/null and b/wallet/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/wallet/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/wallet/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..4ed125402
Binary files /dev/null and b/wallet/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/wallet/src/main/res/mipmap-xhdpi/ic_launcher.webp b/wallet/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 000000000..5ca74bb51
Binary files /dev/null and b/wallet/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/wallet/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/wallet/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..504e880cc
Binary files /dev/null and b/wallet/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/wallet/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/wallet/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..d8f60e8db
Binary files /dev/null and b/wallet/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/wallet/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/wallet/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..3f36d34cd
Binary files /dev/null and b/wallet/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/wallet/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/wallet/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..4f2dae0dd
Binary files /dev/null and b/wallet/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/wallet/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/wallet/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..8635dc1f6
Binary files /dev/null and b/wallet/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/wallet/src/main/res/values/colors.xml b/wallet/src/main/res/values/colors.xml
new file mode 100644
index 000000000..f8c6127d3
--- /dev/null
+++ b/wallet/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/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml
new file mode 100644
index 000000000..99a792063
--- /dev/null
+++ b/wallet/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Wallet
+
\ No newline at end of file
diff --git a/wallet/src/main/res/values/themes.xml b/wallet/src/main/res/values/themes.xml
new file mode 100644
index 000000000..c9a1b821b
--- /dev/null
+++ b/wallet/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/wallet/src/test/java/com/android/identity_credential/wallet/ExampleUnitTest.kt b/wallet/src/test/java/com/android/identity_credential/wallet/ExampleUnitTest.kt
new file mode 100644
index 000000000..e31a9ac6f
--- /dev/null
+++ b/wallet/src/test/java/com/android/identity_credential/wallet/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.android.identity_credential.wallet
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file