Coordinate Android call audio ownership and improve video controls

This commit is contained in:
evgeny committed 2026-10-19 16:51:51 +02:00
1 parent 39ed42da2e
commit 21a20da5ee
15 files changed
+404 -70

No files matched your search

+11
View File
@@ -272,6 +272,17 @@ ACTIVE и выключают Android standby. Состояние не истек
завершения звонка в фоне возвращается standby; экран может гаснуть во время завершения звонка в фоне возвращается standby; экран может гаснуть во время
разговора независимо от частичного wake lock сервиса. разговора независимо от частичного wake lock сервиса.
Если работают рация, публикация или просмотр трансляции, исходящий Android-звонок
требует подтверждения их остановки; входящий показывает предупреждение до принятия.
После подтверждения/принятия `CallController` выключает участие в рации, завершает
публикацию и просмотр, асинхронно ждёт освобождения их аудио и только затем запускает
сигналинг и звонковое аудио. Foreground-сервис резервируется сразу из видимой Activity.
Отмена во время освобождения не запускает устаревший звонок.
До окончания звонка повторное включение рации/трансляции заблокировано.
После закрытия звонкового аудио и Telecom Connection ранее включённая рация
возвращается в прежний канал; удержания PTT сброшены. Удалённый канал и остановленное
ядро не восстанавливаются. Публикация и просмотр трансляции автоматически не возобновляются.
`utun_set_client_activity()` в сборке с `UTUN_HAVE_STANDBY` применяет фактический `utun_set_client_activity()` в сборке с `UTUN_HAVE_STANDBY` применяет фактический
режим duty-cycle до рассылки событий активности; JNI не переключает его отдельно. режим duty-cycle до рассылки событий активности; JNI не переключает его отдельно.
Повторная команда не перезапускает фазу и анонсы, но первый фоновый запуск включает Повторная команда не перезапускает фазу и анонсы, но первый фоновый запуск включает
@@ -542,6 +542,18 @@ class MainActivity : AppCompatActivity() {
pmInvite?.let { state -> pmInvite?.let { state ->
PmInviteDialog(state, vm::selectPmInviteGroup, vm::sendPmInvite, vm::dismissPmInvite) 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() val localInvitations by com.utun.chat.data.LocalDiscovery.invitations.collectAsState()
localInvitations.firstOrNull()?.let { invitation -> localInvitations.firstOrNull()?.let { invitation ->
AlertDialog( AlertDialog(
@@ -83,8 +83,21 @@ class CallAudioEngine {
private val trackRouteListener = AudioRouting.OnRoutingChangedListener { output -> private val trackRouteListener = AudioRouting.OnRoutingChangedListener { output ->
synchronized(trackLock) { synchronized(trackLock) {
val track = audioTrack val track = audioTrack
if (active && track != null && output === track.track && Build.VERSION.SDK_INT < 31) { if (active && track != null && output === track.track) {
confirmedDeviceId = track.routedDevice?.id ?: 0 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 звук) */ /* диагностика: уровень захваченного сигнала (тишина vs звук) */
if (sinceSwap < 10 || frame % 100L == 0L) { if (sinceSwap < 10 || frame % 100L == 0L) {
LogManager.addLog("DEBUG", "CallAudio", 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++ sinceSwap++
frame++ frame++
@@ -478,6 +492,7 @@ class CallAudioEngine {
} }
LogManager.addLog("INFO", "CallAudio", "capture device ready id=$callId minQueryMs=$queryMs constructorMs=$constructorMs " + LogManager.addLog("INFO", "CallAudio", "capture device ready id=$callId minQueryMs=$queryMs constructorMs=$constructorMs " +
"routingMs=${SystemClock.elapsedRealtime() - routeAt} minBytes=$recMin") "routingMs=${SystemClock.elapsedRealtime() - routeAt} minBytes=$recMin")
rec.addOnRoutingChangedListener(recordRouteListener, mainHandler)
return rec return rec
} }
@@ -13,6 +13,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -23,6 +25,8 @@ import kotlinx.coroutines.launch
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
data class OutgoingCallRequest(val channelId: String, val peerNodeId: Long, val peerName: String)
/** /**
* Единственный владелец звонковой сессии в процессе приложения. Все операции — main thread. * Единственный владелец звонковой сессии в процессе приложения. Все операции — main thread.
* Сервис владеет аудио и wake lock, ядро — сигналингом, ViewModel только читает StateFlow. * Сервис владеет аудио и wake lock, ядро — сигналингом, ViewModel только читает StateFlow.
@@ -32,6 +36,37 @@ object CallController : TelecomCallManager.Listener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableStateFlow<CallState?>(null) private val _state = MutableStateFlow<CallState?>(null)
val state: StateFlow<CallState?> = _state val state: StateFlow<CallState?> = _state
private val mutableOutgoing = MutableStateFlow<OutgoingCallRequest?>(null)
val outgoingRequest: StateFlow<OutgoingCallRequest?> = 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<CallVideoEngine?>(null) private val _video = MutableStateFlow<CallVideoEngine?>(null)
val video: StateFlow<CallVideoEngine?> = _video val video: StateFlow<CallVideoEngine?> = _video
private val _videoActive = MutableStateFlow(false) private val _videoActive = MutableStateFlow(false)
@@ -59,6 +94,7 @@ object CallController : TelecomCallManager.Listener {
fun refreshLanguage() { service?.refreshLanguage() } fun refreshLanguage() { service?.refreshLanguage() }
private var serviceRequested = false private var serviceRequested = false
private var signallingStarted = false
private var accepting = false private var accepting = false
private var acceptSent = false private var acceptSent = false
private var answerWhenVisible = 0L private var answerWhenVisible = 0L
@@ -105,13 +141,12 @@ object CallController : TelecomCallManager.Listener {
CoreActivity.call(_state.value?.ongoing == true) 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() checkMain()
if (RadioCallController.busy()) { if (RadioCallController.busy()) {
LogManager.addLog("WARN", "Call", "start rejected: pending radio invitation") LogManager.addLog("WARN", "Call", "start rejected: pending radio invitation")
return return
} }
if (StreamController.blocksCapture()) return
if (channelId.isEmpty() || peerNodeId == 0L) { if (channelId.isEmpty() || peerNodeId == 0L) {
LogManager.addLog("ERROR", "Call", "invalid outgoing call channel=$channelId peer=$peerNodeId") LogManager.addLog("ERROR", "Call", "invalid outgoing call channel=$channelId peer=$peerNodeId")
return return
@@ -120,20 +155,27 @@ object CallController : TelecomCallManager.Listener {
LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}") LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}")
return return
} }
if (!mediaConfirmed && hasMedia()) {
mutableOutgoing.value = OutgoingCallRequest(channelId, peerNodeId, peerName)
LogManager.addLog("INFO", "Call", "outgoing requires media confirmation peer=$peerNodeId")
return
}
if (!MicrophonePermission.granted()) { if (!MicrophonePermission.granted()) {
MicrophonePermission.request("Outgoing call") { start(channelId, peerNodeId, peerName) } MicrophonePermission.request("Outgoing call") { start(channelId, peerNodeId, peerName, mediaConfirmed) }
return return
} }
var id = java.security.SecureRandom().nextLong() var id = java.security.SecureRandom().nextLong()
if (id == 0L) id = 1 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() syncCoreActivity()
requestService(id) requestService(id)
if (_state.value?.callId == id && _state.value?.ongoing == true) prepareMedia(id)
} }
fun accept() { fun accept() {
checkMain() checkMain()
if (StreamController.blocksCapture()) return
val st = _state.value ?: return val st = _state.value ?: return
if (st.phase != CallPhase.INCOMING || accepting) { if (st.phase != CallPhase.INCOMING || accepting) {
LogManager.addLog("WARN", "Call", "accept ignored id=${st.callId} phase=${st.phase} accepting=$accepting") LogManager.addLog("WARN", "Call", "accept ignored id=${st.callId} phase=${st.phase} accepting=$accepting")
@@ -147,7 +189,41 @@ object CallController : TelecomCallManager.Listener {
return return
} }
accepting = true accepting = true
_state.value = st.copy(preparing = true)
IncomingCallManager.stopRinging()
requestService(st.callId) 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) { private fun requestService(id: Long) {
@@ -174,16 +250,26 @@ object CallController : TelecomCallManager.Listener {
} }
service = owner service = owner
LogManager.addLog("INFO", "Call", "service ready id=$id phase=${st.phase}") 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) { if (st.phase == CallPhase.OUTGOING) {
signallingStarted = true
NativeLib.callStart(st.channelId, st.peerNodeId, id) NativeLib.callStart(st.channelId, st.peerNodeId, id)
TelecomCallManager.placeCall(id, st.peerNodeId, st.peerName) TelecomCallManager.placeCall(id, st.peerNodeId, st.peerName)
CallRingback.startConnecting() CallRingback.startConnecting()
} else if (st.phase == CallPhase.INCOMING && accepting) { } else if (st.phase == CallPhase.INCOMING && accepting) {
signallingStarted = true
IncomingCallManager.stopRinging() IncomingCallManager.stopRinging()
acceptSent = true acceptSent = true
NativeLib.callAccept(id) NativeLib.callAccept(id)
} }
return true
} }
fun wantsService(id: Long): Boolean = _state.value?.let { it.callId == id && it.ongoing && serviceRequested } == 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 return
} }
if (NativeLib.isReady()) { 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) finish(id, reason)
} }
@@ -223,6 +310,7 @@ object CallController : TelecomCallManager.Listener {
val owner = service val owner = service
service = null service = null
serviceRequested = false serviceRequested = false
signallingStarted = false
accepting = false accepting = false
acceptSent = false acceptSent = false
answerWhenVisible = 0L answerWhenVisible = 0L
@@ -238,10 +326,18 @@ object CallController : TelecomCallManager.Listener {
_route.value = AudioRoute.SPEAKER _route.value = AudioRoute.SPEAKER
_headset.value = false _headset.value = false
_earpiece.value = true _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) connectionType = CallConnectionType.NONE, connectionNextHop = 0)
syncCoreActivity() syncCoreActivity()
LogManager.addLog("INFO", "Call", "finished id=$id reason=$reason; audio/service/proximity released") 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) { if (reason == 8 && st.phase != CallPhase.INCOMING && st.phase != CallPhase.PENDING_INCOMING) {
val app = ChatApplication.instance val app = ChatApplication.instance
val text = AppLanguage.text(R.string.phone_busy_warning) val text = AppLanguage.text(R.string.phone_busy_warning)
@@ -268,6 +364,8 @@ object CallController : TelecomCallManager.Listener {
/** Перед stop/restart ядра освобождаем аудио, чтобы workers больше не обращались к native-ресурсам. */ /** Перед stop/restart ядра освобождаем аудио, чтобы workers больше не обращались к native-ресурсам. */
fun reset() { fun reset() {
checkMain() checkMain()
mutableOutgoing.value = null
radioToRestore = null
coreReady = false coreReady = false
CoreActivity.reset() CoreActivity.reset()
end() end()
@@ -357,6 +455,7 @@ object CallController : TelecomCallManager.Listener {
return return
} }
incomingAt = SystemClock.elapsedRealtime() incomingAt = SystemClock.elapsedRealtime()
mutableOutgoing.value = null
// Однократное чтение на main завершается до reset; фоновый JNI-запрос не переживает ядро. // Однократное чтение на main завершается до reset; фоновый JNI-запрос не переживает ядро.
val name = ChatRepository().getMembers(channel).firstOrNull { it.nodeId == peer }?.name val name = ChatRepository().getMembers(channel).firstOrNull { it.nodeId == peer }?.name
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(peer) ?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(peer)
@@ -34,7 +34,8 @@ data class CallState(
val stats: CallStats? = null, val stats: CallStats? = null,
val startedAtElapsedRealtime: Long = 0, val startedAtElapsedRealtime: Long = 0,
val connectionType: CallConnectionType = CallConnectionType.NONE, 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 val ongoing: Boolean get() = phase != CallPhase.NONE && phase != CallPhase.ENDED
} }
@@ -150,6 +150,11 @@ object IncomingCallManager {
.setContentIntent(contentPi) .setContentIntent(contentPi)
.addAction(0, AppLanguage.text(if (activeRadio) R.string.decline else R.string.hang_up), declinePi) .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) { if (activeRadio) {
val answerPi = PendingIntent.getActivity(ctx, 2, Intent(launchIntent).apply { val answerPi = PendingIntent.getActivity(ctx, 2, Intent(launchIntent).apply {
action = "com.utun.chat.ANSWER_RADIO" action = "com.utun.chat.ANSWER_RADIO"
@@ -21,6 +21,7 @@ import com.utun.chat.ChatApplication
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
/** /**
@@ -63,7 +64,6 @@ class RadioAudioEngine {
private var captureThread: Thread? = null private var captureThread: Thread? = null
@Volatile private var audioTrack: AudioTrack? = null @Volatile private var audioTrack: AudioTrack? = null
private var playThread: Thread? = null private var playThread: Thread? = null
private var prevMode: Int = AudioManager.MODE_NORMAL
private var prevSpeaker: Boolean = false private var prevSpeaker: Boolean = false
private var audioModeOwned = false private var audioModeOwned = false
private val streamPriorities = AtomicLong() private val streamPriorities = AtomicLong()
@@ -73,6 +73,8 @@ class RadioAudioEngine {
@Volatile private var active = false @Volatile private var active = false
@Volatile private var groupId = 0L @Volatile private var groupId = 0L
private val mutableGroup = MutableStateFlow<Long?>(null)
val activeGroup: StateFlow<Long?> = mutableGroup
@Volatile private var talking = false @Volatile private var talking = false
@Volatile private var captureRunning = false @Volatile private var captureRunning = false
@Volatile private var playbackRunning = false @Volatile private var playbackRunning = false
@@ -180,6 +182,11 @@ class RadioAudioEngine {
/** Начать прослушивание канала (переключатель рации вкл). /** Начать прослушивание канала (переключатель рации вкл).
* Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */ * Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */
fun start(group: Long, onReady: ((Boolean) -> Unit)? = null) { 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) { if (active && groupId == group) {
val version = generation val version = generation
io.execute { mainHandler.post { onReady?.invoke(active && generation == version && audioTrack != null) } } io.execute { mainHandler.post { onReady?.invoke(active && generation == version && audioTrack != null) } }
@@ -189,6 +196,7 @@ class RadioAudioEngine {
val version = ++generation val version = ++generation
active = true active = true
groupId = group groupId = group
mutableGroup.value = group
requestedTalk = false requestedTalk = false
pttHolds.cancel() pttHolds.cancel()
_talkingFlow.value = false _talkingFlow.value = false
@@ -210,14 +218,55 @@ class RadioAudioEngine {
} }
} }
fun stop() { fun stop(): Future<*> {
mainHandler.removeCallbacks(releaseTalk) mainHandler.removeCallbacks(releaseTalk)
active = false active = false
mutableGroup.value = null
requestedTalk = false requestedTalk = false
pttHolds.cancel() pttHolds.cancel()
_talkingFlow.value = false _talkingFlow.value = false
++generation ++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() { private fun setupAudio() {
loadCaptureSettings() loadCaptureSettings()
val group = audioGroup val group = audioGroup
val ctx = ChatApplication.instance
if (streamPriority == 0L) acquireAudioMode() if (streamPriority == 0L) acquireAudioMode()
router.setGlobalRouting(streamPriority == 0L) router.setGlobalRouting(streamPriority == 0L)
@@ -799,7 +847,7 @@ class RadioAudioEngine {
private fun acquireAudioMode() { private fun acquireAudioMode() {
if (audioModeOwned) return if (audioModeOwned) return
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager 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 } try { am.mode = AudioManager.MODE_IN_COMMUNICATION }
catch (e: Exception) { LogManager.addLog("WARN", "radio", "set audio mode failed: ${e.message}") } catch (e: Exception) { LogManager.addLog("WARN", "radio", "set audio mode failed: ${e.message}") }
} }
@@ -810,7 +858,8 @@ class RadioAudioEngine {
try { try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.isSpeakerphoneOn = prevSpeaker am.isSpeakerphoneOn = prevSpeaker
am.mode = prevMode // MODE_NORMAL снимает нашу заявку; прежний глобальный mode мог принадлежать завершаемому Telecom-звонку.
am.mode = AudioManager.MODE_NORMAL
} catch (e: Exception) { } catch (e: Exception) {
LogManager.addLog("WARN", "RadioAudio", "restore audio mode failed: ${e.message}") LogManager.addLog("WARN", "RadioAudio", "restore audio mode failed: ${e.message}")
} }
@@ -14,6 +14,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
enum class StreamPhase { STARTING, LIVE, STOPPING, ENDED, FAILED } enum class StreamPhase { STARTING, LIVE, STOPPING, ENDED, FAILED }
@@ -131,6 +132,16 @@ object StreamController {
service?.shutdown() // До onStartCommand отменённый start завершит foreground-handshake и остановится. 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) { fun ended(id: Long, owner: StreamService) {
if (service === owner) service = null if (service === owner) service = null
val st = mutable.value ?: return val st = mutable.value ?: return
@@ -5,10 +5,14 @@ import android.media.MediaCodecInfo
import android.media.MediaCodecList import android.media.MediaCodecList
import android.media.MediaFormat import android.media.MediaFormat
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.Surface import android.view.Surface
import com.utun.chat.R import com.utun.chat.R
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantLock
@@ -18,7 +22,26 @@ import kotlin.concurrent.withLock
* общий native controller ведёт минутную историю и темп, AudioTrack задаёт фактические часы. * общий native controller ведёт минутную историю и темп, AudioTrack задаёт фактические часы.
* Временная нехватка сохраняет decoder references; GAP/вытеснение начинают новую точку KEY. */ * Временная нехватка сохраняет decoder references; GAP/вытеснение начинают новую точку KEY. */
class StreamPlayerEngine(private val group: Long, private val source: Long, private val epoch: Long) { 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<StreamPlayerEngine>() // Только main thread.
private val mutableViewing = MutableStateFlow(false)
val viewing: StateFlow<Boolean> = 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 class UnsupportedCodec(message: String) : IllegalStateException(message)
private data class Frame(override val pts: Long, val key: Boolean, val width: Int, val height: Int, 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, 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) { fun start(surface: Surface) {
if (worker != null) return 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 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) } fun stop() { running = false; NativeLib.streamStop(id) }
@@ -9,6 +9,7 @@ import android.content.ContextWrapper
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.MicOff
import androidx.compose.material.icons.filled.PhoneInTalk import androidx.compose.material.icons.filled.PhoneInTalk
import androidx.compose.material.icons.filled.Settings 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.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -35,6 +38,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.viewmodel.ChatViewModel
import com.utun.chat.data.CallPhase import com.utun.chat.data.CallPhase
import com.utun.chat.data.CallController import com.utun.chat.data.CallController
import com.utun.chat.data.CallVideoEngine
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
private tailrec fun Context.findActivity(): Activity? = when (this) { private tailrec fun Context.findActivity(): Activity? = when (this) {
@@ -89,6 +94,7 @@ private fun CallControlButton(
active: Boolean, active: Boolean,
activeColor: Color = Color(0xFF1F6FEB), activeColor: Color = Color(0xFF1F6FEB),
showLabel: Boolean = true, showLabel: Boolean = true,
enabled: Boolean = true,
onClick: () -> Unit onClick: () -> Unit
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
@@ -97,8 +103,8 @@ private fun CallControlButton(
.background(if (active) activeColor else Color(0xFF24323D), CircleShape), .background(if (active) activeColor else Color(0xFF24323D), CircleShape),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
IconButton(onClick = onClick) { IconButton(onClick = onClick, enabled = enabled) {
Icon(icon, contentDescription, tint = Color.White, modifier = Modifier.size(28.dp)) Icon(icon, contentDescription, tint = Color.White.copy(alpha = if (enabled) 1f else 0.38f), modifier = Modifier.size(28.dp))
} }
} }
if (showLabel) { 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 @Composable
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
fun CallScreen( fun CallScreen(
@@ -150,6 +177,18 @@ fun CallScreen(
val locked = phase == CallPhase.ACTIVE && !videoActive && route == AudioRoute.EARPIECE && proximityNear == true val locked = phase == CallPhase.ACTIVE && !videoActive && route == AudioRoute.EARPIECE && proximityNear == true
val context = LocalContext.current val context = LocalContext.current
// Launcher живёт вместе с экраном, даже когда панель кнопок автоматически скрыта поверх видео.
var permissionVideo by remember { mutableStateOf<CallVideoEngine?>(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 val view = LocalView.current
DisposableEffect(view, videoActive) { DisposableEffect(view, videoActive) {
val previous = view.keepScreenOn val previous = view.keepScreenOn
@@ -213,11 +252,11 @@ fun CallScreen(
onTap = { controlsVisible = !controlsVisible }, modifier = Modifier.matchParentSize()) onTap = { controlsVisible = !controlsVisible }, modifier = Modifier.matchParentSize())
} }
if (!viewingVideo || controlsVisible) { if (!viewingVideo || controlsVisible) {
if (!viewingVideo) Column( Column(
modifier = Modifier.fillMaxWidth().align(Alignment.Center) modifier = Modifier.fillMaxWidth().align(if (viewingVideo) Alignment.BottomCenter else Alignment.Center)
.windowInsetsPadding(controlInsets) .windowInsetsPadding(controlInsets)
.padding(top = if (hasVideo) 224.dp else 56.dp) .padding(top = if (viewingVideo) 0.dp else if (hasVideo) 224.dp else 56.dp)
.background(Color(0xFF0F1B22)).verticalScroll(rememberScrollState()) .background(Color(0xFF0F1B22).copy(alpha = if (viewingVideo) 0.75f else 1f)).verticalScroll(rememberScrollState())
.padding(bottom = 24.dp), .padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
@@ -237,7 +276,8 @@ fun CallScreen(
Text(name, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Medium) Text(name, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( 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), color = Color(0xFFAEBCC8),
fontSize = 16.sp fontSize = 16.sp
) )
@@ -320,7 +360,7 @@ fun CallScreen(
Spacer(Modifier.height(28.dp)) 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) { if (phase == CallPhase.ACTIVE) {
CallControlButton( CallControlButton(
icon = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic, icon = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
@@ -348,6 +388,25 @@ fun CallScreen(
color = Color(0xFFAEBCC8), fontSize = 12.sp) 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), 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)) 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( 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)) } }, confirmButton = { TextButton(onClick = { settingsVisible = false }) { Text(stringResource(R.string.close)) } },
text = { text = {
Column(Modifier.verticalScroll(rememberScrollState())) { Column(Modifier.verticalScroll(rememberScrollState())) {
@@ -1,14 +1,10 @@
package com.utun.chat.ui.screens package com.utun.chat.ui.screens
import android.Manifest
import android.content.pm.PackageManager
import android.content.Context import android.content.Context
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.view.Surface import android.view.Surface
import android.view.TextureView import android.view.TextureView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -17,12 +13,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.utun.chat.data.CallController import com.utun.chat.data.CallController
import com.utun.chat.data.CallVideoBitrateRange import com.utun.chat.data.CallVideoBitrateRange
import com.utun.chat.data.CallVideoEngine import com.utun.chat.data.CallVideoEngine
@@ -32,23 +26,8 @@ import com.utun.chat.R
@Composable @Composable
internal fun CallVideoPanel(video: CallVideoEngine) { internal fun CallVideoPanel(video: CallVideoEngine) {
val state by video.state.collectAsState() 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)) { Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
if (state.status.isNotEmpty()) Text(state.status) 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 -> 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 camera = state.cameras[index]
val size = camera.sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: camera.sizes.first() val size = camera.sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: camera.sizes.first()
@@ -18,6 +18,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.utun.chat.viewmodel.ChatViewModel 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-окно): * Входящий звонок — inline-оверлей внутри окна активити (не отдельное Dialog-окно):
@@ -29,6 +32,9 @@ fun IncomingCallDialog(
) { ) {
val st = viewModel.callState.collectAsState().value ?: return val st = viewModel.callState.collectAsState().value ?: return
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) } 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( Box(
modifier = Modifier.fillMaxSize().background(Color(0xCC000000)), 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) Text(stringResource(R.string.incoming_call_waiting), color = Color(0xFFAEBCC8), fontSize = 14.sp)
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text(stringResource(R.string.connection_label, st.connectionType.label), color = Color(0xFFAEBCC8), fontSize = 13.sp) 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)) Spacer(Modifier.height(28.dp))
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
@@ -73,7 +87,7 @@ fun IncomingCallDialog(
modifier = Modifier.size(64.dp).background(Color(0xFF43A047), CircleShape), modifier = Modifier.size(64.dp).background(Color(0xFF43A047), CircleShape),
contentAlignment = Alignment.Center 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)) Icon(Icons.Filled.Call, stringResource(R.string.accept), tint = Color.White, modifier = Modifier.size(30.dp))
} }
} }
@@ -195,7 +195,7 @@ class ChatViewModel : ViewModel() {
private var radioToggleCount = 0 private var radioToggleCount = 0
/* рация: активный канал (null = выключена). Одна рация — один канал. */ /* рация: активный канал (null = выключена). Одна рация — один канал. */
private var radioChannelId: String? = null private val radioChannelId: String? get() = radioAudio.activeGroup.value?.toULong()?.toString()
val muted = CallController.muted val muted = CallController.muted
val audioRoute = CallController.route val audioRoute = CallController.route
@@ -357,6 +357,31 @@ class ChatViewModel : ViewModel() {
} }
init { 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 { viewModelScope.launch {
_cpuLoadVisible.collectLatest { visible -> _cpuLoadVisible.collectLatest { visible ->
if (!visible) return@collectLatest if (!visible) return@collectLatest
@@ -576,7 +601,6 @@ class ChatViewModel : ViewModel() {
_radioTalkState.value = RadioTalkState() _radioTalkState.value = RadioTalkState()
_radioTalking.value = null _radioTalking.value = null
_radioActive.value = false _radioActive.value = false
radioChannelId = null
radioAudio.stop() radioAudio.stop()
memberRadios.clear() memberRadios.clear()
memberRadioMutes.clear() memberRadioMutes.clear()
@@ -1018,10 +1042,15 @@ class ChatViewModel : ViewModel() {
"toggleRadio#$radioToggleCount next=$next prevActive=${_radioActive.value} " + "toggleRadio#$radioToggleCount next=$next prevActive=${_radioActive.value} " +
"radioChannelId=$radioChannelId cur=${ch.id} " + "radioChannelId=$radioChannelId cur=${ch.id} " +
"thread=${Thread.currentThread().name} t=${System.currentTimeMillis()} caller=$caller") "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) { if (next) {
radioChannelId?.takeIf { it != ch.id }?.toULongOrNull()?.toLong() radioChannelId?.takeIf { it != ch.id }?.toULongOrNull()?.toLong()
?.let { NativeLib.radioSetActive(it, false) } /* выключаем старый канал */ ?.let { NativeLib.radioSetActive(it, false) } /* выключаем старый канал */
radioChannelId = ch.id
_radioTalkState.value = RadioTalkState(groupId) _radioTalkState.value = RadioTalkState(groupId)
_radioTalking.value = null _radioTalking.value = null
NativeLib.radioSetActive(groupId, true) NativeLib.radioSetActive(groupId, true)
@@ -1029,14 +1058,6 @@ class ChatViewModel : ViewModel() {
if (!ready && radioChannelId == ch.id && !radioAudio.isActive()) turnRadioOff() if (!ready && radioChannelId == ch.id && !radioAudio.isActive()) turnRadioOff()
onReady?.invoke(ready) 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() _radioRoute.value = radioAudio.route()
_radioHeadsetAvailable.value = radioAudio.headsetAvailable() _radioHeadsetAvailable.value = radioAudio.headsetAvailable()
_wiredInputAvailable.value = radioAudio.router.wiredInputAvailable _wiredInputAvailable.value = radioAudio.router.wiredInputAvailable
@@ -1044,7 +1065,6 @@ class ChatViewModel : ViewModel() {
_radioInputSource.value = radioAudio.inputSource() _radioInputSource.value = radioAudio.inputSource()
_radioAuto.value = _radioInputSource.value == RadioAudioEngine.INPUT_AUTO _radioAuto.value = _radioInputSource.value == RadioAudioEngine.INPUT_AUTO
} else { } else {
radioChannelId = null
NativeLib.radioSetActive(groupId, false) NativeLib.radioSetActive(groupId, false)
radioAudio.stop() radioAudio.stop()
_radioTalking.value = null _radioTalking.value = null
@@ -1156,7 +1176,12 @@ class ChatViewModel : ViewModel() {
private fun turnRadioOff() { private fun turnRadioOff() {
val chId = radioChannelId ?: return val chId = radioChannelId ?: return
chId.toULongOrNull()?.toLong()?.let { NativeLib.radioSetActive(it, false) } chId.toULongOrNull()?.toLong()?.let { NativeLib.radioSetActive(it, false) }
radioChannelId = null radioAudio.stop()
clearRadioControls()
syncRadioOverlay()
}
private fun clearRadioControls() {
_radioActive.value = false _radioActive.value = false
_radioTalking.value = null _radioTalking.value = null
_radioTalkState.value = RadioTalkState() _radioTalkState.value = RadioTalkState()
@@ -1166,8 +1191,6 @@ class ChatViewModel : ViewModel() {
_btInputAvailable.value = false _btInputAvailable.value = false
_radioInputSource.value = RadioAudioEngine.INPUT_AUTO _radioInputSource.value = RadioAudioEngine.INPUT_AUTO
_radioAuto.value = false _radioAuto.value = false
radioAudio.stop()
syncRadioOverlay()
} }
private fun syncRadioOverlay() { private fun syncRadioOverlay() {
@@ -18,6 +18,20 @@
<string name="delete_conversation_confirm">Удалить переписку с %1$s и её локальные файлы? У собеседника история сохранится.</string> <string name="delete_conversation_confirm">Удалить переписку с %1$s и её локальные файлы? У собеседника история сохранится.</string>
<string name="delete_conversation_failed">Не удалось удалить переписку. Повторите попытку.</string> <string name="delete_conversation_failed">Не удалось удалить переписку. Повторите попытку.</string>
<string name="call_settings">Настройки звонка</string> <string name="call_settings">Настройки звонка</string>
<string name="call_stop_media_title">Начать звонок?</string>
<string name="call_stop_media_confirm">Продолжить</string>
<string name="call_outgoing_stop_radio">Для звонка необходимо выключить рацию. После звонка она включится снова.</string>
<string name="call_outgoing_stop_stream">Для звонка необходимо завершить трансляцию или её просмотр.</string>
<string name="call_outgoing_stop_media">Для звонка необходимо выключить рацию и завершить трансляцию или её просмотр. После звонка рация включится снова.</string>
<string name="call_incoming_stop_radio">При принятии звонка рация выключится. После звонка она включится снова.</string>
<string name="call_incoming_stop_stream">При принятии звонка трансляция или её просмотр завершится.</string>
<string name="call_incoming_stop_media">При принятии звонка рация выключится, трансляция или её просмотр завершится. После звонка рация включится снова.</string>
<string name="call_preparing_audio">Освобождаем аудиоустройства…</string>
<string name="call_media_stop_failed">Не удалось освободить аудиоустройства для звонка.</string>
<string name="call_media_busy">Во время звонка звук рации недоступен.</string>
<string name="call_video_settings">Настройки видео</string>
<string name="call_video_enable">Включить видео</string>
<string name="call_video_disable">Выключить видео</string>
<string name="call_controls">Управление звонком</string> <string name="call_controls">Управление звонком</string>
<string name="call_video_local">Своё видео</string> <string name="call_video_local">Своё видео</string>
<string name="call_video_remote">Видео собеседника</string> <string name="call_video_remote">Видео собеседника</string>
@@ -18,6 +18,20 @@
<string name="delete_conversation_confirm">Delete the conversation with %1$s and its local files? The other person keeps their history.</string> <string name="delete_conversation_confirm">Delete the conversation with %1$s and its local files? The other person keeps their history.</string>
<string name="delete_conversation_failed">Could not delete the conversation. Please try again.</string> <string name="delete_conversation_failed">Could not delete the conversation. Please try again.</string>
<string name="call_settings">Call settings</string> <string name="call_settings">Call settings</string>
<string name="call_stop_media_title">Start call?</string>
<string name="call_stop_media_confirm">Continue</string>
<string name="call_outgoing_stop_radio">The radio must be turned off for the call. It will turn back on after the call.</string>
<string name="call_outgoing_stop_stream">The broadcast or stream playback must end before starting the call.</string>
<string name="call_outgoing_stop_media">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.</string>
<string name="call_incoming_stop_radio">Answering will turn off the radio. It will turn back on after the call.</string>
<string name="call_incoming_stop_stream">Answering will end the broadcast or stream playback.</string>
<string name="call_incoming_stop_media">Answering will turn off the radio and end the broadcast or stream playback. The radio will turn back on after the call.</string>
<string name="call_preparing_audio">Releasing audio devices…</string>
<string name="call_media_stop_failed">Could not release the audio devices for the call.</string>
<string name="call_media_busy">Radio audio is unavailable during a call.</string>
<string name="call_video_settings">Video settings</string>
<string name="call_video_enable">Turn video on</string>
<string name="call_video_disable">Turn video off</string>
<string name="call_controls">Call controls</string> <string name="call_controls">Call controls</string>
<string name="call_video_local">Your video</string> <string name="call_video_local">Your video</string>
<string name="call_video_remote">Peer video</string> <string name="call_video_remote">Peer video</string>