Keep Android calls active independently of screen and UI
This commit is contained in:
1 parent
fb37ca747a
commit
3c5c9604b3
20 files changed
+930
-571
No files matched your search
@@ -2,6 +2,8 @@ package com.utun.chat
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.data.ConfigProvider
|
||||
import com.utun.chat.data.NativeLib
|
||||
@@ -9,14 +11,19 @@ import com.utun.chat.data.NotificationSound
|
||||
import com.utun.chat.data.IncomingCallManager
|
||||
import com.utun.chat.data.TelecomCallManager
|
||||
import com.utun.chat.data.ChatEventCallback
|
||||
import com.utun.chat.data.CallController
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
object AppEventHandler : ChatEventCallback {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val _events = MutableSharedFlow<Pair<Int, ByteArray?>>(replay = 16, extraBufferCapacity = 64)
|
||||
val events: SharedFlow<Pair<Int, ByteArray?>> = _events
|
||||
|
||||
override fun onEvent(type: Int, data: ByteArray?) {
|
||||
if (type == 12 || type == 13 || type in 34..40 || type == 42) {
|
||||
mainHandler.post { CallController.onEvent(type, data) }
|
||||
}
|
||||
Log.d("utun-gui", "[EVENT] type=$type len=${data?.size ?: 0}")
|
||||
_events.tryEmit(type to data)
|
||||
}
|
||||
@@ -44,6 +51,7 @@ class ChatApplication : Application() {
|
||||
|
||||
/* Telecom self-managed call integration (system audio routing for calls) */
|
||||
TelecomCallManager.init()
|
||||
CallController.init()
|
||||
|
||||
/* First launch config */
|
||||
if (configProvider.isFirstLaunch()) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import com.utun.chat.data.CallController
|
||||
import com.utun.chat.data.CallPhase
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.BackHandler
|
||||
@@ -96,17 +98,15 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
val callState by vm.callState.collectAsState()
|
||||
|
||||
/* Пока идёт звонок — держим экран включённым; после завершения снимаем
|
||||
и showWhenLocked (если он был выставлен для входящего поверх блокировки). */
|
||||
/* После звонка снимаем showWhenLocked входящего. Экран может гаснуть:
|
||||
аудио и CPU удерживает CallService независимо от Activity. */
|
||||
LaunchedEffect(callState) {
|
||||
val st = callState
|
||||
val inCall = st != null && st.phase != ChatViewModel.CallPhase.NONE &&
|
||||
st.phase != ChatViewModel.CallPhase.ENDED
|
||||
val inCall = st != null && st.phase != CallPhase.NONE &&
|
||||
st.phase != CallPhase.ENDED
|
||||
if (inCall) {
|
||||
hadCall = true
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (hadCall) releaseLockScreen()
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,8 @@ class MainActivity : ComponentActivity() {
|
||||
показывает «Завершён», если пользователь уже на нём. INCOMING → диалог поверх. */
|
||||
LaunchedEffect(callState?.phase) {
|
||||
when (callState?.phase) {
|
||||
ChatViewModel.CallPhase.OUTGOING, ChatViewModel.CallPhase.RINGING,
|
||||
ChatViewModel.CallPhase.ACTIVE -> {
|
||||
CallPhase.OUTGOING, CallPhase.RINGING,
|
||||
CallPhase.ACTIVE -> {
|
||||
if (screen != Screen.Call) screenBeforeCall = screen
|
||||
screen = Screen.Call
|
||||
}
|
||||
@@ -177,9 +177,9 @@ class MainActivity : ComponentActivity() {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
val callPhase = callState?.phase
|
||||
val showCallBanner = screen != Screen.Call &&
|
||||
(callPhase == ChatViewModel.CallPhase.OUTGOING ||
|
||||
callPhase == ChatViewModel.CallPhase.RINGING ||
|
||||
callPhase == ChatViewModel.CallPhase.ACTIVE)
|
||||
(callPhase == CallPhase.OUTGOING ||
|
||||
callPhase == CallPhase.RINGING ||
|
||||
callPhase == CallPhase.ACTIVE)
|
||||
if (showCallBanner) {
|
||||
ActiveCallBanner(
|
||||
viewModel = vm,
|
||||
@@ -313,7 +313,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
if (callState?.phase == ChatViewModel.CallPhase.INCOMING) {
|
||||
if (callState?.phase == CallPhase.INCOMING) {
|
||||
IncomingCallDialog(viewModel = vm)
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (NativeLib.isReady()) NativeLib.setActive(true)
|
||||
CallController.setActivityVisible(true)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
@@ -421,7 +421,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (NativeLib.isReady()) NativeLib.setActive(false)
|
||||
CallController.setActivityVisible(false)
|
||||
}
|
||||
|
||||
/** Volume-кнопки (удержание) → PTT рации, при активной рации и включённой опции. */
|
||||
|
||||
@@ -11,6 +11,8 @@ import android.media.AudioTrack
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import com.utun.chat.ChatApplication
|
||||
|
||||
/**
|
||||
@@ -37,6 +39,7 @@ class CallAudioEngine {
|
||||
const val AEC_ACOUSTIC_SAMPLES = 150L
|
||||
}
|
||||
|
||||
private val recordLock = Any()
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var captureThread: Thread? = null
|
||||
private var audioTrack: AudioTrack? = null
|
||||
@@ -62,9 +65,17 @@ class CallAudioEngine {
|
||||
@Volatile private var capFramesRead = 0L
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val aecDelayRunnable = Runnable { measureAecDelay() }
|
||||
private val failed = AtomicBoolean(false)
|
||||
var onFailure: ((Long, String) -> Unit)? = null
|
||||
|
||||
fun isActive(): Boolean = active
|
||||
|
||||
val diagnostics: String get() = synchronized(recordLock) {
|
||||
"active=$active capture=$capFramesRead play=$playFramesWritten " +
|
||||
"workers=${captureThread?.isAlive}/${playThread?.isAlive} " +
|
||||
"record=${audioRecord?.recordingState} track=${audioTrack?.playState} route=${router.route}"
|
||||
}
|
||||
|
||||
/** Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */
|
||||
fun setMuted(m: Boolean) {
|
||||
if (muted == m) return
|
||||
@@ -85,107 +96,154 @@ class CallAudioEngine {
|
||||
|
||||
callId = id
|
||||
muted = false
|
||||
|
||||
val ctx = ChatApplication.instance
|
||||
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
prevMode = am.mode
|
||||
prevSpeaker = am.isSpeakerphoneOn
|
||||
failed.set(false)
|
||||
try {
|
||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
val ctx = ChatApplication.instance
|
||||
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
prevMode = am.mode
|
||||
prevSpeaker = am.isSpeakerphoneOn
|
||||
try {
|
||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}")
|
||||
}
|
||||
|
||||
/* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET,
|
||||
* вынули → сохранённый маршрут, по умолчанию тихая связь). */
|
||||
router.fallbackRoute = if (router.preferredRoute == AudioRoute.SPEAKER) AudioRoute.SPEAKER else AudioRoute.EARPIECE
|
||||
router.setAutoHeadset(true)
|
||||
router.start()
|
||||
|
||||
/* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном
|
||||
* динамике эхо незначимо. Задержка рендер→захват замеряется чуть позже. */
|
||||
playFramesWritten = 0L
|
||||
capFramesRead = 0L
|
||||
syncAecForRoute(router.route)
|
||||
|
||||
// ── AudioRecord (захват) ──
|
||||
wiredActive = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null
|
||||
wantedWired = wiredActive
|
||||
audioRecord = createAudioRecord(wiredActive)
|
||||
if (audioRecord == null) {
|
||||
stop(); return false
|
||||
}
|
||||
|
||||
// ── AudioTrack (воспроизведение) ──
|
||||
val trackMin = AudioTrack.getMinBufferSize(
|
||||
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT)
|
||||
if (trackMin <= 0) {
|
||||
LogManager.addLog("ERROR", "CallAudio", "AudioTrack.getMinBufferSize=$trackMin id=$id")
|
||||
stop(); return false
|
||||
}
|
||||
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)
|
||||
audioTrack = try {
|
||||
AudioTrack(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(),
|
||||
AudioFormat.Builder().setSampleRate(SAMPLE_RATE)
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build(),
|
||||
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "CallAudio", "AudioTrack create failed: ${e.message}")
|
||||
stop(); return false
|
||||
}
|
||||
|
||||
if (audioTrack?.state != AudioTrack.STATE_INITIALIZED) {
|
||||
LogManager.addLog("ERROR", "CallAudio", "AudioTrack not initialized id=$id")
|
||||
stop(); return false
|
||||
}
|
||||
|
||||
/* Прямое роутирование трека на выбранное устройство вывода (обход глючной
|
||||
глобальной маршрутизации на ряде устройств). */
|
||||
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) }
|
||||
applyTrackOutputDevice(router.currentOutputDevice)
|
||||
|
||||
/* Смена маршрута → пересчитать, нужен ли захват с проводной гарнитуры. */
|
||||
router.onInputDevice = { _ ->
|
||||
wantedWired = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null
|
||||
}
|
||||
|
||||
active = true
|
||||
audioRecord?.startRecording()
|
||||
audioTrack?.play()
|
||||
if (audioRecord?.recordingState != AudioRecord.RECORDSTATE_RECORDING ||
|
||||
audioTrack?.playState != AudioTrack.PLAYSTATE_PLAYING) {
|
||||
LogManager.addLog("ERROR", "CallAudio",
|
||||
"I/O not running id=$id record=${audioRecord?.recordingState} track=${audioTrack?.playState}")
|
||||
stop(); return false
|
||||
}
|
||||
|
||||
captureThread = Thread({ runWorker(id, "capture") { captureLoop() } }, "utun-call-capture").apply { start() }
|
||||
playThread = Thread({ runWorker(id, "play") { playLoop() } }, "utun-call-play").apply { start() }
|
||||
|
||||
/* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */
|
||||
mainHandler.postDelayed(aecDelayRunnable, 300)
|
||||
|
||||
LogManager.addLog("INFO", "CallAudio", "started id=$id wired=$wiredActive trackBuf=$trackBuf")
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}")
|
||||
LogManager.addLog("ERROR", "CallAudio", "start failed id=$id: ${e.message}")
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
|
||||
/* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET,
|
||||
* вынули → сохранённый маршрут, по умолчанию тихая связь). */
|
||||
router.fallbackRoute = if (router.preferredRoute == AudioRoute.SPEAKER) AudioRoute.SPEAKER else AudioRoute.EARPIECE
|
||||
router.setAutoHeadset(true)
|
||||
router.start()
|
||||
|
||||
/* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном
|
||||
* динамике эхо незначимо. Задержка рендер→захват замеряется чуть позже. */
|
||||
playFramesWritten = 0L
|
||||
capFramesRead = 0L
|
||||
syncAecForRoute(router.route)
|
||||
|
||||
// ── AudioRecord (захват) ──
|
||||
wiredActive = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null
|
||||
wantedWired = wiredActive
|
||||
audioRecord = createAudioRecord(wiredActive)
|
||||
if (audioRecord == null) {
|
||||
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false
|
||||
}
|
||||
|
||||
// ── AudioTrack (воспроизведение) ──
|
||||
val trackMin = AudioTrack.getMinBufferSize(
|
||||
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT)
|
||||
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)
|
||||
audioTrack = try {
|
||||
AudioTrack(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(),
|
||||
AudioFormat.Builder().setSampleRate(SAMPLE_RATE)
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build(),
|
||||
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "CallAudio", "AudioTrack create failed: ${e.message}")
|
||||
audioRecord?.release(); audioRecord = null
|
||||
audioTrack = null
|
||||
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false
|
||||
}
|
||||
|
||||
/* Прямое роутирование трека на выбранное устройство вывода (обход глючной
|
||||
глобальной маршрутизации на ряде устройств). */
|
||||
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) }
|
||||
applyTrackOutputDevice(router.currentOutputDevice)
|
||||
|
||||
/* Смена маршрута → пересчитать, нужен ли захват с проводной гарнитуры. */
|
||||
router.onInputDevice = { _ ->
|
||||
wantedWired = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null
|
||||
}
|
||||
|
||||
active = true
|
||||
audioRecord?.startRecording()
|
||||
audioTrack?.play()
|
||||
|
||||
captureThread = Thread { captureLoop() }.apply {
|
||||
priority = Thread.MAX_PRIORITY; start()
|
||||
}
|
||||
playThread = Thread { playLoop() }.apply {
|
||||
priority = Thread.MAX_PRIORITY; start()
|
||||
}
|
||||
|
||||
/* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */
|
||||
mainHandler.postDelayed(aecDelayRunnable, 300)
|
||||
|
||||
LogManager.addLog("INFO", "CallAudio", "started id=$id wired=$wiredActive trackBuf=$trackBuf")
|
||||
return true
|
||||
}
|
||||
|
||||
/** Перехватываем сбой worker и передаём владельцу на main; старый callback не завершит новый звонок. */
|
||||
private fun runWorker(id: Long, name: String, work: () -> Unit) {
|
||||
try {
|
||||
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO)
|
||||
work()
|
||||
if (active) reportFailure(id, "$name worker stopped unexpectedly")
|
||||
} catch (e: Exception) {
|
||||
if (active) reportFailure(id, "$name worker failed: ${e.message}")
|
||||
} finally {
|
||||
LogManager.addLog("INFO", "CallAudio", "$name worker exited id=$id active=$active")
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportFailure(id: Long, detail: String) {
|
||||
if (!active || !failed.compareAndSet(false, true)) return
|
||||
LogManager.addLog("ERROR", "CallAudio", "$detail id=$id route=${router.route} capture=$capFramesRead play=$playFramesWritten")
|
||||
mainHandler.post { if (callId == id && active) onFailure?.invoke(id, detail) }
|
||||
}
|
||||
|
||||
/** Сначала разблокируем I/O и дождёмся workers, затем освобождаем Android/native-объекты. */
|
||||
fun stop() {
|
||||
if (!active) return
|
||||
if (callId == 0L) return
|
||||
val id = callId
|
||||
active = false
|
||||
mainHandler.removeCallbacks(aecDelayRunnable)
|
||||
|
||||
captureThread?.join(1000); captureThread = null
|
||||
playThread?.join(1000); playThread = null
|
||||
|
||||
audioRecord?.apply { try { stop() } catch (_: Exception) {}; release() }
|
||||
audioRecord = null
|
||||
audioTrack?.apply { try { stop() } catch (_: Exception) {}; release() }
|
||||
synchronized(recordLock) {
|
||||
audioRecord?.let { rec ->
|
||||
try { if (rec.recordingState == AudioRecord.RECORDSTATE_RECORDING) rec.stop() }
|
||||
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "record stop failed id=$id: ${e.message}") }
|
||||
}
|
||||
}
|
||||
audioTrack?.let { track ->
|
||||
try { if (track.playState == AudioTrack.PLAYSTATE_PLAYING) track.pause() }
|
||||
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "track pause failed id=$id: ${e.message}") }
|
||||
}
|
||||
playThread?.interrupt()
|
||||
captureThread?.join(); captureThread = null
|
||||
playThread?.join(); playThread = null
|
||||
synchronized(recordLock) {
|
||||
try { audioRecord?.release() }
|
||||
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "record release failed id=$id: ${e.message}") }
|
||||
audioRecord = null
|
||||
}
|
||||
try { audioTrack?.release() }
|
||||
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "track release failed id=$id: ${e.message}") }
|
||||
audioTrack = null
|
||||
|
||||
NativeLib.callAudioStop()
|
||||
router.onOutputDevice = null
|
||||
router.onInputDevice = null
|
||||
router.stop()
|
||||
restoreAudioMode()
|
||||
|
||||
callId = 0L
|
||||
muted = false
|
||||
LogManager.addLog("INFO", "CallAudio", "stopped")
|
||||
LogManager.addLog("INFO", "CallAudio", "stopped id=$id capture=$capFramesRead play=$playFramesWritten; workers joined")
|
||||
}
|
||||
|
||||
private fun captureLoop() {
|
||||
@@ -197,27 +255,40 @@ class CallAudioEngine {
|
||||
while (active) {
|
||||
/* проводная гарнитура появилась/исчезла — меняем источник захвата */
|
||||
if (wantedWired != wiredActive) {
|
||||
audioRecord = null /* защита measureAecDelay от UAF на освобождаемой записи */
|
||||
rec?.apply { try { stop() } catch (_: Exception) {}; release() }
|
||||
wiredActive = wantedWired
|
||||
rec = createAudioRecord(wiredActive)
|
||||
if (rec == null) return
|
||||
audioRecord = rec
|
||||
rec.startRecording()
|
||||
synchronized(recordLock) {
|
||||
if (!active) return
|
||||
rec?.apply {
|
||||
stop()
|
||||
release()
|
||||
}
|
||||
audioRecord = null
|
||||
wiredActive = wantedWired
|
||||
rec = createAudioRecord(wiredActive)
|
||||
audioRecord = rec
|
||||
if (rec == null) return
|
||||
rec?.startRecording()
|
||||
}
|
||||
sinceSwap = 0
|
||||
}
|
||||
val r = rec ?: return
|
||||
if (r.recordingState != AudioRecord.RECORDSTATE_RECORDING) return
|
||||
if (r.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||
reportFailure(callId, "capture not recording state=${r.recordingState}")
|
||||
return
|
||||
}
|
||||
var read = 0
|
||||
while (read < FRAME_SAMPLES) {
|
||||
if (!active) return
|
||||
val n = r.read(buf, read, FRAME_SAMPLES - read)
|
||||
if (n <= 0) return
|
||||
// stop() разблокирует read до join; обычный захват не требует polling.
|
||||
val n = r.read(buf, read, FRAME_SAMPLES - read, AudioRecord.READ_BLOCKING)
|
||||
if (n <= 0) {
|
||||
reportFailure(callId, "AudioRecord.read=$n offset=$read state=${r.recordingState}")
|
||||
return
|
||||
}
|
||||
read += n
|
||||
}
|
||||
/* диагностика: уровень захваченного сигнала (тишина vs звук) */
|
||||
if (sinceSwap < 10 || frame % 100L == 0L) {
|
||||
LogManager.addLog("WARN", "CallAudio",
|
||||
LogManager.addLog("DEBUG", "CallAudio",
|
||||
"cap wired=$wiredActive frame=$frame rmsDb=${rmsDb(buf)}")
|
||||
}
|
||||
sinceSwap++
|
||||
@@ -228,7 +299,6 @@ class CallAudioEngine {
|
||||
LogManager.addLog("WARN", "CallAudio", "callAudioFeed returned false")
|
||||
}
|
||||
}
|
||||
LogManager.addLog("DEBUG", "CallAudio", "capture loop done")
|
||||
}
|
||||
|
||||
/** RMS захваченного кадра в dBFS (0 = полная шкала, ниже = тише; тишина ~ -90). */
|
||||
@@ -245,6 +315,10 @@ class CallAudioEngine {
|
||||
private fun createAudioRecord(wired: Boolean): AudioRecord? {
|
||||
val recMin = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
|
||||
if (recMin <= 0) {
|
||||
LogManager.addLog("ERROR", "CallAudio", "AudioRecord.getMinBufferSize=$recMin wired=$wired")
|
||||
return null
|
||||
}
|
||||
val rec = try {
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||
@@ -283,9 +357,34 @@ class CallAudioEngine {
|
||||
while (active) {
|
||||
val track = audioTrack ?: return
|
||||
val n = NativeLib.callAudioPull(callId, buf)
|
||||
if (n > 0) track.write(buf, 0, n)
|
||||
if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n)
|
||||
playFramesWritten += FRAME_SAMPLES
|
||||
if (n !in 0..FRAME_SAMPLES) {
|
||||
reportFailure(callId, "callAudioPull=$n expected=0..$FRAME_SAMPLES")
|
||||
return
|
||||
}
|
||||
if (n > 0) buf.copyInto(silence, 0, 0, n)
|
||||
if (n < FRAME_SAMPLES) silence.fill(0, n, FRAME_SAMPLES)
|
||||
var offset = 0
|
||||
var blockedAt = 0L
|
||||
while (active && offset < FRAME_SAMPLES) {
|
||||
val written = track.write(silence, offset, FRAME_SAMPLES - offset, AudioTrack.WRITE_NON_BLOCKING)
|
||||
if (written < 0) {
|
||||
reportFailure(callId, "AudioTrack.write=$written offset=$offset state=${track.playState}")
|
||||
return
|
||||
}
|
||||
if (written == 0) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (blockedAt == 0L) blockedAt = now
|
||||
if (now - blockedAt > 2000) {
|
||||
reportFailure(callId, "AudioTrack stalled for ${now - blockedAt}ms offset=$offset state=${track.playState}")
|
||||
return
|
||||
}
|
||||
Thread.sleep(2)
|
||||
} else {
|
||||
blockedAt = 0L
|
||||
offset += written
|
||||
playFramesWritten += written
|
||||
}
|
||||
}
|
||||
|
||||
/* абсолютный deadline: не даём Thread.sleep накопить систематический дрейф
|
||||
* (важно для стабильного выравнивания рендер↔захват в AEC). */
|
||||
@@ -298,7 +397,6 @@ class CallAudioEngine {
|
||||
nextNs = System.nanoTime() /* отстали — сбрасываем, не копим долг */
|
||||
}
|
||||
}
|
||||
LogManager.addLog("DEBUG", "CallAudio", "play loop done")
|
||||
}
|
||||
|
||||
/** AEC включается только на громкой связи (спикерфон). */
|
||||
@@ -310,29 +408,31 @@ class CallAudioEngine {
|
||||
|
||||
/** Замер реальной задержки рендер→захват и применение к AEC (один раз после старта). */
|
||||
private fun measureAecDelay() {
|
||||
if (!active) return
|
||||
val track = audioTrack ?: return
|
||||
val rec = audioRecord ?: return
|
||||
try {
|
||||
val tTs = AudioTimestamp()
|
||||
val rTs = AudioTimestamp()
|
||||
val trackOk = track.getTimestamp(tTs)
|
||||
val recOk = rec.getTimestamp(rTs, AudioTimestamp.TIMEBASE_MONOTONIC) == AudioRecord.SUCCESS
|
||||
if (!trackOk || !recOk) {
|
||||
LogManager.addLog("WARN", "CallAudio", "getTimestamp unavailable; AEC delay = $AEC_DEFAULT_DELAY_FRAMES frames")
|
||||
synchronized(recordLock) {
|
||||
if (!active) return
|
||||
val track = audioTrack ?: return
|
||||
val rec = audioRecord ?: return
|
||||
try {
|
||||
val tTs = AudioTimestamp()
|
||||
val rTs = AudioTimestamp()
|
||||
val trackOk = track.getTimestamp(tTs)
|
||||
val recOk = rec.getTimestamp(rTs, AudioTimestamp.TIMEBASE_MONOTONIC) == AudioRecord.SUCCESS
|
||||
if (!trackOk || !recOk) {
|
||||
LogManager.addLog("WARN", "CallAudio", "getTimestamp unavailable; AEC delay = $AEC_DEFAULT_DELAY_FRAMES frames")
|
||||
NativeLib.callAudioSetAecDelay(AEC_DEFAULT_DELAY_FRAMES)
|
||||
return
|
||||
}
|
||||
val trackLatency = (playFramesWritten - tTs.framePosition).coerceAtLeast(0L)
|
||||
val recLatency = (rTs.framePosition - capFramesRead).coerceAtLeast(0L)
|
||||
val total = trackLatency + recLatency + AEC_ACOUSTIC_SAMPLES
|
||||
val delayFrames = ((total + FRAME_SAMPLES - 1) / FRAME_SAMPLES).toInt().coerceIn(1, 20)
|
||||
LogManager.addLog("INFO", "CallAudio",
|
||||
"AEC delay measured: trackLat=$trackLatency recLat=$recLatency total=$total -> $delayFrames frames")
|
||||
NativeLib.callAudioSetAecDelay(delayFrames)
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("WARN", "CallAudio", "AEC delay measure failed: ${e.message}")
|
||||
NativeLib.callAudioSetAecDelay(AEC_DEFAULT_DELAY_FRAMES)
|
||||
return
|
||||
}
|
||||
val trackLatency = (playFramesWritten - tTs.framePosition).coerceAtLeast(0L)
|
||||
val recLatency = (rTs.framePosition - capFramesRead).coerceAtLeast(0L)
|
||||
val total = trackLatency + recLatency + AEC_ACOUSTIC_SAMPLES
|
||||
val delayFrames = ((total + FRAME_SAMPLES - 1) / FRAME_SAMPLES).toInt().coerceIn(1, 20)
|
||||
LogManager.addLog("INFO", "CallAudio",
|
||||
"AEC delay measured: trackLat=$trackLatency recLat=$recLatency total=$total -> $delayFrames frames")
|
||||
NativeLib.callAudioSetAecDelay(delayFrames)
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("WARN", "CallAudio", "AEC delay measure failed: ${e.message}")
|
||||
NativeLib.callAudioSetAecDelay(AEC_DEFAULT_DELAY_FRAMES)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import com.utun.chat.ChatApplication
|
||||
import com.utun.chat.MainActivity
|
||||
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
|
||||
|
||||
/**
|
||||
* Единственный владелец звонковой сессии в процессе приложения. Все операции — main thread.
|
||||
* Сервис владеет аудио и wake lock, ядро — сигналингом, ViewModel только читает StateFlow.
|
||||
* События поступают напрямую из AppEventHandler без replay старых звонков новым подписчикам.
|
||||
*/
|
||||
object CallController : TelecomCallManager.Listener {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private val _state = MutableStateFlow<CallState?>(null)
|
||||
val state: StateFlow<CallState?> = _state
|
||||
private val _muted = MutableStateFlow(false)
|
||||
val muted: StateFlow<Boolean> = _muted
|
||||
private val _route = MutableStateFlow(AudioRoute.SPEAKER)
|
||||
val route: StateFlow<AudioRoute> = _route
|
||||
private val _headset = MutableStateFlow(false)
|
||||
val headset: StateFlow<Boolean> = _headset
|
||||
private val _earpiece = MutableStateFlow(true)
|
||||
val earpiece: StateFlow<Boolean> = _earpiece
|
||||
private val proximity = ProximityManager(ChatApplication.instance)
|
||||
val proximityNear: StateFlow<Boolean> = proximity.near
|
||||
|
||||
private var service: CallService? = null
|
||||
private var serviceRequested = false
|
||||
private var accepting = false
|
||||
private var acceptSent = false
|
||||
private var answerWhenVisible = 0L
|
||||
private var activityVisible = false
|
||||
private var coreReady = false
|
||||
private var startedAt = 0L
|
||||
private var incomingAt = 0L
|
||||
private var initialized = false
|
||||
|
||||
fun init() {
|
||||
checkMain()
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
TelecomCallManager.setListener(this)
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(500)
|
||||
val st = _state.value ?: continue
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (st.phase == CallPhase.ACTIVE) {
|
||||
_state.value = st.copy(durationSeconds = ((now - startedAt) / 1000).toInt())
|
||||
} else if (st.phase == CallPhase.INCOMING && now - incomingAt > 50_000) {
|
||||
LogManager.addLog("WARN", "Call", "incoming timeout id=${st.callId}; declining")
|
||||
end(st.callId)
|
||||
}
|
||||
}
|
||||
}
|
||||
LogManager.addLog("INFO", "Call", "controller initialized; session independent of UI")
|
||||
}
|
||||
|
||||
fun setActivityVisible(visible: Boolean) {
|
||||
checkMain()
|
||||
activityVisible = visible
|
||||
syncCoreActivity()
|
||||
if (visible && answerWhenVisible != 0L) {
|
||||
val id = answerWhenVisible
|
||||
answerWhenVisible = 0L
|
||||
if (_state.value?.callId == id && _state.value?.phase == CallPhase.INCOMING) accept()
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncCoreActivity() {
|
||||
val ongoing = _state.value?.ongoing == true
|
||||
val active = activityVisible || ongoing
|
||||
LogManager.addLog("INFO", "Call", "core activity=$active visible=$activityVisible call=$ongoing")
|
||||
if (coreReady && NativeLib.isReady()) NativeLib.setActive(active)
|
||||
}
|
||||
|
||||
fun start(channelId: String, peerNodeId: Long, peerName: String) {
|
||||
checkMain()
|
||||
if (channelId.isEmpty() || peerNodeId == 0L) {
|
||||
LogManager.addLog("ERROR", "Call", "invalid outgoing call channel=$channelId peer=$peerNodeId")
|
||||
return
|
||||
}
|
||||
if (_state.value?.ongoing == true || !coreReady || !NativeLib.isReady()) {
|
||||
LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}")
|
||||
return
|
||||
}
|
||||
var id = java.security.SecureRandom().nextLong()
|
||||
if (id == 0L) id = 1
|
||||
_state.value = CallState(id, channelId, peerNodeId, peerName.ifEmpty { "0x%016x".format(peerNodeId) }, CallPhase.OUTGOING)
|
||||
syncCoreActivity()
|
||||
requestService(id)
|
||||
}
|
||||
|
||||
fun accept() {
|
||||
checkMain()
|
||||
val st = _state.value ?: return
|
||||
if (st.phase != CallPhase.INCOMING || accepting) {
|
||||
LogManager.addLog("WARN", "Call", "accept ignored id=${st.callId} phase=${st.phase} accepting=$accepting")
|
||||
return
|
||||
}
|
||||
accepting = true
|
||||
requestService(st.callId)
|
||||
}
|
||||
|
||||
private fun requestService(id: Long) {
|
||||
serviceRequested = true
|
||||
try {
|
||||
CallService.start(ChatApplication.instance, id)
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "Call", "service request failed id=$id: ${e.message}")
|
||||
end(id, 7)
|
||||
}
|
||||
}
|
||||
|
||||
/** Вызывается сервисом после startForeground и приобретения wake lock, до запуска сигналинга/микрофона. */
|
||||
fun serviceReady(id: Long, owner: CallService): Boolean {
|
||||
checkMain()
|
||||
val st = _state.value
|
||||
if (st == null || st.callId != id || !st.ongoing || !serviceRequested) {
|
||||
LogManager.addLog("WARN", "Call", "discard stale service start id=$id current=${st?.callId}")
|
||||
return false
|
||||
}
|
||||
if (service != null) {
|
||||
if (service !== owner) LogManager.addLog("ERROR", "Call", "service owner conflict id=$id")
|
||||
return service === owner
|
||||
}
|
||||
service = owner
|
||||
LogManager.addLog("INFO", "Call", "service ready id=$id phase=${st.phase}")
|
||||
if (st.phase == CallPhase.OUTGOING) {
|
||||
NativeLib.callStart(st.channelId, st.peerNodeId, id)
|
||||
TelecomCallManager.placeCall(id, st.peerNodeId, st.peerName)
|
||||
CallRingback.startConnecting()
|
||||
} else if (st.phase == CallPhase.INCOMING && accepting) {
|
||||
IncomingCallManager.stopRinging()
|
||||
acceptSent = true
|
||||
NativeLib.callAccept(id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun wantsService(id: Long): Boolean = _state.value?.let { it.callId == id && it.ongoing && serviceRequested } == true
|
||||
|
||||
fun serviceLost(id: Long, owner: CallService) {
|
||||
checkMain()
|
||||
if (service !== owner) return
|
||||
service = null
|
||||
LogManager.addLog("ERROR", "Call", "service destroyed during call id=$id")
|
||||
end(id, 7)
|
||||
}
|
||||
|
||||
fun audioFailed(id: Long, detail: String) {
|
||||
checkMain()
|
||||
if (_state.value?.callId != id || _state.value?.ongoing != true) return
|
||||
LogManager.addLog("ERROR", "Call", "audio failed id=$id: $detail")
|
||||
end(id, 7)
|
||||
}
|
||||
|
||||
/** Завершение из UI, Telecom и уведомления; идентификатор защищает от действий старого звонка. */
|
||||
fun end(id: Long = _state.value?.callId ?: 0L, reason: Int = 0) {
|
||||
checkMain()
|
||||
val st = _state.value ?: return
|
||||
if (st.callId != id || !st.ongoing) {
|
||||
LogManager.addLog("WARN", "Call", "end ignored id=$id current=${st.callId} phase=${st.phase}")
|
||||
return
|
||||
}
|
||||
if (NativeLib.isReady()) {
|
||||
if (st.phase == CallPhase.INCOMING && !acceptSent) NativeLib.callDecline(id) else NativeLib.callHangup(id)
|
||||
}
|
||||
finish(id, reason)
|
||||
}
|
||||
|
||||
private fun finish(id: Long, reason: Int) {
|
||||
val st = _state.value ?: return
|
||||
if (st.callId != id || !st.ongoing) return
|
||||
val owner = service
|
||||
service = null
|
||||
serviceRequested = false
|
||||
accepting = false
|
||||
acceptSent = false
|
||||
answerWhenVisible = 0L
|
||||
owner?.shutdown()
|
||||
proximity.stop()
|
||||
TelecomCallManager.setDisconnected(id, reason)
|
||||
TelecomCallManager.removeConnection(id)
|
||||
CallRingback.stop()
|
||||
IncomingCallManager.stopRinging()
|
||||
_muted.value = false
|
||||
_route.value = AudioRoute.SPEAKER
|
||||
_headset.value = false
|
||||
_earpiece.value = true
|
||||
_state.value = st.copy(phase = CallPhase.ENDED, reason = reason)
|
||||
syncCoreActivity()
|
||||
LogManager.addLog("INFO", "Call", "finished id=$id reason=$reason; audio/service/proximity released")
|
||||
scope.launch {
|
||||
delay(2500)
|
||||
if (_state.value?.callId == id && _state.value?.phase == CallPhase.ENDED) _state.value = null
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
checkMain()
|
||||
if (_state.value?.ongoing == true) end()
|
||||
_state.value = null
|
||||
}
|
||||
|
||||
/** Перед stop/restart ядра освобождаем аудио, чтобы workers больше не обращались к native-ресурсам. */
|
||||
fun reset() {
|
||||
checkMain()
|
||||
coreReady = false
|
||||
end()
|
||||
_state.value = null
|
||||
}
|
||||
|
||||
fun toggleMute() {
|
||||
checkMain()
|
||||
val audio = service?.audio ?: return
|
||||
if (!audio.isActive()) return
|
||||
audio.setMuted(!audio.isMuted())
|
||||
_muted.value = audio.isMuted()
|
||||
}
|
||||
|
||||
fun setRoute(route: AudioRoute) {
|
||||
checkMain()
|
||||
service?.audio?.router?.setRoute(route)
|
||||
_state.value?.let { TelecomCallManager.setRoute(it.callId, route) }
|
||||
scope.launch { ChatApplication.instance.configProvider.setCallAudioRoute(route) }
|
||||
}
|
||||
|
||||
private fun routeChanged(route: AudioRoute, headset: Boolean) {
|
||||
_route.value = route
|
||||
_headset.value = headset
|
||||
if (route == AudioRoute.EARPIECE && _state.value?.phase == CallPhase.ACTIVE) proximity.start() else proximity.stop()
|
||||
service?.audio?.syncAecForRoute(route)
|
||||
}
|
||||
|
||||
/** Только живые события ядра, сериализованные AppEventHandler на main thread. */
|
||||
fun onEvent(type: Int, data: ByteArray?) {
|
||||
checkMain()
|
||||
if (type == 12) {
|
||||
coreReady = NativeLib.isReady()
|
||||
syncCoreActivity()
|
||||
LogManager.addLog("INFO", "Call", "core started ready=$coreReady")
|
||||
return
|
||||
}
|
||||
if (type == 13) { reset(); return }
|
||||
if (type !in 34..40 && type != 42) return
|
||||
if (!coreReady || !NativeLib.isReady()) {
|
||||
LogManager.addLog("WARN", "Call", "ignore event=$type while core stopped/restarting")
|
||||
return
|
||||
}
|
||||
if (type == 34) {
|
||||
if (data == null || data.size < 17) { badEvent(type, data); return }
|
||||
val len = data[0].toInt() and 0xFF
|
||||
if (len == 0 || data.size < 17 + len) { badEvent(type, data); return }
|
||||
val channel = String(data, 1, len)
|
||||
val buf = ByteBuffer.wrap(data, 1 + len, 16).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val peer = buf.long
|
||||
val id = buf.long
|
||||
if (peer == 0L || id == 0L) { badEvent(type, data); return }
|
||||
val current = _state.value
|
||||
if (current?.callId == id) return
|
||||
if (current?.ongoing == true) {
|
||||
LogManager.addLog("WARN", "Call", "busy; decline incoming id=$id current=${current.callId}")
|
||||
NativeLib.callDecline(id)
|
||||
return
|
||||
}
|
||||
incomingAt = SystemClock.elapsedRealtime()
|
||||
// Однократное чтение на main завершается до reset; фоновый JNI-запрос не переживает ядро.
|
||||
val name = ChatRepository().getMembers(channel).firstOrNull { it.nodeId == peer }?.name
|
||||
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(peer)
|
||||
_state.value = CallState(id, channel, peer, name, CallPhase.INCOMING)
|
||||
syncCoreActivity()
|
||||
IncomingCallManager.show(id, channel, peer)
|
||||
TelecomCallManager.addIncomingCall(id, peer, name)
|
||||
return
|
||||
}
|
||||
if (data == null || data.size < 8 || ((type == 37 || type == 38 || type == 40 || type == 42) && data.size < 9) ||
|
||||
(type == 39 && data.size != 24)) { badEvent(type, data); return }
|
||||
val id = ByteBuffer.wrap(data, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
|
||||
val st = _state.value
|
||||
if (st == null || st.callId != id || !st.ongoing) {
|
||||
LogManager.addLog("DEBUG", "Call", "ignore event=$type id=$id current=${st?.callId} phase=${st?.phase}")
|
||||
return
|
||||
}
|
||||
when (type) {
|
||||
35 -> if (st.phase == CallPhase.OUTGOING) {
|
||||
_state.value = st.copy(phase = CallPhase.RINGING)
|
||||
CallRingback.startRinging()
|
||||
}
|
||||
36 -> {
|
||||
if (st.phase == CallPhase.ACTIVE) return
|
||||
val owner = service
|
||||
if (owner == null || !owner.startAudio(id)) {
|
||||
LogManager.addLog("ERROR", "Call", "accepted without working audio/service id=$id")
|
||||
end(id, 7)
|
||||
return
|
||||
}
|
||||
startedAt = SystemClock.elapsedRealtime()
|
||||
accepting = false
|
||||
CallRingback.stop()
|
||||
IncomingCallManager.stopRinging()
|
||||
TelecomCallManager.setActive(id)
|
||||
_state.value = st.copy(phase = CallPhase.ACTIVE, durationSeconds = 0)
|
||||
owner.audio.router.onChanged = { route, headset -> routeChanged(route, headset) }
|
||||
_earpiece.value = owner.audio.router.hasEarpiece
|
||||
routeChanged(owner.audio.router.route, owner.audio.router.headsetAvailable)
|
||||
owner.updateNotification()
|
||||
LogManager.addLog("INFO", "Call", "active id=$id; service owns audio and CPU wake lock")
|
||||
}
|
||||
37, 38 -> finish(id, data[8].toInt() and 0xFF)
|
||||
40 -> {
|
||||
LogManager.addLog("ERROR", "Call", "native error id=$id code=${data[8]} ${String(data, 9, data.size - 9)}")
|
||||
finish(id, 7)
|
||||
}
|
||||
39 -> if (st.phase == CallPhase.ACTIVE) {
|
||||
val buf = ByteBuffer.wrap(data, 8, 16).order(ByteOrder.BIG_ENDIAN)
|
||||
fun metric() = buf.short.toInt() and 0xFFFF
|
||||
_state.value = st.copy(stats = CallStats(metric(), metric(), metric(), metric(), metric(), metric(), metric(), metric()))
|
||||
}
|
||||
42 -> _state.value = st.copy(bgpPath = String(data, 8, data.size - 8))
|
||||
}
|
||||
}
|
||||
|
||||
private fun badEvent(type: Int, data: ByteArray?) {
|
||||
LogManager.addLog("WARN", "Call", "invalid event=$type size=${data?.size ?: 0}")
|
||||
}
|
||||
|
||||
override fun onAnswer(callId: Long) {
|
||||
if (_state.value?.callId != callId || _state.value?.phase != CallPhase.INCOMING) return
|
||||
if (activityVisible) { accept(); return }
|
||||
// Ответ гарнитурой в фоне: сначала видимый UI, затем разрешённый запуск microphone FGS.
|
||||
answerWhenVisible = callId
|
||||
try {
|
||||
ChatApplication.instance.startActivity(Intent(ChatApplication.instance, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
putExtra(IncomingCallManager.EXTRA_CALL_ID, callId)
|
||||
})
|
||||
LogManager.addLog("INFO", "Call", "Telecom answer id=$callId; waiting for visible Activity before microphone service")
|
||||
} catch (e: Exception) {
|
||||
answerWhenVisible = 0L
|
||||
LogManager.addLog("ERROR", "Call", "cannot show incoming UI for Telecom answer id=$callId: ${e.message}")
|
||||
}
|
||||
}
|
||||
override fun onReject(callId: Long) { end(callId, 2) }
|
||||
override fun onDisconnect(callId: Long) { end(callId) }
|
||||
|
||||
private fun checkMain() = check(Looper.myLooper() == Looper.getMainLooper()) { "CallController requires main thread" }
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.utun.chat.ChatApplication
|
||||
import com.utun.chat.MainActivity
|
||||
|
||||
/**
|
||||
* Владелец аудиодвижка и PARTIAL_WAKE_LOCK на время исходящего/принятого звонка.
|
||||
* Сервис запускается действием пользователя до сигналинга и захвата микрофона.
|
||||
* Не восстанавливает звонок после смерти процесса: native-сессия тогда тоже утрачена.
|
||||
*/
|
||||
class CallService : Service() {
|
||||
companion object {
|
||||
private const val CHANNEL = "ongoing_call"
|
||||
private const val NOTIFICATION_ID = 1002
|
||||
private const val EXTRA_ID = "call_id"
|
||||
private const val ACTION_END = "com.utun.chat.action.END_CALL"
|
||||
|
||||
fun start(context: Context, callId: Long) {
|
||||
ContextCompat.startForegroundService(context,
|
||||
Intent(context, CallService::class.java).putExtra(EXTRA_ID, callId))
|
||||
}
|
||||
}
|
||||
|
||||
val audio = CallAudioEngine()
|
||||
private var callId = 0L
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var powerEventsRegistered = false
|
||||
private val powerEvents = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
LogManager.addLog("INFO", "CallService",
|
||||
"power event=${intent.action} id=$callId interactive=${pm.isInteractive} " +
|
||||
"idle=${pm.isDeviceIdleMode} save=${pm.isPowerSaveMode} lock=${wakeLock?.isHeld} ${audio.diagnostics}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, "Текущий звонок", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
setSound(null, null)
|
||||
})
|
||||
audio.onFailure = { id, detail -> CallController.audioFailed(id, detail) }
|
||||
LogManager.addLog("INFO", "CallService", "created")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val id = intent?.getLongExtra(EXTRA_ID, 0L) ?: 0L
|
||||
if (intent?.action == ACTION_END) {
|
||||
CallController.end(id)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
if (!CallController.wantsService(id)) {
|
||||
LogManager.addLog("WARN", "CallService", "stale start id=$id owned=$callId")
|
||||
if (callId == 0L) {
|
||||
// Даже отменённый startForegroundService должен завершить foreground-handshake.
|
||||
try {
|
||||
promote(false)
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "CallService", "cancelled foreground handshake failed id=$id: ${e.message}")
|
||||
} finally {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
if (callId == id) return START_NOT_STICKY
|
||||
if (callId != 0L) {
|
||||
LogManager.addLog("ERROR", "CallService", "start conflicts id=$id owned=$callId")
|
||||
CallController.audioFailed(id, "service already owns another call")
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
callId = id
|
||||
try {
|
||||
promote(true)
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "utun:ongoing_call").apply {
|
||||
setReferenceCounted(false)
|
||||
}
|
||||
wakeLock!!.acquire()
|
||||
LogManager.addLog("INFO", "CallService",
|
||||
"foreground phoneCall|microphone; CPU lock acquired id=$id held=${wakeLock?.isHeld}")
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
|
||||
addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
|
||||
}
|
||||
ContextCompat.registerReceiver(this, powerEvents, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
powerEventsRegistered = true
|
||||
if (!CallController.serviceReady(id, this)) shutdown()
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "CallService", "start failed id=$id: ${e.message}")
|
||||
CallController.audioFailed(id, "foreground/wake lock failed: ${e.message}")
|
||||
shutdown()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun promote(microphone: Boolean) {
|
||||
val micType = if (microphone && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0
|
||||
val types = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or micType
|
||||
} else 0
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(), types)
|
||||
}
|
||||
|
||||
fun startAudio(id: Long): Boolean {
|
||||
if (id != callId || wakeLock?.isHeld != true) {
|
||||
LogManager.addLog("ERROR", "CallService", "audio start rejected id=$id owned=$callId lock=${wakeLock?.isHeld}")
|
||||
return false
|
||||
}
|
||||
audio.router.setPreferredRoute(ChatApplication.instance.configProvider.getCallAudioRoute())
|
||||
return audio.start(id)
|
||||
}
|
||||
|
||||
fun updateNotification() {
|
||||
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification())
|
||||
}
|
||||
|
||||
private fun notification(): Notification {
|
||||
val st = CallController.state.value
|
||||
val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
val end = PendingIntent.getService(this, 0,
|
||||
Intent(this, CallService::class.java).setAction(ACTION_END)
|
||||
.setData(Uri.parse("utun-call:$callId")).putExtra(EXTRA_ID, callId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
return NotificationCompat.Builder(this, CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_call)
|
||||
.setContentTitle(st?.peerName ?: "vibechat")
|
||||
.setContentText(if (st?.phase == CallPhase.ACTIVE) "Разговор" else "Установка звонка")
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(open)
|
||||
.addAction(0, "Завершить", end)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** Идемпотентное освобождение до остановки сервиса; выполняется в main thread. */
|
||||
fun shutdown() {
|
||||
releaseResources()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun releaseResources() {
|
||||
val id = callId
|
||||
if (powerEventsRegistered) {
|
||||
try { unregisterReceiver(powerEvents) }
|
||||
catch (e: Exception) { LogManager.addLog("WARN", "CallService", "power receiver cleanup failed id=$id: ${e.message}") }
|
||||
powerEventsRegistered = false
|
||||
}
|
||||
audio.router.onChanged = null
|
||||
try {
|
||||
audio.stop()
|
||||
} finally {
|
||||
wakeLock?.let { lock ->
|
||||
try {
|
||||
if (lock.isHeld) lock.release()
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "CallService", "wake lock release failed id=$id: ${e.message}")
|
||||
}
|
||||
}
|
||||
wakeLock = null
|
||||
callId = 0L
|
||||
if (id != 0L) LogManager.addLog("INFO", "CallService", "resources released id=$id; CPU lock cleared")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
val id = callId
|
||||
releaseResources()
|
||||
CallController.serviceLost(id, this)
|
||||
LogManager.addLog("INFO", "CallService", "destroyed id=$id")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
/** Состояние единственной звонковой сессии; не зависит от Activity или ViewModel. */
|
||||
enum class CallPhase { NONE, OUTGOING, RINGING, INCOMING, ACTIVE, ENDED }
|
||||
|
||||
data class CallStats(
|
||||
val rttMs: Int, val bufferMs: Int, val tempoX100: Int, val dropped: Int,
|
||||
val underruns: Int, val minMs: Int, val maxMs: Int, val reserveMs: Int
|
||||
)
|
||||
|
||||
data class CallState(
|
||||
val callId: Long = 0,
|
||||
val channelId: String = "",
|
||||
val peerNodeId: Long = 0,
|
||||
val peerName: String = "",
|
||||
val phase: CallPhase = CallPhase.NONE,
|
||||
val reason: Int = -1,
|
||||
val durationSeconds: Int = 0,
|
||||
val bgpPath: String = "",
|
||||
val stats: CallStats? = null
|
||||
) {
|
||||
val ongoing: Boolean get() = phase != CallPhase.NONE && phase != CallPhase.ENDED
|
||||
}
|
||||
+1
-2
@@ -19,7 +19,6 @@ class IncomingCallActionReceiver : BroadcastReceiver() {
|
||||
return
|
||||
}
|
||||
LogManager.addLog("INFO", "InCall", "DECLINE action callId=0x%016x".format(callId))
|
||||
NativeLib.callDecline(callId)
|
||||
IncomingCallManager.stopRinging()
|
||||
CallController.end(callId, 2)
|
||||
}
|
||||
}
|
||||
@@ -9,26 +9,10 @@ import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.utun.chat.AppEventHandler
|
||||
import com.utun.chat.ChatApplication
|
||||
import com.utun.chat.MainActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Системные эффекты входящего звонка, независимые от жизненного цикла Activity:
|
||||
* пробуждение устройства, полноэкранное уведомление (с кнопкой «Завершить») и мелодия
|
||||
* через профиль звонка. Подписывается на AppEventHandler.events в app-скопе — работает
|
||||
* и при убитом Activity, пока жив foreground-сервис с C-ядром.
|
||||
*
|
||||
* UI-состоянием (accept/decline/отрисовка) по-прежнему владеет ChatViewModel;
|
||||
* этот объект лишь будит устройство и хранит отложенный входящий звонок,
|
||||
* который VM забирает через consumePendingIncoming() при (пере)создании.
|
||||
*/
|
||||
/** Уведомление, пробуждение экрана и мелодия входящего звонка. Жизненным циклом управляет CallController. */
|
||||
object IncomingCallManager {
|
||||
|
||||
const val CHANNEL_CALL = "incoming_call"
|
||||
@@ -38,28 +22,19 @@ object IncomingCallManager {
|
||||
const val EXTRA_CHANNEL_ID = "channel_id"
|
||||
const val EXTRA_PEER_NODE_ID = "peer_node_id"
|
||||
|
||||
data class PendingCall(val callId: Long, val channelId: String, val peerNodeId: Long)
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private val ringtone = RingtonePlayer()
|
||||
|
||||
@Volatile private var initialized = false
|
||||
@Volatile private var activeCallId = 0L
|
||||
@Volatile private var pendingCall: PendingCall? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
fun init() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
createChannel(ChatApplication.instance)
|
||||
scope.launch {
|
||||
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
|
||||
}
|
||||
LogManager.addLog("INFO", "InCall", "manager initialized")
|
||||
}
|
||||
|
||||
fun currentIncomingCallId(): Long = activeCallId
|
||||
|
||||
/** Разрешены ли полноэкранные уведомления (Android 14+ требует ручного включения). */
|
||||
fun canFullScreenIntent(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
|
||||
@@ -67,14 +42,6 @@ object IncomingCallManager {
|
||||
return nm.canUseFullScreenIntent()
|
||||
}
|
||||
|
||||
/** VM забирает отложенный входящий (сценарий «Activity убит, звонок пришёл»). */
|
||||
@Synchronized
|
||||
fun consumePendingIncoming(): PendingCall? {
|
||||
val p = pendingCall
|
||||
pendingCall = null
|
||||
return p
|
||||
}
|
||||
|
||||
/** Немедленная остановка мелодии/уведомления (accept/decline/hangup из VM). Идемпотентно. */
|
||||
fun stopRinging() {
|
||||
val id = activeCallId
|
||||
@@ -83,42 +50,16 @@ object IncomingCallManager {
|
||||
ringtone.stop()
|
||||
releaseWakeLock()
|
||||
cancelNotification()
|
||||
pendingCall = null
|
||||
LogManager.addLog("INFO", "InCall", "ringing stopped callId=0x%016x".format(id))
|
||||
}
|
||||
|
||||
private fun handleEvent(type: Int, data: ByteArray?) {
|
||||
when (type) {
|
||||
34 -> onIncoming(data) /* CALL_INCOMING */
|
||||
36, 37, 38, 40 -> onCallStop(data) /* ACCEPTED/ENDED/DECLINED/ERROR */
|
||||
}
|
||||
}
|
||||
|
||||
private fun onIncoming(data: ByteArray?) {
|
||||
if (data == null || data.size < 1 + 8 + 8) return
|
||||
val cl = data[0].toInt() and 0xFF
|
||||
if (data.size < 1 + cl + 16) return
|
||||
val chId = String(data, 1, cl)
|
||||
val buf = ByteBuffer.wrap(data, 1 + cl, 16).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val caller = buf.long
|
||||
val callId = buf.long
|
||||
if (activeCallId != 0L) {
|
||||
LogManager.addLog("WARN", "InCall", "busy (0x%016x), skip incoming 0x%016x".format(activeCallId, callId))
|
||||
return
|
||||
}
|
||||
fun show(callId: Long, channelId: String, peerNodeId: Long) {
|
||||
stopRinging()
|
||||
activeCallId = callId
|
||||
pendingCall = PendingCall(callId, chId, caller)
|
||||
LogManager.addLog("INFO", "InCall", "incoming call 0x%016x from 0x%016x ch=%s".format(callId, caller, chId))
|
||||
LogManager.addLog("INFO", "InCall", "incoming call id=$callId peer=$peerNodeId ch=$channelId")
|
||||
acquireWakeLock()
|
||||
ringtone.play()
|
||||
showNotification(callId, chId, caller)
|
||||
}
|
||||
|
||||
private fun onCallStop(data: ByteArray?) {
|
||||
if (data == null || data.size < 8) return
|
||||
val callId = ByteBuffer.wrap(data, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
|
||||
if (activeCallId == 0L || callId != activeCallId) return
|
||||
stopRinging()
|
||||
showNotification(callId, channelId, peerNodeId)
|
||||
}
|
||||
|
||||
/* ── пробуждение устройства ── */
|
||||
@@ -139,7 +80,9 @@ object IncomingCallManager {
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
wakeLock?.let {
|
||||
try { if (it.isHeld) it.release() } catch (_: Exception) {}
|
||||
try { if (it.isHeld) it.release() } catch (e: Exception) {
|
||||
LogManager.addLog("WARN", "InCall", "wake lock release failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import com.utun.chat.ChatApplication
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
interface ChatEventCallback {
|
||||
fun onEvent(type: Int, data: ByteArray?)
|
||||
@@ -28,6 +30,7 @@ object NativeLib {
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
runBlocking(Dispatchers.Main.immediate) { CallController.reset() }
|
||||
if (!isReady()) return
|
||||
nativeStop()
|
||||
}
|
||||
@@ -38,9 +41,13 @@ object NativeLib {
|
||||
})
|
||||
}
|
||||
|
||||
fun destroy() { nativeDestroy() }
|
||||
fun destroy() {
|
||||
runBlocking(Dispatchers.Main.immediate) { CallController.reset() }
|
||||
nativeDestroy()
|
||||
}
|
||||
|
||||
fun restart(dbPath: String, controlPort: Int, configText: String): Boolean {
|
||||
runBlocking(Dispatchers.Main.immediate) { CallController.reset() }
|
||||
return nativeRestart(configText)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,15 +13,15 @@ import com.utun.chat.ChatApplication
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Мост между жизненным циклом P2P-звонка (ChatViewModel) и Telecom.
|
||||
* Мост между жизненным циклом P2P-звонка (CallController) и Telecom.
|
||||
*
|
||||
* - Регистрирует self-managed PhoneAccount (делает вызовы uTun «настоящими» для системы).
|
||||
* - placeCall/addIncomingCall создают Connection через UtunConnectionService.
|
||||
* - Хранит Connection по callId и прокидывает state/route из VM в Connection.
|
||||
* - Обратные события (answer/reject/disconnect из системного ввода) — через Listener в VM.
|
||||
* - Хранит Connection по callId и прокидывает state/route из контроллера в Connection.
|
||||
* - Обратные события (answer/reject/disconnect из системного ввода) — через Listener в контроллер.
|
||||
*
|
||||
* Всё «best effort»: если Telecom недоступен (нет прав/стека), методы возвращают false,
|
||||
* и VM продолжает звонок прежним путём без системной маршрутизации.
|
||||
* и контроллер продолжает звонок без системной маршрутизации.
|
||||
*/
|
||||
object TelecomCallManager {
|
||||
|
||||
@@ -120,12 +120,25 @@ object TelecomCallManager {
|
||||
/* ── реестр Connection (вызывается из UtunConnectionService) ── */
|
||||
|
||||
fun registerConnection(callId: Long, conn: UtunConnection) {
|
||||
val st = CallController.state.value
|
||||
if (st == null || st.callId != callId || !st.ongoing) {
|
||||
LogManager.addLog("WARN", TAG, "late connection for ended/stale call id=$callId current=${st?.callId}")
|
||||
conn.markDisconnected(0)
|
||||
conn.destroy()
|
||||
return
|
||||
}
|
||||
connections[callId] = conn
|
||||
when (st.phase) {
|
||||
CallPhase.ACTIVE -> conn.markActive()
|
||||
CallPhase.INCOMING -> conn.markRinging()
|
||||
else -> conn.setDialing()
|
||||
}
|
||||
LogManager.addLog("INFO", TAG, "connection registered id=$callId phase=${st.phase}")
|
||||
}
|
||||
|
||||
fun connectionFor(callId: Long): UtunConnection? = connections[callId]
|
||||
|
||||
/* ── события из Connection (системный ввод) → VM ── */
|
||||
/* ── события из Connection (системный ввод) → контроллер ── */
|
||||
|
||||
fun notifyAnswer(callId: Long) = post { listener?.onAnswer(callId) }
|
||||
|
||||
@@ -135,7 +148,7 @@ object TelecomCallManager {
|
||||
|
||||
fun notifyAbort(callId: Long) = post { listener?.onDisconnect(callId) }
|
||||
|
||||
/* ── управление состоянием из VM ── */
|
||||
/* ── управление состоянием из контроллера ── */
|
||||
|
||||
fun setRinging(callId: Long) = connections[callId]?.markRinging()
|
||||
|
||||
@@ -146,7 +159,7 @@ object TelecomCallManager {
|
||||
fun setRoute(callId: Long, route: AudioRoute) = connections[callId]?.routeTo(route)
|
||||
|
||||
fun removeConnection(callId: Long) {
|
||||
connections.remove(callId)
|
||||
connections.remove(callId)?.destroy()
|
||||
LogManager.addLog("DEBUG", TAG, "connection removed id=0x%016x".format(callId))
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import java.util.concurrent.CopyOnWriteArrayList
|
||||
*
|
||||
* Владелец — TelecomCallManager (создаётся в UtunConnectionService, хранится в
|
||||
* реестре по callId). Ответ/отбой/разрыв приходят сюда из системного ввода
|
||||
* (например, кнопка BT-гарнитуры) и делегируются в VM через TelecomCallManager.Listener.
|
||||
* (например, кнопка BT-гарнитуры) и делегируются в CallController через TelecomCallManager.Listener.
|
||||
*/
|
||||
class UtunConnection(
|
||||
private val callId: Long,
|
||||
|
||||
@@ -24,7 +24,7 @@ class UtunConnectionService : ConnectionService() {
|
||||
}
|
||||
LogManager.addLog("INFO", "UtunConnSvc",
|
||||
"onCreateIncomingConnection id=0x%016x peer=0x%016x".format(callId, peerNodeId))
|
||||
return createConnection(callId, peerNodeId, name).also { it.markRinging() }
|
||||
return createConnection(callId, peerNodeId, name)
|
||||
}
|
||||
|
||||
override fun onCreateOutgoingConnection(
|
||||
@@ -37,7 +37,7 @@ class UtunConnectionService : ConnectionService() {
|
||||
}
|
||||
LogManager.addLog("INFO", "UtunConnSvc",
|
||||
"onCreateOutgoingConnection id=0x%016x peer=0x%016x".format(callId, peerNodeId))
|
||||
return createConnection(callId, peerNodeId, name).also { it.setDialing() }
|
||||
return createConnection(callId, peerNodeId, name)
|
||||
}
|
||||
|
||||
private fun createConnection(callId: Long, peerNodeId: Long, displayName: String): UtunConnection {
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.utun.chat.viewmodel.ChatViewModel
|
||||
import com.utun.chat.viewmodel.ChatViewModel.CallPhase
|
||||
import com.utun.chat.data.CallPhase
|
||||
|
||||
private fun fmtDuration(sec: Int): String {
|
||||
val m = sec / 60
|
||||
@@ -43,7 +43,7 @@ fun ActiveCallBanner(
|
||||
|
||||
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) }
|
||||
val status = when (phase) {
|
||||
CallPhase.ACTIVE -> "Разговор " + fmtDuration(st.durationMs)
|
||||
CallPhase.ACTIVE -> "Разговор " + fmtDuration(st.durationSeconds)
|
||||
CallPhase.RINGING -> "Звоним…"
|
||||
else -> "Пытаемся связаться…"
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.utun.chat.data.AudioRoute
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.viewmodel.ChatViewModel
|
||||
import com.utun.chat.viewmodel.ChatViewModel.CallPhase
|
||||
import com.utun.chat.data.CallPhase
|
||||
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
@@ -105,7 +105,7 @@ fun CallScreen(
|
||||
val proximityNear by viewModel.proximityNear.collectAsState()
|
||||
|
||||
val phase = st?.phase ?: CallPhase.NONE
|
||||
val duration = st?.durationMs ?: 0
|
||||
val duration = st?.durationSeconds ?: 0
|
||||
val peerNodeId = st?.peerNodeId ?: 0L
|
||||
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: ""
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ import java.io.FileOutputStream
|
||||
|
||||
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
|
||||
|
||||
private const val CALL_INCOMING_TIMEOUT_MS = 50_000L
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
|
||||
data class PlaybackState(
|
||||
@@ -35,40 +33,10 @@ class ChatViewModel : ViewModel() {
|
||||
val durMs: Int = 0
|
||||
)
|
||||
|
||||
enum class CallPhase { NONE, OUTGOING, RINGING, INCOMING, ACTIVE, ENDED }
|
||||
|
||||
data class CallStats(
|
||||
val rttMs: Int,
|
||||
val bufferMs: Int,
|
||||
val tempoX100: Int,
|
||||
val dropped: Int,
|
||||
val underruns: Int,
|
||||
val minMs: Int,
|
||||
val maxMs: Int,
|
||||
val reserveMs: Int
|
||||
)
|
||||
|
||||
data class CallState(
|
||||
val callId: Long = 0,
|
||||
val channelId: String = "",
|
||||
val peerNodeId: Long = 0,
|
||||
val peerName: String = "",
|
||||
val phase: CallPhase = CallPhase.NONE,
|
||||
val reason: Int = -1,
|
||||
val durationMs: Int = 0,
|
||||
val bgpPath: String = "",
|
||||
val stats: CallStats? = null
|
||||
)
|
||||
|
||||
private var repo: ChatRepository? = null
|
||||
private var dbReady = false
|
||||
|
||||
private val _callState = MutableStateFlow<CallState?>(null)
|
||||
val callState: StateFlow<CallState?> = _callState
|
||||
|
||||
val callAudio = CallAudioEngine()
|
||||
|
||||
val proximity = ProximityManager(ChatApplication.instance)
|
||||
val callState = CallController.state
|
||||
|
||||
val radioAudio = RadioAudioEngine.instance()
|
||||
|
||||
@@ -102,23 +70,11 @@ class ChatViewModel : ViewModel() {
|
||||
/* рация: активный канал (null = выключена). Одна рация — один канал. */
|
||||
private var radioChannelId: String? = null
|
||||
|
||||
private var callStartedMs = 0L
|
||||
private var callIncomingAtMs = 0L
|
||||
|
||||
private val _muted = MutableStateFlow(false)
|
||||
val muted: StateFlow<Boolean> = _muted
|
||||
|
||||
private val _audioRoute = MutableStateFlow(AudioRoute.SPEAKER)
|
||||
val audioRoute: StateFlow<AudioRoute> = _audioRoute
|
||||
|
||||
private val _headsetAvailable = MutableStateFlow(false)
|
||||
val headsetAvailable: StateFlow<Boolean> = _headsetAvailable
|
||||
|
||||
private val _earpieceAvailable = MutableStateFlow(true)
|
||||
val earpieceAvailable: StateFlow<Boolean> = _earpieceAvailable
|
||||
|
||||
private val _proximityNear = MutableStateFlow(false)
|
||||
val proximityNear: StateFlow<Boolean> = _proximityNear
|
||||
val muted = CallController.muted
|
||||
val audioRoute = CallController.route
|
||||
val headsetAvailable = CallController.headset
|
||||
val earpieceAvailable = CallController.earpiece
|
||||
val proximityNear = CallController.proximityNear
|
||||
|
||||
private val _playbackState = MutableStateFlow(PlaybackState())
|
||||
val playbackState: StateFlow<PlaybackState> = _playbackState
|
||||
@@ -230,18 +186,6 @@ class ChatViewModel : ViewModel() {
|
||||
viewModelScope.launch {
|
||||
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
|
||||
}
|
||||
/* Системный ввод по вызову (кнопка BT-гарнитуры, разрыв из Telecom) → те же действия, что и UI. */
|
||||
TelecomCallManager.setListener(object : TelecomCallManager.Listener {
|
||||
override fun onAnswer(callId: Long) { acceptCall() }
|
||||
override fun onReject(callId: Long) { declineCall() }
|
||||
override fun onDisconnect(callId: Long) {
|
||||
val st = _callState.value
|
||||
if (st != null && st.callId == callId && st.phase != CallPhase.ENDED) {
|
||||
LogManager.addLog("INFO", "VM", "telecom disconnect -> hangup 0x%016x".format(callId))
|
||||
hangupCall()
|
||||
}
|
||||
}
|
||||
})
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val key = ChatApplication.instance.configProvider.getString("node.public_key")
|
||||
if (key != null && key.length == 64) _pubKey.value = key
|
||||
@@ -255,17 +199,6 @@ class ChatViewModel : ViewModel() {
|
||||
refreshChannels()
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
delay(300)
|
||||
val p = IncomingCallManager.consumePendingIncoming() ?: return@launch
|
||||
val cur = _callState.value
|
||||
if (cur != null && cur.callId == p.callId) return@launch
|
||||
LogManager.addLog("INFO", "VM", "restore pending incoming call 0x%016x ch=%s".format(p.callId, p.channelId))
|
||||
callIncomingAtMs = System.currentTimeMillis()
|
||||
_callState.value = CallState(callId = p.callId, channelId = p.channelId, peerNodeId = p.peerNodeId,
|
||||
peerName = "0x%016x".format(p.peerNodeId), phase = CallPhase.INCOMING)
|
||||
resolveCallerName(p.channelId, p.peerNodeId)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_foreground.collectLatest { fg ->
|
||||
while (fg && isActive) {
|
||||
@@ -308,44 +241,6 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
while (isActive) {
|
||||
val st = _callState.value
|
||||
if (st != null && st.phase == CallPhase.ACTIVE) {
|
||||
val dur = ((System.currentTimeMillis() - callStartedMs) / 1000).toInt()
|
||||
_callState.value = st.copy(durationMs = dur)
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
while (isActive) {
|
||||
val st = _callState.value
|
||||
if (st != null && st.phase == CallPhase.INCOMING &&
|
||||
callIncomingAtMs > 0 &&
|
||||
System.currentTimeMillis() - callIncomingAtMs > CALL_INCOMING_TIMEOUT_MS) {
|
||||
LogManager.addLog("WARN", "VM", "INCOMING watchdog: no resolution, clearing call 0x%016x".format(st.callId))
|
||||
IncomingCallManager.stopRinging()
|
||||
callIncomingAtMs = 0L
|
||||
_callState.value = null
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
combine(_callState, _audioRoute) { st, route -> st to route }.collect { (st, route) ->
|
||||
val monitor = st != null && st.phase == CallPhase.ACTIVE && route == AudioRoute.EARPIECE
|
||||
if (monitor) {
|
||||
proximity.start()
|
||||
} else {
|
||||
proximity.stop()
|
||||
_proximityNear.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
proximity.near.collect { _proximityNear.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEvent(type: Int, data: ByteArray?) {
|
||||
@@ -616,114 +511,6 @@ class ChatViewModel : ViewModel() {
|
||||
if (cur != null && cur.convId == convId) refreshDmMessages()
|
||||
refreshDmConversations()
|
||||
}
|
||||
34 -> { /* CALL_INCOMING: [ch_id_len:1][ch_id:var][caller_node_id:8][call_id:8] */
|
||||
if (data == null || data.size < 1 + 8 + 8) return
|
||||
val cl = data[0].toInt() and 0xFF
|
||||
if (data.size < 1 + cl + 8 + 8) return
|
||||
val chId = String(data, 1, cl)
|
||||
val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, 16).order(java.nio.ByteOrder.LITTLE_ENDIAN)
|
||||
val caller = buf.getLong(); val callId = buf.getLong()
|
||||
LogManager.addLog("INFO", "VM", "CALL_INCOMING ch=$chId caller=0x%016x id=0x%016x".format(caller, callId))
|
||||
val cur = _callState.value
|
||||
if (cur != null && cur.callId == callId) return // уже обрабатываем этот звонок (replay/restore)
|
||||
if (cur != null && cur.callId != callId && cur.phase != CallPhase.ENDED) {
|
||||
// заняты другим звонком — авто-отклоняем
|
||||
LogManager.addLog("WARN", "VM", "busy with another call, auto-decline id=0x%016x".format(callId))
|
||||
NativeLib.callDecline(callId)
|
||||
return
|
||||
}
|
||||
callIncomingAtMs = System.currentTimeMillis()
|
||||
_callState.value = CallState(callId = callId, channelId = chId, peerNodeId = caller,
|
||||
peerName = "0x%016x".format(caller), phase = CallPhase.INCOMING)
|
||||
TelecomCallManager.addIncomingCall(callId, caller, "0x%016x".format(caller))
|
||||
resolveCallerName(chId, caller)
|
||||
}
|
||||
35 -> { /* CALL_RINGING: [call_id:8] */
|
||||
if (data == null || data.size < 8) return
|
||||
val callId = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
|
||||
val st = _callState.value
|
||||
if (st != null && st.callId == callId) {
|
||||
_callState.value = st.copy(phase = CallPhase.RINGING)
|
||||
CallRingback.startRinging()
|
||||
LogManager.addLog("INFO", "VM", "CALL_RINGING id=0x%016x".format(callId))
|
||||
}
|
||||
}
|
||||
36 -> { /* CALL_ACCEPTED: [call_id:8] */
|
||||
if (data == null || data.size < 8) return
|
||||
val callId = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
|
||||
val st = _callState.value
|
||||
if (st != null && st.callId == callId) {
|
||||
LogManager.addLog("INFO", "VM", "CALL_ACCEPTED id=0x%016x".format(callId))
|
||||
CallRingback.stop()
|
||||
TelecomCallManager.setActive(callId)
|
||||
callAudio.router.setPreferredRoute(
|
||||
ChatApplication.instance.configProvider.getCallAudioRoute())
|
||||
if (!callAudio.start(callId)) {
|
||||
LogManager.addLog("ERROR", "VM", "call audio engine start failed")
|
||||
NativeLib.callHangup(callId)
|
||||
_callState.value = st.copy(phase = CallPhase.ENDED, reason = 7)
|
||||
} else {
|
||||
callStartedMs = System.currentTimeMillis()
|
||||
_muted.value = false
|
||||
callAudio.router.onChanged = { r, headset ->
|
||||
_audioRoute.value = r
|
||||
_headsetAvailable.value = headset
|
||||
callAudio.syncAecForRoute(r)
|
||||
}
|
||||
_audioRoute.value = callAudio.router.route
|
||||
_headsetAvailable.value = callAudio.router.headsetAvailable
|
||||
_earpieceAvailable.value = callAudio.router.hasEarpiece
|
||||
_callState.value = st.copy(phase = CallPhase.ACTIVE, durationMs = 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
37 -> { /* CALL_ENDED: [call_id:8][reason:1] */
|
||||
if (data == null || data.size < 9) return
|
||||
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
|
||||
val reason = data[8].toInt() and 0xFF
|
||||
finishCall(callId, reason, "ENDED")
|
||||
}
|
||||
38 -> { /* CALL_DECLINED: [call_id:8][reason:1] */
|
||||
if (data == null || data.size < 9) return
|
||||
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
|
||||
val reason = data[8].toInt() and 0xFF
|
||||
finishCall(callId, reason, "DECLINED")
|
||||
}
|
||||
39 -> { /* CALL_STATS: call_id LE64, восемь BE16 метрик */
|
||||
if (data == null || data.size != 24) {
|
||||
LogManager.addLog("WARN", "call", "CALL_STATS invalid size=${data?.size ?: 0}")
|
||||
return
|
||||
}
|
||||
val callId = java.nio.ByteBuffer.wrap(data, 0, 8)
|
||||
.order(java.nio.ByteOrder.LITTLE_ENDIAN).long
|
||||
val st = _callState.value
|
||||
if (st == null || st.callId != callId || st.phase != CallPhase.ACTIVE) return
|
||||
val buf = java.nio.ByteBuffer.wrap(data, 8, 16).order(java.nio.ByteOrder.BIG_ENDIAN)
|
||||
fun readMetric() = buf.short.toInt() and 0xFFFF
|
||||
val stats = CallStats(readMetric(), readMetric(), readMetric(), readMetric(),
|
||||
readMetric(), readMetric(), readMetric(), readMetric())
|
||||
if (st.stats == null)
|
||||
LogManager.addLog("DEBUG", "call", "CALL_STATS started id=0x%016x reserve=${stats.reserveMs}ms".format(callId))
|
||||
_callState.value = st.copy(stats = stats)
|
||||
}
|
||||
40 -> { /* CALL_ERROR: [call_id:8][err:1][text:var] */
|
||||
if (data == null || data.size < 9) return
|
||||
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
|
||||
val err = data[8].toInt() and 0xFF
|
||||
val text = if (data.size > 9) String(data, 9, data.size - 9) else ""
|
||||
LogManager.addLog("ERROR", "VM", "CALL_ERROR id=0x%016x err=$err $text".format(callId))
|
||||
finishCall(callId, 7, "ERROR")
|
||||
}
|
||||
42 -> { /* CALL_PATH: [call_id:8][text:var] — BGP-маршрут до пира */
|
||||
if (data == null || data.size < 9) return
|
||||
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
|
||||
val text = String(data, 8, data.size - 8)
|
||||
val st = _callState.value
|
||||
if (st != null && st.callId == callId) {
|
||||
_callState.value = st.copy(bgpPath = text)
|
||||
LogManager.addLog("INFO", "VM", "CALL_PATH id=0x%016x %s".format(callId, text))
|
||||
}
|
||||
}
|
||||
43 -> { /* RADIO_TALK: [group_id:8][src:8][stream:2][on:1] */
|
||||
if (data == null || data.size < 19) return
|
||||
val bb = java.nio.ByteBuffer.wrap(data, 0, 19).order(java.nio.ByteOrder.LITTLE_ENDIAN)
|
||||
@@ -740,93 +527,12 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveCallerName(chId: String, caller: Long) {
|
||||
val r = repo ?: return
|
||||
viewModelScope.launch {
|
||||
val name = withContext(Dispatchers.IO) { r.getMembers(chId) }
|
||||
.firstOrNull { it.nodeId == caller }?.name?.takeIf { it.isNotEmpty() }
|
||||
val st = _callState.value
|
||||
if (name != null && st != null && st.peerNodeId == caller && st.phase == CallPhase.INCOMING) {
|
||||
_callState.value = st.copy(peerName = name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishCall(callId: Long, reason: Int, tag: String) {
|
||||
val st = _callState.value
|
||||
LogManager.addLog("INFO", "VM", "CALL_$tag id=0x%016x reason=$reason".format(callId))
|
||||
if (st == null || st.callId != callId) return
|
||||
callAudio.stop()
|
||||
TelecomCallManager.setDisconnected(callId, reason)
|
||||
TelecomCallManager.removeConnection(callId)
|
||||
CallRingback.stop()
|
||||
IncomingCallManager.stopRinging()
|
||||
callIncomingAtMs = 0L
|
||||
_muted.value = false
|
||||
_audioRoute.value = AudioRoute.SPEAKER
|
||||
_headsetAvailable.value = false
|
||||
_earpieceAvailable.value = true
|
||||
_callState.value = st.copy(phase = CallPhase.ENDED, reason = reason)
|
||||
viewModelScope.launch {
|
||||
delay(2500)
|
||||
if (_callState.value?.callId == callId) _callState.value = null
|
||||
}
|
||||
}
|
||||
|
||||
fun startCall(channelId: String, peerNodeId: Long, peerName: String) {
|
||||
val existing = _callState.value
|
||||
if (existing != null && existing.phase != CallPhase.ENDED) {
|
||||
LogManager.addLog("WARN", "VM", "startCall: call already active")
|
||||
return
|
||||
}
|
||||
var callId = java.security.SecureRandom().nextLong()
|
||||
if (callId == 0L) callId = 1
|
||||
val name = peerName.ifEmpty { "0x%016x".format(peerNodeId) }
|
||||
_callState.value = CallState(callId = callId, channelId = channelId, peerNodeId = peerNodeId,
|
||||
peerName = name, phase = CallPhase.OUTGOING)
|
||||
NativeLib.callStart(channelId, peerNodeId, callId)
|
||||
TelecomCallManager.placeCall(callId, peerNodeId, name)
|
||||
CallRingback.startConnecting()
|
||||
LogManager.addLog("INFO", "VM", "startCall ch=$channelId peer=0x%016x id=0x%016x".format(peerNodeId, callId))
|
||||
}
|
||||
|
||||
fun acceptCall() {
|
||||
val st = _callState.value ?: return
|
||||
if (st.phase != CallPhase.INCOMING) {
|
||||
LogManager.addLog("WARN", "VM", "acceptCall ignored: phase=${st.phase} id=0x%016x".format(st.callId))
|
||||
return
|
||||
}
|
||||
callIncomingAtMs = 0L
|
||||
IncomingCallManager.stopRinging()
|
||||
NativeLib.callAccept(st.callId)
|
||||
LogManager.addLog("INFO", "VM", "acceptCall id=0x%016x".format(st.callId))
|
||||
}
|
||||
|
||||
fun declineCall() {
|
||||
val st = _callState.value ?: return
|
||||
if (st.phase == CallPhase.INCOMING) {
|
||||
NativeLib.callDecline(st.callId)
|
||||
finishCall(st.callId, 2, "DECLINE")
|
||||
} else {
|
||||
NativeLib.callHangup(st.callId)
|
||||
finishCall(st.callId, 0, "HANGUP")
|
||||
}
|
||||
}
|
||||
|
||||
fun hangupCall() {
|
||||
val st = _callState.value ?: return
|
||||
if (st.phase == CallPhase.OUTGOING || st.phase == CallPhase.RINGING || st.phase == CallPhase.ACTIVE) {
|
||||
NativeLib.callHangup(st.callId)
|
||||
finishCall(st.callId, 0, "HANGUP")
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissCall() {
|
||||
callAudio.stop()
|
||||
CallRingback.stop()
|
||||
callIncomingAtMs = 0L
|
||||
_callState.value = null
|
||||
}
|
||||
/* Звонком владеет CallController; ViewModel передаёт только пользовательские команды. */
|
||||
fun startCall(channelId: String, peerNodeId: Long, peerName: String) = CallController.start(channelId, peerNodeId, peerName)
|
||||
fun acceptCall() = CallController.accept()
|
||||
fun declineCall() = CallController.end(reason = 2)
|
||||
fun hangupCall() = CallController.end()
|
||||
fun dismissCall() = CallController.dismiss()
|
||||
|
||||
/* ── рация ── */
|
||||
|
||||
@@ -985,19 +691,8 @@ class ChatViewModel : ViewModel() {
|
||||
members.value.firstOrNull { it.nodeId == nodeId }?.name
|
||||
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(nodeId)
|
||||
|
||||
fun toggleMute() {
|
||||
val m = !callAudio.isMuted()
|
||||
callAudio.setMuted(m)
|
||||
_muted.value = m
|
||||
LogManager.addLog("INFO", "VM", "toggleMute -> ${if (m) "on" else "off"}")
|
||||
}
|
||||
|
||||
fun setAudioRoute(r: AudioRoute) {
|
||||
callAudio.router.setRoute(r)
|
||||
_callState.value?.let { TelecomCallManager.setRoute(it.callId, r) }
|
||||
viewModelScope.launch { ChatApplication.instance.configProvider.setCallAudioRoute(r) }
|
||||
LogManager.addLog("INFO", "VM", "setAudioRoute -> $r")
|
||||
}
|
||||
fun toggleMute() = CallController.toggleMute()
|
||||
fun setAudioRoute(r: AudioRoute) = CallController.setRoute(r)
|
||||
|
||||
fun requestRegenerateKeys() {
|
||||
_generatingKeys.value = true
|
||||
@@ -1462,8 +1157,6 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun clearState() {
|
||||
callAudio.stop()
|
||||
_callState.value = null
|
||||
repo?.close()
|
||||
repo = null
|
||||
dbReady = false
|
||||
|
||||
Reference in new issue
Block a user