@ -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 )