From a2aab8f704840c54ed4a6ec58985e6067c4582ec Mon Sep 17 00:00:00 2001 From: SanmerDev Date: Sat, 17 Aug 2024 21:50:15 +0800 Subject: [PATCH] Impl `scanImage` --- .../ui/screens/settings/SettingsScreen.kt | 12 ++++- .../screens/settings/component/TokenItem.kt | 25 +++++++++- .../viewmodel/SettingsViewModel.kt | 21 ++++++++ app/src/main/res/drawable/photo_scan.xml | 48 +++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + .../main/kotlin/dev/sanmer/qrcode/QRCode.kt | 48 +++++++++++++++++++ 6 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/drawable/photo_scan.xml diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsScreen.kt index 8d5aa6e..5e431e5 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -27,6 +28,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import dev.sanmer.authenticator.Const import dev.sanmer.authenticator.R @@ -39,18 +41,26 @@ import dev.sanmer.authenticator.ui.screens.settings.component.SettingItem import dev.sanmer.authenticator.ui.screens.settings.component.TokenItem import dev.sanmer.authenticator.ui.screens.settings.component.ToolItem import dev.sanmer.authenticator.viewmodel.SettingsViewModel +import dev.sanmer.otp.OtpUri.Default.isOtpUri @Composable fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), navController: NavController ) { + val uri by viewModel.uri.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + DisposableEffect(uri) { + if (uri.isOtpUri()) navController.navigateSingleTopTo(Screen.Edit(uri)) + onDispose(viewModel::rewind) + } + var token by rememberSaveable { mutableStateOf(false) } if (token) TokenItem( onDismiss = { token = false }, - navController = navController + navController = navController, + scanImage = viewModel::scanImage, ) var database by rememberSaveable { mutableStateOf(false) } diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/TokenItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/TokenItem.kt index a358702..f8ed95c 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/TokenItem.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/TokenItem.kt @@ -1,5 +1,10 @@ package dev.sanmer.authenticator.ui.screens.settings.component +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -12,6 +17,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController @@ -23,8 +29,15 @@ import dev.sanmer.authenticator.ui.main.Screen @Composable fun TokenItem( onDismiss: () -> Unit, - navController: NavController + navController: NavController, + scanImage: (Context, Uri) -> Unit, ) { + val context = LocalContext.current + val pickImage = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { if (it != null) scanImage(context, it) } + ) + ModalBottomSheet( onDismissRequest = onDismiss, shape = MaterialTheme.shapes.large.bottom(0.dp), @@ -61,6 +74,16 @@ fun TokenItem( onDismiss() } ) + + SettingItem( + icon = R.drawable.photo_scan, + title = stringResource(id = R.string.settings_scan_image), + onClick = { + pickImage.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + ) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/viewmodel/SettingsViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/viewmodel/SettingsViewModel.kt index 6d8deb1..33fa1b4 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/viewmodel/SettingsViewModel.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/viewmodel/SettingsViewModel.kt @@ -10,7 +10,10 @@ import dev.sanmer.authenticator.model.serializer.AuthJson import dev.sanmer.authenticator.repository.DbRepository import dev.sanmer.authenticator.ui.CryptoActivity import dev.sanmer.encoding.isBase32 +import dev.sanmer.qrcode.QRCode import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber @@ -23,10 +26,17 @@ class SettingsViewModel @Inject constructor( private var encrypted = emptyList() private var decrypted = emptyList() + private val uriFlow = MutableStateFlow("") + val uri get() = uriFlow.asStateFlow() + init { Timber.d("SettingsViewModel init") } + fun rewind() { + uriFlow.value = "" + } + private fun decrypt( context: Context, auths: List, @@ -119,4 +129,15 @@ class SettingsViewModel @Inject constructor( uri = uri, auths = decrypted ) + + fun scanImage(context: Context, uri: Uri) { + runCatching { + val cr = context.contentResolver + cr.openInputStream(uri).let(::requireNotNull).use(QRCode::decodeFromStream) + }.onSuccess { + uriFlow.value = it + }.onFailure { + Timber.e(it) + } + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/photo_scan.xml b/app/src/main/res/drawable/photo_scan.xml new file mode 100644 index 0000000..d3b0f57 --- /dev/null +++ b/app/src/main/res/drawable/photo_scan.xml @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 37ec92e..a1d1888 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,7 @@ Decrypt, encode and decode Enter Manually Scan QR Code + Scan Image Import from JSON Export to JSON Decrypt a JSON diff --git a/core/src/main/kotlin/dev/sanmer/qrcode/QRCode.kt b/core/src/main/kotlin/dev/sanmer/qrcode/QRCode.kt index da44f2b..bb958b8 100644 --- a/core/src/main/kotlin/dev/sanmer/qrcode/QRCode.kt +++ b/core/src/main/kotlin/dev/sanmer/qrcode/QRCode.kt @@ -1,6 +1,7 @@ package dev.sanmer.qrcode import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.graphics.Color import androidx.annotation.ColorInt import com.google.zxing.BarcodeFormat @@ -9,9 +10,12 @@ import com.google.zxing.DecodeHintType import com.google.zxing.LuminanceSource import com.google.zxing.MultiFormatReader import com.google.zxing.MultiFormatWriter +import com.google.zxing.NotFoundException import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.RGBLuminanceSource import com.google.zxing.common.BitMatrix import com.google.zxing.common.HybridBinarizer +import java.io.InputStream @Suppress("NOTHING_TO_INLINE") object QRCode { @@ -46,6 +50,50 @@ object QRCode { ) ) + private inline fun Bitmap.resize(maxWidth: Int, maxHeight: Int): Bitmap { + if (maxHeight <= 0 || maxWidth <= 0) { + return this + } + + val maxRatio = maxWidth.toFloat() / maxHeight + val ratio = width.toFloat() / height + + var width = maxWidth + var height = maxHeight + if (maxRatio > 1) { + width = (maxHeight.toFloat() * ratio).toInt() + } else { + height = (maxWidth.toFloat() / ratio).toInt() + } + + return Bitmap.createScaledBitmap(this, width, height, true) + } + + fun decodeFromStream(stream: InputStream): String { + var bitmap = requireNotNull( + BitmapFactory.decodeStream( + stream, + null, + BitmapFactory.Options() + ) + ) { "Unable to decode stream to bitmap" } + + for (i in 0..2) { + if (i != 0) { + bitmap = bitmap.resize(bitmap.width / (i * 2), bitmap.height / (i * 2)) + } + + try { + val pixels = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + return decode(source = RGBLuminanceSource(bitmap.width, bitmap.height, pixels)) + } catch (_: NotFoundException) { + } + } + + throw IllegalArgumentException(stream.toString()) + } + private inline fun createBitmap( matrix: BitMatrix, @ColorInt foregroundColor: Int,