17 changed files with 841 additions and 554 deletions
|
After Width: | Height: | Size: 1.3 MiB |
@ -0,0 +1,12 @@ |
|||||||
|
Работа лички |
||||||
|
|
||||||
|
1. отдели логику лички от логики групповых чатов. не смешивай. |
||||||
|
|
||||||
|
Работа лички. сообщения и медиа доставляется напрямую если узел доступен через bgp. |
||||||
|
если недоступен - то pending сообщения сохраняются на суперузле и pending media сохраняется на каком-либо медиаузле, а суперузлу сообщается где хранится медиа. |
||||||
|
при появлении пира суперузел организует доставку медиа до получателя. после успешной доставки медиа/сообщений они удаляются с промежуточных узлов. |
||||||
|
|
||||||
|
время хранения медиафайлов на промежуточных узлах ограничено - 1 неделя по умолчанию (задачется в конфиге этих узлов). |
||||||
|
для доставки медиа поднимаем линк (conn_mgr). |
||||||
|
|
||||||
|
и посмотри реализацию медиа для групповых чатов. |
||||||
@ -0,0 +1,38 @@ |
|||||||
|
package com.utun.chat.data |
||||||
|
|
||||||
|
import android.Manifest |
||||||
|
import android.content.Intent |
||||||
|
import android.content.pm.PackageManager |
||||||
|
import android.os.Looper |
||||||
|
import androidx.core.content.ContextCompat |
||||||
|
import com.utun.chat.ChatApplication |
||||||
|
import com.utun.chat.MainActivity |
||||||
|
|
||||||
|
/* Запросы только из main thread; диалогом разрешений владеет видимая Activity. */ |
||||||
|
object MicrophonePermission { |
||||||
|
const val EXTRA_REQUEST = "request_microphone_permission" |
||||||
|
var requester: ((String, () -> Unit) -> Unit)? = null |
||||||
|
|
||||||
|
fun granted(): Boolean = ContextCompat.checkSelfPermission(ChatApplication.instance, Manifest.permission.RECORD_AUDIO) == |
||||||
|
PackageManager.PERMISSION_GRANTED |
||||||
|
|
||||||
|
fun request(reason: String, onGranted: () -> Unit = {}) { |
||||||
|
check(Looper.myLooper() == Looper.getMainLooper()) |
||||||
|
if (granted()) { onGranted(); return } |
||||||
|
LogManager.addLog("INFO", "PERMISSION", "Microphone required: $reason") |
||||||
|
val handler = requester |
||||||
|
if (handler != null) { |
||||||
|
handler(reason, onGranted) |
||||||
|
} else { |
||||||
|
// PTT из оверлея: открыть видимый экран; после выдачи нужна новая команда передачи. |
||||||
|
try { |
||||||
|
ChatApplication.instance.startActivity(Intent(ChatApplication.instance, MainActivity::class.java).apply { |
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) |
||||||
|
putExtra(EXTRA_REQUEST, true) |
||||||
|
}) |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "PERMISSION", "Cannot open microphone permission screen: $e") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,71 @@ |
|||||||
|
package com.utun.chat.ui.screens |
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.* |
||||||
|
import androidx.compose.foundation.rememberScrollState |
||||||
|
import androidx.compose.foundation.verticalScroll |
||||||
|
import androidx.compose.material3.* |
||||||
|
import androidx.compose.runtime.* |
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import com.utun.chat.data.LogManager |
||||||
|
import kotlinx.coroutines.CancellationException |
||||||
|
import kotlinx.coroutines.launch |
||||||
|
|
||||||
|
@Composable |
||||||
|
fun FirstLaunchScreen(initialName: String, onComplete: suspend (String) -> Unit) { |
||||||
|
var name by rememberSaveable { mutableStateOf(initialName) } |
||||||
|
var saving by remember { mutableStateOf(false) } |
||||||
|
var error by remember { mutableStateOf<String?>(null) } |
||||||
|
val scope = rememberCoroutineScope() |
||||||
|
val validName = name.trim().isNotEmpty() && name.none { it.isISOControl() } |
||||||
|
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) { |
||||||
|
Column( |
||||||
|
modifier = Modifier.fillMaxSize().safeDrawingPadding().imePadding() |
||||||
|
.verticalScroll(rememberScrollState()).padding(24.dp), |
||||||
|
verticalArrangement = Arrangement.Center, |
||||||
|
horizontalAlignment = Alignment.CenterHorizontally |
||||||
|
) { |
||||||
|
Text("Добро пожаловать", style = MaterialTheme.typography.headlineMedium) |
||||||
|
Spacer(Modifier.height(12.dp)) |
||||||
|
Text("Задайте имя устройства (ник), которое увидят другие участники.") |
||||||
|
Spacer(Modifier.height(24.dp)) |
||||||
|
OutlinedTextField( |
||||||
|
value = name, |
||||||
|
onValueChange = { name = it; error = null }, |
||||||
|
label = { Text("Имя устройства (ник)") }, |
||||||
|
singleLine = true, |
||||||
|
enabled = !saving, |
||||||
|
modifier = Modifier.fillMaxWidth() |
||||||
|
) |
||||||
|
error?.let { |
||||||
|
Spacer(Modifier.height(8.dp)) |
||||||
|
Text(it, color = MaterialTheme.colorScheme.error) |
||||||
|
} |
||||||
|
Spacer(Modifier.height(24.dp)) |
||||||
|
Button( |
||||||
|
enabled = validName && !saving, |
||||||
|
modifier = Modifier.fillMaxWidth(), |
||||||
|
onClick = { |
||||||
|
saving = true |
||||||
|
scope.launch { |
||||||
|
try { |
||||||
|
onComplete(name.trim()) |
||||||
|
} catch (e: CancellationException) { |
||||||
|
throw e |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "SETUP", "Failed to save device name: $e") |
||||||
|
error = "Не удалось сохранить имя. Попробуйте ещё раз." |
||||||
|
} finally { |
||||||
|
saving = false |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
) { |
||||||
|
Text(if (saving) "Сохранение…" else "Продолжить") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -1,142 +1,85 @@ |
|||||||
package com.utun.chat.ui.screens |
package com.utun.chat.ui.screens |
||||||
|
|
||||||
import androidx.compose.foundation.layout.* |
import androidx.compose.foundation.layout.* |
||||||
import androidx.compose.foundation.text.KeyboardOptions |
import androidx.compose.foundation.rememberScrollState |
||||||
|
import androidx.compose.foundation.verticalScroll |
||||||
import androidx.compose.material.icons.Icons |
import androidx.compose.material.icons.Icons |
||||||
import androidx.compose.material.icons.filled.QrCodeScanner |
import androidx.compose.material.icons.filled.ContentPaste |
||||||
import androidx.compose.material3.* |
import androidx.compose.material3.* |
||||||
import androidx.compose.runtime.* |
import androidx.compose.runtime.* |
||||||
import androidx.compose.ui.Modifier |
import androidx.compose.ui.Modifier |
||||||
import androidx.compose.ui.text.font.FontFamily |
import androidx.compose.ui.platform.LocalClipboardManager |
||||||
import androidx.compose.ui.text.font.FontWeight |
|
||||||
import androidx.compose.ui.text.input.KeyboardType |
|
||||||
import androidx.compose.ui.unit.dp |
import androidx.compose.ui.unit.dp |
||||||
import androidx.compose.ui.unit.sp |
|
||||||
import com.utun.chat.data.InviteData |
import com.utun.chat.data.InviteData |
||||||
import com.utun.chat.data.InviteLink |
import com.utun.chat.data.InviteLink |
||||||
import com.utun.chat.data.LogManager |
import com.utun.chat.data.LogManager |
||||||
import kotlinx.coroutines.delay |
|
||||||
import kotlinx.coroutines.launch |
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class) |
|
||||||
@Composable |
@Composable |
||||||
fun JoinChannelDialog( |
fun JoinChannelDialog(onConnect: (InviteData) -> Unit, onDismiss: () -> Unit) { |
||||||
autoLink: String? = null, |
val clipboard = LocalClipboardManager.current |
||||||
onScanQr: () -> Unit, |
var link by remember { mutableStateOf("") } |
||||||
onConnect: (InviteData) -> Unit, |
var error by remember { mutableStateOf<String?>(null) } |
||||||
onDismiss: () -> Unit |
|
||||||
) { |
|
||||||
var link by remember { mutableStateOf(autoLink ?: "") } |
|
||||||
var decoded by remember { mutableStateOf<InviteData?>(null) } |
|
||||||
var status by remember { mutableStateOf("") } |
|
||||||
var isError by remember { mutableStateOf(false) } |
|
||||||
var connecting by remember { mutableStateOf(false) } |
var connecting by remember { mutableStateOf(false) } |
||||||
val scope = rememberCoroutineScope() |
val connect by rememberUpdatedState(onConnect) |
||||||
|
|
||||||
if (!autoLink.isNullOrBlank()) { |
LaunchedEffect(Unit) { |
||||||
LaunchedEffect(autoLink) { |
val text = clipboard.getText()?.text?.trim().orEmpty() |
||||||
connecting = true |
if (text.startsWith("utun://")) { |
||||||
isError = false |
LogManager.addLog("INFO", "INVITE", "Invite link pasted from clipboard") |
||||||
|
link = text |
||||||
status = "Decoding..." |
} |
||||||
delay(300) |
} |
||||||
|
|
||||||
val d = InviteLink.decode(autoLink.trim()) |
|
||||||
if (d != null) { |
|
||||||
decoded = d |
|
||||||
status = "Connecting to peer..." |
|
||||||
delay(800) |
|
||||||
|
|
||||||
LogManager.addLog("INFO", "INVITE", "connect: ch=0x%016x node=0x%016x addrs=%d" |
LaunchedEffect(link) { |
||||||
.format(d.channelId, d.nodeId, d.addrs.size)) |
error = null |
||||||
onConnect(d) |
val text = link.trim() |
||||||
} else { |
if (text.isEmpty() || connecting) return@LaunchedEffect |
||||||
connecting = false |
val invite = InviteLink.decode(text) |
||||||
status = "Invalid invite link" |
if (invite == null) { |
||||||
isError = true |
LogManager.addLog("WARN", "INVITE", "Invalid invite link in join dialog") |
||||||
} |
error = "Invalid invite link" |
||||||
|
} else { |
||||||
|
connecting = true |
||||||
|
LogManager.addLog("INFO", "INVITE", "connect: ch=0x%016x node=0x%016x addrs=%d" |
||||||
|
.format(invite.channelId, invite.nodeId, invite.addrs.size)) |
||||||
|
connect(invite) |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
AlertDialog( |
AlertDialog( |
||||||
onDismissRequest = { if (!connecting) onDismiss() }, |
onDismissRequest = onDismiss, |
||||||
title = { Text("Join Channel") }, |
title = { Text("Join Channel") }, |
||||||
text = { |
text = { |
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { |
Column( |
||||||
if (autoLink.isNullOrBlank()) { |
modifier = Modifier.verticalScroll(rememberScrollState()), |
||||||
OutlinedTextField( |
verticalArrangement = Arrangement.spacedBy(12.dp) |
||||||
value = link, |
) { |
||||||
onValueChange = { newVal -> |
if (!connecting) { |
||||||
link = newVal |
QrScannerPreview( |
||||||
status = "" |
modifier = Modifier.fillMaxWidth().height(180.dp), |
||||||
isError = false |
onResult = { link = it } |
||||||
scope.launch { |
|
||||||
delay(400) |
|
||||||
if (link == newVal) { |
|
||||||
if (newVal.isBlank()) { |
|
||||||
decoded = null |
|
||||||
} else { |
|
||||||
decoded = InviteLink.decode(newVal.trim()) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
}, |
|
||||||
label = { Text("Invite link") }, |
|
||||||
placeholder = { Text("utun://...") }, |
|
||||||
trailingIcon = { |
|
||||||
IconButton(onClick = onScanQr) { |
|
||||||
Icon(Icons.Default.QrCodeScanner, "Scan QR") |
|
||||||
} |
|
||||||
}, |
|
||||||
singleLine = true, |
|
||||||
enabled = !connecting, |
|
||||||
modifier = Modifier.fillMaxWidth() |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
decoded?.let { d -> |
|
||||||
val addrStr = d.addrs.joinToString(", ") { InviteLink.addrToString(it) } |
|
||||||
Column(Modifier.fillMaxWidth()) { |
|
||||||
Text("Node: 0x%016x".format(d.nodeId), fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) |
|
||||||
Text("Channel: ${d.channelId}", fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) |
|
||||||
if (addrStr.isNotBlank()) |
|
||||||
Text(addrStr, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (status.isNotBlank()) { |
|
||||||
Text( |
|
||||||
status, |
|
||||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, |
|
||||||
fontSize = 14.sp, fontWeight = FontWeight.Medium |
|
||||||
) |
) |
||||||
} |
} |
||||||
|
OutlinedTextField( |
||||||
|
value = link, |
||||||
|
onValueChange = { link = it }, |
||||||
|
label = { Text("Invite link") }, |
||||||
|
placeholder = { Text("utun://...") }, |
||||||
|
trailingIcon = { |
||||||
|
IconButton(onClick = { link = clipboard.getText()?.text.orEmpty() }, enabled = !connecting) { |
||||||
|
Icon(Icons.Filled.ContentPaste, "Paste invite link") |
||||||
|
} |
||||||
|
}, |
||||||
|
singleLine = true, |
||||||
|
enabled = !connecting, |
||||||
|
isError = error != null, |
||||||
|
modifier = Modifier.fillMaxWidth() |
||||||
|
) |
||||||
|
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } |
||||||
|
if (connecting) Text("Connecting to peer...", color = MaterialTheme.colorScheme.primary) |
||||||
} |
} |
||||||
}, |
}, |
||||||
confirmButton = { |
confirmButton = {}, |
||||||
Button( |
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } |
||||||
onClick = { |
|
||||||
connecting = true |
|
||||||
isError = false |
|
||||||
status = "Connecting to peer..." |
|
||||||
val d = InviteLink.decode(link.trim()) |
|
||||||
if (d != null) { |
|
||||||
onConnect(d) |
|
||||||
} else { |
|
||||||
connecting = false |
|
||||||
status = "Invalid invite link" |
|
||||||
isError = true |
|
||||||
} |
|
||||||
}, |
|
||||||
enabled = !connecting && decoded != null && (autoLink.isNullOrBlank()) |
|
||||||
) { |
|
||||||
Text(if (connecting) "..." else "Connect") |
|
||||||
} |
|
||||||
}, |
|
||||||
dismissButton = { |
|
||||||
if (autoLink.isNullOrBlank()) { |
|
||||||
TextButton(onClick = onDismiss, enabled = !connecting) { Text("Cancel") } |
|
||||||
} |
|
||||||
} |
|
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -1,165 +0,0 @@ |
|||||||
package com.utun.chat.ui.screens |
|
||||||
|
|
||||||
import android.Manifest |
|
||||||
import android.content.pm.PackageManager |
|
||||||
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.core.CameraSelector |
|
||||||
import androidx.camera.core.ImageAnalysis |
|
||||||
import androidx.camera.core.Preview |
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider |
|
||||||
import androidx.camera.view.PreviewView |
|
||||||
import androidx.compose.foundation.layout.* |
|
||||||
import androidx.compose.material.icons.Icons |
|
||||||
import androidx.compose.material.icons.filled.ArrowBack |
|
||||||
import androidx.compose.material3.* |
|
||||||
import androidx.compose.runtime.* |
|
||||||
import androidx.compose.ui.Alignment |
|
||||||
import androidx.compose.ui.Modifier |
|
||||||
import androidx.compose.ui.platform.LocalContext |
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner |
|
||||||
import androidx.compose.ui.unit.dp |
|
||||||
import androidx.compose.ui.viewinterop.AndroidView |
|
||||||
import androidx.core.content.ContextCompat |
|
||||||
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 java.util.concurrent.Executors |
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class) |
|
||||||
@Composable |
|
||||||
fun QrScanScreen( |
|
||||||
onResult: (String) -> Unit, |
|
||||||
onBack: () -> Unit |
|
||||||
) { |
|
||||||
val context = LocalContext.current |
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current |
|
||||||
var hasPermission by remember { mutableStateOf( |
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED |
|
||||||
) } |
|
||||||
|
|
||||||
val launcher = rememberLauncherForActivityResult( |
|
||||||
ActivityResultContracts.RequestPermission() |
|
||||||
) { granted -> hasPermission = granted } |
|
||||||
|
|
||||||
LaunchedEffect(Unit) { |
|
||||||
if (!hasPermission) launcher.launch(Manifest.permission.CAMERA) |
|
||||||
} |
|
||||||
|
|
||||||
/* Camera lifecycle — unbind when leaving this screen */ |
|
||||||
val cameraProviderRef = remember { mutableStateOf<ProcessCameraProvider?>(null) } |
|
||||||
val cameraExecutor = remember { Executors.newSingleThreadExecutor() } |
|
||||||
val mainHandler = remember { Handler(Looper.getMainLooper()) } |
|
||||||
|
|
||||||
DisposableEffect(Unit) { |
|
||||||
onDispose { |
|
||||||
Log.d("QrScan", "disposing camera") |
|
||||||
cameraProviderRef.value?.unbindAll() |
|
||||||
cameraProviderRef.value = null |
|
||||||
cameraExecutor.shutdown() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
Scaffold( |
|
||||||
topBar = { |
|
||||||
TopAppBar( |
|
||||||
title = { Text("Scan QR Code") }, |
|
||||||
navigationIcon = { |
|
||||||
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } |
|
||||||
} |
|
||||||
) |
|
||||||
} |
|
||||||
) { padding -> |
|
||||||
if (!hasPermission) { |
|
||||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { |
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) { |
|
||||||
Text("Camera permission required") |
|
||||||
Spacer(Modifier.height(8.dp)) |
|
||||||
Button(onClick = { launcher.launch(Manifest.permission.CAMERA) }) { |
|
||||||
Text("Grant") |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} else { |
|
||||||
var scanned by remember { mutableStateOf(false) } |
|
||||||
|
|
||||||
AndroidView( |
|
||||||
modifier = Modifier.fillMaxSize().padding(padding), |
|
||||||
factory = { ctx -> |
|
||||||
val previewView = PreviewView(ctx) |
|
||||||
|
|
||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) |
|
||||||
cameraProviderFuture.addListener({ |
|
||||||
val cameraProvider = cameraProviderFuture.get() |
|
||||||
cameraProviderRef.value = cameraProvider |
|
||||||
|
|
||||||
val preview = Preview.Builder().build().also { |
|
||||||
it.surfaceProvider = previewView.surfaceProvider |
|
||||||
} |
|
||||||
|
|
||||||
val options = BarcodeScannerOptions.Builder() |
|
||||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE) |
|
||||||
.build() |
|
||||||
val scanner = BarcodeScanning.getClient(options) |
|
||||||
|
|
||||||
val analysis = ImageAnalysis.Builder() |
|
||||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) |
|
||||||
.build() |
|
||||||
|
|
||||||
analysis.setAnalyzer(cameraExecutor) { imageProxy -> |
|
||||||
if (scanned) { |
|
||||||
imageProxy.close() |
|
||||||
return@setAnalyzer |
|
||||||
} |
|
||||||
val mediaImage = imageProxy.image |
|
||||||
if (mediaImage != null) { |
|
||||||
try { |
|
||||||
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) |
|
||||||
scanner.process(image) |
|
||||||
.addOnSuccessListener { barcodes -> |
|
||||||
for (barcode in barcodes) { |
|
||||||
val text = barcode.rawValue |
|
||||||
if (text != null && text.startsWith("utun://")) { |
|
||||||
scanned = true |
|
||||||
val link = text |
|
||||||
Log.d("QrScan", "detected: $link") |
|
||||||
mainHandler.post { onResult(link) } |
|
||||||
break |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
.addOnCompleteListener { imageProxy.close() } |
|
||||||
} catch (e: Exception) { |
|
||||||
Log.e("QrScan", "scan error", e) |
|
||||||
imageProxy.close() |
|
||||||
} |
|
||||||
} else { |
|
||||||
imageProxy.close() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA |
|
||||||
try { |
|
||||||
cameraProvider.unbindAll() |
|
||||||
cameraProvider.bindToLifecycle( |
|
||||||
lifecycleOwner, |
|
||||||
cameraSelector, |
|
||||||
preview, |
|
||||||
analysis |
|
||||||
) |
|
||||||
Log.d("QrScan", "camera bound ok") |
|
||||||
} catch (e: Exception) { |
|
||||||
Log.e("QrScan", "Camera bind failed", e) |
|
||||||
} |
|
||||||
}, ContextCompat.getMainExecutor(ctx)) |
|
||||||
|
|
||||||
previewView |
|
||||||
} |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -0,0 +1,188 @@ |
|||||||
|
package com.utun.chat.ui.screens |
||||||
|
|
||||||
|
import android.Manifest |
||||||
|
import android.app.Activity |
||||||
|
import android.content.ContextWrapper |
||||||
|
import android.content.Intent |
||||||
|
import android.net.Uri |
||||||
|
import android.provider.Settings |
||||||
|
import androidx.lifecycle.Lifecycle |
||||||
|
import androidx.lifecycle.LifecycleEventObserver |
||||||
|
import android.content.pm.PackageManager |
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult |
||||||
|
import androidx.activity.result.contract.ActivityResultContracts |
||||||
|
import androidx.camera.core.CameraSelector |
||||||
|
import androidx.camera.core.ImageAnalysis |
||||||
|
import androidx.camera.core.Preview |
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider |
||||||
|
import androidx.camera.view.PreviewView |
||||||
|
import androidx.compose.foundation.Canvas |
||||||
|
import androidx.compose.foundation.layout.* |
||||||
|
import androidx.compose.material3.* |
||||||
|
import androidx.compose.runtime.* |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.geometry.Offset |
||||||
|
import androidx.compose.ui.graphics.Color |
||||||
|
import androidx.compose.ui.platform.LocalContext |
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import androidx.compose.ui.viewinterop.AndroidView |
||||||
|
import androidx.core.content.ContextCompat |
||||||
|
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.utun.chat.data.InviteLink |
||||||
|
import com.utun.chat.data.LogManager |
||||||
|
import java.util.concurrent.Executors |
||||||
|
import java.util.concurrent.atomic.AtomicBoolean |
||||||
|
|
||||||
|
@Composable |
||||||
|
fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit) { |
||||||
|
val context = LocalContext.current |
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current |
||||||
|
val result by rememberUpdatedState(onResult) |
||||||
|
var hasPermission by remember { mutableStateOf( |
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED |
||||||
|
) } |
||||||
|
var cameraError by remember { mutableStateOf<String?>(null) } |
||||||
|
var permanentDenial by remember { mutableStateOf(false) } |
||||||
|
val activity = remember(context) { |
||||||
|
var current = context |
||||||
|
while (current is ContextWrapper && current !is Activity) current = current.baseContext |
||||||
|
current as? Activity |
||||||
|
} |
||||||
|
DisposableEffect(lifecycleOwner) { |
||||||
|
val observer = LifecycleEventObserver { _, event -> |
||||||
|
if (event == Lifecycle.Event.ON_RESUME) { |
||||||
|
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED |
||||||
|
if (granted != hasPermission) { |
||||||
|
LogManager.addLog("INFO", "QR", "Camera permission on resume=$granted") |
||||||
|
hasPermission = granted |
||||||
|
cameraError = null |
||||||
|
} |
||||||
|
if (granted) permanentDenial = false |
||||||
|
} |
||||||
|
} |
||||||
|
lifecycleOwner.lifecycle.addObserver(observer) |
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } |
||||||
|
} |
||||||
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> |
||||||
|
hasPermission = granted |
||||||
|
permanentDenial = !granted && activity?.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) == false |
||||||
|
LogManager.addLog(if (granted) "INFO" else "WARN", "QR", "Camera permission=$granted permanentDenial=$permanentDenial") |
||||||
|
} |
||||||
|
LaunchedEffect(Unit) { |
||||||
|
if (!hasPermission) { |
||||||
|
LogManager.addLog("INFO", "QR", "Requesting CAMERA permission") |
||||||
|
launcher.launch(Manifest.permission.CAMERA) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Box(modifier, contentAlignment = Alignment.Center) { |
||||||
|
if (!hasPermission) { |
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) { |
||||||
|
Text("Camera permission required") |
||||||
|
TextButton(onClick = { |
||||||
|
if (permanentDenial) { |
||||||
|
try { |
||||||
|
context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, |
||||||
|
Uri.parse("package:${context.packageName}"))) |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "QR", "Cannot open camera permission settings: $e") |
||||||
|
} |
||||||
|
} else launcher.launch(Manifest.permission.CAMERA) |
||||||
|
}) { Text(if (permanentDenial) "Settings" else "Grant") } |
||||||
|
} |
||||||
|
} else { |
||||||
|
val previewView = remember { PreviewView(context).apply { implementationMode = PreviewView.ImplementationMode.COMPATIBLE } } |
||||||
|
DisposableEffect(lifecycleOwner, previewView) { |
||||||
|
val disposed = AtomicBoolean(false) |
||||||
|
val scanned = AtomicBoolean(false) |
||||||
|
val executor = Executors.newSingleThreadExecutor() |
||||||
|
val mainExecutor = ContextCompat.getMainExecutor(context) |
||||||
|
val scanner = BarcodeScanning.getClient(BarcodeScannerOptions.Builder() |
||||||
|
.setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()) |
||||||
|
val preview = Preview.Builder().build().also { it.surfaceProvider = previewView.surfaceProvider } |
||||||
|
val analysis = ImageAnalysis.Builder().setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build() |
||||||
|
var provider: ProcessCameraProvider? = null |
||||||
|
analysis.setAnalyzer(executor) { imageProxy -> |
||||||
|
val mediaImage = imageProxy.image |
||||||
|
if (disposed.get() || scanned.get() || mediaImage == null) { |
||||||
|
imageProxy.close() |
||||||
|
} else { |
||||||
|
try { |
||||||
|
scanner.process(InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)) |
||||||
|
.addOnSuccessListener(mainExecutor) { barcodes -> |
||||||
|
if (!disposed.get()) { |
||||||
|
val link = barcodes.mapNotNull { it.rawValue } |
||||||
|
.firstOrNull { it.startsWith("utun://") && InviteLink.decode(it) != null } |
||||||
|
if (link != null && scanned.compareAndSet(false, true)) { |
||||||
|
LogManager.addLog("INFO", "QR", "Valid invite QR detected") |
||||||
|
result(link) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
.addOnFailureListener(mainExecutor) { e -> |
||||||
|
if (!disposed.get()) { |
||||||
|
LogManager.addLog("ERROR", "QR", "QR scan failed: $e") |
||||||
|
cameraError = "QR scanning failed. Paste an invite link." |
||||||
|
analysis.clearAnalyzer() |
||||||
|
} |
||||||
|
} |
||||||
|
.addOnCompleteListener(mainExecutor) { imageProxy.close() } |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "QR", "QR image processing failed: $e") |
||||||
|
imageProxy.close() |
||||||
|
mainExecutor.execute { |
||||||
|
if (!disposed.get()) { |
||||||
|
cameraError = "QR scanning failed. Paste an invite link." |
||||||
|
analysis.clearAnalyzer() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
val future = ProcessCameraProvider.getInstance(context) |
||||||
|
future.addListener({ |
||||||
|
if (!disposed.get()) { |
||||||
|
try { |
||||||
|
provider = future.get() |
||||||
|
provider!!.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis) |
||||||
|
LogManager.addLog("INFO", "QR", "Join dialog camera started") |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "QR", "Camera initialization failed: $e") |
||||||
|
cameraError = "Camera unavailable. Paste an invite link." |
||||||
|
} |
||||||
|
} |
||||||
|
}, mainExecutor) |
||||||
|
onDispose { |
||||||
|
disposed.set(true) |
||||||
|
analysis.clearAnalyzer() |
||||||
|
provider?.unbind(preview, analysis) |
||||||
|
scanner.close() |
||||||
|
executor.shutdown() |
||||||
|
LogManager.addLog("INFO", "QR", "Join dialog camera released") |
||||||
|
} |
||||||
|
} |
||||||
|
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize()) |
||||||
|
Canvas(Modifier.fillMaxSize()) { |
||||||
|
val side = size.minDimension * 0.75f |
||||||
|
val left = (size.width - side) / 2 |
||||||
|
val top = (size.height - side) / 2 |
||||||
|
val length = 20.dp.toPx() |
||||||
|
val stroke = 3.dp.toPx() |
||||||
|
for (xSign in listOf(-1, 1)) { |
||||||
|
for (ySign in listOf(-1, 1)) { |
||||||
|
val x = if (xSign == -1) left else left + side |
||||||
|
val y = if (ySign == -1) top else top + side |
||||||
|
drawLine(Color.Red, Offset(x, y), Offset(x - xSign * length, y), stroke) |
||||||
|
drawLine(Color.Red, Offset(x, y), Offset(x, y - ySign * length), stroke) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
cameraError?.let { Text(it, color = MaterialTheme.colorScheme.error) } |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue