diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ce1063c..e99224f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -109,6 +109,12 @@ kotlin { implementation(libs.androidx.biometric) implementation(libs.favre.bcrypt) + + // for reading EPC QR Codes from camera + implementation(libs.zxing.core) + implementation(libs.camerax.camera2) + implementation(libs.camerax.view) + implementation(libs.camerax.lifecycle) } iosMain.dependencies { diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index e2bd569..14039c8 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -4,6 +4,9 @@ + + + Unit) { + val context = AndroidContext.mainActivity + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + + val localContext = LocalContext.current + log.info { "LocalContext.current = ${localContext.javaClass} ${localContext}" } + + val lifecycleOwner = LocalLifecycleOwner.current +// val context = LocalContext.current + val previewView = remember { + PreviewView(context) + } + + cameraProviderFuture.addListener({ + // Used to bind the lifecycle of cameras to the lifecycle owner + val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() + + // Preview + val preview = Preview.Builder() + .build() + .also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + // Select back camera as a default + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + val imageAnalyzer = ImageAnalysis.Builder() + .build() + .also { + it.setAnalyzer(cameraExecutor, QrCodeImageAnalyzer(resultCallback)) + } + + try { + // Unbind use cases before rebinding + cameraProvider.unbindAll() + + // Bind use cases to camera + cameraProvider.bindToLifecycle(context as FragmentActivity, cameraSelector, preview, imageAnalyzer) + + } catch(e: Exception) { + log.error(e) { "Use case binding failed" } + } + + }, ContextCompat.getMainExecutor(context)) + + + AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize()) + } + +} + + +class QrCodeImageAnalyzer(private val resultCallback: (QrCodeReadResult) -> Unit) : ImageAnalysis.Analyzer { + + private val reader = QRCodeReader() + + private val readerHints = readerHintsForCharset(Charsets.UTF_8.name()) + + private val log by logger() + + + override fun analyze(image: ImageProxy) { + try { + val bitmap = getBinaryBitmap(image) + + val result = reader.decode(bitmap, readerHints) + + if (result != null && result.text != null) { + val decodeResult = DI.epcQrCodeService.decode(result.text) + if (decodeResult.charset == null || decodeResult.charset == "UTF-8") { + this.resultCallback(QrCodeReadResult(result.text)) + } else { // the charset for receiver name, reference, ... was not UTF-8 -> decode image in EPC QR Code's charset + val resultForEncoding = reader.decode(bitmap, readerHintsForCharset(decodeResult.charset)) + + this.resultCallback(QrCodeReadResult(resultForEncoding?.text ?: result.text)) + } + } + } catch (e: Throwable) { + if (e !is NotFoundException) { + log.error(e) { "Could not decode image to QR code" } + } + } + + image.close() // to continue image analysis / avoid blocking production of further images + } + + private fun ByteBuffer.toIntArray(): IntArray { + val bytes = this.toByteArray() + + val pixels = IntArray(bytes.size) + bytes.indices.forEach { index -> + pixels[index] = bytes[index].toInt() and 0xFF + } + + return pixels + } + + private fun ByteBuffer.toByteArray(): ByteArray { + rewind() // Rewind the buffer to zero + val data = ByteArray(remaining()) + get(data) // Copy the buffer into a byte array + return data // Return the byte array + } + + private fun getBinaryBitmap(image: ImageProxy): BinaryBitmap { + val buffer = image.planes[0].buffer + val bitmapBuffer = buffer.toIntArray() + + val luminanceSource = RGBLuminanceSource(image.width, image.height, bitmapBuffer) + return BinaryBitmap(HybridBinarizer(luminanceSource)) + } + + private fun readerHintsForCharset(charset: String): Map = buildMap { +// put(DecodeHintType.TRY_HARDER, true) // optimize for accuracy, not speed + put(DecodeHintType.CHARACTER_SET, charset) + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/appskeleton/FloatingActionMenu.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/appskeleton/FloatingActionMenu.kt index 3fd0fc8..eca754d 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/appskeleton/FloatingActionMenu.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/appskeleton/FloatingActionMenu.kt @@ -23,6 +23,7 @@ import net.codinux.banking.ui.config.Style.FabMenuSpacing import net.codinux.banking.ui.config.Style.FabSize import net.codinux.banking.ui.config.Style.SmallFabSize import net.codinux.banking.ui.model.ShowTransferMoneyDialogData +import net.codinux.banking.ui.service.QrCodeService private val uiState = DI.uiState @@ -62,6 +63,14 @@ fun FloatingActionMenu( } } + if (QrCodeService.supportsReadingQrCodesFromCamera) { + FloatingActionMenuItem("Überweisung aus QR-Code", "Neue Überweisung mit Daten aus EPC QR Code (GiroCode, scan2Code, Zahlen mit Code, ...)", enabled = accountsThatSupportMoneyTransfer.isNotEmpty()) { + handleClick { + uiState.showTransferMoneyFromEpcQrCodeScreen.value = true + } + } + } + FloatingActionMenuItem("Überweisung", "Neue Überweisung", enabled = accountsThatSupportMoneyTransfer.isNotEmpty()) { handleClick { uiState.showTransferMoneyDialogData.value = ShowTransferMoneyDialogData() diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/StateHandler.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/StateHandler.kt index 3ff4206..47f2798 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/StateHandler.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/StateHandler.kt @@ -15,6 +15,7 @@ private val formatUtil = DI.formatUtil fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) { val showAddAccountDialog by uiState.showAddAccountDialog.collectAsState() val showTransferMoneyDialogData by uiState.showTransferMoneyDialogData.collectAsState() + val showTransferMoneyFromEpcQrCodeScreen by uiState.showTransferMoneyFromEpcQrCodeScreen.collectAsState() val showCreateEpcQrCodeScreen by uiState.showCreateEpcQrCodeScreen.collectAsState() val showAccountTransactionDetailsScreenForId by uiState.showAccountTransactionDetailsScreenForId.collectAsState() @@ -39,6 +40,10 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) { TransferMoneyDialog(data) { uiState.showTransferMoneyDialogData.value = null } } + if (showTransferMoneyFromEpcQrCodeScreen) { + TransferMoneyFromQrCodeScreen { uiState.showTransferMoneyFromEpcQrCodeScreen.value = false } + } + if (showCreateEpcQrCodeScreen) { CreateEpcQrCodeScreen { uiState.showCreateEpcQrCodeScreen.value = false } } diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/config/Internationalization.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/config/Internationalization.kt index 159a684..72b740d 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/config/Internationalization.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/config/Internationalization.kt @@ -13,7 +13,9 @@ object Internationalization { const val ErrorTransferMoney = "Überweisung konnte nicht ausgeführt werden" - const val SaveToDatabase = "Daten konnten nicht in der Datenbank gespeichert werden" + const val ErrorReadEpcQrCode = "Überweisungsdaten konnten nicht aus dem QR Code ausgelesen werden" + + const val ErrorSaveToDatabase = "Daten konnten nicht in der Datenbank gespeichert werden" const val ErrorBiometricAuthentication = "Biometrische Authentifizierung fehlgeschlagen" diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/dialogs/ApplicationErrorDialog.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/dialogs/ApplicationErrorDialog.kt index 44b9f49..32ac52e 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/dialogs/ApplicationErrorDialog.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/dialogs/ApplicationErrorDialog.kt @@ -11,11 +11,10 @@ fun ApplicationErrorDialog(error: ApplicationError, onDismiss: (() -> Unit)? = n ErroneousAction.AddAccount -> Internationalization.ErrorAddAccount ErroneousAction.UpdateAccountTransactions -> Internationalization.ErrorUpdateAccountTransactions ErroneousAction.TransferMoney -> Internationalization.ErrorTransferMoney - ErroneousAction.SaveToDatabase -> Internationalization.SaveToDatabase + ErroneousAction.ReadEpcQrCode -> Internationalization.ErrorReadEpcQrCode + ErroneousAction.SaveToDatabase -> Internationalization.ErrorSaveToDatabase ErroneousAction.BiometricAuthentication -> Internationalization.ErrorBiometricAuthentication } - // add exception stacktrace? - ErrorDialog(error.errorMessage, title, error.exception, onDismiss = onDismiss) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/DecodeEpcQrCodeResult.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/DecodeEpcQrCodeResult.kt new file mode 100644 index 0000000..d19c703 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/DecodeEpcQrCodeResult.kt @@ -0,0 +1,7 @@ +package net.codinux.banking.ui.model + +data class DecodeEpcQrCodeResult( + val data: ShowTransferMoneyDialogData?, + val error: String? = null, + val charset: String? = null +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/error/ErroneousAction.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/error/ErroneousAction.kt index 2bf4454..ae81ef9 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/error/ErroneousAction.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/error/ErroneousAction.kt @@ -7,6 +7,8 @@ enum class ErroneousAction { TransferMoney, + ReadEpcQrCode, + SaveToDatabase, BiometricAuthentication diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/screens/TransferMoneyFromQrCodeScreen.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/screens/TransferMoneyFromQrCodeScreen.kt new file mode 100644 index 0000000..cedbae0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/screens/TransferMoneyFromQrCodeScreen.kt @@ -0,0 +1,29 @@ +package net.codinux.banking.ui.screens + +import androidx.compose.runtime.Composable +import net.codinux.banking.ui.config.DI +import net.codinux.banking.ui.model.error.ErroneousAction +import net.codinux.banking.ui.service.QrCodeService + +@Composable +fun TransferMoneyFromQrCodeScreen(onClosed: () -> Unit) { + + if (QrCodeService.supportsReadingQrCodesFromCamera) { + FullscreenViewBase("Überweisungsdaten aus QR Code lesen", "Abbrechen", onClosed = onClosed) { + QrCodeService.readQrCodeFromCamera { result -> + onClosed() + + if (result.decodedQrCodeText != null) { + val decodingResult = DI.epcQrCodeService.decode(result.decodedQrCodeText) + + if (decodingResult.data != null) { + DI.uiState.showTransferMoneyDialogData.value = decodingResult.data + } else if (decodingResult.error != null) { + DI.uiState.applicationErrorOccurred(ErroneousAction.ReadEpcQrCode, decodingResult.error) + } + } + } + } + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/EpcQrCodeService.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/EpcQrCodeService.kt index 467e20a..b6ecd96 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/EpcQrCodeService.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/EpcQrCodeService.kt @@ -1,15 +1,36 @@ package net.codinux.banking.ui.service +import net.codinux.banking.client.model.Amount import net.codinux.banking.epcqrcode.EpcQrCode import net.codinux.banking.epcqrcode.EpcQrCodeConfig import net.codinux.banking.epcqrcode.EpcQrCodeGenerator +import net.codinux.banking.epcqrcode.parser.EpcQrCodeParser +import net.codinux.banking.ui.model.DecodeEpcQrCodeResult +import net.codinux.banking.ui.model.ShowTransferMoneyDialogData class EpcQrCodeService { - fun generateEpcQrCode(receiverName: String, iban: String, bic: String?, amount: String?, reference: String?, informationForUser: String? = null, heightAndWidth: Int = EpcQrCode.DefaultHeightAndWidth): ByteArray { - val generator = EpcQrCodeGenerator() + private val generator = EpcQrCodeGenerator() + private val parser = EpcQrCodeParser() + + + fun generateEpcQrCode(receiverName: String, iban: String, bic: String?, amount: String?, reference: String?, informationForUser: String? = null, heightAndWidth: Int = EpcQrCode.DefaultHeightAndWidth): ByteArray { return generator.generateEpcQrCode(EpcQrCodeConfig(receiverName, iban, bic, amount, reference, informationForUser, qrCodeHeightAndWidth = heightAndWidth)).bytes } + fun decode(decodedQrCodeText: String): DecodeEpcQrCodeResult { + val result = parser.parseEpcQrCode(decodedQrCodeText) + + return if (result.epcQrCode != null) { + val code = result.epcQrCode!! + DecodeEpcQrCodeResult( + ShowTransferMoneyDialogData(null, code.receiverName, code.bic, code.iban, code.amount?.let { Amount(it) }, code.remittance), + charset = code.coding.charsetName + ) + } else { + DecodeEpcQrCodeResult(null, result.error) + } + } + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/QrCodeService.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/QrCodeService.kt new file mode 100644 index 0000000..2a2ac07 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/QrCodeService.kt @@ -0,0 +1,16 @@ +package net.codinux.banking.ui.service + +import androidx.compose.runtime.Composable + +expect object QrCodeService { + + val supportsReadingQrCodesFromCamera: Boolean + + @Composable + fun readQrCodeFromCamera(resultCallback: (QrCodeReadResult) -> Unit) + +} + +data class QrCodeReadResult( + val decodedQrCodeText: String? +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/state/UiState.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/state/UiState.kt index e2fe157..cb3d74a 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/state/UiState.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/state/UiState.kt @@ -67,6 +67,8 @@ class UiState : ViewModel() { val showTransferMoneyDialogData = MutableStateFlow(null) + val showTransferMoneyFromEpcQrCodeScreen = MutableStateFlow(false) + val showCreateEpcQrCodeScreen = MutableStateFlow(false) val showAccountTransactionDetailsScreenForId = MutableStateFlow(null) diff --git a/composeApp/src/desktopMain/kotlin/net/codinux/banking/ui/service/QrCodeService.desktop.kt b/composeApp/src/desktopMain/kotlin/net/codinux/banking/ui/service/QrCodeService.desktop.kt new file mode 100644 index 0000000..59104b1 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/net/codinux/banking/ui/service/QrCodeService.desktop.kt @@ -0,0 +1,14 @@ +package net.codinux.banking.ui.service + +import androidx.compose.runtime.Composable + +actual object QrCodeService { + + actual val supportsReadingQrCodesFromCamera = false + + @Composable + actual fun readQrCodeFromCamera(resultCallback: (QrCodeReadResult) -> Unit) { + + } + +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/net/codinux/banking/ui/service/QrCodeService.ios.kt b/composeApp/src/iosMain/kotlin/net/codinux/banking/ui/service/QrCodeService.ios.kt new file mode 100644 index 0000000..59104b1 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/net/codinux/banking/ui/service/QrCodeService.ios.kt @@ -0,0 +1,14 @@ +package net.codinux.banking.ui.service + +import androidx.compose.runtime.Composable + +actual object QrCodeService { + + actual val supportsReadingQrCodesFromCamera = false + + @Composable + actual fun readQrCodeFromCamera(resultCallback: (QrCodeReadResult) -> Unit) { + + } + +} \ No newline at end of file diff --git a/composeApp/src/jsMain/kotlin/net/codinux/banking/ui/service/QrCodeService.js.kt b/composeApp/src/jsMain/kotlin/net/codinux/banking/ui/service/QrCodeService.js.kt new file mode 100644 index 0000000..59104b1 --- /dev/null +++ b/composeApp/src/jsMain/kotlin/net/codinux/banking/ui/service/QrCodeService.js.kt @@ -0,0 +1,14 @@ +package net.codinux.banking.ui.service + +import androidx.compose.runtime.Composable + +actual object QrCodeService { + + actual val supportsReadingQrCodesFromCamera = false + + @Composable + actual fun readQrCodeFromCamera(resultCallback: (QrCodeReadResult) -> Unit) { + + } + +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0014f23..989bf6c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,9 @@ androidx-biometric = "1.1.0" androidx-test-junit = "1.2.1" compose-plugin = "1.6.11" +zxing = "3.5.3" +camerax = "1.3.4" + junit = "4.13.2" [libraries] @@ -69,6 +72,11 @@ androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "androidx-biometric" } kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" } +camerax-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } +camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } + kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } junit = { group = "junit", name = "junit", version.ref = "junit" }