From 623344369f9d4eea3f2fd875f474f17dcb9447b5 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 03:18:08 +0100 Subject: [PATCH] Guard call permission results and release video targets idempotently --- .../com/utun/chat/ui/screens/CallScreen.kt | 56 ++++++++++--------- .../utun/chat/ui/screens/CallVideoPanel.kt | 28 +++++++--- 2 files changed, 51 insertions(+), 33 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt index 00c11ee1..2e9001aa 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt @@ -61,9 +61,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat import com.utun.chat.data.AudioRoute import com.utun.chat.data.LogManager import com.utun.chat.viewmodel.ChatViewModel @@ -147,6 +144,9 @@ private fun CallVideoButton(video: CallVideoEngine, showLabel: Boolean, onOption ) } +/** Полноэкранный звонок: сигналинг/аудио/видео принадлежат контроллеру и сервису, minimize не завершает звонок. + * При ACTIVE proximity блокирует ввод только у разговорного динамика без видео; видеопанели сохраняют TextureView при смене геометрии. + * Ориентация окна фиксируется на время экрана и восстанавливается при выходе. */ @Composable @OptIn(ExperimentalLayoutApi::class) fun CallScreen( @@ -228,22 +228,30 @@ fun CallScreen( // Геометрию видео меняет TextureView; Activity сохраняет ориентацию окна звонка. DisposableEffect(context) { val activity = context.findActivity(); val previous = activity?.requestedOrientation - activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED - LogManager.addLog("INFO", "call", "call screen orientation locked; video rotates independently") + try { + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED + LogManager.addLog("INFO", "call", "call screen orientation locked; video rotates independently") + } catch (e: Exception) { LogManager.addLog("ERROR", "call", "Cannot lock call orientation: $e") } onDispose { if (activity != null && previous != null && activity.requestedOrientation != previous) { - activity.requestedOrientation = previous - LogManager.addLog("INFO", "call", "video screen orientation restored=$previous") + try { + activity.requestedOrientation = previous + LogManager.addLog("INFO", "call", "video screen orientation restored=$previous") + } catch (e: Exception) { LogManager.addLog("ERROR", "call", "Cannot restore call orientation=$previous: $e") } } } } val hasBtPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + var bluetoothCallId by rememberSaveable { mutableStateOf(null) } val btPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> - if (granted) viewModel.setAudioRoute(AudioRoute.HEADSET) + val requestedCall = bluetoothCallId; bluetoothCallId = null + LogManager.addLog(if (granted) "INFO" else "WARN", "call", "Bluetooth permission=$granted call=$requestedCall") + if (granted && requestedCall != null && st?.callId == requestedCall && phase == CallPhase.ACTIVE) + viewModel.setAudioRoute(AudioRoute.HEADSET) } /* Пока телефон у уха (proximity) — глотаем Back, чтобы не свернуть звонок. */ @@ -252,21 +260,7 @@ fun CallScreen( /* При блокировке скрываем системные панели (иначе статус-бар светится поверх оверлея). */ val hideBars = locked || (viewingVideo && !controlsVisible) - DisposableEffect(hideBars) { - val act = context.findActivity() - val controller = act?.let { WindowCompat.getInsetsController(it.window, it.window.decorView) } - if (hideBars) { - controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - controller?.hide(WindowInsetsCompat.Type.systemBars()) - LogManager.addLog("DEBUG", "CallScreen", "system bars hidden locked=$locked receiving=$receiving") - } - onDispose { - if (hideBars) { - controller?.show(WindowInsetsCompat.Type.systemBars()) - LogManager.addLog("DEBUG", "CallScreen", "system bars restored") - } - } - } + if (hideBars) ViewerFullscreenWindow("call id=${st?.callId}") Box( modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)).onGloballyPositioned { rootPosition = it.positionInRoot() } @@ -302,7 +296,8 @@ fun CallScreen( if (!hasVideo) Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) { Box(Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape), contentAlignment = Alignment.Center) { - Text(name.take(1).uppercase(), fontSize = 40.sp, color = Color.White, fontWeight = FontWeight.Bold) + Text(if (name.isEmpty()) "" else String(Character.toChars(name.codePointAt(0))).uppercase(), + fontSize = 40.sp, color = Color.White, fontWeight = FontWeight.Bold) } } Spacer(Modifier.height(16.dp)) @@ -386,7 +381,12 @@ fun CallScreen( if (hasBtPermission) { viewModel.setAudioRoute(AudioRoute.HEADSET) } else { - btPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT) + bluetoothCallId = st?.callId + try { btPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT) } catch (e: Exception) { + bluetoothCallId = null + LogManager.addLog("ERROR", "call", "Cannot request Bluetooth permission: $e") + Toast.makeText(context, R.string.media_audio_failed, Toast.LENGTH_LONG).show() + } } } ) @@ -429,7 +429,11 @@ fun CallScreen( videoOptionsRequested = true; controlsVisible = true }, requestPermission = { permissionVideo = engine - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + try { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) } catch (e: Exception) { + permissionVideo = null + LogManager.addLog("ERROR", "call", "Cannot request call camera permission: $e") + Toast.makeText(context, R.string.camera_unavailable, Toast.LENGTH_LONG).show() + } }) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt index 2e5e534d..a9c17a9e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt @@ -33,6 +33,7 @@ import com.utun.chat.data.CallVideoBitrateRange import com.utun.chat.data.CallVideoEngine import com.utun.chat.data.CallVideoFormat import com.utun.chat.data.StreamVideoCodec +import com.utun.chat.data.StreamPreviewTarget import com.utun.chat.R internal enum class CallVideoOption { CAMERA, CODEC, BITRATE, RESOLUTION } @@ -185,7 +186,7 @@ internal fun CallVideoStage(video: CallVideoEngine, expanded: CallVideoSource?, "frame=${frameWidth.value}x${frameHeight.value}dp; preview fits available space") } // Обе поверхности живут до конца видеосессии, даже пока аватар скрывает пустой слот. - for (source in CallVideoSource.entries) key(source) { + for (source in CallVideoSource.entries) key(video, source) { val large = expanded == source val index = if (source == CallVideoSource.REMOTE && localVisible) 1 else 0 val x = if (expanded != null) with(density) { maxWidth.toPx() } - widthPx - gapPx @@ -246,6 +247,8 @@ private fun CallVideoPlayback(video: CallVideoEngine, onTap: () -> Unit, modifie private class CallVideoTexture(context: Context, private val video: CallVideoEngine) : TextureView(context), ViewTreeObserver.OnPreDrawListener { private var attached: Surface? = null + private var attachedTexture: SurfaceTexture? = null + private val ownedTextures = java.util.Collections.newSetFromMap(java.util.IdentityHashMap()) private var closed = false private var format: CallVideoFormat? = null private var large = false @@ -263,13 +266,24 @@ private class CallVideoTexture(context: Context, private val video: CallVideoEng isOpaque = false surfaceTextureListener = object : SurfaceTextureListener { override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) { - if (closed) return - val surface = Surface(texture); attached = surface - // RX worker освобождает Surface/texture только после остановки MediaCodec. - video.surface(surface) { surface.release(); texture.release() }; fit() + try { + val target = StreamPreviewTarget(texture) + ownedTextures.add(texture) + if (closed) { target.release(); return } + closeOutput() + attached = target.surface; attachedTexture = texture + // RX worker передаёт Surface/texture в идемпотентный cleanup после остановки decoder. + video.surface(target.surface) { target.release() }; fit() + } catch (e: Exception) { + LogManager.addLog("ERROR", "call", "video UI surface attach failed texture=${System.identityHashCode(texture)}: $e") + } } override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) { fit() } - override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { closeOutput(); return false } + override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { + if (!ownedTextures.remove(texture)) return true + if (attachedTexture === texture) closeOutput() + return false + } override fun onSurfaceTextureUpdated(texture: SurfaceTexture) { updateAt = System.nanoTime(); updates++ } @@ -320,7 +334,7 @@ private class CallVideoTexture(context: Context, private val video: CallVideoEng }) com.utun.chat.data.LogManager.addLog("DEBUG", "call", "video fit view=${width}x$height frame=${w}x$h expanded=$large") } - private fun closeOutput() { attached?.let(video::detachSurface); attached = null } + private fun closeOutput() { attached?.let(video::detachSurface); attached = null; attachedTexture = null } fun close() { closed = true closeOutput()