diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt index 53c4265d..c41d0898 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt @@ -12,6 +12,8 @@ import com.utun.chat.ChatApplication import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock /** * Запись голосового сообщения/тест микрофона и отдельный плеер native Opus-файла. @@ -54,6 +56,7 @@ class AudioRecorderManager { @Volatile private var captureRunning = false @Volatile private var captureFailed = false private val closing = AtomicReference?>(null) + private val captureLock = ReentrantLock() private val playbackWorkers = ConcurrentHashMap.newKeySet() val isTesting: Boolean get() = captureMode == CaptureMode.TEST && captureRunning @@ -67,7 +70,8 @@ class AudioRecorderManager { * true — Android/native захват запущен; false — отказ с диагностикой и откатом принадлежащих ресурсов. */ 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) } + if (!captureLock.tryLock()) { LogManager.addLog("WARN", "AudioRecorder", "record start rejected: capture lifecycle busy"); return false } + try { return startCapture(CaptureMode.RECORD, channelId, replyTo, requestId, generation) } finally { captureLock.unlock() } } private fun startCapture(mode: CaptureMode, target: String, reply: String = "", request: Long = 0, generation: Long = 0): Boolean { @@ -192,14 +196,16 @@ class AudioRecorderManager { /** Длительность native в мс, -1 — отказ/запись отброшена после ошибки, 0 — нет записи. * Успех означает передачу подготовки в native, публикация сообщения подтверждается отдельно событиями. */ - @Synchronized fun stopRecording(): Int { - if (captureMode == CaptureMode.RECORD) return finishCapture(true) + fun stopRecording(): Int = captureLock.withLock { + if (captureMode == CaptureMode.RECORD) return@withLock finishCapture(true) if (captureMode == null) releaseCaptureDevice() - return 0 + 0 } fun cancelRecording() { if (closing.get()?.isDone == false) return - synchronized(this) { if (captureMode == CaptureMode.RECORD) finishCapture(false) else if (captureMode == null) releaseCaptureDevice() } + if (!captureLock.tryLock()) { if (captureMode == CaptureMode.RECORD) requestClose(); return } + try { if (captureMode == CaptureMode.RECORD) finishCapture(false) else if (captureMode == null) releaseCaptureDevice() } + finally { captureLock.unlock() } } fun isActive(): Boolean = captureMode == CaptureMode.RECORD && captureRunning @@ -210,11 +216,14 @@ class AudioRecorderManager { LogManager.addLog("WARN", "AudioRecorder", "microphone test rejected: stream owns capture") return false } - return synchronized(this) { if (isTesting) true else startCapture(CaptureMode.TEST, "__test__") } + if (!captureLock.tryLock()) { LogManager.addLog("WARN", "AudioRecorder", "microphone test rejected: capture lifecycle busy"); return false } + try { return if (isTesting) true else startCapture(CaptureMode.TEST, "__test__") } finally { captureLock.unlock() } } fun stopMicTest() { if (closing.get()?.isDone == false) return - synchronized(this) { if (captureMode == CaptureMode.TEST) finishCapture(false) else if (captureMode == null) releaseCaptureDevice() } + if (!captureLock.tryLock()) { if (captureMode == CaptureMode.TEST) requestClose(); return } + try { if (captureMode == CaptureMode.TEST) finishCapture(false) else if (captureMode == null) releaseCaptureDevice() } + finally { captureLock.unlock() } } @Volatile private var playing = false @@ -222,7 +231,7 @@ class AudioRecorderManager { private var playThread: Thread? = null @Volatile private var playGeneration = 0 - @Synchronized private fun forgetIfIdle() { + private fun forgetIfIdle() = captureLock.withLock { if (captureMode == null && audioRecord == null && playbackWorkers.isEmpty()) owners.remove(this) } @@ -241,7 +250,7 @@ class AudioRecorderManager { var retryMs = 250L try { while (true) { - val released = synchronized(this) { + val released = captureLock.withLock { if (captureMode != null) finishCapture(false) else releaseCaptureDevice() stopPlayback() captureMode == null && audioRecord == null && playbackWorkers.isEmpty() diff --git a/tools/chatgui-android/capture-tests/src/test/kotlin/com/utun/chat/data/AudioRecorderManagerTest.kt b/tools/chatgui-android/capture-tests/src/test/kotlin/com/utun/chat/data/AudioRecorderManagerTest.kt index bb5fabcc..09b1f09e 100644 --- a/tools/chatgui-android/capture-tests/src/test/kotlin/com/utun/chat/data/AudioRecorderManagerTest.kt +++ b/tools/chatgui-android/capture-tests/src/test/kotlin/com/utun/chat/data/AudioRecorderManagerTest.kt @@ -126,6 +126,7 @@ class AudioRecorderManagerTest { val worker = Thread(stopped, "voice-stop-race").apply { start() } try { assertTrue(device.stopEntered.await(1, TimeUnit.SECONDS)) + assertFalse(manager.startMicTest()) leave.countDown() assertEquals(-1, stopped.get(1, TimeUnit.SECONDS)) assertEquals(0, NativeLib.stops.get()); assertEquals(1, NativeLib.cancels.get())