improvement(Desktop): Scan QR codes by loading them from an image file
This commit is contained in:
parent
6e964c96e1
commit
d8fb02f0c2
|
@ -27,6 +27,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.artemchep.keyguard.feature.navigation.NavigationIcon
|
import com.artemchep.keyguard.feature.navigation.NavigationIcon
|
||||||
import com.artemchep.keyguard.feature.navigation.RouteResultTransmitter
|
import com.artemchep.keyguard.feature.navigation.RouteResultTransmitter
|
||||||
|
import com.artemchep.keyguard.res.Res
|
||||||
import com.artemchep.keyguard.ui.CollectedEffect
|
import com.artemchep.keyguard.ui.CollectedEffect
|
||||||
import com.artemchep.keyguard.ui.ScaffoldColumn
|
import com.artemchep.keyguard.ui.ScaffoldColumn
|
||||||
import com.artemchep.keyguard.ui.theme.Dimens
|
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.BarcodeScanning
|
||||||
import com.google.mlkit.vision.barcode.common.Barcode
|
import com.google.mlkit.vision.barcode.common.Barcode
|
||||||
import com.google.mlkit.vision.common.InputImage
|
import com.google.mlkit.vision.common.InputImage
|
||||||
|
import dev.icerock.moko.resources.compose.stringResource
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@ -71,7 +73,7 @@ fun ScanQrScreen(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.statusBarsPadding(),
|
.statusBarsPadding(),
|
||||||
title = {
|
title = {
|
||||||
Text("Scan QR code")
|
Text(stringResource(Res.strings.scanqr_title))
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
NavigationIcon()
|
NavigationIcon()
|
||||||
|
|
|
@ -481,6 +481,9 @@
|
||||||
<string name="error_failed_open_uri">Failed to open a URI</string>
|
<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="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 -->
|
<!-- Title of the 'Show as Barcode' dialog -->
|
||||||
<string name="barcodetype_title">Barcode</string>
|
<string name="barcodetype_title">Barcode</string>
|
||||||
<string name="barcodetype_action_show_in_barcode_title">Show as Barcode</string>
|
<string name="barcodetype_action_show_in_barcode_title">Show as Barcode</string>
|
||||||
|
|
|
@ -41,6 +41,8 @@ actual fun FilePickerEffect(
|
||||||
when {
|
when {
|
||||||
mimeType == "text/plain" -> "txt"
|
mimeType == "text/plain" -> "txt"
|
||||||
mimeType == "text/wordlist" -> "wordlist"
|
mimeType == "text/wordlist" -> "wordlist"
|
||||||
|
mimeType == "image/png" -> "png"
|
||||||
|
mimeType == "image/jpg" -> "jpg"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,5 +7,8 @@ import com.artemchep.keyguard.feature.navigation.RouteResultTransmitter
|
||||||
actual object ScanQrRoute : RouteForResult<String> {
|
actual object ScanQrRoute : RouteForResult<String> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun Content(transmitter: RouteResultTransmitter<String>) {
|
override fun Content(transmitter: RouteResultTransmitter<String>) {
|
||||||
|
ScanQrScreen(
|
||||||
|
transmitter = transmitter,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue