From 0568d263e05545cc69296e75a465c76c980d90ec Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 12:46:40 +0100 Subject: [PATCH] Reject fresh radio and publisher owners during core cleanup --- .../java/com/utun/chat/data/StreamController.kt | 2 +- .../utun/chat/data/StreamServiceLifecycleTest.kt | 14 ++++++++++++-- .../com/utun/chat/data/RadioAudioLifecycleTest.kt | 13 +++++++++++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamController.kt index 13a63d23..2792baf9 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamController.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamController.kt @@ -59,7 +59,7 @@ object StreamController { check(Looper.myLooper() == Looper.getMainLooper()) if (mutable.value?.ongoing == true) { LogManager.addLog("WARN", "stream", "publisher already owned"); return } val id = nextId() - if (!NativeLib.isReady() || group == 0L || CallController.blocksMedia() || CallController.state.value?.ongoing == true || + if (NativeLib.isCoreStopping() || !NativeLib.isReady() || group == 0L || CallController.blocksMedia() || CallController.state.value?.ongoing == true || NativeLib.voiceIsActive()) { LogManager.addLog("WARN", "stream", "start rejected: core/call/voice busy group=$group") mutable.value = StreamState(id, group, StreamPhase.FAILED, R.string.stream_resources_busy); return diff --git a/tools/chatgui-android/call-service-tests/src/test/kotlin/com/utun/chat/data/StreamServiceLifecycleTest.kt b/tools/chatgui-android/call-service-tests/src/test/kotlin/com/utun/chat/data/StreamServiceLifecycleTest.kt index 5c2655b3..0e0a3a6b 100644 --- a/tools/chatgui-android/call-service-tests/src/test/kotlin/com/utun/chat/data/StreamServiceLifecycleTest.kt +++ b/tools/chatgui-android/call-service-tests/src/test/kotlin/com/utun/chat/data/StreamServiceLifecycleTest.kt @@ -29,14 +29,14 @@ class StreamServiceLifecycleTest { assertSame(main, Looper.myLooper()); Dispatchers.setMain(dispatcher) context = Context().also { ChatApplication.instance = it } Handler.rejectImmediate = false; Handler.rejectDelayed = false; PendingIntent.reset(); NotificationCompat.failBuild = false - NativeLib.ready = true; NativeLib.voiceActive = false; NativeLib.streamFailures = emptySet() + NativeLib.ready = true; NativeLib.coreStopping = false; NativeLib.voiceActive = false; NativeLib.streamFailures = emptySet() NativeLib.streamStops.clear(); NativeLib.streamEvents.clear(); NativeLib.streamStats = null StreamCaptureEngine.nextFailures = emptySet(); StreamCaptureEngine.created.clear(); AppLanguage.failText = false CallController.reset(); StreamController.reset(); main.dispatchAll() } @After fun release() { Handler.rejectImmediate = false; Handler.rejectDelayed = false; NativeLib.streamFailures = emptySet() - NativeLib.ready = true; NativeLib.voiceActive = false; AppLanguage.failText = false; NotificationCompat.failBuild = false + NativeLib.ready = true; NativeLib.coreStopping = false; NativeLib.voiceActive = false; AppLanguage.failText = false; NotificationCompat.failBuild = false StreamCaptureEngine.created.forEach { it.failures = emptySet() } context.powerManager?.let { power -> power.failures = emptySet(); power.locks.forEach { it.failures = emptySet() } } context.notificationManager?.failures = emptySet() @@ -66,6 +66,16 @@ class StreamServiceLifecycleTest { StreamController.requireReleased() } + @Test fun coreCleanupRejectsNewPublisherBeforeForegroundOrCameraAcquisition() { + NativeLib.coreStopping = true + StreamController.start(7, StreamCaptureConfig(), true) + assertEquals(StreamPhase.FAILED, state.phase) + assertFalse(StreamController.capturing) + assertTrue(context.serviceStarts.isEmpty()) + assertTrue(StreamCaptureEngine.created.isEmpty()) + StreamController.requireReleased() + } + @Test fun nullForegroundRequestFailsWithoutLeavingAStartingPublisher() { context.acceptServiceStart = false; request() assertEquals(StreamPhase.FAILED, state.phase); assertFalse(StreamController.capturing) diff --git a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/RadioAudioLifecycleTest.kt b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/RadioAudioLifecycleTest.kt index 8278c891..cede4d7f 100644 --- a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/RadioAudioLifecycleTest.kt +++ b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/RadioAudioLifecycleTest.kt @@ -30,7 +30,7 @@ class RadioAudioLifecycleTest { AudioManager.shared = AudioManager(); CallController.blocked = false AndroidMicrophoneCapture.reset(); AudioTrack.created.clear(); AudioTrack.nextFailures = emptySet(); AudioTrack.minimumBuffer = 1920 Handler.rejectDelayed = false; Handler.rejectImmediate = false - NativeLib.radioFailures = emptySet(); NativeLib.radioCommands.clear() + NativeLib.coreStopping = false; NativeLib.radioFailures = emptySet(); NativeLib.radioCommands.clear() LogManager.entries.clear(); LogManager.console = true engine = RadioAudioEngine() io = RadioAudioEngine::class.java.getDeclaredField("io").apply { isAccessible = true }.get(engine) as ExecutorService @@ -39,7 +39,7 @@ class RadioAudioLifecycleTest { @After fun release() { context.receiverFailures = emptySet(); context.failPowerService = false; context.configProvider.failRadioSettings = false AudioTrack.created.forEach { it.failures = emptySet() }; AudioTrack.nextFailures = emptySet(); AudioTrack.minimumBuffer = 1920 - NativeLib.radioFailures = emptySet(); Handler.rejectDelayed = false; Handler.rejectImmediate = false + NativeLib.coreStopping = false; NativeLib.radioFailures = emptySet(); Handler.rejectDelayed = false; Handler.rejectImmediate = false if (!io.isShutdown) { try { engine.stop().get(5, TimeUnit.SECONDS) } catch (e: ExecutionException) { if (e.cause !is IllegalArgumentException || context.receivers.isNotEmpty()) throw e @@ -80,6 +80,15 @@ class RadioAudioLifecycleTest { assertTrue("radio state did not settle", condition()) } + @Test fun coreCleanupRejectsNewRadioBeforeAcquiringAudioDevices() { + NativeLib.coreStopping = true + start(expected = false) + assertFalse(engine.isActive()) + assertTrue(AudioTrack.created.isEmpty()) + assertTrue(AndroidMicrophoneCapture.opened.isEmpty()) + diagnostic("core cleanup") + } + @Test fun receiverCleanupFailureDoesNotSkipCaptureOutputOrNativeStopAndCanBeRetried() { start(); assertTrue(engine.talkBegin()); drain(); await { NativeLib.radioTransmitting() } val track = AudioTrack.created.single()