diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt index 5f79a2e1..b61b6aa6 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt @@ -17,6 +17,7 @@ import android.util.Size import android.view.Surface import android.view.OrientationEventListener import com.utun.chat.R +import com.utun.chat.ChatApplication import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import java.nio.ByteBuffer @@ -26,7 +27,22 @@ data class CallVideoMode(val width: Int, val height: Int, val fps: Int) data class CallVideoCamera(val id: String, val name: String, val modes: List, val sensor: Int, val front: Boolean, val focal35: Float) data class CallVideoSettings(val enabled: Boolean = false, val camera: String = "", val width: Int = 640, - val height: Int = 480, val codec: Int = 0, val fps: Int = 30) + val height: Int = 480, val codec: Int = 0, val fps: Int = 30) { + /** Восстановить поддерживаемый режим; включение передачи относится только к текущему звонку. */ + fun restore(state: CallVideoState): CallVideoSettings { + val format = codec.takeIf { it == 0 || (it in state.codecs && state.cameras.any { camera -> + state.modes(camera.id, it).isNotEmpty() + }) } ?: 0 + val selected = state.cameras.firstOrNull { it.id == camera && state.modes(it.id, format).isNotEmpty() } + ?: state.cameras.firstOrNull { state.modes(it.id, format).isNotEmpty() } + val modes = selected?.let { state.modes(it.id, format) }.orEmpty() + val mode = modes.firstOrNull { it.width == width && it.height == height && it.fps == fps } + ?: modes.filter { it.width == width && it.height == height && it.fps <= fps }.maxByOrNull { it.fps } + ?: modes.minWithOrNull(compareBy { it.width * it.height }.thenBy { it.fps }) + return copy(enabled = false, camera = selected?.id.orEmpty(), codec = format, + width = mode?.width ?: 640, height = mode?.height ?: 480, fps = mode?.fps ?: 30) + } +} data class CallVideoState(val ready: Boolean = false, val settings: CallVideoSettings = CallVideoSettings(), val cameras: List = emptyList(), val codecs: List = emptyList(), val bitrate: Int = 0, val range: CallVideoBitrateRange = CallVideoBitrateRange(), val status: String = "", val transmitting: Boolean = false, @@ -186,13 +202,22 @@ class CallVideoEngine(private val context: Context, private val id: Long) { c.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0, c.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT, lens.eqFocal) } - val firstCamera = cameras.firstOrNull() - val firstMode = firstCamera?.modes?.firstOrNull { it.width == wanted.width && it.height == wanted.height && it.fps == wanted.fps } - ?: firstCamera?.modes?.minWithOrNull(compareBy { it.width * it.height }.thenBy { it.fps }) - wanted = wanted.copy(camera = firstCamera?.id.orEmpty(), width = firstMode?.width ?: 640, - height = firstMode?.height ?: 480, fps = firstMode?.fps ?: 30) - mutable.value = mutable.value.copy(ready = true, cameras = cameras, codecs = encoders.keys.toList(), settings = wanted, + val available = mutable.value.copy(ready = true, cameras = cameras, codecs = encoders.keys.toList(), codecModes = encoders.mapValues { entry -> entry.value.modes.map { CallVideoMode(it.size.width, it.size.height, it.fps) } }) + val preferences = ChatApplication.instance.configProvider + val saved = try { preferences.getCallVideoCaptureSettings() } + catch (e: Exception) { LogManager.addLog("ERROR", "call", "Video settings load failed id=$id: $e"); CallVideoSettings() } + wanted = saved.restore(available) + if (saved.camera.isNotEmpty() && saved != wanted) + LogManager.addLog("WARN", "call", "Saved video settings adjusted id=$id saved=$saved restored=$wanted") + mutable.value = available.copy(settings = wanted) + val savedRange = try { preferences.getCallVideoBitrateRange() } + catch (e: Exception) { LogManager.addLog("ERROR", "call", "Video bitrate settings load failed id=$id: $e"); CallVideoBitrateRange() } + if (stopped) { LogManager.addLog("INFO", "call", "Video settings restore cancelled id=$id"); return@post } + if (CallVideoNative.range(id, savedRange.minimum * 1000, savedRange.maximum * 1000)) + mutable.value = mutable.value.copy(range = savedRange) + else LogManager.addLog("WARN", "call", "Saved video bitrate range rejected id=$id range=$savedRange") + LogManager.addLog("INFO", "call", "Video settings restored id=$id settings=$wanted range=${mutable.value.range}") report(if (encoders.isEmpty()) "Receive-only video" else "Video ready") tick() } catch (e: Exception) { @@ -222,13 +247,21 @@ class CallVideoEngine(private val context: Context, private val id: Long) { wanted = selected; excluded = emptySet(); observeOrientation(selected.enabled) mutable.value = current.copy(settings = selected, transmitting = selected.enabled && current.transmitting, activeCodec = if (selected.enabled) current.activeCodec else 0, notice = null) + ChatApplication.instance.configProvider.saveCallVideoCaptureSettings(selected) LogManager.addLog("INFO", "call", "video selection accepted id=$id requested=$settings resolved=$selected ready=${current.ready}") }) LogManager.addLog("WARN", "call", "video selection rejected by stopped worker id=$id settings=$settings") } - fun range(value: CallVideoBitrateRange) { handler.post { - if (CallVideoNative.range(id, value.minimum * 1000, value.maximum * 1000)) mutable.value = mutable.value.copy(range = value) - else report("Bitrate range rejected") - } } + fun range(value: CallVideoBitrateRange) { + if (!handler.post { + if (stopped) { LogManager.addLog("WARN", "call", "Bitrate selection after stop id=$id range=$value"); return@post } + if (CallVideoNative.range(id, value.minimum * 1000, value.maximum * 1000)) { + mutable.value = mutable.value.copy(range = value) + ChatApplication.instance.configProvider.saveCallVideoBitrateRange(value) + NativeLib.setChatSetting("call_video_bitrate_range_kbps", value.encode()) + LogManager.addLog("INFO", "call", "Video bitrate selected id=$id range=$value") + } else LogManager.addLog("WARN", "call", "Bitrate range rejected id=$id range=$value") + }) LogManager.addLog("WARN", "call", "Bitrate selection rejected by stopped worker id=$id range=$value") + } private fun report(message: String) { mutable.value = mutable.value.copy(status = message) LogManager.addLog("INFO", "call", "video call=$id $message") diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallVideoSettingsTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallVideoSettingsTest.kt new file mode 100644 index 00000000..73d270c8 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallVideoSettingsTest.kt @@ -0,0 +1,40 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test + +class CallVideoSettingsTest { + private val sd = CallVideoMode(640, 480, 30) + private val hd = CallVideoMode(1280, 720, 60) + private val fullHd = CallVideoMode(1920, 1080, 30) + private val state = CallVideoState(ready = true, cameras = listOf( + CallVideoCamera("front", "Front", listOf(sd, fullHd), 90, true, 25f), + CallVideoCamera("back", "Back", listOf(sd, hd, fullHd), 90, false, 24f)), + codecs = listOf(1, 2), codecModes = mapOf(1 to listOf(sd, fullHd), 2 to listOf(sd, hd))) + + @Test fun savedCameraCodecAndCadenceRestoreWithoutEnablingTransmission() { + val saved = CallVideoSettings(true, "back", 1280, 720, 2, 60) + assertEquals(saved.copy(enabled = false), saved.restore(state)) + } + + @Test fun unsupportedCadenceKeepsResolutionWithAvailableLowerRate() { + val saved = CallVideoSettings(camera = "front", width = 1920, height = 1080, codec = 1, fps = 60) + assertEquals(saved.copy(fps = 30), saved.restore(state)) + } + + @Test fun missingCameraAndCodecChooseUsableAutoMode() { + val saved = CallVideoSettings(camera = "gone", width = 1920, height = 1080, codec = 5) + assertEquals(saved.copy(camera = "front", codec = 0), saved.restore(state)) + } + + @Test fun listedCodecWithoutCameraModesFallsBackToAuto() { + val saved = CallVideoSettings(camera = "back", codec = 2) + val available = state.copy(codecModes = mapOf(1 to listOf(sd, fullHd), 2 to emptyList())) + assertEquals(saved.copy(codec = 0), saved.restore(available)) + } + + @Test fun missingModesProduceDisabledReceiveOnlySettings() { + val restored = CallVideoSettings(true, "gone", 1920, 1080, 2, 60).restore(CallVideoState()) + assertEquals(CallVideoSettings(), restored) + } +}