From f4e312ca56d255c5774c1978bd7804ac45307096 Mon Sep 17 00:00:00 2001 From: Neeraj Date: Fri, 24 Jul 2026 23:18:23 +0530 Subject: [PATCH 1/2] feat: allow scanning verification QR codes from gallery Adds a gallery picker on the verify Scan tab so users can decode a saved QR image without a second device. Closes #726. --- .../bitchat/android/ui/VerificationSheet.kt | 235 +++++++++++++++--- app/src/main/res/values/strings.xml | 3 + 2 files changed, 202 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index 97392eae..548ea945 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -1,9 +1,13 @@ package com.bitchat.android.ui +import android.content.Context import android.graphics.Bitmap +import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.camera.compose.CameraXViewfinder import androidx.camera.core.CameraSelector import androidx.camera.core.ExperimentalGetImage @@ -29,14 +33,19 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Photo import androidx.compose.material.icons.outlined.QrCodeScanner import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Tab import androidx.compose.material3.TabRow @@ -54,7 +63,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext @@ -320,8 +328,33 @@ private fun ScanTabContent( accent: Color, onScan: (String) -> Unit ) { + val context = LocalContext.current val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA) - + var galleryMessage by remember { mutableStateOf(null) } + var isScanningGallery by remember { mutableStateOf(false) } + val noQrMessage = stringResource(R.string.verify_gallery_no_qr) + val failedMessage = stringResource(R.string.verify_gallery_failed) + + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + galleryMessage = null + isScanningGallery = true + scanQrFromUri(context, uri) { result -> + isScanningGallery = false + when (result) { + is GalleryQrResult.Success -> onScan(result.text) + GalleryQrResult.NoQrFound -> galleryMessage = noQrMessage + GalleryQrResult.Failed -> galleryMessage = failedMessage + } + } + } + + fun openGallery() { + galleryLauncher.launch("image/*") + } + Column( modifier = Modifier .fillMaxSize() @@ -339,19 +372,14 @@ private fun ScanTabContent( contentAlignment = Alignment.Center ) { ScannerView(onScan = onScan) - + // Overlay border Box( modifier = Modifier .size(280.dp) .border(2.dp, accent.copy(alpha = 0.8f), RoundedCornerShape(16.dp)) ) - - // Corner accents for the overlay - Box(modifier = Modifier.size(260.dp)) { - // This could be drawn with Canvas for cooler effect, but simple border is cleaner for now - } - + Text( text = stringResource(R.string.verify_scan_prompt_friend), color = Color.White, @@ -363,45 +391,180 @@ private fun ScanTabContent( .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp)) .padding(horizontal = 12.dp, vertical = 8.dp) ) + + GalleryScanButton( + accent = accent, + enabled = !isScanningGallery, + onClick = { openGallery() }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) + + if (isScanningGallery) { + CircularProgressIndicator( + color = accent, + modifier = Modifier.align(Alignment.Center) + ) + } } } else { - Column( + Box( modifier = Modifier .fillMaxWidth() .weight(1f) - .background( - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - RoundedCornerShape(24.dp) - ) - .padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally ) { - Icon( - imageVector = Icons.Outlined.QrCodeScanner, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = accent - ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource(R.string.verify_camera_permission), - fontFamily = FontFamily.Monospace, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(modifier = Modifier.height(32.dp)) - Button( - onClick = { permissionState.launchPermissionRequest() }, - colors = ButtonDefaults.buttonColors(containerColor = accent) + Column( + modifier = Modifier + .fillMaxSize() + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + RoundedCornerShape(24.dp) + ) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { + Icon( + imageVector = Icons.Outlined.QrCodeScanner, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = accent + ) + Spacer(modifier = Modifier.height(24.dp)) Text( - text = stringResource(R.string.verify_request_camera), - fontFamily = FontFamily.Monospace + text = stringResource(R.string.verify_camera_permission), + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(32.dp)) + Button( + onClick = { permissionState.launchPermissionRequest() }, + colors = ButtonDefaults.buttonColors(containerColor = accent) + ) { + Text( + text = stringResource(R.string.verify_request_camera), + fontFamily = FontFamily.Monospace + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = { openGallery() }, + enabled = !isScanningGallery, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = accent + ) + ) { + Icon( + imageVector = Icons.Filled.Photo, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.verify_scan_gallery), + fontFamily = FontFamily.Monospace + ) + } + } + + if (isScanningGallery) { + CircularProgressIndicator( + color = accent, + modifier = Modifier.align(Alignment.Center) ) } } } + + galleryMessage?.let { message -> + Text( + text = message, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun GalleryScanButton( + accent: Color, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton( + onClick = onClick, + enabled = enabled, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.65f), + contentColor = Color.White, + disabledContainerColor = Color.Black.copy(alpha = 0.35f), + disabledContentColor = Color.White.copy(alpha = 0.5f) + ), + modifier = modifier + .size(48.dp) + .border(1.dp, accent.copy(alpha = 0.8f), CircleShape) + .clip(CircleShape) + ) { + Icon( + imageVector = Icons.Filled.Photo, + contentDescription = stringResource(R.string.verify_scan_gallery), + modifier = Modifier.size(22.dp) + ) + } +} + +private sealed class GalleryQrResult { + data class Success(val text: String) : GalleryQrResult() + data object NoQrFound : GalleryQrResult() + data object Failed : GalleryQrResult() +} + +private fun scanQrFromUri( + context: Context, + uri: Uri, + onResult: (GalleryQrResult) -> Unit +) { + val mainHandler = Handler(Looper.getMainLooper()) + fun deliver(result: GalleryQrResult) { + if (Looper.myLooper() == Looper.getMainLooper()) { + onResult(result) + } else { + mainHandler.post { onResult(result) } + } + } + + val scanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + ) + try { + val image = InputImage.fromFilePath(context, uri) + scanner.process(image) + .addOnSuccessListener { barcodes -> + val text = barcodes.firstOrNull()?.rawValue?.takeIf { it.isNotBlank() } + deliver( + if (text != null) GalleryQrResult.Success(text) + else GalleryQrResult.NoQrFound + ) + } + .addOnFailureListener { error -> + Log.w("VerificationSheet", "Gallery QR scan failed: ${error.message}") + deliver(GalleryQrResult.Failed) + } + .addOnCompleteListener { scanner.close() } + } catch (e: Exception) { + Log.w("VerificationSheet", "Could not load gallery image: ${e.message}") + scanner.close() + deliver(GalleryQrResult.Failed) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27a5c5e5..c8b4516e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -278,6 +278,9 @@ qr unavailable camera permission is needed to scan qr codes enable camera + scan from gallery + no qr code found in image + could not read image paste verification url validate verification requested From de1fe2f569c13bae91ad262b67d257b0341ccae2 Mon Sep 17 00:00:00 2001 From: Neeraj Date: Sat, 25 Jul 2026 00:33:01 +0530 Subject: [PATCH 2/2] fix: load gallery QR images off the main thread Decode gallery URIs on Dispatchers.IO and clear stale error messages when opening the picker, addressing PR #734 review feedback. --- .../bitchat/android/ui/VerificationSheet.kt | 90 ++++++++++--------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index 548ea945..88ed9a2f 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -58,6 +58,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -92,10 +93,14 @@ import com.google.mlkit.vision.common.InputImage import com.google.zxing.BarcodeFormat import com.google.zxing.common.BitMatrix import com.google.zxing.qrcode.QRCodeWriter +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import java.util.concurrent.ExecutorService import java.util.concurrent.Executors - +import kotlin.coroutines.resume @OptIn(ExperimentalMaterial3Api::class) @Composable fun VerificationSheet( @@ -329,11 +334,13 @@ private fun ScanTabContent( onScan: (String) -> Unit ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA) var galleryMessage by remember { mutableStateOf(null) } var isScanningGallery by remember { mutableStateOf(false) } val noQrMessage = stringResource(R.string.verify_gallery_no_qr) val failedMessage = stringResource(R.string.verify_gallery_failed) + val onScanState = rememberUpdatedState(onScan) val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() @@ -341,10 +348,11 @@ private fun ScanTabContent( if (uri == null) return@rememberLauncherForActivityResult galleryMessage = null isScanningGallery = true - scanQrFromUri(context, uri) { result -> + scope.launch { + val result = scanQrFromUri(context, uri) isScanningGallery = false when (result) { - is GalleryQrResult.Success -> onScan(result.text) + is GalleryQrResult.Success -> onScanState.value(result.text) GalleryQrResult.NoQrFound -> galleryMessage = noQrMessage GalleryQrResult.Failed -> galleryMessage = failedMessage } @@ -352,6 +360,8 @@ private fun ScanTabContent( } fun openGallery() { + // Clear any prior error before opening the picker (including cancel) + galleryMessage = null galleryLauncher.launch("image/*") } @@ -527,45 +537,45 @@ private sealed class GalleryQrResult { data object Failed : GalleryQrResult() } -private fun scanQrFromUri( - context: Context, - uri: Uri, - onResult: (GalleryQrResult) -> Unit -) { - val mainHandler = Handler(Looper.getMainLooper()) - fun deliver(result: GalleryQrResult) { - if (Looper.myLooper() == Looper.getMainLooper()) { - onResult(result) - } else { - mainHandler.post { onResult(result) } +/** + * Load and decode a QR from a gallery URI off the main thread to avoid ANR/jank + * on large or cloud-backed images. + */ +private suspend fun scanQrFromUri(context: Context, uri: Uri): GalleryQrResult { + return withContext(Dispatchers.IO) { + val scanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + ) + try { + val image = InputImage.fromFilePath(context, uri) + suspendCancellableCoroutine { cont -> + scanner.process(image) + .addOnSuccessListener { barcodes -> + val text = barcodes.firstOrNull()?.rawValue?.takeIf { it.isNotBlank() } + if (cont.isActive) { + cont.resume( + if (text != null) GalleryQrResult.Success(text) + else GalleryQrResult.NoQrFound + ) + } + } + .addOnFailureListener { error -> + Log.w("VerificationSheet", "Gallery QR scan failed: ${error.message}") + if (cont.isActive) cont.resume(GalleryQrResult.Failed) + } + .addOnCompleteListener { scanner.close() } + cont.invokeOnCancellation { + runCatching { scanner.close() } + } + } + } catch (e: Exception) { + Log.w("VerificationSheet", "Could not load gallery image: ${e.message}") + runCatching { scanner.close() } + GalleryQrResult.Failed } } - - val scanner = BarcodeScanning.getClient( - BarcodeScannerOptions.Builder() - .setBarcodeFormats(Barcode.FORMAT_QR_CODE) - .build() - ) - try { - val image = InputImage.fromFilePath(context, uri) - scanner.process(image) - .addOnSuccessListener { barcodes -> - val text = barcodes.firstOrNull()?.rawValue?.takeIf { it.isNotBlank() } - deliver( - if (text != null) GalleryQrResult.Success(text) - else GalleryQrResult.NoQrFound - ) - } - .addOnFailureListener { error -> - Log.w("VerificationSheet", "Gallery QR scan failed: ${error.message}") - deliver(GalleryQrResult.Failed) - } - .addOnCompleteListener { scanner.close() } - } catch (e: Exception) { - Log.w("VerificationSheet", "Could not load gallery image: ${e.message}") - scanner.close() - deliver(GalleryQrResult.Failed) - } } @Composable