Skip to content

Commit

Permalink
identity-appsupport: Add QR code generation and scanning composables.
Browse files Browse the repository at this point in the history
This adds Compose Multiplatform support for QR code generation and scanning.

A future change will start using this in the wallet app, currently
it's only used in samples/testapp.

This adds dependencies on https://github.com/alexzhirkevich/qrose and
https://github.com/kalinjul/EasyQRScan which are under the MIT and
Apache 2.0 license, respectively. These depencies are wholly hidden
behind the `ScanQrCodeDialog` and `ShowQrCodeDialog` in case we want
to swap these out in the future. For example, for the scanning part we
likely want to just use MLKit directly.

Test: New screens in samples/testapp for testing. Tested on Android and iOS.
Signed-off-by: David Zeuthen <[email protected]>
  • Loading branch information
davidz25 committed Nov 1, 2024
1 parent 4ffa288 commit 5e105f8
Show file tree
Hide file tree
Showing 12 changed files with 281 additions and 1 deletion.
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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"]
Expand Down
2 changes: 2 additions & 0 deletions identity-appsupport/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ kotlin {
implementation(project(":identity-mdoc"))
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.io.core)
implementation(libs.qrose)
implementation(libs.easyqrscan)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@
<string name="consent_modal_bottom_sheet_data_element_icon_description">Data Element Icon</string>
<string name="consent_modal_bottom_sheet_warning_icon_description">Warning Icon</string>

<!-- ShowQrCodeDialog -->
<string name="show_qr_code_dialog_qr_content_description">QR code image</string>

</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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,
confirmButton = {},
dismissButton = {
TextButton(onClick = { onDismiss() }) {
Text(dismissButton)
}
}
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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 identitycredential.identity_appsupport.generated.resources.Res
import identitycredential.identity_appsupport.generated.resources.show_qr_code_dialog_qr_content_description
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
import org.jetbrains.compose.resources.stringResource

/**
* 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 = stringResource(Res.string.show_qr_code_dialog_qr_content_description),
modifier = Modifier
.size(300.dp)
.padding(16.dp)
)
}
}
}
},
onDismissRequest = onDismiss,
confirmButton = {},
dismissButton = {
TextButton(onClick = { onDismiss() }) {
Text(dismissButton)
}
}
)
}
2 changes: 2 additions & 0 deletions samples/testapp/iosApp/TestApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>This app uses the camera to read QR codes</string>
<key>NSFaceIDUsageDescription</key>
<string>This app uses FaceID to protect keys</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
Expand Down
5 changes: 5 additions & 0 deletions samples/testapp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

<uses-permission android:name="android.permission.INTERNET"/>

<!-- For QR scanning -->
<uses-feature android:name="android.hardware.camera"/>
<uses-feature android:name="android.hardware.camera.autofocus"/>
<uses-permission android:name="android.permission.CAMERA"/>

<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@
<string name="passphrase_entry_field_screen_title">PassphraseEntryField use-cases</string>
<string name="consent_modal_bottom_sheet_screen_title">ConsentModalBottomSheet</string>
<string name="consent_modal_bottom_sheet_list_screen_title">ConsentModalBottomSheet use-cases</string>
<string name="qr_codes_screen_title">QR code generation and scanning</string>

</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -141,6 +143,11 @@ class App {
onSheetDismissed = { navController.popBackStack() },
)
}
composable(route = QrCodesDestination.route) {
QrCodesScreen(
showToast = { message -> showToast(message) }
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -80,5 +86,6 @@ val appDestinations = listOf(
CloudSecureAreaDestination,
PassphraseEntryFieldDestination,
ConsentModalBottomSheetListDestination,
ConsentModalBottomSheetDestination
ConsentModalBottomSheetDestination,
QrCodesDestination,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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",
// This is the DeviceEngagement test vector from ISO/IEC 18013-5:2021 Annex D encoded
// as specified in clause 8.2.2.3.
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") }
)
}
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +32,7 @@ fun StartScreen(
onClickSecureEnclaveSecureArea: () -> Unit = {},
onClickPassphraseEntryField: () -> Unit = {},
onClickConsentSheetList: () -> Unit = {},
onClickQrCodes: () -> Unit = {},
) {
Surface(
modifier = Modifier.fillMaxSize(),
Expand Down Expand Up @@ -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))
}
}
}
}
}

0 comments on commit 5e105f8

Please sign in to comment.