From 8d3cee4a563a228f8ecd8452d85bebf142754a02 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 02:19:28 +0100 Subject: [PATCH] Exercise real Telecom ownership, callbacks and endpoint routing --- .../utun/chat/data/TelecomLifecycleTest.kt | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/TelecomLifecycleTest.kt diff --git a/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/TelecomLifecycleTest.kt b/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/TelecomLifecycleTest.kt new file mode 100644 index 000000000..35639a7ea --- /dev/null +++ b/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/TelecomLifecycleTest.kt @@ -0,0 +1,144 @@ +package com.utun.chat.data + +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.ParcelUuid +import android.telecom.CallAudioState +import android.telecom.CallEndpoint +import android.telecom.Connection +import android.telecom.ConnectionRequest +import android.telecom.TelecomManager +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** Настоящие manager/Connection/ConnectionService, управляемые SDK IPC и endpoint outcomes. */ +class TelecomLifecycleTest { + private val owned = mutableListOf>() + private val events = mutableListOf() + private val main get() = Looper.getMainLooper() + private val earpiece = endpoint(CallEndpoint.TYPE_EARPIECE) + private val speaker = endpoint(CallEndpoint.TYPE_SPEAKER) + private var failAvailable = false + private val listener = object : TelecomCallManager.Listener { + override fun onAnswer(callId: Long) { events.add("answer:$callId") } + override fun onReject(callId: Long) { events.add("reject:$callId") } + override fun onDisconnect(callId: Long) { events.add("disconnect:$callId") } + override fun onIncomingUiAllowed(callId: Long) { events.add("ui:$callId") } + override fun onConnectionFailed(callId: Long) { events.add("failed:$callId") } + override fun onConnectionAvailable(callId: Long, connection: UtunConnection) { + check(!failAvailable) { "listener unavailable" }; events.add("available:$callId") + } + } + @Before fun prepare() { + Build.VERSION.SDK_INT = 36; Handler.rejectImmediate = false; Handler.rejectDelayed = false + TelecomManager.shared = TelecomManager(); TelecomManager.failLookup = false; Connection.nextFailures = emptySet(); Connection.created.clear() + for (name in listOf("ready", "phoneAccountHandle")) TelecomCallManager::class.java.getDeclaredField(name) + .apply { isAccessible = true }.set(TelecomCallManager, if (name == "ready") false else null) + TelecomCallManager.setListener(listener); CallController.state.value = null; LogManager.entries.clear() + } + @After fun release() { + Handler.rejectImmediate = false; Handler.rejectDelayed = false; Build.VERSION.SDK_INT = 36 + TelecomCallManager.setListener(null); Connection.created.forEach { it.failures = emptySet() } + owned.forEach { TelecomCallManager.removeConnection(it.first) }; main.advanceBy(30_000) + assertTrue(owned.all { it.second.destroyed }) + val pending = TelecomCallManager::class.java.getDeclaredField("closing").apply { isAccessible = true }.get(TelecomCallManager) as Map<*, *> + assertTrue(pending.isEmpty()); CallController.state.value = null + } + private fun endpoint(type: Int) = CallEndpoint("endpoint $type", type, ParcelUuid(java.util.UUID.randomUUID())) + private fun connection(id: Long = 17) = UtunConnection(id, 99, "peer").also { owned.add(id to it) } + private fun current(id: Long = 17, phase: CallPhase = CallPhase.ACTIVE) { CallController.state.value = HostCallState(id, phase) } + private fun registered(id: Long = 17): UtunConnection { current(id); return connection(id).also { assertTrue(TelecomCallManager.registerConnection(id, it)) } } + private fun routed() = registered().also { it.onAvailableCallEndpointsChanged(mutableListOf(earpiece, speaker)); it.onCallEndpointChanged(earpiece) } + + @Test fun accountLookupAndRegistrationFailuresRemainRetryable() { + TelecomManager.failLookup = true; TelecomCallManager.init(); assertFalse(TelecomCallManager.isReady()) + TelecomManager.failLookup = false; TelecomManager.shared!!.failures = setOf("register after") + TelecomCallManager.init(); assertFalse(TelecomCallManager.isReady()) + TelecomManager.shared!!.failures = emptySet(); TelecomCallManager.init(); assertTrue(TelecomCallManager.isReady()) + } + @Test fun outgoingSdkExtrasReachConnectionServiceWithUnsignedIdsUnchanged() { + TelecomCallManager.init(); val id = Long.MIN_VALUE + 7; current(id, CallPhase.OUTGOING) + assertTrue(TelecomCallManager.placeCall(id, Long.MIN_VALUE, "peer")) + val extras = TelecomManager.shared!!.outgoing.single().second.values[TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS] as Bundle + val result = UtunConnectionService().onCreateOutgoingConnection(null, ConnectionRequest(extras)) + assertTrue(result is UtunConnection); owned.add(id to (result as UtunConnection)) + assertSame(result, TelecomCallManager.connectionFor(id)); assertEquals(2, result.state) + } + @Test fun sdkRequestFailuresReturnFalseWithoutCrashingTheCaller() { + TelecomCallManager.init(); TelecomManager.shared!!.failures = setOf("place", "incoming") + assertFalse(TelecomCallManager.placeCall(17, 99, "peer")); assertFalse(TelecomCallManager.addIncomingCall(17, 99, "peer")) + } + @Test fun duplicateRegistrationRetainsTheOriginalAndDestroysTheRejectedOwner() { + val first = registered(); val duplicate = connection() + assertFalse(TelecomCallManager.registerConnection(17, duplicate)); assertSame(first, TelecomCallManager.connectionFor(17)) + assertTrue(duplicate.destroyed); assertFalse(first.destroyed); assertEquals(listOf("available:17"), events) + } + @Test fun lateRegistrationIsRejectedAndRemainsOwnedIfDestroyFails() { + val late = connection(); late.failures = setOf("destroy") + assertFalse(TelecomCallManager.registerConnection(17, late)); assertFalse(late.destroyed) + late.failures = emptySet(); main.advanceBy(250); assertTrue(late.destroyed) + } + @Test fun stateFailureDuringRegistrationRetiresTheConnectionAndReportsFailure() { + current(); val conn = connection(); conn.failures = setOf("active") + assertFalse(TelecomCallManager.registerConnection(17, conn)); main.advanceBy(0) + assertTrue(conn.destroyed); assertNull(TelecomCallManager.connectionFor(17)); assertTrue("failed:17" in events) + } + @Test fun availableListenerFailureDoesNotLoseTheRegisteredConnection() { + current(); val conn = connection(); failAvailable = true + assertFalse(TelecomCallManager.registerConnection(17, conn)); assertTrue(conn.destroyed) + main.advanceBy(0); assertTrue("failed:17" in events) + } + @Test fun disconnectFailureCannotSkipIndependentDestroy() { + val conn = registered(); conn.failures = setOf("disconnected") + TelecomCallManager.setDisconnected(17, 2); TelecomCallManager.removeConnection(17) + assertTrue(conn.destroyed); assertNull(TelecomCallManager.connectionFor(17)) + } + @Test fun rejectedRetryTimerRetainsTheOwnerForExplicitRemovalRetry() { + val conn = registered(); conn.failures = setOf("destroy"); Handler.rejectDelayed = true + TelecomCallManager.setDisconnected(17, 2); TelecomCallManager.removeConnection(17); assertFalse(conn.destroyed) + conn.failures = emptySet(); Handler.rejectDelayed = false; TelecomCallManager.removeConnection(17); assertTrue(conn.destroyed) + } + @Test fun destroyFailureAfterEffectStillNeedsAnAcknowledgedRetry() { + val conn = registered(); conn.failures = setOf("destroy after") + TelecomCallManager.removeConnection(17); assertTrue(conn.destroyed) + conn.failures = emptySet(); main.advanceBy(250); TelecomCallManager.removeConnection(17) + } + @Test fun systemInputAndRouteRequestsAfterDisconnectAreIgnored() { + val conn = routed(); TelecomCallManager.setDisconnected(17, 0) + conn.onAnswer(); conn.onReject(); conn.onDisconnect(); conn.onAbort(); conn.onShowIncomingCallUi(); conn.routeTo(AudioRoute.SPEAKER) + main.advanceBy(0); assertEquals(listOf("available:17"), events); assertTrue(conn.endpointRequests.isEmpty()) + } + @Test fun endpointResultDoesNotOptimisticallyConfirmTheAudioRoute() { + val conn = routed(); conn.routeTo(AudioRoute.SPEAKER); conn.endpointRequests.single().succeed(); main.advanceBy(0) + assertEquals(AudioRoute.EARPIECE, conn.confirmedRoute) + conn.onCallEndpointChanged(speaker); assertEquals(AudioRoute.SPEAKER, conn.confirmedRoute) + } + @Test fun latestDesiredRouteIsSentAfterTheOutstandingEndpointRequestCompletes() { + val conn = routed(); conn.routeTo(AudioRoute.SPEAKER); conn.routeTo(AudioRoute.EARPIECE) + assertEquals(1, conn.endpointRequests.size); conn.endpointRequests.first().succeed(); main.advanceBy(0) + assertEquals(2, conn.endpointRequests.size); assertEquals(earpiece, conn.endpointRequests.last().endpoint) + conn.onCallEndpointChanged(speaker); conn.endpointRequests.last().succeed(); main.advanceBy(0); conn.onCallEndpointChanged(earpiece) + assertEquals(AudioRoute.EARPIECE, conn.confirmedRoute) + } + @Test fun outcomeAfterDisconnectCannotRequestAnotherEndpoint() { + val conn = routed(); conn.routeTo(AudioRoute.SPEAKER); conn.routeTo(AudioRoute.EARPIECE) + TelecomCallManager.setDisconnected(17, 0); conn.endpointRequests.single().succeed(); main.advanceBy(0) + assertEquals(1, conn.endpointRequests.size) + } + @Test fun routeListenerFailuresDoNotEscapeIntoTheSdk() { + val conn = registered(); conn.onRouteChanged = { error("route callback failed") }; conn.onEndpointsChanged = { error("endpoints callback failed") } + conn.onAvailableCallEndpointsChanged(mutableListOf(earpiece)); conn.onCallEndpointChanged(earpiece) + assertEquals(AudioRoute.EARPIECE, conn.confirmedRoute) + } + @Test fun legacyRouteWaitsForAudioStateAndThenUsesTheSupportedHeadset() { + Build.VERSION.SDK_INT = 33; val conn = registered(); conn.routeTo(AudioRoute.HEADSET); assertTrue(conn.legacyRequests.isEmpty()) + conn.callAudioState = CallAudioState(CallAudioState.ROUTE_SPEAKER, CallAudioState.ROUTE_SPEAKER or CallAudioState.ROUTE_WIRED_HEADSET) + conn.onCallAudioStateChanged(conn.callAudioState); assertEquals(listOf(CallAudioState.ROUTE_WIRED_HEADSET), conn.legacyRequests) + conn.callAudioState = CallAudioState(CallAudioState.ROUTE_WIRED_HEADSET, 12); conn.onCallAudioStateChanged(conn.callAudioState) + assertEquals(AudioRoute.HEADSET, conn.confirmedRoute) + } +}