Await radio and shared audio release before native core teardown

This commit is contained in:
evgeny committed 2026-10-29 13:35:42 +01:00
1 parent ea67b491f9
commit 044ff034a6
3 files changed
+29 -15

No files matched your search

@@ -2,7 +2,6 @@ package com.utun.chat.data
import com.utun.chat.ChatApplication
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
interface ChatEventCallback {
@@ -11,6 +10,8 @@ interface ChatEventCallback {
object NativeLib {
@Volatile private var coreStopping = false
@Volatile private var eventCallback: ChatEventCallback? = null
fun isCoreStopping(): Boolean = coreStopping
init {
System.loadLibrary("utun")
@@ -18,6 +19,7 @@ object NativeLib {
/** Save Kotlin callbacks to C globals (log, config). Must be called before start(). */
fun init(dbPath: String, controlPort: Int): Boolean {
if (coreStopping) { LogManager.addLog("ERROR", "Core", "Initialization rejected: frontend cleanup pending"); return false }
val provider = ChatApplication.instance.configProvider
val settings = provider.getVoiceSettings()
val ok = nativeInit(
@@ -29,6 +31,7 @@ object NativeLib {
LogManager.addLog("ERROR", "AudioSettings", "Native initialization failed; voice settings not applied")
return false
}
eventCallback?.let { nativeSetEventCallback(it) }
nativeVoiceSetPreset(settings.preset)
nativeVoiceSetCompressor(settings.compressorEnabled)
nativeVoiceSetCompressorConfig(settings.maxGainDb, settings.lookbackMs, settings.lookaheadMs,
@@ -46,39 +49,43 @@ object NativeLib {
/** Снимает frontend media на main, дожидается worker на IO даже до ready; ошибка запрещает следующий init. */
suspend fun stop() {
withContext(Dispatchers.Main.immediate) { prepareForCoreStop() }
prepareForCoreStop()
check(withContext(Dispatchers.IO) { nativeStop() }) { "native worker stop failed" }
withContext(Dispatchers.Main.immediate) { ChatApplication.instance.memberFiles.coreStopped() }
coreStopping = false
}
fun setEventCallback(cb: ChatEventCallback) {
nativeSetEventCallback(object : ChatEventCallback {
override fun onEvent(type: Int, data: ByteArray?) { cb.onEvent(type, data) }
})
nativeSetEventCallback(cb)
eventCallback = cb
}
/** Как stop, дополнительно освобождает JNI global refs только после подтверждённого join. */
suspend fun destroy() {
withContext(Dispatchers.Main.immediate) { prepareForCoreStop() }
prepareForCoreStop()
check(withContext(Dispatchers.IO) { nativeDestroy() }) { "native worker destroy failed" }
withContext(Dispatchers.Main.immediate) { ChatApplication.instance.memberFiles.coreStopped() }
coreStopping = false
}
fun restart(dbPath: String, controlPort: Int, configText: String): Boolean {
try { prepareForCoreStop(); return nativeRestart(configText) }
suspend fun restart(dbPath: String, controlPort: Int, configText: String): Boolean {
try { prepareForCoreStop(); return withContext(Dispatchers.IO) { nativeRestart(configText) } }
finally { coreStopping = false }
}
/** Снять frontend-владельцев перед stop/restart/сменой ключей. Неподтверждённый CallService либо ещё
* активный mediaPreparation запрещает native stop с ошибкой; Main остаётся доступен для retry. */
private fun prepareForCoreStop() {
runBlocking(Dispatchers.Main.immediate) {
private suspend fun prepareForCoreStop() {
val radioStopped = withContext(Dispatchers.Main.immediate) {
coreStopping = true
val radio = RadioAudioEngine.instance().stop()
AudioRecorderManager.stopAll()
RadioCallController.reset()
StreamController.reset(); CallController.reset(); ChatApplication.instance.avatars.stop(); ChatApplication.instance.memberFiles.cancel()
radio
}
withContext(Dispatchers.IO) { radioStopped.get() }
withContext(Dispatchers.Main.immediate) {
CallService.requireReleased() // Не останавливать native при неподтверждённой очистке его media-владельца.
StreamPlayerEngine.stopForCore()
StreamController.requireReleased()
@@ -88,6 +95,7 @@ object NativeLib {
"Call media preparation still running"
}
}
withContext(Dispatchers.IO) { MediaAudioController.awaitIdle() }
}
/** Лёгкое обновление сокетов (auto_sockets=android) без полного рестарта ядра */
@@ -154,8 +162,8 @@ object NativeLib {
fun subscribeNativeLogs(enabled: Boolean) { nativeSubscribeLogs(enabled) }
fun setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) }
/** Принимает полный INI для пересоздания ядра с новой сохранённой идентичностью; true подтверждает приём запроса. */
fun regenerateKeys(configText: String): Boolean {
try { prepareForCoreStop(); return nativeRegenerateKeys(configText) }
suspend fun regenerateKeys(configText: String): Boolean {
try { prepareForCoreStop(); return withContext(Dispatchers.IO) { nativeRegenerateKeys(configText) } }
finally { coreStopping = false }
}
@@ -339,7 +347,7 @@ object NativeLib {
private external fun nativeRadioCall(id: Long, group: Long, peer: Long, action: Int): Boolean
/* ── P2P аудио-звонок (медиа) ── */
fun callAudioStart(callId: Long): Boolean = nativeCallAudioStart(callId)
fun callAudioStart(callId: Long): Boolean = !coreStopping && nativeCallAudioStart(callId)
/** Настоящий raw PCM и mute кадра: зануление выполняется в core после AEC/AGC. */
fun callAudioFeed(callId: Long, samples: ShortArray, muted: Boolean, capturePtsUs: Long): Boolean =
nativeCallAudioFeed(callId, samples, muted, capturePtsUs)
@@ -359,11 +367,11 @@ object NativeLib {
/* ── Рация (PTT walkie-talkie) ── */
fun radioSetActive(groupId: Long, on: Boolean) { nativeRadioSetActive(groupId, on) }
fun radioSubscribers(groupId: Long): Int = nativeRadioSubscribers(groupId)
fun radioAudioStart(groupId: Long, channels: Int): Boolean = nativeRadioAudioStart(groupId, channels)
fun radioAudioStart(groupId: Long, channels: Int): Boolean = !coreStopping && nativeRadioAudioStart(groupId, channels)
fun radioAudioFeed(groupId: Long, samples: ShortArray): Boolean = nativeRadioAudioFeed(groupId, samples)
fun radioAudioPull(groupId: Long, out: ShortArray, playedFrames: Long): Int = nativeRadioAudioPull(groupId, out, playedFrames)
fun radioAudioStop() { nativeRadioAudioStop() }
fun radioCaptureStart(groupId: Long, channels: Int, mode: Int): Boolean = nativeRadioCaptureStart(groupId, channels, mode)
fun radioCaptureStart(groupId: Long, channels: Int, mode: Int): Boolean = !coreStopping && nativeRadioCaptureStart(groupId, channels, mode)
/** true — копия снимка поставлена живому worker; проверка и применение ещё не подтверждены.
* ConfigProvider.onRadioSettingsApplied вернёт тот же requestId и результат атомарной проверки/применения.
* false — запрос отклонён (включая отсутствие callback). Stop/restart может отменить ожидающий снимок без ACK;
@@ -126,6 +126,10 @@ class RadioAudioEngine {
* Ошибка очистки/запуска даёт false. Новый start/stop инвалидирует прежний результат, также возвращая false. */
fun start(group: Long, onReady: ((Boolean) -> Unit)? = null) {
checkMain("start")
if (NativeLib.isCoreStopping()) {
LogManager.addLog("WARN", "radio", "start rejected during core cleanup group=$group")
onReady?.invoke(false); return
}
if (group == 0L) {
LogManager.addLog("ERROR", "RadioAudio", "start rejected: zero group ID")
onReady?.invoke(false)
@@ -35,6 +35,8 @@ object LogManager {
/** Только host-тест: считает уведомления standby, не загружает JNI и не запускает ядро. */
object NativeLib {
@Volatile var coreStopping = false
fun isCoreStopping() = coreStopping
@Volatile var callAudioFailures = emptySet<String>()
@Volatile var onCallFeed: () -> Unit = {}
val callAudioCommands: MutableList<Pair<String, Long>> = Collections.synchronizedList(mutableListOf())