mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 08:45:20 +00:00
480 lines
17 KiB
Kotlin
480 lines
17 KiB
Kotlin
package com.bitchat.android.ui
|
|
|
|
import android.graphics.Bitmap
|
|
import android.os.Handler
|
|
import android.os.Looper
|
|
import android.util.Log
|
|
import androidx.camera.core.CameraSelector
|
|
import androidx.camera.core.ExperimentalGetImage
|
|
import androidx.camera.core.ImageAnalysis
|
|
import androidx.camera.core.ImageProxy
|
|
import androidx.camera.core.Preview
|
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
|
import androidx.camera.view.PreviewView
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.isSystemInDarkTheme
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.statusBarsPadding
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.outlined.QrCode
|
|
import androidx.compose.material.icons.outlined.QrCodeScanner
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.ButtonDefaults
|
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.ModalBottomSheet
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.rememberModalBottomSheetState
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberUpdatedState
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clipToBounds
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.asImageBitmap
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.compose.ui.viewinterop.AndroidView
|
|
import androidx.core.graphics.createBitmap
|
|
import androidx.core.graphics.set
|
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import com.bitchat.android.R
|
|
import com.bitchat.android.services.VerificationService
|
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
|
import com.google.accompanist.permissions.isGranted
|
|
import com.google.accompanist.permissions.rememberPermissionState
|
|
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
|
import com.google.mlkit.vision.barcode.BarcodeScanning
|
|
import com.google.mlkit.vision.barcode.common.Barcode
|
|
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 java.util.concurrent.ExecutorService
|
|
import java.util.concurrent.Executors
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun VerificationSheet(
|
|
isPresented: Boolean,
|
|
onDismiss: () -> Unit,
|
|
viewModel: ChatViewModel,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
if (!isPresented) return
|
|
|
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
val isDark = isSystemInDarkTheme()
|
|
val accent = if (isDark) Color.Green else Color(0xFF008000)
|
|
val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f)
|
|
|
|
var showingScanner by remember { mutableStateOf(false) }
|
|
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
|
val npub = remember {
|
|
viewModel.getCurrentNpub()
|
|
}
|
|
|
|
|
|
val qrString = remember(nickname, npub) {
|
|
viewModel.buildMyQRString(nickname, npub)
|
|
}
|
|
|
|
ModalBottomSheet(
|
|
modifier = modifier.statusBarsPadding(),
|
|
onDismissRequest = onDismiss,
|
|
sheetState = sheetState,
|
|
containerColor = MaterialTheme.colorScheme.background,
|
|
dragHandle = null
|
|
) {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
VerificationHeader(
|
|
accent = accent,
|
|
onClose = onDismiss
|
|
)
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.fillMaxWidth()
|
|
) {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.verticalScroll(rememberScrollState()),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
if (showingScanner) {
|
|
QRScannerPanel(
|
|
accent = accent,
|
|
boxColor = boxColor,
|
|
onScan = { code ->
|
|
val qr = VerificationService.verifyScannedQR(code)
|
|
if (qr != null && viewModel.beginQRVerification(qr)) {
|
|
showingScanner = false
|
|
}
|
|
}
|
|
)
|
|
} else {
|
|
MyQrPanel(
|
|
qrString = qrString,
|
|
accent = accent,
|
|
boxColor = boxColor,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
Column(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
|
) {
|
|
ToggleVerificationModeButton(
|
|
showingScanner = showingScanner,
|
|
onToggle = { showingScanner = !showingScanner }
|
|
)
|
|
|
|
val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
|
val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
|
if (peerID != null) {
|
|
val fingerprint = viewModel.meshService.getPeerFingerprint(peerID!!)
|
|
if (fingerprint != null && fingerprints.contains(fingerprint)) {
|
|
Button(
|
|
onClick = { viewModel.unverifyFingerprint(peerID!!) },
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
|
contentColor = MaterialTheme.colorScheme.onSurface
|
|
),
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.verify_remove),
|
|
fontFamily = FontFamily.Monospace,
|
|
fontSize = 12.sp
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun VerificationHeader(
|
|
accent: Color,
|
|
onClose: () -> Unit
|
|
) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.verify_title).uppercase(),
|
|
fontSize = 14.sp,
|
|
fontFamily = FontFamily.Monospace,
|
|
color = accent
|
|
)
|
|
CloseButton(onClick = onClose)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun MyQrPanel(
|
|
qrString: String,
|
|
accent: Color,
|
|
boxColor: Color,
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.verify_my_qr_title),
|
|
fontSize = 16.sp,
|
|
fontFamily = FontFamily.Monospace,
|
|
color = accent,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
textAlign = TextAlign.Center
|
|
)
|
|
|
|
QRCodeCard(qrString = qrString, boxColor = boxColor)
|
|
}
|
|
|
|
@Composable
|
|
private fun ToggleVerificationModeButton(
|
|
showingScanner: Boolean,
|
|
onToggle: () -> Unit
|
|
) {
|
|
Button(
|
|
onClick = onToggle,
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
|
contentColor = MaterialTheme.colorScheme.onSurface
|
|
),
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
if (showingScanner) {
|
|
Icon(
|
|
imageVector = Icons.Outlined.QrCode,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(16.dp)
|
|
)
|
|
Spacer(modifier = Modifier.size(8.dp))
|
|
Text(
|
|
text = stringResource(R.string.verify_show_my_qr),
|
|
fontFamily = FontFamily.Monospace,
|
|
fontSize = 13.sp
|
|
)
|
|
} else {
|
|
Icon(
|
|
imageVector = Icons.Outlined.QrCodeScanner,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(16.dp)
|
|
)
|
|
Spacer(modifier = Modifier.size(8.dp))
|
|
Text(
|
|
text = stringResource(R.string.verify_scan_someone),
|
|
fontFamily = FontFamily.Monospace,
|
|
fontSize = 13.sp
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun QRCodeCard(qrString: String, boxColor: Color) {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.background(boxColor, RoundedCornerShape(12.dp))
|
|
.padding(12.dp),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
) {
|
|
if (qrString.isNotBlank()) {
|
|
QRCodeImage(data = qrString, size = 220.dp)
|
|
} else {
|
|
Box(
|
|
modifier = Modifier
|
|
.size(220.dp)
|
|
.background(Color.Transparent, RoundedCornerShape(8.dp)),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.verify_qr_unavailable),
|
|
fontFamily = FontFamily.Monospace,
|
|
fontSize = 12.sp,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
)
|
|
}
|
|
}
|
|
|
|
SelectionContainer {
|
|
Text(
|
|
text = qrString,
|
|
fontFamily = FontFamily.Monospace,
|
|
fontSize = 11.sp,
|
|
color = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun QRCodeImage(data: String, size: Dp) {
|
|
val sizePx = with(LocalDensity.current) { size.toPx().toInt() }
|
|
val bitmap = remember(data, sizePx) { generateQrBitmap(data, sizePx) }
|
|
if (bitmap != null) {
|
|
Image(
|
|
bitmap = bitmap.asImageBitmap(),
|
|
contentDescription = null,
|
|
modifier = Modifier.size(size)
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
|
|
if (data.isBlank() || sizePx <= 0) return null
|
|
return try {
|
|
val matrix = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, sizePx, sizePx)
|
|
bitmapFromMatrix(matrix)
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
}
|
|
|
|
private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
|
|
val width = matrix.width
|
|
val height = matrix.height
|
|
val bitmap = createBitmap(width, height)
|
|
for (x in 0 until width) {
|
|
for (y in 0 until height) {
|
|
bitmap[x, y] =
|
|
if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE
|
|
}
|
|
}
|
|
return bitmap
|
|
}
|
|
|
|
@OptIn(ExperimentalPermissionsApi::class)
|
|
@Composable
|
|
private fun QRScannerPanel(
|
|
onScan: (String) -> Unit,
|
|
accent: Color,
|
|
boxColor: Color,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
|
|
val context = LocalContext.current
|
|
val lifecycleOwner = LocalLifecycleOwner.current
|
|
var lastValid by remember { mutableStateOf<String?>(null) }
|
|
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
|
|
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
|
|
val barcodeView = remember { PreviewView(context) }
|
|
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
|
|
|
val onCodeState = rememberUpdatedState(onScan)
|
|
val analyzer = remember {
|
|
QRCodeAnalyzer { text ->
|
|
mainHandler.post {
|
|
if (text == lastValid) return@post
|
|
lastValid = text
|
|
onCodeState.value(text)
|
|
}
|
|
}
|
|
}
|
|
|
|
DisposableEffect(permissionState.status.isGranted) {
|
|
if (permissionState.status.isGranted) {
|
|
val cameraProvider = cameraProviderFuture.get()
|
|
val preview = Preview.Builder()
|
|
.build()
|
|
.also { it.surfaceProvider = barcodeView.surfaceProvider }
|
|
val analysis = ImageAnalysis.Builder()
|
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
|
.build()
|
|
.also { it.setAnalyzer(cameraExecutor, analyzer) }
|
|
|
|
runCatching {
|
|
cameraProvider.unbindAll()
|
|
cameraProvider.bindToLifecycle(
|
|
lifecycleOwner,
|
|
CameraSelector.DEFAULT_BACK_CAMERA,
|
|
preview,
|
|
analysis
|
|
)
|
|
}.onFailure {
|
|
Log.w("VerificationSheet", "Failed to bind camera: ${it.message}")
|
|
}
|
|
}
|
|
|
|
onDispose {
|
|
runCatching { cameraProviderFuture.get().unbindAll() }
|
|
}
|
|
}
|
|
|
|
DisposableEffect(Unit) {
|
|
onDispose { cameraExecutor.shutdown() }
|
|
}
|
|
|
|
Column(
|
|
modifier = modifier
|
|
.fillMaxWidth()
|
|
.background(boxColor, RoundedCornerShape(12.dp))
|
|
.padding(12.dp),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.verify_scan_prompt_friend),
|
|
fontSize = 16.sp,
|
|
fontFamily = FontFamily.Monospace,
|
|
color = accent,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
textAlign = TextAlign.Center
|
|
)
|
|
|
|
if (permissionState.status.isGranted) {
|
|
AndroidView(
|
|
factory = { barcodeView },
|
|
modifier = Modifier
|
|
.size(220.dp)
|
|
.clipToBounds()
|
|
)
|
|
} else {
|
|
Text(
|
|
text = stringResource(R.string.verify_camera_permission),
|
|
fontFamily = FontFamily.Monospace,
|
|
fontSize = 12.sp,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
|
)
|
|
Button(
|
|
onClick = { permissionState.launchPermissionRequest() },
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
|
contentColor = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.verify_request_camera),
|
|
fontFamily = FontFamily.Monospace,
|
|
fontSize = 12.sp
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private class QRCodeAnalyzer(
|
|
private val onCode: (String) -> Unit
|
|
) : ImageAnalysis.Analyzer {
|
|
private val scanner = BarcodeScanning.getClient(
|
|
BarcodeScannerOptions.Builder()
|
|
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
|
.build()
|
|
)
|
|
|
|
@ExperimentalGetImage
|
|
override fun analyze(imageProxy: ImageProxy) {
|
|
val mediaImage = imageProxy.image ?: run {
|
|
imageProxy.close()
|
|
return
|
|
}
|
|
val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
|
scanner.process(input)
|
|
.addOnSuccessListener { barcodes ->
|
|
val text = barcodes.firstOrNull()?.rawValue
|
|
if (!text.isNullOrBlank()) onCode(text)
|
|
}
|
|
.addOnCompleteListener { imageProxy.close() }
|
|
}
|
|
}
|