Handle stream capture permission failures and document preview release

This commit is contained in:
evgeny committed 2026-10-29 13:34:25 +01:00
1 parent 45d1b50cb4
commit 36af8d077f
2 files changed
+39 -5

No files matched your search

@@ -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<List<StreamCamera>>(emptyList()) }
var loadingModes by remember { mutableStateOf(true) }
var menu by remember(group) { mutableStateOf<StreamOption?>(null) }
LaunchedEffect(Unit) {
var permissionError by remember(group) { mutableStateOf<Int?>(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<Activity>().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
@@ -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, активацию,