Browse Source

Coordinate Android call audio ownership and improve video controls

stream-multiview
evgeny 6 days ago
parent
commit
21a20da5ee
  1. 11
      doc/service_lifecycle.md
  2. 12
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  3. 21
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt
  4. 115
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt
  5. 3
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt
  6. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt
  7. 61
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  8. 11
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamController.kt
  9. 36
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt
  10. 81
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
  11. 21
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt
  12. 16
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt
  13. 53
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  14. 14
      tools/chatgui-android/app/src/main/res/values-ru/strings.xml
  15. 14
      tools/chatgui-android/app/src/main/res/values/strings.xml

11
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 не переключает его отдельно.
Повторная команда не перезапускает фазу и анонсы, но первый фоновый запуск включает

12
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(

21
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
}

115
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<CallState?>(null)
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)
val video: StateFlow<CallVideoEngine?> = _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)

3
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
}

5
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"

61
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<Long?>(null)
val activeGroup: StateFlow<Long?> = 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}")
}

11
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

36
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<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 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) }

81
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<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
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())) {

21
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()

16
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))
}
}

53
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() {

14
tools/chatgui-android/app/src/main/res/values-ru/strings.xml

@ -18,6 +18,20 @@
<string name="delete_conversation_confirm">Удалить переписку с %1$s и её локальные файлы? У собеседника история сохранится.</string>
<string name="delete_conversation_failed">Не удалось удалить переписку. Повторите попытку.</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_video_local">Своё видео</string>
<string name="call_video_remote">Видео собеседника</string>

14
tools/chatgui-android/app/src/main/res/values/strings.xml

@ -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_failed">Could not delete the conversation. Please try again.</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_video_local">Your video</string>
<string name="call_video_remote">Peer video</string>

Loading…
Cancel
Save