mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge de1fe2f569c13bae91ad262b67d257b0341ccae2 into d615fc9cc07faa8ba243c25bb769fb617ecc38ea
This commit is contained in:
commit
426acd06fb
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user