Own pending radio starts and diagnose every signaling queue refusal

This commit is contained in:
evgeny committed 2026-10-29 13:34:23 +01:00
1 parent 16f52c0136
commit b24bee7a89
1 file changed
+61 -12
@@ -9,6 +9,7 @@ import com.utun.chat.ChatApplication
import com.utun.chat.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -24,8 +25,10 @@ data class RadioCallState(
)
data class RadioCallActivation(val id: Long, val channelId: String)
/** Живые события, без replay в UI. Ядро владеет deadline, приложение — уведомлением.
* Принятие сначала поднимает видимую Activity и обычную рацию, затем отправляет ACCEPT. */
/** На main хранит живое приглашение рации; StateFlow повторяет текущий снимок после пересоздания UI.
* Ядро владеет deadline/сигналингом, приложение — оповещением и запросом async-включения обычной рации.
* Входящий ACCEPT отправляется только после activated(success=true) из видимой Activity.
* Завершение приглашения не выключает обычную рацию. Отказ очереди логируется и завершает локальную попытку. */
object RadioCallController {
private const val START = 1
private const val READY = 2
@@ -35,6 +38,10 @@ object RadioCallController {
private const val FAIL = 6
private const val BUSY = 7
private const val CONTINUE = 8
private const val RESULT_NOTIFICATION_ID = 1004 // Отдельно от incoming, foreground-сервисов и результата P2P-звонка.
private data class PendingStart(val id: Long, val group: Long, val peer: Long)
private var pendingStart: PendingStart? = null // START принят приложением, первое событие ядра ещё не получено.
private var clearJob: Job? = null // Только задержка удаления ENDED-карточки; deadline вызова принадлежит ядру.
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableStateFlow<RadioCallState?>(null)
val state: StateFlow<RadioCallState?> = _state
@@ -43,7 +50,7 @@ object RadioCallController {
private var visible = false
private var answerOnVisible = 0L
private var ready = false
fun busy(): Boolean = _state.value?.phase?.let { it != RadioCallPhase.ENDED } == true
fun busy(): Boolean = pendingStart != null || _state.value?.phase?.let { it != RadioCallPhase.ENDED } == true
fun start(channel: String, peer: Long) {
checkMain()
@@ -51,10 +58,16 @@ object RadioCallController {
LogManager.addLog("WARN", "radio", "Call start unavailable core=$ready busy=${busy()}")
showOutcome(3); return
}
val group = channel.toULongOrNull()?.toLong() ?: return
val group = channel.toULongOrNull()?.toLong()
if (group == null || group == 0L || peer == 0L) {
LogManager.addLog("ERROR", "radio", "Invalid call target channel=$channel peer=$peer")
showOutcome(5); return
}
var id = java.security.SecureRandom().nextLong()
if (id == 0L) id = 1
if (!NativeLib.radioCall(id, START, group, peer)) showOutcome(5)
pendingStart = PendingStart(id, group, peer)
syncActivity()
if (!command(id, START, group, peer)) { pendingStart = null; syncActivity(); showOutcome(5) }
}
fun setVisible(value: Boolean) {
@@ -68,6 +81,7 @@ object RadioCallController {
/** Старый PendingIntent не принимает новый вызов и не восстанавливает завершённый. */
fun notificationIntent(intent: Intent?) {
checkMain()
if (intent?.getBooleanExtra(IncomingCallManager.EXTRA_RADIO_CALL, false) != true) return
val id = intent.getLongExtra(IncomingCallManager.EXTRA_CALL_ID, 0)
if (!intent.getBooleanExtra(IncomingCallManager.EXTRA_ANSWER_RADIO, false)) return
@@ -86,7 +100,11 @@ object RadioCallController {
}
return
}
if (StreamController.blocksCapture()) { NativeLib.radioCall(st.id, FAIL); return }
if (StreamController.blocksCapture()) {
LogManager.addLog("WARN", "radio", "Call capture blocked by stream id=${st.id}")
command(st.id, FAIL)
finish(st, 7); return
}
IncomingCallManager.stopRinging()
_state.value = st.copy(phase = RadioCallPhase.PREPARING)
_activation.value = RadioCallActivation(st.id, st.channelId)
@@ -100,7 +118,7 @@ object RadioCallController {
_activation.value = null
LogManager.addLog(if (success) "INFO" else "ERROR", "radio", "Call activation id=$id success=$success incoming=${st.incoming}")
if (st.incoming) {
if (!NativeLib.radioCall(id, if (success) ACCEPT else FAIL)) finish(st, 7)
if (!command(id, if (success) ACCEPT else FAIL)) finish(st, 7)
} else if (success) clear() else finish(st, 7)
}
@@ -108,14 +126,17 @@ object RadioCallController {
checkMain()
val st = _state.value ?: return
if (id != st.id || st.phase == RadioCallPhase.ENDED) return
NativeLib.radioCall(id, if (st.incoming && st.phase == RadioCallPhase.INCOMING) DECLINE else CANCEL)
command(id, if (st.incoming && st.phase == RadioCallPhase.INCOMING) DECLINE else CANCEL)
finish(st, if (st.incoming && st.phase == RadioCallPhase.INCOMING) 2 else 4)
}
fun reset() {
checkMain()
ready = false
if (busy()) end()
if (NativeLib.isReady()) {
if (_state.value?.phase?.let { it != RadioCallPhase.ENDED } == true) end()
pendingStart?.let { command(it.id, CANCEL) }
}
clear()
}
@@ -187,7 +208,7 @@ object RadioCallController {
if (st.id != id || st.phase != RadioCallPhase.WAIT_DECISION) return
_state.value = st.copy(phase = RadioCallPhase.OUTGOING)
LogManager.addLog("INFO", "radio", "Caller overrides phone busy id=$id")
if (!NativeLib.radioCall(id, CONTINUE)) { finish(st, 5); return }
if (!command(id, CONTINUE)) { finish(st, 5); return }
CallRingback.startConnecting()
}
@@ -214,7 +235,8 @@ object RadioCallController {
val ctx = ChatApplication.instance
val text = AppLanguage.text(reasonText(reason))
if (visible) { Toast.makeText(ctx, text, Toast.LENGTH_LONG).show(); return }
requireNotNull(ctx.getSystemService(NotificationManager::class.java)) { "notification manager unavailable" }.notify(1002,
IncomingCallManager.init()
requireNotNull(ctx.getSystemService(NotificationManager::class.java)) { "notification manager unavailable" }.notify(RESULT_NOTIFICATION_ID,
NotificationCompat.Builder(ctx, IncomingCallManager.CHANNEL_CALL)
.setSmallIcon(android.R.drawable.ic_dialog_info).setContentTitle(AppLanguage.text(R.string.radio_call))
.setContentText(text).setAutoCancel(true).build())
@@ -223,5 +245,32 @@ object RadioCallController {
}
}
private fun checkMain() = check(Looper.myLooper() == Looper.getMainLooper()) { "RadioCallController requires main thread" }
/** Отказ/исключение JNI — отказ постановки команды, а true ещё не означает успех сигналинга. */
private fun command(id: Long, action: Int, group: Long = 0, peer: Long = 0): Boolean {
try {
if (NativeLib.radioCall(id, action, group, peer)) {
LogManager.addLog("DEBUG", "radio", "Call command queued id=$id action=$action group=$group peer=$peer")
return true
}
LogManager.addLog("ERROR", "radio", "Call command rejected id=$id action=$action group=$group peer=$peer")
} catch (e: Exception) {
LogManager.addLog("ERROR", "radio", "Call command failed id=$id action=$action group=$group peer=$peer: $e")
}
return false
}
/** CoreActivity сохраняет намерение до подтверждения очереди; его отказ не прерывает сигналинг/очистку UI. */
private fun syncActivity() {
val active = busy()
try { CoreActivity.radioCall(active) } catch (e: Exception) {
LogManager.addLog("ERROR", "radio", "Call activity update failed active=$active id=${_state.value?.id} " +
"pending=${pendingStart?.id}; retained for next sync: $e")
}
}
private fun checkMain() = check(Looper.myLooper() == Looper.getMainLooper()) {
val message = "RadioCallController requires main thread: caller=${Thread.currentThread().name}"
LogManager.addLog("ERROR", "radio", message)
message
}
}