diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89f1742e5..3e5ced4eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,6 +58,8 @@ ausweis-sdk = "2.1.1" jetbrains-navigation = "2.7.0-alpha07" cameraLifecycle = "1.3.4" buildconfig = "5.3.5" +qrose = "1.0.1" +easyqrscan = "0.2.0" [libraries] face-detection = { module = "com.google.mlkit:face-detection", version.ref = "faceDetection" } @@ -126,6 +128,8 @@ ausweis-sdk = { module = "com.governikus:ausweisapp", version.ref = "ausweis-sdk jetbrains-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref="jetbrains-navigation" } jetbrains-navigation-runtime = { module = "org.jetbrains.androidx.navigation:navigation-runtime", version.ref="jetbrains-navigation" } camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraLifecycle" } +qrose = { group = "io.github.alexzhirkevich", name = "qrose", version.ref="qrose"} +easyqrscan = { module = "io.github.kalinjul.easyqrscan:scanner", version.ref = "easyqrscan" } [bundles] google-play-services = ["play-services-base", "play-services-basement", "play-services-tasks"] diff --git a/identity-appsupport/build.gradle.kts b/identity-appsupport/build.gradle.kts index c24dabb83..bf0c5d122 100644 --- a/identity-appsupport/build.gradle.kts +++ b/identity-appsupport/build.gradle.kts @@ -55,6 +55,8 @@ kotlin { implementation(project(":identity-mdoc")) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.io.core) + implementation(libs.qrose) + implementation(libs.easyqrscan) } } diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ScanQrCodeDialog.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ScanQrCodeDialog.kt new file mode 100644 index 000000000..3d3d2d026 --- /dev/null +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ScanQrCodeDialog.kt @@ -0,0 +1,67 @@ +package com.android.identity.appsupport.ui.qrcode + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.publicvalue.multiplatform.qrcode.CodeType +import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions + +/** + * Shows a dialog for scanning QR codes. + * + * If the application doesn't have the necessary permission, the user is prompted to grant it. + * + * @param title The title of the dialog. + * @param description The description text to include in the dialog. + * @param dismissButton The text for the dismiss button. + * @param onCodeScanned called when a QR code is scanned, the parameter is the parsed data. Should + * return `true` to stop scanning, `false` to continue scanning. + * @param onDismiss called when the dismiss button is pressed. + * @param modifier A [Modifier] or `null`. + */ +@Composable +fun ScanQrCodeDialog( + title: String, + description: String, + dismissButton: String, + onCodeScanned: (data: String) -> Boolean, + onDismiss: () -> Unit, + modifier: Modifier? = null +) { + AlertDialog( + modifier = modifier ?: Modifier, + title = { Text(text = title) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(text = description) + + ScannerWithPermissions( + modifier = Modifier.height(300.dp), + onScanned = { data -> + onCodeScanned(data) + }, + types = listOf(CodeType.QR) + ) + } + }, + onDismissRequest = { onDismiss.invoke() }, + confirmButton = {}, + dismissButton = { + TextButton( + onClick = { + onDismiss.invoke() + } + ) { + Text(dismissButton) + } + } + ) +} diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ShowQrCodeDialog.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ShowQrCodeDialog.kt new file mode 100644 index 000000000..5062216eb --- /dev/null +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ShowQrCodeDialog.kt @@ -0,0 +1,88 @@ +package com.android.identity.appsupport.ui.qrcode + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.github.alexzhirkevich.qrose.rememberQrCodePainter + +/** + * Renders a QR code and shows it in a dialog. + * + * @param title The title of the dialog. + * @param description The description text to include in the dialog. + * @param dismissButton The text for the dismiss button. + * @param data the QR code to show, e.g. mdoc:owBjMS4... or https://github.com/.... + * @param onDismiss called when the dismiss button is pressed. + * @param modifier A [Modifier] or `null`. + */ +@Composable +fun ShowQrCodeDialog( + title: String, + description: String, + dismissButton: String, + data: String, + onDismiss: () -> Unit, + modifier: Modifier? = null +) { + val painter = rememberQrCodePainter( + data = data, + ) + + AlertDialog( + modifier = modifier ?: Modifier, + title = { Text(text = title) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text(text = description) + + Row( + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .clip(shape = RoundedCornerShape(16.dp)) + .background(Color.White) + ) { + Image( + painter = painter, + contentDescription = "TODO", + modifier = Modifier + .size(300.dp) + .padding(16.dp) + ) + } + } + } + }, + onDismissRequest = { onDismiss.invoke() }, + confirmButton = {}, + dismissButton = { + TextButton( + onClick = { + onDismiss.invoke() + } + ) { + Text(dismissButton) + } + } + ) +} diff --git a/samples/testapp/iosApp/TestApp/Info.plist b/samples/testapp/iosApp/TestApp/Info.plist index 502925fc5..ede320772 100644 --- a/samples/testapp/iosApp/TestApp/Info.plist +++ b/samples/testapp/iosApp/TestApp/Info.plist @@ -2,6 +2,8 @@ + NSCameraUsageDescription + This app uses the camera to read QR codes NSFaceIDUsageDescription This app uses FaceID to protect keys CADisableMinimumFrameDurationOnPhone diff --git a/samples/testapp/src/androidMain/AndroidManifest.xml b/samples/testapp/src/androidMain/AndroidManifest.xml index d5e0d69c4..0eeab9fe9 100644 --- a/samples/testapp/src/androidMain/AndroidManifest.xml +++ b/samples/testapp/src/androidMain/AndroidManifest.xml @@ -3,6 +3,12 @@ + + + + + + PassphraseEntryField use-cases ConsentModalBottomSheet ConsentModalBottomSheet use-cases + QR code generation and scanning \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt index 9b0173f2b..28ceffa8c 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt @@ -32,6 +32,7 @@ import com.android.identity.testapp.ui.AndroidKeystoreSecureAreaScreen import com.android.identity.testapp.ui.ConsentModalBottomSheetListScreen import com.android.identity.testapp.ui.ConsentModalBottomSheetScreen import com.android.identity.testapp.ui.PassphraseEntryFieldScreen +import com.android.identity.testapp.ui.QrCodesScreen import com.android.identity.testapp.ui.SecureEnclaveSecureAreaScreen import com.android.identity.testapp.ui.SoftwareSecureAreaScreen import com.android.identity.testapp.ui.StartScreen @@ -94,6 +95,7 @@ class App { onClickSecureEnclaveSecureArea = { navController.navigate(SecureEnclaveSecureAreaDestination.route) }, onClickPassphraseEntryField = { navController.navigate(PassphraseEntryFieldDestination.route) }, onClickConsentSheetList = { navController.navigate(ConsentModalBottomSheetListDestination.route) }, + onClickQrCodes = { navController.navigate(QrCodesDestination.route) } ) } composable(route = AboutDestination.route) { @@ -141,6 +143,11 @@ class App { onSheetDismissed = { navController.popBackStack() }, ) } + composable(route = QrCodesDestination.route) { + QrCodesScreen( + showToast = { message -> showToast(message) } + ) + } } } } diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt index 976e9002b..9ccae185d 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt @@ -9,6 +9,7 @@ import identitycredential.samples.testapp.generated.resources.cloud_secure_area_ import identitycredential.samples.testapp.generated.resources.consent_modal_bottom_sheet_list_screen_title import identitycredential.samples.testapp.generated.resources.consent_modal_bottom_sheet_screen_title import identitycredential.samples.testapp.generated.resources.passphrase_entry_field_screen_title +import identitycredential.samples.testapp.generated.resources.qr_codes_screen_title import identitycredential.samples.testapp.generated.resources.secure_enclave_secure_area_screen_title import identitycredential.samples.testapp.generated.resources.software_secure_area_screen_title import identitycredential.samples.testapp.generated.resources.start_screen_title @@ -71,6 +72,11 @@ data object ConsentModalBottomSheetDestination : Destination { ) } +data object QrCodesDestination : Destination { + override val route = "qr_codes" + override val title = Res.string.qr_codes_screen_title +} + val appDestinations = listOf( StartDestination, AboutDestination, @@ -80,5 +86,6 @@ val appDestinations = listOf( CloudSecureAreaDestination, PassphraseEntryFieldDestination, ConsentModalBottomSheetListDestination, - ConsentModalBottomSheetDestination + ConsentModalBottomSheetDestination, + QrCodesDestination, ) \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/QrCodesScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/QrCodesScreen.kt new file mode 100644 index 000000000..f91371384 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/QrCodesScreen.kt @@ -0,0 +1,89 @@ +package com.android.identity.testapp.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.android.identity.appsupport.ui.qrcode.ShowQrCodeDialog +import com.android.identity.appsupport.ui.qrcode.ScanQrCodeDialog + +@Composable +fun QrCodesScreen( + showToast: (message: String) -> Unit +) { + val showMdocQrCodeDialog = remember { mutableStateOf(false) } + val showUrlQrCodeDialog = remember { mutableStateOf(false) } + val showQrScanDialog = remember { mutableStateOf(false) } + + if (showMdocQrCodeDialog.value) { + ShowQrCodeDialog( + title = "Scan code on reader", + description = "Your personal information won't be shared yet. You don't need to hand your phone to anyone to share your ID.", + dismissButton = "Close", + data = "mdoc:owBjMS4wAYIB2BhYS6QBAiABIVggWojRgrzl9C76WZQ/MzWdAuipaP8onZPl" + + "+kRLYkNDFn/iJYILFujPhY3cdpBAe6YdTDOCNwqM/PPeaqZy/GClV6oy/GcCgYMCAaMA9AH1C1BF7+90KyxIN6kKOw4dBaaRBw==", + onDismiss = { showMdocQrCodeDialog.value = false } + ) + } + + if (showUrlQrCodeDialog.value) { + ShowQrCodeDialog( + title = "Scan code with phone", + description = "This is a QR code for https://github.com/openwallet-foundation-labs/identity-credential", + dismissButton = "Close", + data = "https://github.com/openwallet-foundation-labs/identity-credential", + onDismiss = { showUrlQrCodeDialog.value = false } + ) + } + + if (showQrScanDialog.value) { + ScanQrCodeDialog( + title = "Scan code", + description = "Ask the person you wish to request identity attributes from to present" + + " a QR code. This is usually in their identity wallet.", + dismissButton = "Close", + onCodeScanned = { data -> + if (data.startsWith("mdoc:")) { + showToast("Scanned mdoc URI $data") + showQrScanDialog.value = false + true + } else { + false + } + }, + onDismiss = { showQrScanDialog.value = false } + ) + } + + LazyColumn( + modifier = Modifier.padding(8.dp) + ) { + item { + TextButton( + onClick = { showMdocQrCodeDialog.value = true }, + content = { Text("Show mdoc QR code") } + ) + } + + item { + TextButton( + onClick = { showUrlQrCodeDialog.value = true }, + content = { Text("Show URL QR code") } + ) + } + + item { + TextButton( + onClick = { showQrScanDialog.value = true }, + content = { Text("Scan mdoc QR code") } + ) + } + } + +} + diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt index 9a71dbf2f..81c61a0b8 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt @@ -18,6 +18,7 @@ import identitycredential.samples.testapp.generated.resources.android_keystore_s import identitycredential.samples.testapp.generated.resources.passphrase_entry_field_screen_title import identitycredential.samples.testapp.generated.resources.cloud_secure_area_screen_title import identitycredential.samples.testapp.generated.resources.consent_modal_bottom_sheet_list_screen_title +import identitycredential.samples.testapp.generated.resources.qr_codes_screen_title import identitycredential.samples.testapp.generated.resources.secure_enclave_secure_area_screen_title import identitycredential.samples.testapp.generated.resources.software_secure_area_screen_title import org.jetbrains.compose.resources.stringResource @@ -31,6 +32,7 @@ fun StartScreen( onClickSecureEnclaveSecureArea: () -> Unit = {}, onClickPassphraseEntryField: () -> Unit = {}, onClickConsentSheetList: () -> Unit = {}, + onClickQrCodes: () -> Unit = {}, ) { Surface( modifier = Modifier.fillMaxSize(), @@ -85,6 +87,12 @@ fun StartScreen( Text(stringResource(Res.string.consent_modal_bottom_sheet_list_screen_title)) } } + + item { + TextButton(onClick = onClickQrCodes) { + Text(stringResource(Res.string.qr_codes_screen_title)) + } + } } } }