Retain failed call end commands until the worker accepts them

This commit is contained in:
evgeny committed 2026-10-29 13:34:25 +01:00
1 parent 1600f5710c
commit 38e2cc4336
2 files changed
+62 -14

No files matched your search

@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -38,12 +39,15 @@ object CallController : TelecomCallManager.Listener {
val state: StateFlow<CallState?> = _state
private val mutableOutgoing = MutableStateFlow<OutgoingCallRequest?>(null)
val outgoingRequest: StateFlow<OutgoingCallRequest?> = mutableOutgoing
private var mediaPreparation: Job? = null
@Volatile private var mediaPreparation: Job? = null
private var radioToRestore: Long? = null
private val pendingEnds = MutableStateFlow<Map<Long, Boolean>>(emptyMap()) // true=decline, false=hangup; main.
private var endRetry: Job? = null
/** Подготовка, разговор и неподтверждённый cleanup сервиса запрещают запуск чужого media.
* Predicate читается также media workers; CallService публикует retained-владельца через volatile. */
fun blocksMedia(): Boolean = CallService.cleanupPending() || TelecomCallManager.cleanupPending() || mediaPreparation?.isActive == true || _state.value?.let {
fun blocksMedia(): Boolean = pendingEnds.value.isNotEmpty() || CallService.cleanupPending() || TelecomCallManager.cleanupPending() ||
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
@@ -156,6 +160,10 @@ object CallController : TelecomCallManager.Listener {
LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}")
return
}
if (pendingEnds.value.isNotEmpty() || CallService.cleanupPending() || TelecomCallManager.cleanupPending()) {
LogManager.addLog("WARN", "Call", "start rejected: previous signalling/service cleanup pending")
return
}
if (!mediaConfirmed && hasMedia()) {
mutableOutgoing.value = OutgoingCallRequest(channelId, peerNodeId, peerName)
LogManager.addLog("INFO", "Call", "outgoing requires media confirmation peer=$peerNodeId")
@@ -261,15 +269,21 @@ object CallController : TelecomCallManager.Listener {
if (service == null || !st.ongoing || st.preparing || signallingStarted) return
val id = st.callId
if (st.phase == CallPhase.OUTGOING) {
if (!NativeLib.callStart(st.channelId, st.peerNodeId, id)) {
LogManager.addLog("ERROR", "Call", "start queue rejected id=$id; releasing reserved service")
finish(id, 7); return
}
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()
if (!NativeLib.callAccept(id)) {
LogManager.addLog("ERROR", "Call", "accept queue rejected id=$id; declining and releasing service")
end(id, 7); return
}
signallingStarted = true
acceptSent = true
NativeLib.callAccept(id)
}
}
@@ -298,12 +312,39 @@ object CallController : TelecomCallManager.Listener {
return
}
if (NativeLib.isReady()) {
if ((st.phase == CallPhase.INCOMING || st.phase == CallPhase.PENDING_INCOMING) && !acceptSent) NativeLib.callDecline(id)
else if (signallingStarted) NativeLib.callHangup(id)
if ((st.phase == CallPhase.INCOMING || st.phase == CallPhase.PENDING_INCOMING) && !acceptSent) queueEnd(id, decline = true)
else if (signallingStarted) queueEnd(id, decline = false)
}
finish(id, reason)
}
/** Ошибка постановки не удерживает FGS/audio, но сохраняет команду и запрещает новый media до queue ACK. */
private fun queueEnd(id: Long, decline: Boolean) {
if (sendEnd(id, decline)) return
pendingEnds.value = pendingEnds.value + (id to decline)
if (endRetry?.isActive == true) return
endRetry = scope.launch {
var pause = 250L
while (pendingEnds.value.isNotEmpty()) {
delay(pause)
if (!coreReady || !NativeLib.isReady()) { pendingEnds.value = emptyMap(); return@launch }
for ((call, reject) in pendingEnds.value) if (sendEnd(call, reject)) {
pendingEnds.value = pendingEnds.value - call
LogManager.addLog("INFO", "Call", "Deferred ${if (reject) "decline" else "hangup"} queued id=$call")
}
pause = (pause * 2).coerceAtMost(30_000)
}
}
}
private fun sendEnd(id: Long, decline: Boolean): Boolean = try {
val queued = if (decline) NativeLib.callDecline(id) else NativeLib.callHangup(id)
if (!queued) LogManager.addLog("ERROR", "Call", "${if (decline) "decline" else "hangup"} queue rejected id=$id; retained for retry")
queued
} catch (e: Exception) {
LogManager.addLog("ERROR", "Call", "End command threw id=$id decline=$decline; retained for retry: $e")
false
}
private fun finish(id: Long, reason: Int) {
val st = _state.value ?: return
if (st.callId != id || !st.ongoing) return
@@ -335,6 +376,7 @@ object CallController : TelecomCallManager.Listener {
serviceReleased?.await()
released?.join()
TelecomCallManager.awaitReleased()
pendingEnds.first { it.isEmpty() }
if (!coreReady || !NativeLib.isReady() || blocksMedia()) return@launch
val group = radioToRestore ?: return@launch
radioToRestore = null
@@ -372,6 +414,7 @@ object CallController : TelecomCallManager.Listener {
coreReady = false
CoreActivity.reset()
end()
endRetry?.cancel(); endRetry = null; pendingEnds.value = emptyMap()
_state.value = null
CoreActivity.call(false)
}
@@ -454,7 +497,7 @@ object CallController : TelecomCallManager.Listener {
if (current?.callId == id) return
if (current?.ongoing == true) {
LogManager.addLog("WARN", "Call", "busy; decline incoming id=$id current=${current.callId}")
NativeLib.callDecline(id)
queueEnd(id, decline = true)
return
}
incomingAt = SystemClock.elapsedRealtime()
@@ -474,6 +517,7 @@ object CallController : TelecomCallManager.Listener {
(type == 39 && data.size != 24) ||
(type == 44 && (data.size != 17 || (data[8].toInt() and 0xFF) > 3))) { badEvent(type, data); return }
val id = ByteBuffer.wrap(data, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
if (type in listOf(37, 38, 40)) pendingEnds.value = pendingEnds.value - id
val st = _state.value
if (st == null || st.callId != id || !st.ongoing) {
LogManager.addLog("DEBUG", "Call", "ignore event=$type id=$id current=${st?.callId} phase=${st?.phase}")
@@ -591,8 +635,11 @@ object CallController : TelecomCallManager.Listener {
val st = _state.value ?: return
if (st.callId != id || st.phase != CallPhase.WAIT_DECISION) return
LogManager.addLog("INFO", "Call", "Caller overrides phone busy id=$id")
if (!NativeLib.callContinue(id)) {
LogManager.addLog("ERROR", "Call", "continue queue rejected id=$id; caller choice remains available")
return
}
_state.value = st.copy(phase = CallPhase.OUTGOING)
NativeLib.callContinue(id)
CallRingback.startConnecting()
service?.updateNotification()
}
@@ -54,11 +54,12 @@ object NativeLib {
fun callAudioSetAec(enabled: Boolean) {}
fun callAudioSetAecDelay(frames: Int) {}
val callCommands = mutableListOf<Pair<String, Long>>()
fun callStart(channel: String, peer: Long, id: Long) { callCommands.add("start" to id) }
fun callAccept(id: Long) { callCommands.add("accept" to id) }
fun callDecline(id: Long) { callCommands.add("decline" to id) }
fun callHangup(id: Long) { callCommands.add("hangup" to id) }
fun callContinue(id: Long) { callCommands.add("continue" to id) }
var rejectedCallCommands = emptySet<String>()
fun callStart(channel: String, peer: Long, id: Long): Boolean { callCommands.add("start" to id); return "start" !in rejectedCallCommands }
fun callAccept(id: Long): Boolean { callCommands.add("accept" to id); return "accept" !in rejectedCallCommands }
fun callDecline(id: Long): Boolean { callCommands.add("decline" to id); return "decline" !in rejectedCallCommands }
fun callHangup(id: Long): Boolean { callCommands.add("hangup" to id); return "hangup" !in rejectedCallCommands }
fun callContinue(id: Long): Boolean { callCommands.add("continue" to id); return "continue" !in rejectedCallCommands }
data class RadioCallCommand(val id: Long, val action: Int, val group: Long, val peer: Long)
val radioCalls = mutableListOf<RadioCallCommand>()
var onRadioCall: (RadioCallCommand) -> Boolean = { true }