improvement(Desktop): Scan QR codes by loading them from an image file

This commit is contained in:
Artem Chepurnyi 2024-04-23 22:20:05 +03:00
parent 6e964c96e1
commit d8fb02f0c2
8 changed files with 244 additions and 1 deletions

View File

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

View File

@ -481,6 +481,9 @@
<string name="error_failed_open_uri">Failed to open a URI</string>
<string name="error_failed_format_placeholder">Failed to format the placeholder</string>
<string name="scanqr_title">Scan QR code</string>
<string name="scanqr_load_from_image_note">Load and parse a QR code from an image file.</string>
<!-- Title of the 'Show as Barcode' dialog -->
<string name="barcodetype_title">Barcode</string>
<string name="barcodetype_action_show_in_barcode_title">Show as Barcode</string>

View File

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

View File

@ -7,5 +7,8 @@ import com.artemchep.keyguard.feature.navigation.RouteResultTransmitter
actual object ScanQrRoute : RouteForResult<String> {
@Composable
override fun Content(transmitter: RouteResultTransmitter<String>) {
ScanQrScreen(
transmitter = transmitter,
)
}
}

View File

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

View File

@ -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<Content>,
val onSuccessFlow: Flow<String>,
val filePickerIntentFlow: Flow<FilePickerIntent<*>> = emptyFlow(),
) {
data class Content(
val onSelectFile: () -> Unit,
)
}

View File

@ -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<ScanQrState> = with(localDI().direct) {
produceScanQrState(
windowCoroutineScope = instance(),
)
}
@Composable
fun produceScanQrState(
windowCoroutineScope: WindowCoroutineScope,
): Loadable<ScanQrState> = produceScreenState(
key = "scan_qr",
initial = Loadable.Loading,
args = arrayOf(
),
) {
val onSuccessSink = EventFlow<String>()
val filePickerIntentSink = EventFlow<FilePickerIntent<*>>()
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)
}

View File

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