Guard call permission results and release video targets idempotently

This commit is contained in:
evgeny committed 2026-10-29 13:34:25 +01:00
1 parent c8ecf89424
commit 623344369f
2 files changed
+51 -33

No files matched your search

@@ -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<Long?>(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()
}
})
}
}
@@ -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<SurfaceTexture, Boolean>())
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()