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 |
||||
|
||||
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.filled.QrCodeScanner |
||||
import androidx.compose.material.icons.filled.ContentPaste |
||||
import androidx.compose.material3.* |
||||
import androidx.compose.runtime.* |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.text.font.FontFamily |
||||
import androidx.compose.ui.text.font.FontWeight |
||||
import androidx.compose.ui.text.input.KeyboardType |
||||
import androidx.compose.ui.platform.LocalClipboardManager |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.unit.sp |
||||
import com.utun.chat.data.InviteData |
||||
import com.utun.chat.data.InviteLink |
||||
import com.utun.chat.data.LogManager |
||||
import kotlinx.coroutines.delay |
||||
import kotlinx.coroutines.launch |
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class) |
||||
@Composable |
||||
fun JoinChannelDialog( |
||||
autoLink: String? = null, |
||||
onScanQr: () -> Unit, |
||||
onConnect: (InviteData) -> Unit, |
||||
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) } |
||||
fun JoinChannelDialog(onConnect: (InviteData) -> Unit, onDismiss: () -> Unit) { |
||||
val clipboard = LocalClipboardManager.current |
||||
var link by remember { mutableStateOf("") } |
||||
var error by remember { mutableStateOf<String?>(null) } |
||||
var connecting by remember { mutableStateOf(false) } |
||||
val scope = rememberCoroutineScope() |
||||
val connect by rememberUpdatedState(onConnect) |
||||
|
||||
if (!autoLink.isNullOrBlank()) { |
||||
LaunchedEffect(autoLink) { |
||||
connecting = true |
||||
isError = false |
||||
|
||||
status = "Decoding..." |
||||
delay(300) |
||||
|
||||
val d = InviteLink.decode(autoLink.trim()) |
||||
if (d != null) { |
||||
decoded = d |
||||
status = "Connecting to peer..." |
||||
delay(800) |
||||
LaunchedEffect(Unit) { |
||||
val text = clipboard.getText()?.text?.trim().orEmpty() |
||||
if (text.startsWith("utun://")) { |
||||
LogManager.addLog("INFO", "INVITE", "Invite link pasted from clipboard") |
||||
link = text |
||||
} |
||||
} |
||||
|
||||
LogManager.addLog("INFO", "INVITE", "connect: ch=0x%016x node=0x%016x addrs=%d" |
||||
.format(d.channelId, d.nodeId, d.addrs.size)) |
||||
onConnect(d) |
||||
} else { |
||||
connecting = false |
||||
status = "Invalid invite link" |
||||
isError = true |
||||
} |
||||
LaunchedEffect(link) { |
||||
error = null |
||||
val text = link.trim() |
||||
if (text.isEmpty() || connecting) return@LaunchedEffect |
||||
val invite = InviteLink.decode(text) |
||||
if (invite == null) { |
||||
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( |
||||
onDismissRequest = { if (!connecting) onDismiss() }, |
||||
onDismissRequest = onDismiss, |
||||
title = { Text("Join Channel") }, |
||||
text = { |
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { |
||||
if (autoLink.isNullOrBlank()) { |
||||
OutlinedTextField( |
||||
value = link, |
||||
onValueChange = { newVal -> |
||||
link = newVal |
||||
status = "" |
||||
isError = false |
||||
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 |
||||
Column( |
||||
modifier = Modifier.verticalScroll(rememberScrollState()), |
||||
verticalArrangement = Arrangement.spacedBy(12.dp) |
||||
) { |
||||
if (!connecting) { |
||||
QrScannerPreview( |
||||
modifier = Modifier.fillMaxWidth().height(180.dp), |
||||
onResult = { link = it } |
||||
) |
||||
} |
||||
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 = { |
||||
Button( |
||||
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") } |
||||
} |
||||
} |
||||
confirmButton = {}, |
||||
dismissButton = { TextButton(onClick = onDismiss) { 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