Implemented decoding EPC QR Code on Android

This commit is contained in:
dankito 2024-10-04 03:40:02 +02:00
parent fbd9c9485a
commit d47bc46cf8
17 changed files with 309 additions and 6 deletions

View File

@ -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 {

View File

@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"

View File

@ -0,0 +1,152 @@
package net.codinux.banking.ui.service
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.google.zxing.*
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader
import net.codinux.banking.persistence.AndroidContext
import net.codinux.banking.ui.config.DI
import net.codinux.log.logger
import java.nio.ByteBuffer
import java.util.concurrent.Executors
actual object QrCodeService {
private val cameraExecutor = Executors.newCachedThreadPool()
private val log by logger()
actual val supportsReadingQrCodesFromCamera = true
@Composable
actual fun readQrCodeFromCamera(resultCallback: (QrCodeReadResult) -> 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<DecodeHintType, *> = buildMap {
// put(DecodeHintType.TRY_HARDER, true) // optimize for accuracy, not speed
put(DecodeHintType.CHARACTER_SET, charset)
}
}

View File

@ -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()

View File

@ -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 }
}

View File

@ -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"

View File

@ -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)
}

View File

@ -0,0 +1,7 @@
package net.codinux.banking.ui.model
data class DecodeEpcQrCodeResult(
val data: ShowTransferMoneyDialogData?,
val error: String? = null,
val charset: String? = null
)

View File

@ -7,6 +7,8 @@ enum class ErroneousAction {
TransferMoney,
ReadEpcQrCode,
SaveToDatabase,
BiometricAuthentication

View File

@ -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)
}
}
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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?
)

View File

@ -67,6 +67,8 @@ class UiState : ViewModel() {
val showTransferMoneyDialogData = MutableStateFlow<ShowTransferMoneyDialogData?>(null)
val showTransferMoneyFromEpcQrCodeScreen = MutableStateFlow(false)
val showCreateEpcQrCodeScreen = MutableStateFlow(false)
val showAccountTransactionDetailsScreenForId = MutableStateFlow<Long?>(null)

View File

@ -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) {
}
}

View File

@ -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) {
}
}

View File

@ -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) {
}
}

View File

@ -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" }