Merge de1fe2f569c13bae91ad262b67d257b0341ccae2 into d615fc9cc07faa8ba243c25bb769fb617ecc38ea

This commit is contained in:
Neeraj 2026-07-26 20:30:17 -04:00 committed by GitHub
commit 426acd06fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 220 additions and 44 deletions

View File

@ -1,9 +1,13 @@
package com.bitchat.android.ui package com.bitchat.android.ui
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.compose.CameraXViewfinder import androidx.camera.compose.CameraXViewfinder
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Photo
import androidx.compose.material.icons.outlined.QrCodeScanner import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
@ -49,12 +58,12 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -84,10 +93,14 @@ import com.google.mlkit.vision.common.InputImage
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.common.BitMatrix import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow 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.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.coroutines.resume
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun VerificationSheet( fun VerificationSheet(
@ -320,8 +333,38 @@ private fun ScanTabContent(
accent: Color, accent: Color,
onScan: (String) -> Unit onScan: (String) -> Unit
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA) val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
var galleryMessage by remember { mutableStateOf<String?>(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()
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
galleryMessage = null
isScanningGallery = true
scope.launch {
val result = scanQrFromUri(context, uri)
isScanningGallery = false
when (result) {
is GalleryQrResult.Success -> onScanState.value(result.text)
GalleryQrResult.NoQrFound -> galleryMessage = noQrMessage
GalleryQrResult.Failed -> galleryMessage = failedMessage
}
}
}
fun openGallery() {
// Clear any prior error before opening the picker (including cancel)
galleryMessage = null
galleryLauncher.launch("image/*")
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -339,19 +382,14 @@ private fun ScanTabContent(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
ScannerView(onScan = onScan) ScannerView(onScan = onScan)
// Overlay border // Overlay border
Box( Box(
modifier = Modifier modifier = Modifier
.size(280.dp) .size(280.dp)
.border(2.dp, accent.copy(alpha = 0.8f), RoundedCornerShape(16.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(
text = stringResource(R.string.verify_scan_prompt_friend), text = stringResource(R.string.verify_scan_prompt_friend),
color = Color.White, color = Color.White,
@ -363,44 +401,179 @@ private fun ScanTabContent(
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp)) .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 8.dp) .padding(horizontal = 12.dp, vertical = 8.dp)
) )
}
} else { GalleryScanButton(
Column( accent = accent,
modifier = Modifier enabled = !isScanningGallery,
.fillMaxWidth() onClick = { openGallery() },
.weight(1f) modifier = Modifier
.background( .align(Alignment.BottomEnd)
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), .padding(16.dp)
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( if (isScanningGallery) {
text = stringResource(R.string.verify_camera_permission), CircularProgressIndicator(
fontFamily = FontFamily.Monospace, color = accent,
textAlign = TextAlign.Center, modifier = Modifier.align(Alignment.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
) )
} }
} }
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
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_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()
}
/**
* 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<GalleryQrResult> { 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
} }
} }
} }

View File

@ -287,6 +287,9 @@
<string name="verify_qr_unavailable">qr unavailable</string> <string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string> <string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string> <string name="verify_request_camera">enable camera</string>
<string name="verify_scan_gallery">scan from gallery</string>
<string name="verify_gallery_no_qr">no qr code found in image</string>
<string name="verify_gallery_failed">could not read image</string>
<string name="verify_paste_label">paste verification url</string> <string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string> <string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string> <string name="verify_scanned">verification requested</string>