Merge pull request #2 from fknives/support-qrcode-parsing-from-image

Support parsing QRCode data from image intent
This commit is contained in:
Gergely Hegedis 2023-12-18 21:07:31 +01:00 committed by GitHub
commit 60b181fcb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 327 additions and 27 deletions

View file

@ -32,6 +32,7 @@
<data android:mimeType="text/*" />
<data android:mimeType="message/*" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>

View file

@ -1,8 +1,11 @@
package org.fnives.android.qrcodetransfer
import android.app.Activity
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@ -26,8 +29,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import org.fnives.android.qrcodetransfer.create.CreateQRCode
import org.fnives.android.qrcodetransfer.intent.LocalIntentTextProvider
import org.fnives.android.qrcodetransfer.intent.LocalIntentImageUri
import org.fnives.android.qrcodetransfer.intent.LocalIntentProvider
import org.fnives.android.qrcodetransfer.read.ReadQRCode
import org.fnives.android.qrcodetransfer.read.image.ImageReadQRCode
import org.fnives.android.qrcodetransfer.storage.LocalAppPreferencesProvider
import org.fnives.android.qrcodetransfer.ui.theme.QRCodeTransferTheme
@ -37,14 +42,35 @@ class MainActivity : ComponentActivity() {
setContent {
LocalAppPreferencesProvider(this) {
LocalIntentTextProvider(intent) {
LocalIntentProvider(intent) {
QRCodeTransferTheme {
var writerSelected by rememberSaveable { mutableStateOf(true) }
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background,
) {
val intentImage = LocalIntentImageUri.current
if (intentImage != null) {
ImageReadQRCode(intentImage,
onErrorLoadingFile = {
showToast(R.string.could_not_read_content)
finishAfterTransition()
}
)
} else {
NormalState()
}
}
}
}
}
}
}
}
@Composable
fun NormalState() {
var writerSelected by rememberSaveable { mutableStateOf(true) }
Scaffold(bottomBar = {
NavBar(
writerSelected = writerSelected,
@ -61,12 +87,6 @@ class MainActivity : ComponentActivity() {
}
}
}
}
}
}
}
}
}
@Composable
fun NavBar(writerSelected: Boolean, setWriterSelected: (Boolean) -> Unit) {
@ -86,3 +106,11 @@ fun NavBar(writerSelected: Boolean, setWriterSelected: (Boolean) -> Unit) {
)
}
}
private fun Activity.showToast(@StringRes stringRes: Int) {
Toast.makeText(
this,
getString(stringRes),
Toast.LENGTH_LONG
).show()
}

View file

@ -37,11 +37,16 @@ fun BitMatrix.toBitmap(): Bitmap {
}
fun Bitmap.toBinaryBitmap(): BinaryBitmap {
val bitmap = if (this.config != Bitmap.Config.ARGB_8888) {
this.copy(Bitmap.Config.ARGB_8888, false)
} else {
this
}
//copy pixel data from the Bitmap into the 'intArray' array
val intArray = IntArray(width * height)
getPixels(intArray, 0, width, 0, 0, width, height)
val intArray = IntArray(bitmap.width * bitmap.height)
bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
val source: LuminanceSource = RGBLuminanceSource(width, height, intArray)
val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray)
return BinaryBitmap(HybridBinarizer(source))
}

View file

@ -0,0 +1,11 @@
package org.fnives.android.qrcodetransfer.intent
import android.content.Intent
import androidx.compose.runtime.Composable
@Composable
fun LocalIntentProvider(intent: Intent?, content: @Composable () -> Unit) {
LocalIntentImageUriProvider(intent) {
LocalIntentTextProvider(intent, content)
}
}

View file

@ -0,0 +1,26 @@
package org.fnives.android.qrcodetransfer.intent
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.core.content.IntentCompat
val LocalIntentImageUri = compositionLocalOf<Uri?> {
error("CompositionLocal LocalIntentImageUri not present")
}
@Composable
fun LocalIntentImageUriProvider(intent: Intent?, content: @Composable () -> Unit) {
CompositionLocalProvider(LocalIntentImageUri provides intent?.uri, content = content)
}
private val Intent.uri: Uri?
get() {
return try {
IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
} catch (ignore: Throwable) {
null
}
}

View file

@ -15,19 +15,34 @@ import org.fnives.android.qrcodetransfer.R
import org.fnives.android.qrcodetransfer.copyToClipboard
import org.fnives.android.qrcodetransfer.isMaybeLink
import org.fnives.android.qrcodetransfer.openLink
import org.fnives.android.qrcodetransfer.read.parsed.DataFormatter
@Composable
fun ActionRow(readState: ReadState?) {
val context = LocalContext.current
fun ActionRow(readState: ReadState?) =
ActionRow(
parsedContent = readState?.currentText.orEmpty(),
show = readState?.smallestMissingIndex == null
)
Row(Modifier.alpha(if (readState?.smallestMissingIndex == null) 1f else 0f)) {
@Composable
fun ActionRow(parsedContent: String, show: Boolean = true) {
val context = LocalContext.current
val formatted = DataFormatter.receivedDataFormatter(parsedContent)
Row(Modifier.alpha(if (show) 1f else 0f)) {
Spacer(Modifier.weight(1f))
Button(onClick = { context.copyToClipboard(readState?.currentText.orEmpty()) }) {
Button(onClick = { context.copyToClipboard(formatted) }) {
Text(stringResource(id = R.string.copy))
}
if (readState?.currentText?.isMaybeLink() == true) {
if (formatted != parsedContent) {
Spacer(Modifier.width(16.dp))
Button(onClick = { context.openLink(readState.currentText.orEmpty()) }) {
Button(onClick = { context.copyToClipboard(parsedContent) }) {
Text(stringResource(id = R.string.copy_raw))
}
}
if (formatted.isMaybeLink()) {
Spacer(Modifier.width(16.dp))
Button(onClick = { context.openLink(formatted) }) {
Text(stringResource(id = R.string.open))
}
}

View file

@ -28,10 +28,10 @@ import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.rememberPermissionState
import java.time.Duration
import org.fnives.android.qrcodetransfer.R
import org.fnives.android.qrcodetransfer.SequenceProtocol
import org.fnives.android.qrcodetransfer.create.Base64EncodeCheckbox
import org.fnives.android.qrcodetransfer.read.parsed.DataFormatter
import org.fnives.android.qrcodetransfer.storage.LocalAppPreferences
import org.fnives.android.qrcodetransfer.toBinaryBitmap
@ -88,7 +88,7 @@ fun QRCodeReader() {
modifier = Modifier
.heightIn(max = 128.dp)
.verticalScroll(textScrollState),
text = readState?.currentText.orEmpty(),
text = DataFormatter.receivedDataFormatter(readState?.currentText.orEmpty()),
)
}

View file

@ -0,0 +1,29 @@
package org.fnives.android.qrcodetransfer.read.image
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@Composable
fun Uri.toBitmap(): Bitmap? {
val context = LocalContext.current
return remember(this) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, this))
} else {
@Suppress("DEPRECATION")
MediaStore.Images.Media.getBitmap(context.contentResolver, this)
}
} catch (ignore: Throwable) {
null
}
}
}

View file

@ -0,0 +1,113 @@
package org.fnives.android.qrcodetransfer.read.image
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import org.fnives.android.qrcodetransfer.BuildConfig
import org.fnives.android.qrcodetransfer.R
import org.fnives.android.qrcodetransfer.SequenceProtocol
import org.fnives.android.qrcodetransfer.create.Base64EncodeCheckbox
import org.fnives.android.qrcodetransfer.read.ActionRow
import org.fnives.android.qrcodetransfer.read.parsed.DataFormatter
import org.fnives.android.qrcodetransfer.storage.LocalAppPreferences
import org.fnives.android.qrcodetransfer.toBinaryBitmap
@Composable
fun ImageReadQRCode(imageUri: Uri, onErrorLoadingFile: () -> Unit) {
val imageBitmap = imageUri.toBitmap()
LaunchedEffect(imageBitmap) {
if (imageBitmap == null) {
onErrorLoadingFile()
}
}
if (imageBitmap == null) {
return
}
val appPreferences = LocalAppPreferences.current
val encodeBase64 by appPreferences.encodeBase64.collectAsState(initial = SequenceProtocol.encodeBase64)
val imageParsedContent = remember(imageUri, encodeBase64) {
try {
SequenceProtocol.read(imageBitmap.toBinaryBitmap())?.sequenceInfo?.content
} catch (ignored: Throwable) {
if (BuildConfig.DEBUG) {
ignored.printStackTrace()
}
null
}
}
val textScrollState = rememberScrollState()
Column {
Box(
Modifier
.weight(1f)
.fillMaxWidth(),
) {
Image(
modifier = Modifier.fillMaxSize(),
bitmap = imageBitmap.asImageBitmap(),
contentScale = ContentScale.Fit,
contentDescription = null
)
}
Column {
Base64EncodeCheckbox(encode = encodeBase64, setEncode = {
SequenceProtocol.encodeBase64 = it
appPreferences.setEncodeBase64(SequenceProtocol.encodeBase64)
})
Column(
Modifier.padding(24.dp)
) {
if (imageParsedContent == null) {
Text(
text = stringResource(id = R.string.could_not_read_content),
style = LocalTextStyle.current.copy(fontSize = TextUnit(16f, TextUnitType.Sp))
)
} else {
Text(
text = stringResource(id = R.string.content_read),
style = LocalTextStyle.current.copy(fontSize = TextUnit(12f, TextUnitType.Sp))
)
Spacer(Modifier.height(4.dp))
SelectionContainer {
Text(
modifier = Modifier
.heightIn(max = 128.dp)
.verticalScroll(textScrollState),
text = DataFormatter.receivedDataFormatter(imageParsedContent),
)
}
ActionRow(imageParsedContent)
}
}
}
}
}

View file

@ -0,0 +1,8 @@
package org.fnives.android.qrcodetransfer.read.parsed
object DataFormatter {
fun receivedDataFormatter(data: String): String {
return WiFiInfoFormatter.tryToParse(data)?.format() ?: data
}
}

View file

@ -0,0 +1,62 @@
package org.fnives.android.qrcodetransfer.read.parsed
import org.fnives.android.qrcodetransfer.BuildConfig
object WiFiInfoFormatter {
private const val PREFIX = "WIFI:"
private const val EXTRA_KEY = "EXTRA"
private const val NAME_KEY = "S"
private const val PASSWORD_KEY = "P"
private const val SECURITY_KEY = "T"
private const val HIDDEN_KEY = "H"
fun tryToParse(data: String): WifiInfo? {
if (data.startsWith(PREFIX)) {
try {
val result = data.drop(PREFIX.length).split(";").map {
if (it.contains(":")) {
val (key, value) = it.split(":")
key to value
} else {
EXTRA_KEY to it
}
}.toMap()
return WifiInfo(
name = result[NAME_KEY]
?: throw IllegalArgumentException("Could not find name"),
password = result[PASSWORD_KEY]
?: throw IllegalArgumentException("Could not find password"),
security = result[SECURITY_KEY].orEmpty(),
extra = result[EXTRA_KEY].orEmpty(),
hidden = result[HIDDEN_KEY] == "true",
)
} catch (ignored: Throwable) {
if (BuildConfig.DEBUG) {
ignored.printStackTrace()
}
return null
}
} else {
return null
}
}
}
data class WifiInfo(
val name: String,
val security: String,
val password: String,
val hidden: Boolean,
val extra: String,
)
fun WifiInfo.format(): String =
"""
name: $name
password: $password
hidden: $hidden
security: $security
extra: $extra
""".trimIndent()

View file

@ -13,9 +13,11 @@
<string name="allow">Allow</string>
<string name="content_read_partial">Content read so far:</string>
<string name="content_read">Content read:</string>
<string name="could_not_read_content">Content could not be parsed.</string>
<string name="next_qr_code">Navigate to %1$s QR Code</string>
<string name="qr_code_content">QRCode Content</string>
<string name="copy">Copy</string>
<string name="copy_raw">Copy Raw</string>
<string name="open">Open</string>
<string name="read_qr_code">Scan QR Code</string>
<string name="create_qr_code">Create QR Code</string>