Own pending Android radio calls independently of Activity and ViewModel
This commit is contained in:
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" }
|
||||
}
|
||||
Reference in new issue
Block a user