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