diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt index d672c5025..9fd1d9636 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt @@ -76,11 +76,12 @@ class RadioAudioEngine { @Volatile private var generation = 0L private var audioGroup = 0L private val vadWindow = RadioVadWindow(VAD_BACKGROUND_MS) - private var screenReceiver: BroadcastReceiver? = null + @Volatile private var screenReceiver: BroadcastReceiver? = null @Volatile private var screenInteractive = false private var autoSettings = RadioAutoSettings() private var settingsReady = false private var settingsRequest = 0L + private var settingsTimeout: Runnable? = null // Только io: текущая отменяемая проверка ACK. private var captureRequestedMode = RadioAutoSettings.OFF @Volatile private var autoMode = RadioAutoSettings.OFF @Volatile private var levelMonitoring = false @@ -310,12 +311,20 @@ class RadioAudioEngine { (ChatApplication.instance.getSystemService(Context.POWER_SERVICE) as PowerManager).isInteractive private fun registerScreenReceiver() { + val version = generation val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { + if (!active || generation != version || screenReceiver !== this) { + LogManager.addLog("DEBUG", "radio", "stale screen event ignored action=${intent.action} generation=$version") + return + } + if (intent.action != Intent.ACTION_SCREEN_ON && intent.action != Intent.ACTION_SCREEN_OFF) { + LogManager.addLog("WARN", "radio", "unknown screen event action=${intent.action} group=$audioGroup") + return + } screenInteractive = intent.action == Intent.ACTION_SCREEN_ON - val version = generation io.execute { - if (!active || generation != version) return@execute + if (!active || generation != version || screenReceiver !== this) return@execute if (intent.action == Intent.ACTION_SCREEN_OFF) { vadWindow.close() LogManager.addLog("INFO", "radio", "Auto screen off: window closed group=$audioGroup") @@ -333,10 +342,10 @@ class RadioAudioEngine { addAction(Intent.ACTION_SCREEN_OFF) } val ctx = ChatApplication.instance + screenReceiver = receiver // Владение сохраняется также при исключении после регистрации на стороне ОС. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) ctx.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) else ctx.registerReceiver(receiver, filter) - screenReceiver = receiver screenInteractive = screenOn() } @@ -427,45 +436,107 @@ class RadioAudioEngine { private fun stopCapture() { captureRunning = false val subscription = captureSubscription - if (subscription != null) { - subscription.close() - captureThread?.join() + var failure: Exception? = null + var interrupted = false + try { + if (subscription != null) try { + subscription.close() + captureSubscription = null + } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioAudio", "capture subscription close failed group=$audioGroup: $e") + failure = e + } + interrupted = joinWorker(captureThread, "capture") captureThread = null - captureSubscription = null - NativeLib.radioCaptureStop() - LogManager.addLog("INFO", "radio", "Auto/PTT capture released group=$audioGroup") + if (subscription != null) try { + NativeLib.radioCaptureStop() + LogManager.addLog("INFO", "radio", "Auto/PTT capture released group=$audioGroup") + } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioAudio", "native capture stop failed group=$audioGroup: $e") + if (failure == null) failure = e + } + autoMode = RadioAutoSettings.OFF + capturePttRequested = false + talking = false + if (_transmittingGroup.value != null) { + LogManager.addLog("DEBUG", "radio", "TX cleared after capture stop group=${_transmittingGroup.value}") + _transmittingGroup.value = null + } + _talkingFlow.value = requestedTalk + _autoActiveFlow.value = false + failure?.let { throw it } + } finally { + if (interrupted) Thread.currentThread().interrupt() } - autoMode = RadioAutoSettings.OFF - capturePttRequested = false - talking = false - if (_transmittingGroup.value != null) { - LogManager.addLog("DEBUG", "radio", "TX cleared after capture stop group=${_transmittingGroup.value}") - _transmittingGroup.value = null - } - _talkingFlow.value = requestedTalk - _autoActiveFlow.value = false } + /** Дождаться своего worker до остановки DSP; interruption не разрешает освободить используемые им ресурсы. */ + private fun joinWorker(worker: Thread?, kind: String): Boolean { + var interrupted = Thread.interrupted() + while (worker?.isAlive == true) try { worker.join() } catch (e: InterruptedException) { + interrupted = true + LogManager.addLog("WARN", "RadioAudio", "$kind join interrupted group=$audioGroup; finishing cleanup") + } + return interrupted + } + + /** Только io: независимая очистка всех владельцев; первый отказ возвращается после остальных попыток. */ private fun releaseLocked() { mainHandler.removeCallbacks(vadTimeout) + settingsTimeout?.let { mainHandler.removeCallbacks(it) }; settingsTimeout = null settingsReady = false; ++settingsRequest vadWindow.setEnabled(false, SystemClock.elapsedRealtime()) - screenReceiver?.let { ChatApplication.instance.unregisterReceiver(it) } - screenReceiver = null - stopCapture() - playbackRunning = false - audioTrack?.let { track -> - try { track.stop() } catch (e: Exception) { - LogManager.addLog("WARN", "RadioAudio", "playback stop failed: ${e.message}") + var failure: Exception? = null + var interrupted = Thread.interrupted() + try { + screenReceiver?.let { receiver -> + try { + ChatApplication.instance.unregisterReceiver(receiver) + screenReceiver = null + LogManager.addLog("DEBUG", "radio", "screen receiver removed group=$audioGroup") + } catch (e: IllegalArgumentException) { + screenReceiver = null // ОС подтверждает отсутствие регистрации, в том числе после частичного отказа. + LogManager.addLog("WARN", "radio", "screen receiver already absent group=$audioGroup: $e") + } catch (e: Exception) { + LogManager.addLog("ERROR", "radio", "screen receiver removal failed group=$audioGroup; retained for retry: $e") + failure = e + } + } + try { stopCapture() } catch (e: Exception) { if (failure == null) failure = e } + interrupted = Thread.interrupted() || interrupted + playbackRunning = false + val track = audioTrack + if (playThread != null) try { track?.stop() } catch (e: Exception) { + LogManager.addLog("WARN", "RadioAudio", "playback stop failed group=$audioGroup; release will follow: $e") } playThread?.interrupt() - playThread?.join() + interrupted = joinWorker(playThread, "playback") || interrupted playThread = null - outputLease?.close(); outputLease = null - track.release() + try { + outputLease?.close() + outputLease = null + } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioAudio", "output detach failed group=$audioGroup; retained for retry: $e") + if (e is InterruptedException) interrupted = true + if (failure == null) failure = e + } + if (track != null && outputLease == null) try { + track.release() + audioTrack = null + LogManager.addLog("DEBUG", "RadioAudio", "output released group=$audioGroup track=${System.identityHashCode(track)}") + } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioAudio", "output release failed group=$audioGroup " + + "track=${System.identityHashCode(track)}; retained for retry: $e") + if (failure == null) failure = e + } + try { NativeLib.radioAudioStop() } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioAudio", "native audio stop failed group=$audioGroup: $e") + if (failure == null) failure = e + } + failure?.let { throw it } + } finally { + if (interrupted) Thread.currentThread().interrupt() } - audioTrack = null - NativeLib.radioAudioStop() } /** На main удержать ручной PTT от source; true — запрос принят, false — рация неактивна либо нет RECORD_AUDIO.