From 23ece16940f9e8fb437a05edb3873fd0db73057d Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 19 Oct 2026 14:00:44 +0200 Subject: [PATCH] Own pending Android radio calls independently of Activity and ViewModel --- .../java/com/utun/chat/data/CoreActivity.kt | 6 +- .../com/utun/chat/data/RadioCallController.kt | 209 ++++++++++++++++++ 2 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioCallController.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CoreActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CoreActivity.kt index c4f077d7..3569ad48 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CoreActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CoreActivity.kt @@ -6,19 +6,21 @@ import android.os.Looper object CoreActivity { private var visible = false private var call = false + private var radioCall = false private var transfer = false private var applied: Boolean? = null fun visible(value: Boolean) { visible = value; sync() } fun call(value: Boolean) { call = value; sync() } + fun radioCall(value: Boolean) { radioCall = value; sync() } fun transfer(value: Boolean) { transfer = value; sync() } fun reset() { applied = null } fun sync() { check(Looper.myLooper() == Looper.getMainLooper()) if (!NativeLib.isReady()) { applied = null; return } - val active = visible || call || transfer + val active = visible || call || radioCall || transfer if (applied == active) return applied = active - LogManager.addLog("INFO", "Activity", "core active=$active visible=$visible call=$call transfer=$transfer") + LogManager.addLog("INFO", "Activity", "core active=$active visible=$visible call=$call radioCall=$radioCall transfer=$transfer") NativeLib.setActive(active) } } 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 new file mode 100644 index 00000000..feeeb9a1 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioCallController.kt @@ -0,0 +1,209 @@ +package com.utun.chat.data + +import android.app.NotificationManager +import android.content.Intent +import android.os.Looper +import android.widget.Toast +import androidx.core.app.NotificationCompat +import com.utun.chat.ChatApplication +import com.utun.chat.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.nio.ByteBuffer +import java.nio.ByteOrder + +enum class RadioCallPhase { OUTGOING, INCOMING, RINGING, PREPARING, ENDED } +data class RadioCallState( + val id: Long, val channelId: String, val peer: Long, val peerName: String, val channelName: String, + val incoming: Boolean, val phase: RadioCallPhase, val reason: Int = 0 +) +data class RadioCallActivation(val id: Long, val channelId: String) + +/** Живые события, без replay в UI. Ядро владеет deadline, приложение — уведомлением. + * Принятие сначала поднимает видимую Activity и обычную рацию, затем отправляет ACCEPT. */ +object RadioCallController { + private const val START = 1 + private const val READY = 2 + private const val ACCEPT = 3 + private const val DECLINE = 4 + private const val CANCEL = 5 + private const val FAIL = 6 + private const val BUSY = 7 + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val _state = MutableStateFlow(null) + val state: StateFlow = _state + private val _activation = MutableStateFlow(null) + val activation: StateFlow = _activation + private var visible = false + private var answerOnVisible = 0L + private var ready = false + fun busy(): Boolean = _state.value?.phase?.let { it != RadioCallPhase.ENDED } == true + + fun start(channel: String, peer: Long) { + checkMain() + if (!ready || !NativeLib.isReady() || busy() || CallController.state.value?.ongoing == true) { + LogManager.addLog("WARN", "radio", "Call start unavailable core=$ready busy=${busy()}") + showOutcome(3); return + } + val group = channel.toULongOrNull()?.toLong() ?: return + var id = java.security.SecureRandom().nextLong() + if (id == 0L) id = 1 + if (!NativeLib.radioCall(id, START, group, peer)) showOutcome(5) + } + + fun setVisible(value: Boolean) { + checkMain() + visible = value + if (value && answerOnVisible != 0L) { + val id = answerOnVisible; answerOnVisible = 0 + if (_state.value?.id == id) accept() + } + } + + /** Старый PendingIntent не принимает новый вызов и не восстанавливает завершённый. */ + fun notificationIntent(intent: Intent?) { + 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 + intent.removeExtra(IncomingCallManager.EXTRA_ANSWER_RADIO) + if (_state.value?.id != id || _state.value?.phase != RadioCallPhase.INCOMING) return + if (visible) accept() else answerOnVisible = id + } + + fun accept() { + checkMain() + val st = _state.value ?: return + if (!visible || st.phase != RadioCallPhase.INCOMING) return + if (!MicrophonePermission.granted()) { + MicrophonePermission.request("Answer radio call id=${st.id}") { + if (_state.value?.id == st.id && _state.value?.phase == RadioCallPhase.INCOMING) accept() + } + return + } + if (StreamController.blocksCapture()) { NativeLib.radioCall(st.id, FAIL); return } + IncomingCallManager.stopRinging() + _state.value = st.copy(phase = RadioCallPhase.PREPARING) + _activation.value = RadioCallActivation(st.id, st.channelId) + } + + /** Вызывается после фактического async-запуска аудио, а не установки флага в ViewModel. */ + fun activated(id: Long, success: Boolean) { + checkMain() + val st = _state.value ?: return + if (st.id != id || st.phase != RadioCallPhase.PREPARING || _activation.value?.id != id) return + _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) + } else if (success) clear() else finish(st, 7) + } + + fun end(id: Long = _state.value?.id ?: 0L) { + 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) + finish(st, if (st.incoming && st.phase == RadioCallPhase.INCOMING) 2 else 4) + } + + fun reset() { + checkMain() + ready = false + if (busy()) end() + clear() + } + + fun onEvent(type: Int, data: ByteArray?) { + checkMain() + if (type == 12) { ready = NativeLib.isReady(); return } + if (type == 13) { reset(); return } + if (type != 65 || !ready || !NativeLib.isReady()) return + if (data == null || data.size != 27 || data[24].toInt() !in 1..4 || data[25].toInt() !in 0..7 || data[26].toInt() !in 0..1) { + LogManager.addLog("ERROR", "radio", "Invalid call event size=${data?.size}"); return + } + val buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + val id = buf.long; val group = buf.long; val peer = buf.long + val phase = buf.get().toInt(); val reason = buf.get().toInt(); val incoming = buf.get().toInt() != 0 + val current = _state.value + if (phase == 1 || phase == 2) { + if (current?.id == id) return + if (busy() || CallController.state.value?.ongoing == true) { NativeLib.radioCall(id, BUSY); return } + val channelId = group.toULong().toString() + val repo = ChatRepository() + val name = repo.getMembers(channelId).firstOrNull { it.nodeId == peer }?.name?.takeIf { it.isNotEmpty() } + ?: "0x%016x".format(peer) + val channelName = repo.getChannels().firstOrNull { it.id == channelId }?.name ?: channelId + _state.value = RadioCallState(id, channelId, peer, name, channelName, incoming, + if (incoming) RadioCallPhase.INCOMING else RadioCallPhase.OUTGOING) + CoreActivity.radioCall(true) + if (incoming) { + IncomingCallManager.show(id, channelId, peer, radio = true) + NativeLib.radioCall(id, READY) + } else CallRingback.startConnecting() + return + } + if (current == null || current.id != id) { + if (phase == 4 && !incoming) showOutcome(reason) + return + } + if (current.phase == RadioCallPhase.ENDED) return + if (phase == 3 && !incoming) { + _state.value = current.copy(phase = RadioCallPhase.RINGING) + CallRingback.startRinging() + } else if (phase == 4) { + IncomingCallManager.stopRinging(); CallRingback.stop() + if (reason == 1) { + if (incoming) clear() + else { + _state.value = current.copy(phase = RadioCallPhase.PREPARING) + _activation.value = RadioCallActivation(id, current.channelId) + } + } else finish(current, reason) + } + } + + private fun finish(st: RadioCallState, reason: Int) { + IncomingCallManager.stopRinging(); CallRingback.stop() + _activation.value = null; answerOnVisible = 0 + _state.value = st.copy(phase = RadioCallPhase.ENDED, reason = reason) + CoreActivity.radioCall(false) + LogManager.addLog("INFO", "radio", "Call finished id=${st.id} reason=$reason") + if (!st.incoming || reason == 7) showOutcome(reason) + scope.launch { delay(2500); if (_state.value?.id == st.id && _state.value?.phase == RadioCallPhase.ENDED) clear() } + } + + private fun clear() { + if (busy()) { IncomingCallManager.stopRinging(); CallRingback.stop() } + _state.value = null; _activation.value = null; answerOnVisible = 0 + CoreActivity.radioCall(false) + } + + fun reasonText(reason: Int): Int = when (reason) { + 2 -> R.string.radio_call_declined + 3 -> R.string.radio_call_busy + 4 -> R.string.radio_call_cancelled + 6 -> R.string.radio_call_no_answer + 7 -> R.string.radio_call_audio_error + else -> R.string.radio_call_unavailable + } + + private fun showOutcome(reason: Int) { + if (reason == 4 || reason == 1) return + val ctx = ChatApplication.instance + val text = AppLanguage.text(reasonText(reason)) + if (visible) Toast.makeText(ctx, text, Toast.LENGTH_LONG).show() + else try { + ctx.getSystemService(NotificationManager::class.java).notify(1002, + 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()) + } catch (e: SecurityException) { LogManager.addLog("WARN", "radio", "Call result notification denied: $e") } + } + + private fun checkMain() = check(Looper.myLooper() == Looper.getMainLooper()) { "RadioCallController requires main thread" } +}