From d8fb02f0c2a0890d3af61a1f76fdd1241fddf73c Mon Sep 17 00:00:00 2001 From: Artem Chepurnyi Date: Tue, 23 Apr 2024 22:20:05 +0300 Subject: [PATCH] improvement(Desktop): Scan QR codes by loading them from an image file --- .../keyguard/feature/qr/ScanQrScreen.kt | 4 +- .../commonMain/resources/MR/base/strings.xml | 3 + .../feature/filepicker/FilePickerEffect.kt | 2 + .../keyguard/feature/qr/ScanQrRoute.kt | 3 + .../keyguard/feature/qr/ScanQrScreen.kt | 96 +++++++++++++++++ .../keyguard/feature/qr/ScanQrState.kt | 18 ++++ .../feature/qr/ScanQrStateProducer.kt | 101 ++++++++++++++++++ .../keyguard/feature/qr/ScanQrUtil.kt | 18 ++++ 8 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrScreen.kt create mode 100644 common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrState.kt create mode 100644 common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrStateProducer.kt create mode 100644 common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrUtil.kt diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrScreen.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrScreen.kt index 9e724fcb..0c89cde8 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrScreen.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import com.artemchep.keyguard.feature.navigation.NavigationIcon import com.artemchep.keyguard.feature.navigation.RouteResultTransmitter +import com.artemchep.keyguard.res.Res import com.artemchep.keyguard.ui.CollectedEffect import com.artemchep.keyguard.ui.ScaffoldColumn import com.artemchep.keyguard.ui.theme.Dimens @@ -37,6 +38,7 @@ import com.google.accompanist.permissions.rememberPermissionState import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage +import dev.icerock.moko.resources.compose.stringResource import java.util.concurrent.Executors @Composable @@ -71,7 +73,7 @@ fun ScanQrScreen( modifier = Modifier .statusBarsPadding(), title = { - Text("Scan QR code") + Text(stringResource(Res.strings.scanqr_title)) }, navigationIcon = { NavigationIcon() diff --git a/common/src/commonMain/resources/MR/base/strings.xml b/common/src/commonMain/resources/MR/base/strings.xml index 511a3822..4b19ad1e 100644 --- a/common/src/commonMain/resources/MR/base/strings.xml +++ b/common/src/commonMain/resources/MR/base/strings.xml @@ -481,6 +481,9 @@ Failed to open a URI Failed to format the placeholder + Scan QR code + Load and parse a QR code from an image file. + Barcode Show as Barcode diff --git a/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/filepicker/FilePickerEffect.kt b/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/filepicker/FilePickerEffect.kt index 96922854..e243b8fc 100644 --- a/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/filepicker/FilePickerEffect.kt +++ b/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/filepicker/FilePickerEffect.kt @@ -41,6 +41,8 @@ actual fun FilePickerEffect( when { mimeType == "text/plain" -> "txt" mimeType == "text/wordlist" -> "wordlist" + mimeType == "image/png" -> "png" + mimeType == "image/jpg" -> "jpg" else -> null } } diff --git a/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrRoute.kt b/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrRoute.kt index 5fca3b33..44eaa4c3 100644 --- a/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrRoute.kt +++ b/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrRoute.kt @@ -7,5 +7,8 @@ import com.artemchep.keyguard.feature.navigation.RouteResultTransmitter actual object ScanQrRoute : RouteForResult { @Composable override fun Content(transmitter: RouteResultTransmitter) { + ScanQrScreen( + transmitter = transmitter, + ) } } diff --git a/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrScreen.kt b/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrScreen.kt new file mode 100644 index 00000000..5a1cf6c8 --- /dev/null +++ b/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrScreen.kt @@ -0,0 +1,96 @@ +package com.artemchep.keyguard.feature.qr + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import com.artemchep.keyguard.common.model.Loadable +import com.artemchep.keyguard.feature.filepicker.FilePickerEffect +import com.artemchep.keyguard.feature.navigation.NavigationIcon +import com.artemchep.keyguard.feature.navigation.RouteResultTransmitter +import com.artemchep.keyguard.res.Res +import com.artemchep.keyguard.ui.CollectedEffect +import com.artemchep.keyguard.ui.ScaffoldColumn +import com.artemchep.keyguard.ui.theme.Dimens +import com.artemchep.keyguard.ui.toolbar.LargeToolbar +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun ScanQrScreen( + modifier: Modifier = Modifier, + transmitter: RouteResultTransmitter, +) { + val loadableState = produceScanQrState() + when (loadableState) { + is Loadable.Ok -> { + val state = loadableState.value + CollectedEffect(state.onSuccessFlow) { rawValue -> + // Notify that we have successfully logged in, and that + // the caller can now decide what to do. + transmitter.invoke(rawValue) + } + FilePickerEffect( + flow = state.filePickerIntentFlow, + ) + } + + else -> { + // Do nothing. + } + } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + ScaffoldColumn( + modifier = modifier + .nestedScroll(scrollBehavior.nestedScrollConnection), + topAppBarScrollBehavior = scrollBehavior, + topBar = { + LargeToolbar( + title = { + Text(stringResource(Res.strings.scanqr_title)) + }, + navigationIcon = { + NavigationIcon() + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { + when (loadableState) { + is Loadable.Ok -> { + val contentState = loadableState.value.contentFlow + .collectAsState() + Text( + modifier = Modifier + .padding(horizontal = Dimens.horizontalPadding), + text = stringResource(Res.strings.scanqr_load_from_image_note), + ) + Spacer( + modifier = Modifier + .height(Dimens.verticalPadding), + ) + Button( + modifier = Modifier + .padding(horizontal = Dimens.horizontalPadding), + onClick = { + contentState.value.onSelectFile() + }, + ) { + Text( + text = stringResource(Res.strings.select_file), + ) + } + } + + is Loadable.Loading -> { + // Do nothing + } + } + } +} diff --git a/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrState.kt b/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrState.kt new file mode 100644 index 00000000..313e2034 --- /dev/null +++ b/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrState.kt @@ -0,0 +1,18 @@ +package com.artemchep.keyguard.feature.qr + +import androidx.compose.runtime.Immutable +import com.artemchep.keyguard.feature.filepicker.FilePickerIntent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow + +@Immutable +data class ScanQrState( + val contentFlow: StateFlow, + val onSuccessFlow: Flow, + val filePickerIntentFlow: Flow> = emptyFlow(), +) { + data class Content( + val onSelectFile: () -> Unit, + ) +} diff --git a/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrStateProducer.kt b/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrStateProducer.kt new file mode 100644 index 00000000..e73d4aa4 --- /dev/null +++ b/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrStateProducer.kt @@ -0,0 +1,101 @@ +package com.artemchep.keyguard.feature.qr + +import androidx.compose.runtime.Composable +import com.artemchep.keyguard.common.io.effectMap +import com.artemchep.keyguard.common.io.flatMap +import com.artemchep.keyguard.common.io.handleErrorWith +import com.artemchep.keyguard.common.io.io +import com.artemchep.keyguard.common.io.ioRaise +import com.artemchep.keyguard.common.io.launchIn +import com.artemchep.keyguard.common.model.Loadable +import com.artemchep.keyguard.common.usecase.WindowCoroutineScope +import com.artemchep.keyguard.common.util.flow.EventFlow +import com.artemchep.keyguard.feature.filepicker.FilePickerIntent +import com.artemchep.keyguard.feature.navigation.state.produceScreenState +import com.google.zxing.ChecksumException +import com.google.zxing.FormatException +import com.google.zxing.NotFoundException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import org.kodein.di.compose.localDI +import org.kodein.di.direct +import org.kodein.di.instance +import java.net.URL +import javax.imageio.ImageIO + +@Composable +fun produceScanQrState(): Loadable = with(localDI().direct) { + produceScanQrState( + windowCoroutineScope = instance(), + ) +} + +@Composable +fun produceScanQrState( + windowCoroutineScope: WindowCoroutineScope, +): Loadable = produceScreenState( + key = "scan_qr", + initial = Loadable.Loading, + args = arrayOf( + ), +) { + val onSuccessSink = EventFlow() + val filePickerIntentSink = EventFlow>() + + fun onSelect(uri: String) = io(uri) + .effectMap(Dispatchers.IO) { + val url = URL(it) + val image = ImageIO.read(url) + requireNotNull(image) { + "Failed to read a file as an image" + } + } + .flatMap { image -> + io(image) + .effectMap(Dispatchers.Default) { + ScanQrUtil.qrDecodeFromImage(it) + } + .handleErrorWith { e -> + val msg = when (e) { + is NotFoundException -> "Failed to find a barcode in the image" + is FormatException -> "Failed to parse a barcode" + is ChecksumException -> "Failed to parse a barcode" + else -> return@handleErrorWith ioRaise(e) + } + ioRaise(RuntimeException(msg)) + } + } + .effectMap { text -> + onSuccessSink.emit(text) + } + .launchIn(screenScope) + + fun onClick() { + val intent = FilePickerIntent.OpenDocument( + mimeTypes = arrayOf( + "image/png", + "image/jpg", + ), + ) { info -> + if (info != null) { + val uri = info.uri.toString() + onSelect(uri) + } + } + filePickerIntentSink.emit(intent) + } + + val content = ScanQrState.Content( + onSelectFile = ::onClick, + ) + val contentFlow = MutableStateFlow(content) + + val state = ScanQrState( + contentFlow = contentFlow, + onSuccessFlow = onSuccessSink, + filePickerIntentFlow = filePickerIntentSink, + ) + val success = Loadable.Ok(state) + flowOf(success) +} diff --git a/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrUtil.kt b/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrUtil.kt new file mode 100644 index 00000000..63635054 --- /dev/null +++ b/common/src/desktopMain/kotlin/com/artemchep/keyguard/feature/qr/ScanQrUtil.kt @@ -0,0 +1,18 @@ +package com.artemchep.keyguard.feature.qr + +import com.google.zxing.BinaryBitmap +import com.google.zxing.client.j2se.BufferedImageLuminanceSource +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.qrcode.QRCodeReader +import java.awt.image.BufferedImage + +object ScanQrUtil { + fun qrDecodeFromImage( + bufferedImage: BufferedImage, + ): String { + val bfImgLuminanceSource = BufferedImageLuminanceSource(bufferedImage) + val binaryBmp = BinaryBitmap(HybridBinarizer(bfImgLuminanceSource)) + val qrReader = QRCodeReader() + return qrReader.decode(binaryBmp).text + } +}