diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt index d32a441f..c3d85456 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt @@ -6,6 +6,7 @@ import android.Manifest import android.app.Activity import android.content.pm.PackageManager import android.content.Context +import android.content.ContextWrapper import android.graphics.Matrix import android.graphics.SurfaceTexture import android.hardware.display.DisplayManager @@ -116,7 +117,8 @@ private fun StreamPublishScreen(group: Long, channelName: String, onBack: () -> var cameras by remember { mutableStateOf>(emptyList()) } var loadingModes by remember { mutableStateOf(true) } var menu by remember(group) { mutableStateOf(null) } - LaunchedEffect(Unit) { + var permissionError by remember(group) { mutableStateOf(null) } + LaunchedEffect(group) { val saved = try { ChatApplication.instance.configProvider.getStreamCaptureSettings() } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -131,7 +133,22 @@ private fun StreamPublishScreen(group: Long, channelName: String, onBack: () -> loadingModes = false } var permissionVersion by remember { mutableStateOf(0) } - val permissions = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissionVersion++ } + val permissions = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted -> + permissionVersion++ + val denied = granted.filterValues { !it }.keys + permissionError = when { + denied.isEmpty() -> null + Manifest.permission.CAMERA in denied -> R.string.camera_permission_required + else -> R.string.microphone_access_required + } + LogManager.addLog(if (denied.isEmpty()) "INFO" else "WARN", "stream", "capture permission result group=$group denied=$denied") + } + val lifecycle = LocalLifecycleOwner.current + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) permissionVersion++ } + lifecycle.lifecycle.addObserver(observer) + onDispose { lifecycle.lifecycle.removeObserver(observer) } + } val cameraGranted = permissionVersion.let { ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED } val micGranted = permissionVersion.let { ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED } @@ -175,9 +192,15 @@ private fun StreamPublishScreen(group: Long, channelName: String, onBack: () -> StreamController.stop() } else if (!cameraGranted || (audio && !micGranted)) { LogManager.addLog("INFO", "stream", "screen permissions group=$group camera=$cameraGranted mic=$micGranted audio=$audio") - permissions.launch(listOf(Manifest.permission.CAMERA) - .plus(if (audio) listOf(Manifest.permission.RECORD_AUDIO) else emptyList()).toTypedArray()) + try { + permissions.launch(listOf(Manifest.permission.CAMERA) + .plus(if (audio) listOf(Manifest.permission.RECORD_AUDIO) else emptyList()).toTypedArray()) + } catch (e: Exception) { + LogManager.addLog("ERROR", "stream", "Cannot request capture permissions group=$group: $e") + permissionError = R.string.stream_failed + } } else config?.let { + permissionError = null LogManager.addLog("INFO", "stream", "screen start group=$group audio=$audio config=$it") StreamController.start(group, it, audio) } @@ -189,6 +212,7 @@ private fun StreamPublishScreen(group: Long, channelName: String, onBack: () -> }) }) { padding -> Column(Modifier.fillMaxSize().padding(padding).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + permissionError?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error) } StreamCaptureToolbar(cameras, shownConfig, displayDegrees, Modifier.fillMaxWidth(), menu = menu, onMenuChange = { menu = it }, enabled = if (ongoing != null) ongoing.phase == StreamPhase.LIVE && ongoing.pendingConfig == null else !loadingModes, @@ -401,7 +425,10 @@ internal fun StreamPreviewPlayback(localPreview: StreamPreview, displayDegrees: internal fun StreamDisplayDegrees(): Int { val context = LocalContext.current val configuration = LocalConfiguration.current - val display = (context as? Activity)?.windowManager?.defaultDisplay + val activity = remember(context) { + generateSequence(context) { (it as? ContextWrapper)?.baseContext }.filterIsInstance().firstOrNull() + } + val display = activity?.windowManager?.defaultDisplay var degrees by remember(display) { mutableStateOf((display?.rotation ?: 0) * 90) } DisposableEffect(context, display, configuration) { degrees = (display?.rotation ?: 0) * 90 diff --git a/tools/chatgui-android/doc/ARCHITECTURE.md b/tools/chatgui-android/doc/ARCHITECTURE.md index 99f53b88..f4b976a1 100644 --- a/tools/chatgui-android/doc/ARCHITECTURE.md +++ b/tools/chatgui-android/doc/ARCHITECTURE.md @@ -429,6 +429,9 @@ Video close ждёт worker до 250 мс и подтверждает release de Ошибки listener detach сохраняют отдельную неактивную регистрацию до SDK ACK, включая failed attach без lease; поздний routing callback не читает released track. Повтор очистки выполняет media control executor. Viewer UI передаёт физический Surface/texture cleanup идемпотентному StreamPreviewTarget. +Прямой CameraPreviewView снимает свои leases при onRelease, не ожидая destroy TextureView; +отдельная camera lease сохраняет output до ACK сессии. Поздний destroy возвращает false +и не закрывает texture средствами framework. Смена имени источника обновляет плитку без смены reader. Publisher service сохраняет пассивный capture до start, CPU lock — до acquire, foreground-флаг — до IPC. Capture stop=true подтверждает camera/session callbacks, encoder/input Surface, ориентацию, подписку микрофона и Opus worker. @@ -721,6 +724,10 @@ ACK показа/PTS и EOS drain без смены Surface. OEM/реально StreamPreviewLifecycleTest проверяет реальные preview/leases: потерю/частичный эффект callback, отменённые команды, независимые и конкурентные владельцы, физические отказы release до/после эффекта, retry Handler, detach/visibility и поздние update. +Также проверяются onRelease без destroy, позднее появление texture после dispose и destroy прежнего output. +QrScannerSessionTest проверяет настоящего владельца CameraX/ML Kit с управляемыми +use cases, Task и SDK отказами. Dispose во время распознавания, поздняя camera future, +частичный bind, отказ listener и независимый cleanup не требуют камеры для воспроизведения. RadioCallLifecycleTest использует настоящие RadioCallController, IncomingCallManager, CoreActivity и гудки. Управляемые Android/JNI/repository-границы проверяют все команды сигналинга, pending START, валидацию событий/полной identity, активацию,