Own pending Android radio calls independently of Activity and ViewModel

This commit is contained in:
evgeny committed 2026-10-19 14:00:44 +02:00
1 parent dbb07df7e9
commit 23ece16940
2 files changed
+213 -2

No files matched your search

@@ -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)
}
}
@@ -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<RadioCallState?>(null)
val state: StateFlow<RadioCallState?> = _state
private val _activation = MutableStateFlow<RadioCallActivation?>(null)
val activation: StateFlow<RadioCallActivation?> = _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" }
}