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(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() } } }