Reproduce stuck radio PTT and verify native release ordering
This commit is contained in:
1 parent
a162cc6eca
commit
ef95ff54b9
1 file changed
+194
+194
@@ -0,0 +1,194 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.utun.chat.ChatApplication
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/** Настоящие engine/PCM/session: проверяется native-команда отбоя, а не только UI-флаг PTT. */
|
||||
class RadioPttLifecycleTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var engine: RadioAudioEngine
|
||||
private lateinit var io: ExecutorService
|
||||
private val main get() = Looper.getMainLooper()
|
||||
|
||||
@Before fun prepare() {
|
||||
assertSame(main, Looper.myLooper())
|
||||
context = Context(); ChatApplication.instance = context
|
||||
AudioManager.shared = AudioManager(); CallController.blocked = false
|
||||
AndroidMicrophoneCapture.reset(); AudioTrack.created.clear()
|
||||
Handler.rejectDelayed = false; MicrophonePermission.requester = null
|
||||
LogManager.entries.clear(); LogManager.console = true
|
||||
NativeLib.radioCommands.clear()
|
||||
engine = RadioAudioEngine()
|
||||
// Барьер собственного io-потока без подмены состояния движка или его capture/updateCapture.
|
||||
io = RadioAudioEngine::class.java.getDeclaredField("io").apply { isAccessible = true }.get(engine) as ExecutorService
|
||||
start(7)
|
||||
}
|
||||
|
||||
@After fun release() {
|
||||
Handler.rejectDelayed = false; context.permissionGranted = true
|
||||
engine.stop().get(5, TimeUnit.SECONDS)
|
||||
io.shutdown(); assertTrue(io.awaitTermination(5, TimeUnit.SECONDS))
|
||||
val barrier = Executors.newSingleThreadExecutor()
|
||||
try { barrier.submit { MediaAudioController.awaitIdle() }.get(5, TimeUnit.SECONDS) }
|
||||
finally { barrier.shutdownNow() }
|
||||
main.advanceBy(10_000)
|
||||
assertTrue(context.receivers.isEmpty())
|
||||
assertTrue(AudioTrack.created.all { it.released })
|
||||
assertTrue(AndroidMicrophoneCapture.opened.all { it.closed })
|
||||
}
|
||||
|
||||
private fun drain() { io.submit {}.get(5, TimeUnit.SECONDS) }
|
||||
private fun start(group: Long) {
|
||||
var ready = false
|
||||
engine.start(group) { ready = it }
|
||||
drain(); main.dispatchAll()
|
||||
assertTrue("radio must be listening", ready)
|
||||
engine.onSettingsApplied(NativeLib.radioRequest, true); drain()
|
||||
}
|
||||
private fun await(message: String, condition: () -> Boolean) {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3)
|
||||
while (!condition() && System.nanoTime() < deadline) Thread.sleep(2)
|
||||
assertTrue(message, condition())
|
||||
}
|
||||
private fun begin(source: RadioPttSource = RadioPttSource.BUTTON) {
|
||||
assertTrue(engine.talkBegin(source)); drain()
|
||||
await("native transmission must start") { NativeLib.radioTransmitting() }
|
||||
}
|
||||
private fun stopped() {
|
||||
drain()
|
||||
await("native transmission and UI request must stop") { !NativeLib.radioTransmitting() && !engine.isTalking() }
|
||||
}
|
||||
private fun count(kind: String) = synchronized(NativeLib.radioCommands) { NativeLib.radioCommands.count { it.kind == kind } }
|
||||
private fun diagnostic(fragment: String) = assertTrue("missing diagnostic: $fragment",
|
||||
synchronized(LogManager.entries) { LogManager.entries.any { fragment in it.message && it.level in listOf("ERROR", "WARN") } })
|
||||
|
||||
@Test fun rejectedReleaseTimerImmediatelyEndsNativeTransmissionAndDoesNotLogScheduled() {
|
||||
begin(); Handler.rejectDelayed = true
|
||||
engine.talkEnd()
|
||||
diagnostic("PTT release timer rejected")
|
||||
stopped()
|
||||
assertEquals(1, count("capture stop"))
|
||||
assertFalse(synchronized(LogManager.entries) { LogManager.entries.any { "scheduled +" in it.message } })
|
||||
}
|
||||
|
||||
@Test fun normalReleaseKeepsExactlyTheConfiguredTailBeforeStoppingCapture() {
|
||||
begin(); engine.talkEnd()
|
||||
main.advanceBy(RadioAudioEngine.RELEASE_HOLD_MS - 1); drain()
|
||||
assertTrue(engine.isTalking()); assertTrue(NativeLib.radioTransmitting())
|
||||
main.advanceBy(1); stopped()
|
||||
assertEquals(1, count("begin")); assertEquals(1, count("capture stop"))
|
||||
}
|
||||
|
||||
@Test fun repressDuringTailCancelsTheOldStopWithoutStartingAnotherTransmission() {
|
||||
begin(); engine.talkEnd(); main.advanceBy(100)
|
||||
begin()
|
||||
main.advanceBy(1000); drain()
|
||||
assertTrue(engine.isTalking()); assertEquals(1, count("begin")); assertEquals(0, count("capture stop"))
|
||||
engine.talkEnd(); main.advanceBy(RadioAudioEngine.RELEASE_HOLD_MS); stopped()
|
||||
}
|
||||
|
||||
@Test fun independentOwnersKeepTransmissionUntilTheLastReleaseEvenIfTimersAreRejected() {
|
||||
begin(); begin(RadioPttSource.OVERLAY)
|
||||
Handler.rejectDelayed = true
|
||||
engine.talkEnd(RadioPttSource.BUTTON); drain()
|
||||
assertTrue(engine.isTalking()); assertEquals(0, count("capture stop"))
|
||||
engine.talkEnd(RadioPttSource.OVERLAY)
|
||||
diagnostic("PTT release timer rejected"); stopped()
|
||||
assertEquals(1, count("capture stop"))
|
||||
}
|
||||
|
||||
@Test fun duplicateAndUnknownReleasesDoNotExtendTheTailOrRequireExtraPressCounts() {
|
||||
begin(); begin()
|
||||
engine.talkEnd(RadioPttSource.OVERLAY)
|
||||
assertTrue(engine.isTalking())
|
||||
engine.talkEnd(); main.advanceBy(100); engine.talkEnd()
|
||||
main.advanceBy(RadioAudioEngine.RELEASE_HOLD_MS - 100); stopped()
|
||||
assertEquals(1, count("begin")); assertEquals(1, count("capture stop"))
|
||||
}
|
||||
|
||||
@Test fun cancellationPreservesOtherOwnersAndCancelAllStopsImmediately() {
|
||||
begin(); begin(RadioPttSource.VOLUME)
|
||||
engine.cancelTalk(RadioPttSource.BUTTON); engine.cancelTalk(RadioPttSource.OVERLAY); drain()
|
||||
assertTrue(engine.isTalking()); assertTrue(NativeLib.radioTransmitting())
|
||||
engine.cancelTalk(); stopped()
|
||||
assertEquals(1, count("capture stop"))
|
||||
}
|
||||
|
||||
@Test fun cancellingAlreadyReleasedOwnerRemovesThePendingTailImmediately() {
|
||||
begin(); engine.talkEnd()
|
||||
engine.cancelTalk(RadioPttSource.BUTTON); stopped()
|
||||
main.advanceBy(1000); drain()
|
||||
assertEquals(1, count("capture stop"))
|
||||
}
|
||||
|
||||
@Test fun stoppingEngineDuringTailRemovesCallbacksAndReleasesOutputAndCapture() {
|
||||
begin(); engine.talkEnd()
|
||||
engine.stop().get(5, TimeUnit.SECONDS)
|
||||
main.advanceBy(1000); drain()
|
||||
assertFalse(engine.isActive()); assertFalse(engine.isTalking()); assertFalse(NativeLib.radioTransmitting())
|
||||
assertEquals(1, count("capture stop")); assertTrue(AudioTrack.created.single().released)
|
||||
assertTrue(context.receivers.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun oldTailCannotStopANewGroupOrKeepItsPreviousPttOwners() {
|
||||
begin(); engine.talkEnd()
|
||||
start(8); begin(RadioPttSource.VOLUME)
|
||||
main.advanceBy(1000); drain()
|
||||
assertEquals(8L, engine.activeGroup.value); assertTrue(NativeLib.radioTransmitting())
|
||||
engine.talkEnd(RadioPttSource.BUTTON); drain(); assertTrue(engine.isTalking())
|
||||
engine.cancelTalk(RadioPttSource.VOLUME); stopped()
|
||||
assertEquals(listOf(7L, 8L), synchronized(NativeLib.radioCommands) {
|
||||
NativeLib.radioCommands.filter { it.kind == "begin" }.map { it.group }
|
||||
})
|
||||
}
|
||||
|
||||
@Test fun deniedPermissionDoesNotRetainAnOwnerOrResumeTransmissionAfterPermissionChanges() {
|
||||
context.permissionGranted = false
|
||||
assertFalse(engine.talkBegin()); assertEquals(1, context.activityAttempts.size)
|
||||
assertFalse(engine.isTalking()); assertEquals(0, count("capture start"))
|
||||
context.permissionGranted = true; main.advanceBy(1000); drain()
|
||||
assertEquals(0, count("begin"))
|
||||
begin(RadioPttSource.VOLUME)
|
||||
engine.talkEnd(RadioPttSource.VOLUME); main.advanceBy(RadioAudioEngine.RELEASE_HOLD_MS); stopped()
|
||||
}
|
||||
|
||||
@Test fun workerPttCommandsAreRejectedBeforeChangingOwnersOrTransmission() {
|
||||
begin()
|
||||
for (action in listOf<() -> Unit>({ engine.talkBegin(RadioPttSource.OVERLAY) },
|
||||
{ engine.talkEnd() }, { engine.cancelTalk() })) {
|
||||
val failure = AtomicReference<Throwable?>()
|
||||
Thread({ try { action() } catch (e: Throwable) { failure.set(e) } }, "radio-ptt-worker").apply { start(); join() }
|
||||
assertTrue("PTT must reject worker before mutation: ${failure.get()}", failure.get() is IllegalStateException)
|
||||
}
|
||||
diagnostic("PTT requires main thread")
|
||||
assertTrue(engine.isTalking()); assertEquals(1, count("begin"))
|
||||
engine.cancelTalk(RadioPttSource.BUTTON); stopped()
|
||||
}
|
||||
|
||||
@Test fun rejectedTailWithLevelMonitorQueuesNativeEndWhileKeepingTheCaptureSubscription() {
|
||||
engine.setLevelMonitoring(true); drain()
|
||||
begin()
|
||||
Handler.rejectDelayed = true; engine.talkEnd()
|
||||
diagnostic("PTT release timer rejected"); stopped()
|
||||
assertEquals(1, count("end")); assertEquals(1, count("capture start")); assertEquals(0, count("capture stop"))
|
||||
}
|
||||
|
||||
@Test fun inactiveEngineRejectsPttWithoutRequestingMicrophoneOrStartingCapture() {
|
||||
engine.stop().get(5, TimeUnit.SECONDS); context.permissionGranted = false
|
||||
assertFalse(engine.talkBegin())
|
||||
diagnostic("PTT begin ignored: inactive")
|
||||
assertTrue(context.activityAttempts.isEmpty()); assertEquals(0, count("capture start"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user