diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioCallController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioCallController.kt index 8f88c298f..84b9faebe 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioCallController.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioCallController.kt @@ -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(null) val state: StateFlow = _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 + } }