Exercise retained router cleanup, stale proxies and SCO failure recovery

This commit is contained in:
evgeny committed 2026-10-29 13:34:23 +01:00
1 parent 3ef245fc0a
commit b3e1356d16
1 file changed
+190
@@ -0,0 +1,190 @@
package com.utun.chat.data
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.utun.chat.ChatApplication
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
/** Настоящий router: подтверждения Android cleanup, поздние proxy и независимость нового маршрута. */
class CallAudioRouterCleanupTest {
private lateinit var router: CallAudioRouter
private lateinit var adapter: BluetoothAdapter
private val audio get() = AudioManager.shared
private val main get() = Looper.getMainLooper()
@Before fun prepare() {
assertSame(main, Looper.myLooper()); Build.VERSION.SDK_INT = 36
AudioManager.shared = AudioManager(); ChatApplication.instance = Context()
adapter = BluetoothAdapter(); BluetoothAdapter.current = adapter
Handler.rejectDelayed = false
router = CallAudioRouter(); LogManager.entries.clear(); LogManager.console = true
}
@After fun release() {
audio.failures = emptySet(); adapter.closeFailures = emptySet(); adapter.closeFailuresAfter = emptySet()
Handler.rejectDelayed = false
router.stop()
adapter.requests.forEachIndexed { index, request -> if (request.proxy in adapter.live) adapter.connect(index) }
router.stop(); main.advanceBy(30_000)
assertTrue(adapter.live.isEmpty()); assertTrue(audio.callbacks.isEmpty()); assertFalse(router.cleanupPending)
Build.VERSION.SDK_INT = 36; BluetoothAdapter.current = null
}
private fun diagnostic(text: String) = assertTrue("Missing diagnostic: $text", LogManager.entries.any {
it.level in listOf("ERROR", "WARN") && text in it.message
})
private fun stopRejected() = assertThrows(IllegalStateException::class.java) { router.stop() }
private fun operation(name: String) = audio.operations.count { it == name }
@Test fun failedUnregisterRetainsTheCallbackAndBlocksAnotherRegistration() {
router.start(); adapter.connect(0)
val callback = audio.callbacks.single()
audio.failures = setOf("unregister")
stopRejected(); assertTrue(router.cleanupPending)
assertEquals(setOf(callback), audio.callbacks); assertTrue(adapter.live.isEmpty()); assertNull(audio.communicationDevice)
assertThrows(IllegalStateException::class.java) { router.start() }
assertEquals(1, audio.callbackHistory.size); assertEquals(1, adapter.requests.size)
val operations = audio.operations.size
callback.onAudioDevicesAdded(emptyArray()); callback.onAudioDevicesRemoved(emptyArray())
assertEquals(operations, audio.operations.size)
audio.failures = emptySet(); router.start()
assertEquals(2, audio.callbackHistory.size); assertFalse(router.cleanupPending)
}
@Test fun independentFailuresRetainOnlyOperationsStillAwaitingAcknowledgment() {
router.start(); adapter.connect(0)
val proxy = adapter.requests.single().proxy
adapter.closeFailures = setOf(proxy); audio.failures = setOf("unregister", "clear")
stopRejected()
assertEquals(1, adapter.closeAttempts); assertEquals(1, operation("clear")); assertEquals(1, operation("unregister"))
assertNull(router.currentOutputDevice); assertFalse(router.headsetAvailable)
audio.failures = setOf("clear"); stopRejected()
assertTrue(audio.callbacks.isEmpty()); assertEquals(2, operation("unregister"))
adapter.closeFailures = emptySet(); stopRejected()
assertTrue(adapter.live.isEmpty()); assertEquals(2, operation("unregister"))
audio.failures = emptySet(); router.stop()
assertFalse(router.cleanupPending); assertEquals(3, adapter.closeAttempts); assertEquals(4, operation("clear"))
assertEquals(2, operation("unregister"))
diagnostic("device callback unregister failed"); diagnostic("bt owned proxy close failed")
diagnostic("communication device release failed")
}
@Test fun failuresAfterUnregisterAndCloseStillRequireAnAcknowledgedRetry() {
router.start(); adapter.connect(0)
adapter.closeFailuresAfter = setOf(adapter.requests.single().proxy)
audio.failures = setOf("unregister after")
stopRejected()
assertTrue(audio.callbacks.isEmpty()); assertTrue(adapter.live.isEmpty()); assertTrue(router.cleanupPending)
audio.failures = emptySet(); adapter.closeFailuresAfter = emptySet(); router.stop()
assertFalse(router.cleanupPending); assertEquals(1, adapter.closes); assertEquals(2, adapter.closeAttempts)
assertEquals(2, operation("unregister")); assertEquals(1, operation("clear"))
}
@Test fun failedStartupPreservesItsOriginalErrorAndItsFailedRollback() {
audio.failures = setOf("register after", "unregister")
val failure = assertThrows(IllegalStateException::class.java) { router.start() }
assertTrue(failure.message!!.contains("register after")); assertEquals(1, failure.suppressed.size)
assertEquals(1, audio.callbacks.size); assertTrue(router.cleanupPending)
audio.failures = emptySet(); router.start()
assertEquals(1, audio.callbacks.size); assertEquals(2, audio.callbackHistory.size)
diagnostic("startup rollback incomplete")
}
@Test fun mainRetryCompletesCleanupWithoutAnotherOwnerCall() {
router.start(); adapter.connect(0)
audio.failures = setOf("unregister"); stopRejected()
main.advanceBy(250); assertTrue(router.cleanupPending)
audio.failures = emptySet(); main.advanceBy(500)
assertFalse(router.cleanupPending); assertTrue(audio.callbacks.isEmpty()); assertTrue(adapter.live.isEmpty())
}
@Test fun rejectedRetryKeepsExplicitCleanupAvailable() {
router.start(); adapter.connect(0)
Handler.rejectDelayed = true; adapter.closeFailures = adapter.live.toSet()
stopRejected(); diagnostic("cleanup retry rejected; owner retained")
assertTrue(router.cleanupPending); assertEquals(1, adapter.live.size)
adapter.closeFailures = emptySet(); router.stop(); assertFalse(router.cleanupPending)
}
@Test fun lateProxyCloseFailureIsRetriedAfterTheOwnerAlreadyStopped() {
router.start(); router.stop()
adapter.closeFailures = adapter.live.toSet()
adapter.connect(0)
assertTrue(router.cleanupPending); assertEquals(1, adapter.live.size)
adapter.closeFailures = emptySet(); main.advanceBy(250)
assertTrue(adapter.live.isEmpty()); assertFalse(router.cleanupPending)
assertEquals(1, operation("clear")); assertTrue(audio.callbacks.isEmpty())
}
@Test fun oldProxyRetryDoesNotStopTheNewRouterSession() {
router.start(); router.stop(); router.start(); adapter.connect(1)
val current = adapter.requests[1].proxy
val old = adapter.requests[0].proxy
adapter.closeFailures = setOf(old); adapter.connect(0)
assertTrue(router.cleanupPending); assertTrue(router.headsetAvailable)
val callback = audio.callbacks.single()
val clears = operation("clear"); val selected = audio.communicationDevice
adapter.closeFailures = emptySet(); main.advanceBy(250)
assertEquals(setOf(current), adapter.live); assertTrue(router.headsetAvailable)
assertEquals(setOf(callback), audio.callbacks); assertEquals(clears, operation("clear"))
assertSame(selected, audio.communicationDevice); assertFalse(router.cleanupPending)
}
@Test fun replacementProxyDoesNotLoseTheOldOwnerOrReactivateItsLateCallback() {
router.start(); adapter.connect(0)
val old = adapter.requests[0].proxy
val replacement = BluetoothHeadset(99).apply { connected = true }
adapter.live.add(replacement); adapter.closeFailures = setOf(old)
adapter.requests[0].listener.onServiceConnected(BluetoothProfile.HEADSET, replacement)
assertTrue(router.cleanupPending); assertEquals(2, adapter.live.size)
adapter.connect(0) // Повтор старого callback не заменяет уже принятый новый proxy.
assertTrue(replacement in adapter.live); assertTrue(router.headsetAvailable)
adapter.closeFailures = emptySet(); main.advanceBy(500)
assertEquals(setOf(replacement), adapter.live); assertFalse(router.cleanupPending)
val attempts = adapter.closeAttempts
adapter.connect(0); assertEquals(attempts, adapter.closeAttempts)
assertEquals(setOf(replacement), adapter.live)
}
@Test fun legacyScoStopAndFlagClearAreIndependentAndIndividuallyRetryable() {
Build.VERSION.SDK_INT = 30
router.start(); adapter.connect(0); router.setRoute(AudioRoute.HEADSET)
assertTrue(audio.isBluetoothScoOn)
audio.failures = setOf("stop sco", "sco=false"); stopRejected()
assertTrue(audio.callbacks.isEmpty()); assertTrue(adapter.live.isEmpty())
assertEquals(1, operation("stop sco")); assertEquals(1, operation("sco=false"))
audio.failures = setOf("sco=false"); stopRejected()
audio.failures = emptySet(); router.stop()
assertEquals(2, operation("stop sco")); assertEquals(3, operation("sco=false"))
assertFalse(audio.isBluetoothScoOn); assertFalse(router.cleanupPending)
}
@Test fun failedLegacyScoStartRetainsThePossibleEffectForStop() {
Build.VERSION.SDK_INT = 30
router.start(); adapter.connect(0)
audio.failures = setOf("start sco", "stop sco"); router.setRoute(AudioRoute.HEADSET)
stopRejected(); assertTrue(router.cleanupPending)
audio.failures = emptySet(); router.stop()
assertEquals(2, operation("stop sco")); assertFalse(router.cleanupPending)
}
@Test fun failedGlobalReleasePreventsTelecomHandoffAndLaterStopLeavesTelecomUntouched() {
router.start()
val connection = UtunConnection()
audio.failures = setOf("clear")
assertThrows(IllegalStateException::class.java) { router.bindTelecom(connection) }
assertNull(router.telecom); assertNull(connection.onRouteChanged)
audio.failures = emptySet(); router.bindTelecom(connection)
val clears = operation("clear")
router.stop(); assertEquals(clears, operation("clear")); assertFalse(router.cleanupPending)
}
}