From 21a20da5ee8530649a43f3accb6fee17f5e8f32a Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 19 Oct 2026 16:51:51 +0200 Subject: [PATCH] Coordinate Android call audio ownership and improve video controls --- doc/service_lifecycle.md | 11 ++ .../main/java/com/utun/chat/MainActivity.kt | 12 ++ .../com/utun/chat/data/CallAudioEngine.kt | 21 +++- .../java/com/utun/chat/data/CallController.kt | 115 ++++++++++++++++-- .../main/java/com/utun/chat/data/CallState.kt | 3 +- .../com/utun/chat/data/IncomingCallManager.kt | 5 + .../com/utun/chat/data/RadioAudioEngine.kt | 61 +++++++++- .../com/utun/chat/data/StreamController.kt | 11 ++ .../com/utun/chat/data/StreamPlayerEngine.kt | 36 +++++- .../com/utun/chat/ui/screens/CallScreen.kt | 81 ++++++++++-- .../utun/chat/ui/screens/CallVideoPanel.kt | 21 ---- .../chat/ui/screens/IncomingCallDialog.kt | 16 ++- .../com/utun/chat/viewmodel/ChatViewModel.kt | 53 +++++--- .../app/src/main/res/values-ru/strings.xml | 14 +++ .../app/src/main/res/values/strings.xml | 14 +++ 15 files changed, 404 insertions(+), 70 deletions(-) diff --git a/doc/service_lifecycle.md b/doc/service_lifecycle.md index 8ecce733..219bfb94 100644 --- a/doc/service_lifecycle.md +++ b/doc/service_lifecycle.md @@ -272,6 +272,17 @@ ACTIVE и выключают Android standby. Состояние не истек завершения звонка в фоне возвращается standby; экран может гаснуть во время разговора независимо от частичного wake lock сервиса. +Если работают рация, публикация или просмотр трансляции, исходящий Android-звонок +требует подтверждения их остановки; входящий показывает предупреждение до принятия. +После подтверждения/принятия `CallController` выключает участие в рации, завершает +публикацию и просмотр, асинхронно ждёт освобождения их аудио и только затем запускает +сигналинг и звонковое аудио. Foreground-сервис резервируется сразу из видимой Activity. +Отмена во время освобождения не запускает устаревший звонок. +До окончания звонка повторное включение рации/трансляции заблокировано. +После закрытия звонкового аудио и Telecom Connection ранее включённая рация +возвращается в прежний канал; удержания PTT сброшены. Удалённый канал и остановленное +ядро не восстанавливаются. Публикация и просмотр трансляции автоматически не возобновляются. + `utun_set_client_activity()` в сборке с `UTUN_HAVE_STANDBY` применяет фактический режим duty-cycle до рассылки событий активности; JNI не переключает его отдельно. Повторная команда не перезапускает фазу и анонсы, но первый фоновый запуск включает diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 5cf6726c..608d3eb9 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -542,6 +542,18 @@ class MainActivity : AppCompatActivity() { pmInvite?.let { state -> PmInviteDialog(state, vm::selectPmInviteGroup, vm::sendPmInvite, vm::dismissPmInvite) } + val outgoingCall by CallController.outgoingRequest.collectAsState() + outgoingCall?.let { + AlertDialog( + onDismissRequest = CallController::cancelOutgoing, + title = { Text(stringResource(R.string.call_stop_media_title)) }, + text = { Text(stringResource(CallController.mediaWarning(incoming = false))) }, + confirmButton = { TextButton(onClick = CallController::confirmOutgoing) { + Text(stringResource(R.string.call_stop_media_confirm)) + } }, + dismissButton = { TextButton(onClick = CallController::cancelOutgoing) { Text(stringResource(R.string.cancel)) } } + ) + } val localInvitations by com.utun.chat.data.LocalDiscovery.invitations.collectAsState() localInvitations.firstOrNull()?.let { invitation -> AlertDialog( diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt index 075aac3e..d9464832 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt @@ -83,8 +83,21 @@ class CallAudioEngine { private val trackRouteListener = AudioRouting.OnRoutingChangedListener { output -> synchronized(trackLock) { val track = audioTrack - if (active && track != null && output === track.track && Build.VERSION.SDK_INT < 31) { - confirmedDeviceId = track.routedDevice?.id ?: 0 + if (active && track != null && output === track.track) { + val device = track.routedDevice + if (Build.VERSION.SDK_INT < 31) confirmedDeviceId = device?.id ?: 0 + LogManager.addLog("INFO", "CallAudio", "playback routed id=$callId backend=${track.name} " + + "device=${device?.id}/${device?.type} communication=$confirmedDeviceId selected=${router.route}") + } + } + } + private val recordRouteListener = AudioRouting.OnRoutingChangedListener { input -> + synchronized(recordLock) { + val record = audioRecord + if (active && record != null && input === record) { + val device = record.routedDevice + LogManager.addLog("INFO", "CallAudio", "capture routed id=$callId device=${device?.id}/${device?.type} " + + "preferred=${record.preferredDevice?.id} communication=$confirmedDeviceId selected=${router.route} wired=$wiredActive") } } } @@ -396,7 +409,8 @@ class CallAudioEngine { /* диагностика: уровень захваченного сигнала (тишина vs звук) */ if (sinceSwap < 10 || frame % 100L == 0L) { LogManager.addLog("DEBUG", "CallAudio", - "cap wired=$wiredActive frame=$frame rmsDb=${rmsDb(buf)}") + "cap wired=$wiredActive frame=$frame rmsDb=${rmsDb(buf)} selected=${router.route} " + + "communication=$confirmedDeviceId muted=$muted") } sinceSwap++ frame++ @@ -478,6 +492,7 @@ class CallAudioEngine { } LogManager.addLog("INFO", "CallAudio", "capture device ready id=$callId minQueryMs=$queryMs constructorMs=$constructorMs " + "routingMs=${SystemClock.elapsedRealtime() - routeAt} minBytes=$recMin") + rec.addOnRoutingChangedListener(recordRouteListener, mainHandler) return rec } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt index d9b7fac7..6dfa889b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.Job +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -23,6 +25,8 @@ import kotlinx.coroutines.launch import java.nio.ByteBuffer import java.nio.ByteOrder +data class OutgoingCallRequest(val channelId: String, val peerNodeId: Long, val peerName: String) + /** * Единственный владелец звонковой сессии в процессе приложения. Все операции — main thread. * Сервис владеет аудио и wake lock, ядро — сигналингом, ViewModel только читает StateFlow. @@ -32,6 +36,37 @@ object CallController : TelecomCallManager.Listener { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val _state = MutableStateFlow(null) val state: StateFlow = _state + private val mutableOutgoing = MutableStateFlow(null) + val outgoingRequest: StateFlow = mutableOutgoing + private var mediaPreparation: Job? = null + private var radioToRestore: Long? = null + + /** На время подготовки и разговора только звонок может запускать аудиоустройства. */ + fun blocksMedia(): Boolean = mediaPreparation?.isActive == true || _state.value?.let { + it.preparing || it.phase == CallPhase.OUTGOING || it.phase == CallPhase.RINGING || + it.phase == CallPhase.WAIT_DECISION || it.phase == CallPhase.ACTIVE + } == true + + fun hasMedia(): Boolean = RadioAudioEngine.instance().activeGroup.value != null || + StreamController.state.value?.ongoing == true || StreamPlayerEngine.viewing.value + + fun mediaWarning(incoming: Boolean): Int { + val radio = RadioAudioEngine.instance().activeGroup.value != null + val stream = StreamController.state.value?.ongoing == true || StreamPlayerEngine.viewing.value + return when { + radio && stream -> if (incoming) R.string.call_incoming_stop_media else R.string.call_outgoing_stop_media + radio -> if (incoming) R.string.call_incoming_stop_radio else R.string.call_outgoing_stop_radio + else -> if (incoming) R.string.call_incoming_stop_stream else R.string.call_outgoing_stop_stream + } + } + + fun cancelOutgoing() { mutableOutgoing.value = null } + + fun confirmOutgoing() { + val request = mutableOutgoing.value ?: return + mutableOutgoing.value = null + start(request.channelId, request.peerNodeId, request.peerName, mediaConfirmed = true) + } private val _video = MutableStateFlow(null) val video: StateFlow = _video private val _videoActive = MutableStateFlow(false) @@ -59,6 +94,7 @@ object CallController : TelecomCallManager.Listener { fun refreshLanguage() { service?.refreshLanguage() } private var serviceRequested = false + private var signallingStarted = false private var accepting = false private var acceptSent = false private var answerWhenVisible = 0L @@ -105,13 +141,12 @@ object CallController : TelecomCallManager.Listener { CoreActivity.call(_state.value?.ongoing == true) } - fun start(channelId: String, peerNodeId: Long, peerName: String) { + fun start(channelId: String, peerNodeId: Long, peerName: String, mediaConfirmed: Boolean = false) { checkMain() if (RadioCallController.busy()) { LogManager.addLog("WARN", "Call", "start rejected: pending radio invitation") return } - if (StreamController.blocksCapture()) return if (channelId.isEmpty() || peerNodeId == 0L) { LogManager.addLog("ERROR", "Call", "invalid outgoing call channel=$channelId peer=$peerNodeId") return @@ -120,20 +155,27 @@ object CallController : TelecomCallManager.Listener { LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}") return } + if (!mediaConfirmed && hasMedia()) { + mutableOutgoing.value = OutgoingCallRequest(channelId, peerNodeId, peerName) + LogManager.addLog("INFO", "Call", "outgoing requires media confirmation peer=$peerNodeId") + return + } if (!MicrophonePermission.granted()) { - MicrophonePermission.request("Outgoing call") { start(channelId, peerNodeId, peerName) } + MicrophonePermission.request("Outgoing call") { start(channelId, peerNodeId, peerName, mediaConfirmed) } return } var id = java.security.SecureRandom().nextLong() if (id == 0L) id = 1 - _state.value = CallState(id, channelId, peerNodeId, peerName.ifEmpty { "0x%016x".format(peerNodeId) }, CallPhase.OUTGOING) + mutableOutgoing.value = null + _state.value = CallState(id, channelId, peerNodeId, peerName.ifEmpty { "0x%016x".format(peerNodeId) }, + CallPhase.OUTGOING, preparing = true) syncCoreActivity() requestService(id) + if (_state.value?.callId == id && _state.value?.ongoing == true) prepareMedia(id) } fun accept() { checkMain() - if (StreamController.blocksCapture()) return val st = _state.value ?: return if (st.phase != CallPhase.INCOMING || accepting) { LogManager.addLog("WARN", "Call", "accept ignored id=${st.callId} phase=${st.phase} accepting=$accepting") @@ -147,7 +189,41 @@ object CallController : TelecomCallManager.Listener { return } accepting = true + _state.value = st.copy(preparing = true) + IncomingCallManager.stopRinging() requestService(st.callId) + if (_state.value?.callId == st.callId && _state.value?.ongoing == true) prepareMedia(st.callId) + } + + /** Сначала release чужих аудиотрактов, затем сигналинг; отмена вызова не прерывает release. */ + private fun prepareMedia(id: Long) { + val previous = mediaPreparation + if (radioToRestore == null) radioToRestore = RadioAudioEngine.instance().activeGroup.value + mediaPreparation = scope.launch { + try { + previous?.join() + if (_state.value?.callId != id || _state.value?.ongoing != true) return@launch + LogManager.addLog("INFO", "Call", "media release begins id=$id restoreRadio=$radioToRestore") + val radioStopped = RadioAudioEngine.instance().stopForCall() + StreamController.stopForCall() + StreamPlayerEngine.stopForCall() + withContext(Dispatchers.IO) { radioStopped.get() } + val current = _state.value + if (current?.callId != id || !current.ongoing) { + LogManager.addLog("INFO", "Call", "media released after call cancellation id=$id") + return@launch + } + _state.value = current.copy(preparing = false) + LogManager.addLog("INFO", "Call", "media released id=$id; call signalling allowed") + startSignalling() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LogManager.addLog("ERROR", "Call", "media release failed id=$id: $e") + Toast.makeText(ChatApplication.instance, R.string.call_media_stop_failed, Toast.LENGTH_LONG).show() + end(id, 7) + } + } } private fun requestService(id: Long) { @@ -174,16 +250,26 @@ object CallController : TelecomCallManager.Listener { } service = owner LogManager.addLog("INFO", "Call", "service ready id=$id phase=${st.phase}") + startSignalling() + return true + } + + /** Сервис резервируется видимым UI сразу, сигналинг ждёт фактического освобождения медиа. */ + private fun startSignalling() { + val st = _state.value ?: return + if (service == null || !st.ongoing || st.preparing || signallingStarted) return + val id = st.callId if (st.phase == CallPhase.OUTGOING) { + signallingStarted = true NativeLib.callStart(st.channelId, st.peerNodeId, id) TelecomCallManager.placeCall(id, st.peerNodeId, st.peerName) CallRingback.startConnecting() } else if (st.phase == CallPhase.INCOMING && accepting) { + signallingStarted = true IncomingCallManager.stopRinging() acceptSent = true NativeLib.callAccept(id) } - return true } fun wantsService(id: Long): Boolean = _state.value?.let { it.callId == id && it.ongoing && serviceRequested } == true @@ -212,7 +298,8 @@ object CallController : TelecomCallManager.Listener { return } if (NativeLib.isReady()) { - if (st.phase == CallPhase.INCOMING && !acceptSent) NativeLib.callDecline(id) else NativeLib.callHangup(id) + if ((st.phase == CallPhase.INCOMING || st.phase == CallPhase.PENDING_INCOMING) && !acceptSent) NativeLib.callDecline(id) + else if (signallingStarted) NativeLib.callHangup(id) } finish(id, reason) } @@ -223,6 +310,7 @@ object CallController : TelecomCallManager.Listener { val owner = service service = null serviceRequested = false + signallingStarted = false accepting = false acceptSent = false answerWhenVisible = 0L @@ -238,10 +326,18 @@ object CallController : TelecomCallManager.Listener { _route.value = AudioRoute.SPEAKER _headset.value = false _earpiece.value = true - _state.value = st.copy(phase = CallPhase.ENDED, reason = reason, + _state.value = st.copy(phase = CallPhase.ENDED, reason = reason, preparing = false, connectionType = CallConnectionType.NONE, connectionNextHop = 0) syncCoreActivity() LogManager.addLog("INFO", "Call", "finished id=$id reason=$reason; audio/service/proximity released") + val released = mediaPreparation + scope.launch { + released?.join() + if (!coreReady || !NativeLib.isReady() || blocksMedia()) return@launch + val group = radioToRestore ?: return@launch + radioToRestore = null + RadioAudioEngine.instance().restoreAfterCall(group) + } if (reason == 8 && st.phase != CallPhase.INCOMING && st.phase != CallPhase.PENDING_INCOMING) { val app = ChatApplication.instance val text = AppLanguage.text(R.string.phone_busy_warning) @@ -268,6 +364,8 @@ object CallController : TelecomCallManager.Listener { /** Перед stop/restart ядра освобождаем аудио, чтобы workers больше не обращались к native-ресурсам. */ fun reset() { checkMain() + mutableOutgoing.value = null + radioToRestore = null coreReady = false CoreActivity.reset() end() @@ -357,6 +455,7 @@ object CallController : TelecomCallManager.Listener { return } incomingAt = SystemClock.elapsedRealtime() + mutableOutgoing.value = null // Однократное чтение на main завершается до reset; фоновый JNI-запрос не переживает ядро. val name = ChatRepository().getMembers(channel).firstOrNull { it.nodeId == peer }?.name ?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(peer) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt index 17c4b556..df81e5bb 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt @@ -34,7 +34,8 @@ data class CallState( val stats: CallStats? = null, val startedAtElapsedRealtime: Long = 0, val connectionType: CallConnectionType = CallConnectionType.NONE, - val connectionNextHop: Long = 0 + val connectionNextHop: Long = 0, + val preparing: Boolean = false // Освобождаем рацию/трансляцию до сигналинга и запуска звонкового аудио. ) { val ongoing: Boolean get() = phase != CallPhase.NONE && phase != CallPhase.ENDED } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt index 8ad3dbc4..0f06e9ff 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt @@ -150,6 +150,11 @@ object IncomingCallManager { .setContentIntent(contentPi) .addAction(0, AppLanguage.text(if (activeRadio) R.string.decline else R.string.hang_up), declinePi) + if (!activeRadio && CallController.hasMedia()) { + val warning = AppLanguage.text(CallController.mediaWarning(incoming = true)) + builder.setContentText(warning).setStyle(NotificationCompat.BigTextStyle().bigText(warning)) + } + if (activeRadio) { val answerPi = PendingIntent.getActivity(ctx, 2, Intent(launchIntent).apply { action = "com.utun.chat.ANSWER_RADIO" diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt index f3246533..1bdd953c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt @@ -21,6 +21,7 @@ import com.utun.chat.ChatApplication import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import java.util.concurrent.Executors +import java.util.concurrent.Future import java.util.concurrent.atomic.AtomicLong /** @@ -63,7 +64,6 @@ class RadioAudioEngine { private var captureThread: Thread? = null @Volatile private var audioTrack: AudioTrack? = null private var playThread: Thread? = null - private var prevMode: Int = AudioManager.MODE_NORMAL private var prevSpeaker: Boolean = false private var audioModeOwned = false private val streamPriorities = AtomicLong() @@ -73,6 +73,8 @@ class RadioAudioEngine { @Volatile private var active = false @Volatile private var groupId = 0L + private val mutableGroup = MutableStateFlow(null) + val activeGroup: StateFlow = mutableGroup @Volatile private var talking = false @Volatile private var captureRunning = false @Volatile private var playbackRunning = false @@ -180,6 +182,11 @@ class RadioAudioEngine { /** Начать прослушивание канала (переключатель рации вкл). * Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */ fun start(group: Long, onReady: ((Boolean) -> Unit)? = null) { + if (CallController.blocksMedia()) { + LogManager.addLog("WARN", "radio", "start blocked by call group=$group") + onReady?.invoke(false) + return + } if (active && groupId == group) { val version = generation io.execute { mainHandler.post { onReady?.invoke(active && generation == version && audioTrack != null) } } @@ -189,6 +196,7 @@ class RadioAudioEngine { val version = ++generation active = true groupId = group + mutableGroup.value = group requestedTalk = false pttHolds.cancel() _talkingFlow.value = false @@ -210,14 +218,55 @@ class RadioAudioEngine { } } - fun stop() { + fun stop(): Future<*> { mainHandler.removeCallbacks(releaseTalk) active = false + mutableGroup.value = null requestedTalk = false pttHolds.cancel() _talkingFlow.value = false ++generation - io.execute { releaseLocked() } + return io.submit { + try { + releaseLocked() + LogManager.addLog("INFO", "radio", "audio resources released group=$audioGroup") + } catch (e: Exception) { + LogManager.addLog("ERROR", "radio", "audio release failed group=$audioGroup: $e") + throw e + } + } + } + + /** Выключить участие в канале и вернуть завершение фактического освобождения аудиотракта. */ + fun stopForCall(): Future<*> { + mutableGroup.value?.let { group -> + NativeLib.radioSetActive(group, false) + LogManager.addLog("INFO", "radio", "disabled for call group=$group") + } + val stopped = stop() + RadioOverlayService.sync(ChatApplication.instance) + return stopped + } + + /** Восстановить режим прослушивания; старые удержания PTT уже сброшены в stop(). */ + fun restoreAfterCall(group: Long) { + if (active || CallController.blocksMedia()) { + LogManager.addLog("WARN", "radio", "call restore skipped: audio busy group=$group active=$active") + return + } + val exists = ChatRepository().getChannels().any { it.id.toULongOrNull()?.toLong() == group } + if (!exists) { LogManager.addLog("WARN", "radio", "call restore skipped: channel removed group=$group"); return } + NativeLib.radioSetActive(group, true) + start(group) { ready -> + val superseded = mutableGroup.value != group || CallController.blocksMedia() + LogManager.addLog(if (ready || superseded) "INFO" else "ERROR", "radio", + "restored after call group=$group ready=$ready superseded=$superseded") + if (!ready && mutableGroup.value == group && !isActive()) { + NativeLib.radioSetActive(group, false) + stop() + } + RadioOverlayService.sync(ChatApplication.instance) + } } /** Смена настроек сохраняет окно автоматики; явное включение открывает новое. */ @@ -247,7 +296,6 @@ class RadioAudioEngine { private fun setupAudio() { loadCaptureSettings() val group = audioGroup - val ctx = ChatApplication.instance if (streamPriority == 0L) acquireAudioMode() router.setGlobalRouting(streamPriority == 0L) @@ -799,7 +847,7 @@ class RadioAudioEngine { private fun acquireAudioMode() { if (audioModeOwned) return val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager - prevMode = am.mode; prevSpeaker = am.isSpeakerphoneOn; audioModeOwned = true + prevSpeaker = am.isSpeakerphoneOn; audioModeOwned = true try { am.mode = AudioManager.MODE_IN_COMMUNICATION } catch (e: Exception) { LogManager.addLog("WARN", "radio", "set audio mode failed: ${e.message}") } } @@ -810,7 +858,8 @@ class RadioAudioEngine { try { val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager am.isSpeakerphoneOn = prevSpeaker - am.mode = prevMode + // MODE_NORMAL снимает нашу заявку; прежний глобальный mode мог принадлежать завершаемому Telecom-звонку. + am.mode = AudioManager.MODE_NORMAL } catch (e: Exception) { LogManager.addLog("WARN", "RadioAudio", "restore audio mode failed: ${e.message}") } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamController.kt index f28f76b5..b716957e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamController.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamController.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import java.util.concurrent.atomic.AtomicLong enum class StreamPhase { STARTING, LIVE, STOPPING, ENDED, FAILED } @@ -131,6 +132,16 @@ object StreamController { service?.shutdown() // До onStartCommand отменённый start завершит foreground-handshake и остановится. } + /** STOPPING заканчивается только после release capture и завершения foreground-сервиса. */ + suspend fun stopForCall() { + val st = mutable.value ?: return + if (!st.ongoing) return + LogManager.addLog("INFO", "stream", "stopping publisher for call id=${st.id}") + stop() + state.first { it?.id != st.id || !it.ongoing } + LogManager.addLog("INFO", "stream", "publisher released for call id=${st.id}") + } + fun ended(id: Long, owner: StreamService) { if (service === owner) service = null val st = mutable.value ?: return diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt index 067d0279..9a9b739e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt @@ -5,10 +5,14 @@ import android.media.MediaCodecInfo import android.media.MediaCodecList import android.media.MediaFormat import android.os.Build +import android.os.Handler +import android.os.Looper import android.view.Surface import com.utun.chat.R import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.concurrent.locks.ReentrantLock @@ -18,7 +22,26 @@ import kotlin.concurrent.withLock * общий native controller ведёт минутную историю и темп, AudioTrack задаёт фактические часы. * Временная нехватка сохраняет decoder references; GAP/вытеснение начинают новую точку KEY. */ class StreamPlayerEngine(private val group: Long, private val source: Long, private val epoch: Long) { - companion object { private val viewerLock = ReentrantLock() } + companion object { + private val viewerLock = ReentrantLock() + private val main = Handler(Looper.getMainLooper()) + private val players = mutableSetOf() // Только main thread. + private val mutableViewing = MutableStateFlow(false) + val viewing: StateFlow = mutableViewing + + /** Закрыть просмотр и дождаться освобождения AudioTrack/decoder вне main thread. */ + suspend fun stopForCall() { + val stopped = players.toList() + for (player in stopped) { + player.mutableStatus.value = R.string.stream_ended + player.stop() + } + withContext(Dispatchers.IO) { for (player in stopped) player.worker?.join() } + players.removeAll(stopped.toSet()) + mutableViewing.value = players.isNotEmpty() + LogManager.addLog("INFO", "stream", "viewers released for call count=${stopped.size}") + } + } private class UnsupportedCodec(message: String) : IllegalStateException(message) private data class Frame(override val pts: Long, val key: Boolean, val width: Int, val height: Int, val rotation: Int, val revision: Long, val codec: StreamVideoCodec, @@ -33,8 +56,17 @@ class StreamPlayerEngine(private val group: Long, private val source: Long, priv fun start(surface: Surface) { if (worker != null) return + if (CallController.blocksMedia()) { + LogManager.addLog("WARN", "stream", "viewer blocked by call id=$id") + mutableStatus.value = R.string.stream_ended + return + } + players.add(this); mutableViewing.value = true running = true - worker = Thread({ viewerLock.withLock { if (running) play(surface) } }, "stream-player").apply { start() } + worker = Thread({ + try { viewerLock.withLock { if (running) play(surface) } } + finally { main.post { players.remove(this); mutableViewing.value = players.isNotEmpty() } } + }, "stream-player").apply { start() } } fun stop() { running = false; NativeLib.streamStop(id) } 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 95b0112e..e47fe744 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 @@ -9,6 +9,7 @@ import android.content.ContextWrapper import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.os.Build +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -27,6 +28,8 @@ import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.PhoneInTalk import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material.icons.filled.VideocamOff import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -35,6 +38,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -57,6 +61,7 @@ import com.utun.chat.data.LogManager import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.data.CallPhase import com.utun.chat.data.CallController +import com.utun.chat.data.CallVideoEngine import kotlinx.coroutines.delay private tailrec fun Context.findActivity(): Activity? = when (this) { @@ -89,6 +94,7 @@ private fun CallControlButton( active: Boolean, activeColor: Color = Color(0xFF1F6FEB), showLabel: Boolean = true, + enabled: Boolean = true, onClick: () -> Unit ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -97,8 +103,8 @@ private fun CallControlButton( .background(if (active) activeColor else Color(0xFF24323D), CircleShape), contentAlignment = Alignment.Center ) { - IconButton(onClick = onClick) { - Icon(icon, contentDescription, tint = Color.White, modifier = Modifier.size(28.dp)) + IconButton(onClick = onClick, enabled = enabled) { + Icon(icon, contentDescription, tint = Color.White.copy(alpha = if (enabled) 1f else 0.38f), modifier = Modifier.size(28.dp)) } } if (showLabel) { @@ -108,6 +114,27 @@ private fun CallControlButton( } } +@Composable +private fun CallVideoButton(video: CallVideoEngine, showLabel: Boolean, requestPermission: () -> Unit) { + val state by video.state.collectAsState() + val context = LocalContext.current + CallControlButton( + icon = if (state.settings.enabled) Icons.Filled.Videocam else Icons.Filled.VideocamOff, + contentDescription = stringResource(if (state.settings.enabled) R.string.call_video_disable else R.string.call_video_enable), + label = stringResource(R.string.camera), + active = state.settings.enabled, + showLabel = showLabel, + enabled = state.settings.enabled || (state.ready && state.codecs.isNotEmpty() && state.cameras.isNotEmpty()), + onClick = { + val enabled = !state.settings.enabled + LogManager.addLog("INFO", "call", "video button enabled=$enabled") + if (!enabled || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) + CallController.setVideo(state.settings.copy(enabled = enabled)) + else requestPermission() + } + ) +} + @Composable @OptIn(ExperimentalLayoutApi::class) fun CallScreen( @@ -150,6 +177,18 @@ fun CallScreen( val locked = phase == CallPhase.ACTIVE && !videoActive && route == AudioRoute.EARPIECE && proximityNear == true val context = LocalContext.current + // Launcher живёт вместе с экраном, даже когда панель кнопок автоматически скрыта поверх видео. + var permissionVideo by remember { mutableStateOf(null) } + val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + val requestedVideo = permissionVideo + permissionVideo = null + LogManager.addLog(if (granted) "INFO" else "WARN", "call", "camera permission granted=$granted") + if (granted && requestedVideo != null && CallController.video.value === requestedVideo) { + CallController.setVideo(requestedVideo.state.value.settings.copy(enabled = true)) + } else if (!granted) { + Toast.makeText(context, R.string.camera_permission_required, Toast.LENGTH_LONG).show() + } + } val view = LocalView.current DisposableEffect(view, videoActive) { val previous = view.keepScreenOn @@ -213,11 +252,11 @@ fun CallScreen( onTap = { controlsVisible = !controlsVisible }, modifier = Modifier.matchParentSize()) } if (!viewingVideo || controlsVisible) { - if (!viewingVideo) Column( - modifier = Modifier.fillMaxWidth().align(Alignment.Center) + Column( + modifier = Modifier.fillMaxWidth().align(if (viewingVideo) Alignment.BottomCenter else Alignment.Center) .windowInsetsPadding(controlInsets) - .padding(top = if (hasVideo) 224.dp else 56.dp) - .background(Color(0xFF0F1B22)).verticalScroll(rememberScrollState()) + .padding(top = if (viewingVideo) 0.dp else if (hasVideo) 224.dp else 56.dp) + .background(Color(0xFF0F1B22).copy(alpha = if (viewingVideo) 0.75f else 1f)).verticalScroll(rememberScrollState()) .padding(bottom = 24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -237,7 +276,8 @@ fun CallScreen( Text(name, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.height(8.dp)) Text( - if (phase == CallPhase.ACTIVE) fmtDuration(duration) else callStatusText(phase, st?.reason ?: -1), + if (st?.preparing == true) stringResource(R.string.call_preparing_audio) + else if (phase == CallPhase.ACTIVE) fmtDuration(duration) else callStatusText(phase, st?.reason ?: -1), color = Color(0xFFAEBCC8), fontSize = 16.sp ) @@ -320,7 +360,7 @@ fun CallScreen( Spacer(Modifier.height(28.dp)) } - Row(horizontalArrangement = Arrangement.spacedBy(48.dp), verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { if (phase == CallPhase.ACTIVE) { CallControlButton( icon = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic, @@ -348,6 +388,25 @@ fun CallScreen( color = Color(0xFFAEBCC8), fontSize = 12.sp) } } + if (phase == CallPhase.ACTIVE) { + video?.let { engine -> + CallVideoButton(engine, showLabel = !viewingVideo, requestPermission = { + permissionVideo = engine + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + }) + } + CallControlButton( + icon = Icons.Filled.Settings, + contentDescription = stringResource(R.string.call_video_settings), + label = stringResource(R.string.settings), + active = false, + showLabel = !viewingVideo, + onClick = { + LogManager.addLog("DEBUG", "call", "video settings opened") + settingsVisible = true + } + ) + } } } } @@ -362,13 +421,9 @@ fun CallScreen( Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(if (viewingVideo) R.string.call_controls else R.string.minimize_call), tint = Color.White, modifier = Modifier.size(24.dp)) } - if (phase == CallPhase.ACTIVE) IconButton(onClick = { selectedVideo = null; settingsVisible = true }, - modifier = Modifier.align(Alignment.TopEnd).windowInsetsPadding(controlInsets).padding(8.dp)) { - Icon(Icons.Filled.Settings, stringResource(R.string.settings), tint = Color.White) - } } if (settingsVisible && phase == CallPhase.ACTIVE && !locked) AlertDialog( - onDismissRequest = { settingsVisible = false }, title = { Text(stringResource(R.string.call_settings)) }, + onDismissRequest = { settingsVisible = false }, title = { Text(stringResource(R.string.call_video_settings)) }, confirmButton = { TextButton(onClick = { settingsVisible = false }) { Text(stringResource(R.string.close)) } }, text = { Column(Modifier.verticalScroll(rememberScrollState())) { 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 22eb1e18..83efb178 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 @@ -1,14 +1,10 @@ package com.utun.chat.ui.screens -import android.Manifest -import android.content.pm.PackageManager import android.content.Context import android.graphics.Matrix import android.graphics.SurfaceTexture import android.view.Surface import android.view.TextureView -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material3.* @@ -17,12 +13,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat import com.utun.chat.data.CallController import com.utun.chat.data.CallVideoBitrateRange import com.utun.chat.data.CallVideoEngine @@ -32,23 +26,8 @@ import com.utun.chat.R @Composable internal fun CallVideoPanel(video: CallVideoEngine) { val state by video.state.collectAsState() - val context = LocalContext.current - var denied by remember(video) { mutableStateOf(false) } - val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - denied = !granted - if (granted && CallController.video.value === video) CallController.setVideo(state.settings.copy(enabled = true)) - } Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { if (state.status.isNotEmpty()) Text(state.status) - if (denied) Text(stringResource(R.string.camera_permission_required), color = MaterialTheme.colorScheme.error) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(R.string.send_video), modifier = Modifier.weight(1f).padding(top = 12.dp)) - Switch(state.settings.enabled, enabled = state.ready && state.codecs.isNotEmpty(), onCheckedChange = { enabled -> - if (!enabled || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) - CallController.setVideo(state.settings.copy(enabled = enabled)) - else permission.launch(Manifest.permission.CAMERA) - }) - } VideoChoice(stringResource(R.string.camera), state.cameras.map { it.name }, state.cameras.indexOfFirst { it.id == state.settings.camera }) { index -> val camera = state.cameras[index] val size = camera.sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: camera.sizes.first() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt index b8179852..31dc9a0e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt @@ -18,6 +18,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.utun.chat.viewmodel.ChatViewModel +import com.utun.chat.data.CallController +import com.utun.chat.data.StreamController +import com.utun.chat.data.StreamPlayerEngine /** * Входящий звонок — inline-оверлей внутри окна активити (не отдельное Dialog-окно): @@ -29,6 +32,9 @@ fun IncomingCallDialog( ) { val st = viewModel.callState.collectAsState().value ?: return val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) } + val radio = viewModel.radioAudio.activeGroup.collectAsState().value + val publisher = StreamController.state.collectAsState().value + val viewing = StreamPlayerEngine.viewing.collectAsState().value Box( modifier = Modifier.fillMaxSize().background(Color(0xCC000000)), @@ -54,6 +60,14 @@ fun IncomingCallDialog( Text(stringResource(R.string.incoming_call_waiting), color = Color(0xFFAEBCC8), fontSize = 14.sp) Spacer(Modifier.height(6.dp)) Text(stringResource(R.string.connection_label, st.connectionType.label), color = Color(0xFFAEBCC8), fontSize = 13.sp) + if (radio != null || publisher?.ongoing == true || viewing) { + Spacer(Modifier.height(12.dp)) + Text(stringResource(CallController.mediaWarning(incoming = true)), color = Color(0xFFFFCC80), fontSize = 14.sp) + } + if (st.preparing) { + Spacer(Modifier.height(12.dp)) + Text(stringResource(R.string.call_preparing_audio), color = Color.White, fontSize = 14.sp) + } Spacer(Modifier.height(28.dp)) Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) { @@ -73,7 +87,7 @@ fun IncomingCallDialog( modifier = Modifier.size(64.dp).background(Color(0xFF43A047), CircleShape), contentAlignment = Alignment.Center ) { - IconButton(onClick = { viewModel.acceptCall() }) { + IconButton(onClick = { viewModel.acceptCall() }, enabled = !st.preparing) { Icon(Icons.Filled.Call, stringResource(R.string.accept), tint = Color.White, modifier = Modifier.size(30.dp)) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index c8b8701a..a21b0dfd 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -195,7 +195,7 @@ class ChatViewModel : ViewModel() { private var radioToggleCount = 0 /* рация: активный канал (null = выключена). Одна рация — один канал. */ - private var radioChannelId: String? = null + private val radioChannelId: String? get() = radioAudio.activeGroup.value?.toULong()?.toString() val muted = CallController.muted val audioRoute = CallController.route @@ -357,6 +357,31 @@ class ChatViewModel : ViewModel() { } init { + radioAudio.router.onChanged = { route, headset -> + _radioRoute.value = route + _radioHeadsetAvailable.value = headset + } + radioAudio.router.onInputDevicesChanged = { wired, bt -> + _wiredInputAvailable.value = wired + _btInputAvailable.value = bt + } + viewModelScope.launch { + radioAudio.activeGroup.collect { group -> + if (group == null) clearRadioControls() + else { + _radioActive.value = radioChannelId == _currentChannel.value?.id + _radioTalkState.value = RadioTalkState(group) + _radioInputSource.value = radioAudio.inputSource() + _radioAuto.value = _radioInputSource.value == RadioAudioEngine.INPUT_AUTO + } + syncRadioOverlay() + } + } + viewModelScope.launch { + CallController.state.collect { call -> + if (call?.preparing == true || CallController.blocksMedia()) _streamSelection.value = null + } + } viewModelScope.launch { _cpuLoadVisible.collectLatest { visible -> if (!visible) return@collectLatest @@ -576,7 +601,6 @@ class ChatViewModel : ViewModel() { _radioTalkState.value = RadioTalkState() _radioTalking.value = null _radioActive.value = false - radioChannelId = null radioAudio.stop() memberRadios.clear() memberRadioMutes.clear() @@ -1018,10 +1042,15 @@ class ChatViewModel : ViewModel() { "toggleRadio#$radioToggleCount next=$next prevActive=${_radioActive.value} " + "radioChannelId=$radioChannelId cur=${ch.id} " + "thread=${Thread.currentThread().name} t=${System.currentTimeMillis()} caller=$caller") + if (next && CallController.blocksMedia()) { + LogManager.addLog("WARN", "radio", "enable rejected: call owns audio group=$groupId") + android.widget.Toast.makeText(ChatApplication.instance, R.string.call_media_busy, android.widget.Toast.LENGTH_SHORT).show() + onReady?.invoke(false) + return + } if (next) { radioChannelId?.takeIf { it != ch.id }?.toULongOrNull()?.toLong() ?.let { NativeLib.radioSetActive(it, false) } /* выключаем старый канал */ - radioChannelId = ch.id _radioTalkState.value = RadioTalkState(groupId) _radioTalking.value = null NativeLib.radioSetActive(groupId, true) @@ -1029,14 +1058,6 @@ class ChatViewModel : ViewModel() { if (!ready && radioChannelId == ch.id && !radioAudio.isActive()) turnRadioOff() onReady?.invoke(ready) } - radioAudio.router.onChanged = { r, headset -> - _radioRoute.value = r - _radioHeadsetAvailable.value = headset - } - radioAudio.router.onInputDevicesChanged = { wired, bt -> - _wiredInputAvailable.value = wired - _btInputAvailable.value = bt - } _radioRoute.value = radioAudio.route() _radioHeadsetAvailable.value = radioAudio.headsetAvailable() _wiredInputAvailable.value = radioAudio.router.wiredInputAvailable @@ -1044,7 +1065,6 @@ class ChatViewModel : ViewModel() { _radioInputSource.value = radioAudio.inputSource() _radioAuto.value = _radioInputSource.value == RadioAudioEngine.INPUT_AUTO } else { - radioChannelId = null NativeLib.radioSetActive(groupId, false) radioAudio.stop() _radioTalking.value = null @@ -1156,7 +1176,12 @@ class ChatViewModel : ViewModel() { private fun turnRadioOff() { val chId = radioChannelId ?: return chId.toULongOrNull()?.toLong()?.let { NativeLib.radioSetActive(it, false) } - radioChannelId = null + radioAudio.stop() + clearRadioControls() + syncRadioOverlay() + } + + private fun clearRadioControls() { _radioActive.value = false _radioTalking.value = null _radioTalkState.value = RadioTalkState() @@ -1166,8 +1191,6 @@ class ChatViewModel : ViewModel() { _btInputAvailable.value = false _radioInputSource.value = RadioAudioEngine.INPUT_AUTO _radioAuto.value = false - radioAudio.stop() - syncRadioOverlay() } private fun syncRadioOverlay() { diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index fd0ba2a4..17f68530 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -18,6 +18,20 @@ Удалить переписку с %1$s и её локальные файлы? У собеседника история сохранится. Не удалось удалить переписку. Повторите попытку. Настройки звонка + Начать звонок? + Продолжить + Для звонка необходимо выключить рацию. После звонка она включится снова. + Для звонка необходимо завершить трансляцию или её просмотр. + Для звонка необходимо выключить рацию и завершить трансляцию или её просмотр. После звонка рация включится снова. + При принятии звонка рация выключится. После звонка она включится снова. + При принятии звонка трансляция или её просмотр завершится. + При принятии звонка рация выключится, трансляция или её просмотр завершится. После звонка рация включится снова. + Освобождаем аудиоустройства… + Не удалось освободить аудиоустройства для звонка. + Во время звонка звук рации недоступен. + Настройки видео + Включить видео + Выключить видео Управление звонком Своё видео Видео собеседника diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index 5a2f6dfe..071170a8 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -18,6 +18,20 @@ Delete the conversation with %1$s and its local files? The other person keeps their history. Could not delete the conversation. Please try again. Call settings + Start call? + Continue + The radio must be turned off for the call. It will turn back on after the call. + The broadcast or stream playback must end before starting the call. + The radio must be turned off and the broadcast or stream playback must end before starting the call. The radio will turn back on after the call. + Answering will turn off the radio. It will turn back on after the call. + Answering will end the broadcast or stream playback. + Answering will turn off the radio and end the broadcast or stream playback. The radio will turn back on after the call. + Releasing audio devices… + Could not release the audio devices for the call. + Radio audio is unavailable during a call. + Video settings + Turn video on + Turn video off Call controls Your video Peer video