Retain voice resources after ViewModel disposal and block premature core teardown
This commit is contained in:
1 parent
85af6f5b87
commit
a85d2cc8c7
3 files changed
+139
-28
No files matched your search
+117
-23
@@ -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
|
||||
|
||||
Reference in new issue
Block a user