Retain voice resources after ViewModel disposal and block premature core teardown

This commit is contained in:
evgeny committed 2026-10-29 13:35:19 +01:00
1 parent 85af6f5b87
commit a85d2cc8c7
3 files changed
+139 -28

No files matched your search

@@ -9,6 +9,9 @@ import android.media.AudioTrack
import android.media.MediaRecorder
import androidx.core.content.ContextCompat
import com.utun.chat.ChatApplication
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
/**
* Запись голосового сообщения/тест микрофона и отдельный плеер native Opus-файла.
@@ -27,14 +30,29 @@ class AudioRecorderManager {
const val FRAME_MS = 20
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000
const val MIN_DURATION_MS = 500
private val owners = ConcurrentHashMap.newKeySet<AudioRecorderManager>()
/** Main: запросить независимое освобождение всех recorder/player перед звонком. Ожидать futures только на IO. */
fun stopAll(): List<CompletableFuture<Unit>> = owners.toList().map { it.requestClose() }
/** Main: незавершённая очистка запрещает разрушать native recorder; CoreShutdown повторяет проверку. */
fun requireReleasedForCore() {
val pending = stopAll()
check(pending.all { it.isDone && !it.isCompletedExceptionally } && owners.isEmpty()) {
LogManager.addLog("ERROR", "AudioRecorder", "Core teardown blocked: voice resource owners=${owners.size}")
"Voice resource cleanup pending"
}
}
}
private enum class CaptureMode { RECORD, TEST }
@Volatile private var captureMode: CaptureMode? = null
private var audioRecord: AudioRecord? = null
private var recordingThread: Thread? = null
@Volatile private var audioRecord: AudioRecord? = null
@Volatile private var recordingThread: Thread? = null
@Volatile private var captureRunning = false
@Volatile private var captureFailed = false
private val closing = AtomicReference<CompletableFuture<Unit>?>(null)
private val playbackWorkers = ConcurrentHashMap.newKeySet<Thread>()
val isTesting: Boolean get() = captureMode == CaptureMode.TEST && captureRunning
fun hasPermission(): Boolean {
@@ -45,8 +63,10 @@ class AudioRecorderManager {
/** channelId — канал либо dm:conv_id; replyTo относится к каналу, requestId подтверждает commit, generation — к PM.
* true — Android/native захват запущен; false — отказ с диагностикой и откатом принадлежащих ресурсов. */
@Synchronized fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean =
startCapture(CaptureMode.RECORD, channelId, replyTo, requestId, generation)
fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean {
if (closing.get()?.isDone == false) { LogManager.addLog("WARN", "AudioRecorder", "record start rejected: cleanup pending"); return false }
return synchronized(this) { startCapture(CaptureMode.RECORD, channelId, replyTo, requestId, generation) }
}
private fun startCapture(mode: CaptureMode, target: String, reply: String = "", request: Long = 0, generation: Long = 0): Boolean {
if (captureMode != null) {
@@ -59,8 +79,10 @@ class AudioRecorderManager {
LogManager.addLog("WARN", "AudioRecorder", "microphone permission missing mode=$mode")
return false
}
owners.add(this) // До JNI: stopForCore видит также запуск, ещё не закончивший подготовку устройства.
if (!NativeLib.voiceStart(target, reply, request, generation)) {
LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart rejected mode=$mode target=$target")
forgetIfIdle()
return false
}
captureMode = mode; captureFailed = false
@@ -81,6 +103,7 @@ class AudioRecorderManager {
} catch (e: Exception) {
LogManager.addLog("ERROR", "AudioRecorder", "capture start failed mode=$mode target=$target: $e")
if (captureMode != null) { captureFailed = true; finishCapture(false) }
forgetIfIdle()
return false
}
}
@@ -119,6 +142,7 @@ class AudioRecorderManager {
recordingThread?.interrupt()
var failed = captureFailed
var interrupted = Thread.interrupted()
var nativeReleased = false
try {
try { audioRecord?.stop() }
catch (e: Exception) { failed = true; LogManager.addLog("WARN", "AudioRecorder", "capture stop failed mode=$mode: $e") }
@@ -132,17 +156,21 @@ class AudioRecorderManager {
recordingThread = null
failed = failed || captureFailed
if (!releaseCaptureDevice()) failed = true
val duration = if (publish && !failed) NativeLib.voiceStop() else { NativeLib.voiceCancel(); if (publish) -1 else 0 }
val duration = if (publish && !failed) NativeLib.voiceStop().also { if (it < 0) NativeLib.voiceCancel() }
else { NativeLib.voiceCancel(); if (publish) -1 else 0 }
nativeReleased = true
LogManager.addLog(if (duration < 0) "ERROR" else "INFO", "AudioRecorder",
"capture finished mode=$mode publish=$publish failed=$failed durationMs=$duration")
return duration
} catch (e: Exception) {
LogManager.addLog("ERROR", "AudioRecorder", "native capture finish failed mode=$mode: $e")
try { NativeLib.voiceCancel() }
try { NativeLib.voiceCancel(); nativeReleased = true }
catch (cleanup: Exception) { LogManager.addLog("ERROR", "AudioRecorder", "native capture cancel failed: $cleanup") }
return -1
} finally {
captureMode = null
if (nativeReleased) captureMode = null else captureFailed = true
forgetIfIdle()
if (!nativeReleased || audioRecord != null) requestClose()
if (interrupted) Thread.currentThread().interrupt()
}
}
@@ -173,13 +201,13 @@ class AudioRecorderManager {
fun isActive(): Boolean = captureMode == CaptureMode.RECORD && captureRunning
/** Индикатор уровня через native recorder; успешный тест нужно завершить stopMicTest без публикации. */
@Synchronized fun startMicTest(): Boolean {
fun startMicTest(): Boolean {
if (closing.get()?.isDone == false) { LogManager.addLog("WARN", "AudioRecorder", "microphone test rejected: cleanup pending"); return false }
if (StreamController.blocksCapture()) {
LogManager.addLog("WARN", "AudioRecorder", "microphone test rejected: stream owns capture")
return false
}
if (isTesting) return true
return startCapture(CaptureMode.TEST, "__test__")
return synchronized(this) { if (isTesting) true else startCapture(CaptureMode.TEST, "__test__") }
}
@Synchronized fun stopMicTest() {
if (captureMode == CaptureMode.TEST) finishCapture(false) else if (captureMode == null) releaseCaptureDevice()
@@ -189,6 +217,50 @@ class AudioRecorderManager {
private var playThread: Thread? = null
@Volatile private var playGeneration = 0
@Synchronized private fun forgetIfIdle() {
if (captureMode == null && audioRecord == null && playbackWorkers.isEmpty()) owners.remove(this)
}
/** Не ждёт SDK на caller: process-владелец повторяет stop/release до подтверждения и переживает ViewModel.onCleared. */
fun requestClose(): CompletableFuture<Unit> {
val result: CompletableFuture<Unit>
while (true) {
val previous = closing.get()
if (previous != null && !previous.isDone) return previous
val next = CompletableFuture<Unit>()
if (closing.compareAndSet(previous, next)) { result = next; break }
}
captureRunning = false; recordingThread?.interrupt()
try {
Thread({
var retryMs = 250L
try {
while (true) {
val released = synchronized(this) {
if (captureMode != null) finishCapture(false) else releaseCaptureDevice()
stopPlayback()
captureMode == null && audioRecord == null && playbackWorkers.isEmpty()
}
if (released) {
forgetIfIdle(); result.complete(Unit)
LogManager.addLog("INFO", "AudioRecorder", "Voice resources released owner=${System.identityHashCode(this)}")
break
}
LogManager.addLog("WARN", "AudioRecorder", "Voice cleanup pending owner=${System.identityHashCode(this)} retryMs=$retryMs")
Thread.sleep(retryMs); retryMs = (retryMs * 2).coerceAtMost(30_000)
}
} catch (e: Exception) {
LogManager.addLog("ERROR", "AudioRecorder", "Voice cleanup worker failed; owner retained: $e")
result.completeExceptionally(e)
}
}, "utun-voice-cleanup").start()
} catch (e: Exception) {
LogManager.addLog("ERROR", "AudioRecorder", "Voice cleanup worker start failed; owner retained: $e")
result.completeExceptionally(e)
}
return result
}
/** Запустить с начала; callbacks приходят из worker только для текущего поколения, completion также при ошибке. */
fun playVoiceFile(filePath: String, onProgress: ((Float, Int) -> Unit)? = null, onComplete: () -> Unit = {}) {
playFromPosition(filePath, 0, onProgress, onComplete)
@@ -197,11 +269,12 @@ class AudioRecorderManager {
/** Запустить со startMs, округлённого вниз до 20 мс; seek декодирует и отбрасывает предшествующий PCM.
* Отрицательный startMs ограничивается нулём; прогресс учитывает только принятые AudioTrack сэмплы. */
@Synchronized fun playFromPosition(filePath: String, startMs: Int, onProgress: ((Float, Int) -> Unit)? = null, onComplete: () -> Unit = {}) {
if (closing.get()?.isDone == false) { LogManager.addLog("WARN", "AudioPlayer", "play rejected: cleanup pending"); return }
stopPlayback()
playing = true
val myGen = playGeneration
playThread = Thread {
val worker = Thread {
var handle = 0L
var track: AudioTrack? = null
try {
@@ -270,22 +343,43 @@ class AudioRecorderManager {
} catch (e: Exception) {
LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}")
} finally {
track?.apply {
try { stop() } catch (e: Exception) { LogManager.addLog("WARN", "AudioPlayer", "stop failed: ${e.message}") }
try { release() } catch (e: Exception) { LogManager.addLog("WARN", "AudioPlayer", "release failed: $e") }
}
try { if (handle != 0L) NativeLib.voiceDecodeClose(handle) }
catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "decode close failed generation=$myGen: $e") }
synchronized(this@AudioRecorderManager) {
if (playGeneration == myGen) {
playing = false; playThread = null
try { onComplete() }
catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "completion callback failed generation=$myGen: $e") }
var retryMs = 250L
var notified = false
var interrupted = Thread.interrupted()
while (true) {
track?.let { output ->
try { output.stop() } catch (e: Exception) { LogManager.addLog("WARN", "AudioPlayer", "stop failed: $e") }
try { output.release(); track = null }
catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "release failed; worker retains output: $e") }
}
try { if (handle != 0L) { NativeLib.voiceDecodeClose(handle); handle = 0 } }
catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "decode close failed generation=$myGen; retained: $e") }
if (!notified) {
synchronized(this@AudioRecorderManager) {
if (playGeneration == myGen) {
playing = false; playThread = null
try { onComplete() }
catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "completion callback failed generation=$myGen: $e") }
}
}
notified = true
}
if (track == null && handle == 0L) break
try { Thread.sleep(retryMs) } catch (_: InterruptedException) { interrupted = true }
retryMs = (retryMs * 2).coerceAtMost(30_000)
}
playbackWorkers.remove(Thread.currentThread()); forgetIfIdle()
if (interrupted) Thread.currentThread().interrupt()
LogManager.addLog("DEBUG", "AudioPlayer", "playback worker exited generation=$myGen current=$playGeneration")
}
}.apply { start() }
}
playThread = worker; playbackWorkers.add(worker); owners.add(this)
try { worker.start() }
catch (e: Exception) {
playbackWorkers.remove(worker); playThread = null; playing = false; forgetIfIdle()
LogManager.addLog("ERROR", "AudioPlayer", "Playback worker start failed: $e")
onComplete()
}
}
/** Асинхронно остановить текущий worker; позицию для следующего playFromPosition хранит вызывающий код. */
@@ -10,6 +10,7 @@ interface ChatEventCallback {
}
object NativeLib {
@Volatile private var coreStopping = false
init {
System.loadLibrary("utun")
@@ -38,6 +39,7 @@ object NativeLib {
/** Start full uTun instance from INI config text */
fun start(configText: String): Boolean {
if (coreStopping) { LogManager.addLog("ERROR", "Core", "Start rejected: previous frontend cleanup pending"); return false }
if (isReady()) return true
return nativeStart(configText)
}
@@ -47,6 +49,7 @@ object NativeLib {
withContext(Dispatchers.Main.immediate) { prepareForCoreStop() }
check(withContext(Dispatchers.IO) { nativeStop() }) { "native worker stop failed" }
withContext(Dispatchers.Main.immediate) { ChatApplication.instance.memberFiles.coreStopped() }
coreStopping = false
}
fun setEventCallback(cb: ChatEventCallback) {
@@ -60,22 +63,26 @@ object NativeLib {
withContext(Dispatchers.Main.immediate) { 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 {
prepareForCoreStop()
return nativeRestart(configText)
return nativeRestart(configText).also { coreStopping = false }
}
/** Снять frontend-владельцев перед stop/restart/сменой ключей. Неподтверждённый CallService либо ещё
* активный mediaPreparation запрещает native stop с ошибкой; Main остаётся доступен для retry. */
private fun prepareForCoreStop() {
runBlocking(Dispatchers.Main.immediate) {
coreStopping = true
AudioRecorderManager.stopAll()
RadioCallController.reset()
StreamController.reset(); CallController.reset(); ChatApplication.instance.avatars.stop(); ChatApplication.instance.memberFiles.cancel()
CallService.requireReleased() // Не останавливать native при неподтверждённой очистке его media-владельца.
StreamPlayerEngine.stopForCore()
StreamController.requireReleased()
AudioRecorderManager.requireReleasedForCore()
check(!CallController.blocksMedia()) {
LogManager.addLog("ERROR", "Call", "Core teardown blocked: call media preparation still running")
"Call media preparation still running"
@@ -148,7 +155,7 @@ object NativeLib {
/** Принимает полный INI для пересоздания ядра с новой сохранённой идентичностью; true подтверждает приём запроса. */
fun regenerateKeys(configText: String): Boolean {
prepareForCoreStop()
return nativeRegenerateKeys(configText)
return nativeRegenerateKeys(configText).also { coreStopping = false }
}
/* ── Channel members ── */
@@ -197,8 +204,13 @@ object NativeLib {
fun requestLocalSockets() = nativeRequestLocalSockets()
/* ── Voice recording ── */
fun voiceStart(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean =
!StreamController.blocksCapture() && nativeVoiceStart(channelId, messageIdBytes(replyTo), requestId, generation)
fun voiceStart(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean {
if (coreStopping || (channelId != "__test__" && !isReady()) || StreamController.blocksCapture() || CallController.blocksMedia()) {
LogManager.addLog("WARN", "Voice", "Capture rejected target=$channelId coreStopping=$coreStopping; core/media unavailable")
return false
}
return nativeVoiceStart(channelId, messageIdBytes(replyTo), requestId, generation)
}
fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples)
fun voiceStop(): Int = nativeVoiceStop()
fun voiceCancel() { nativeVoiceCancel() }
@@ -412,7 +412,7 @@ class ChatViewModel : ViewModel() {
}
viewModelScope.launch {
CallController.state.collect { call ->
if (call?.preparing == true || CallController.blocksMedia()) selectStream(null)
if (call?.preparing == true || CallController.blocksMedia()) { selectStream(null); cancelVoiceRecording() }
}
}
viewModelScope.launch {
@@ -1904,6 +1904,11 @@ class ChatViewModel : ViewModel() {
_channelOwner.value = false
}
override fun onCleared() {
audioRecorder.requestClose()
super.onCleared()
}
class Factory : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = ChatViewModel() as T