diff --git a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobotCallAsserts.kt b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobotCallAsserts.kt index c38eb3eab56..2e4b20e062e 100644 --- a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobotCallAsserts.kt +++ b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobotCallAsserts.kt @@ -16,12 +16,15 @@ package io.getstream.video.android.robots +import android.app.Notification +import android.app.NotificationManager import androidx.test.uiautomator.BySelector import io.getstream.video.android.pages.CallPage import io.getstream.video.android.pages.CallPage.SettingsMenu import io.getstream.video.android.pages.RingPage import io.getstream.video.android.robots.UserControls.DISABLE import io.getstream.video.android.robots.UserControls.ENABLE +import io.getstream.video.android.uiautomator.appContext import io.getstream.video.android.uiautomator.defaultTimeout import io.getstream.video.android.uiautomator.device import io.getstream.video.android.uiautomator.findObject @@ -29,6 +32,7 @@ import io.getstream.video.android.uiautomator.findObjects import io.getstream.video.android.uiautomator.isDisplayed import io.getstream.video.android.uiautomator.retryOnStaleObjectException import io.getstream.video.android.uiautomator.seconds +import io.getstream.video.android.uiautomator.waitDisplayed import io.getstream.video.android.uiautomator.waitForCount import io.getstream.video.android.uiautomator.waitForText import io.getstream.video.android.uiautomator.waitToAppear @@ -70,15 +74,23 @@ fun UserRobot.assertThatCallIsEnded(): UserRobot { } fun UserRobot.assertUserMicrophone(isEnabled: Boolean, videoCall: Boolean = true): UserRobot { + // The participant view icon updates slightly after the control toggle, so both + // checks poll instead of asserting the icon at the instant the toggle appears. if (isEnabled) { - assertTrue(CallPage.microphoneEnabledToggle.waitToAppear().isDisplayed()) + assertTrue("Microphone enabled toggle", CallPage.microphoneEnabledToggle.waitDisplayed()) if (videoCall) { - assertTrue(CallPage.ParticipantView.microphoneEnabledIcon.isDisplayed()) + assertTrue( + "Participant microphone enabled icon", + CallPage.ParticipantView.microphoneEnabledIcon.waitDisplayed(), + ) } } else { - assertTrue(CallPage.microphoneDisabledToggle.waitToAppear().isDisplayed()) + assertTrue("Microphone disabled toggle", CallPage.microphoneDisabledToggle.waitDisplayed()) if (videoCall) { - assertTrue(CallPage.ParticipantView.microphoneDisabledIcon.isDisplayed()) + assertTrue( + "Participant microphone disabled icon", + CallPage.ParticipantView.microphoneDisabledIcon.waitDisplayed(), + ) } } return this @@ -197,7 +209,12 @@ fun UserRobot.assertRecordingView(isDisplayed: Boolean): UserRobot { if (isDisplayed) { // The backend composite recorder can take 20-30s to actually start and emit // call.recording_started, so the icon needs a longer window than the 5s default. - assertTrue(CallPage.recordingIcon.waitToAppear(timeOutMillis = 30.seconds).isDisplayed()) + // waitDisplayed also absorbs stale reads: the node returned by waitToAppear could + // go stale before isDisplayed() and leak a StaleObjectException. + assertTrue( + "Recording icon", + CallPage.recordingIcon.waitDisplayed(timeOutMillis = 30.seconds), + ) // After a network drop the label can briefly read "Reconnecting.." before it settles // back to "Recording", so poll instead of asserting on the first read. val callInfoText = CallPage.callInfoView.waitForText( @@ -271,14 +288,17 @@ fun UserRobot.assertOutgoingCall(audioOnly: Boolean = true, isDisplayed: Boolean "Decline call button", RingPage.declineCallButton.waitToAppear(timeOutMillis = 30.seconds).isDisplayed(), ) - assertTrue("Call label", RingPage.outgoingCallLabel.isDisplayed()) - assertTrue("Avatar", RingPage.callParticipantAvatar.isDisplayed()) - assertTrue("Microphone", RingPage.microphoneEnabledToggle.isDisplayed()) - assertEquals( - "Camera should be displayed: ${!audioOnly}", - !audioOnly, - RingPage.cameraEnabledToggle.isDisplayed(), - ) + // The control toggles reflect async call state (the microphone can still show the + // muted state right after the screen renders), so poll instead of instant asserts. + assertTrue("Call label", RingPage.outgoingCallLabel.waitDisplayed()) + assertTrue("Avatar", RingPage.callParticipantAvatar.waitDisplayed()) + assertTrue("Microphone", RingPage.microphoneEnabledToggle.waitDisplayed()) + if (audioOnly) { + assertFalse("Camera enabled toggle", RingPage.cameraEnabledToggle.isDisplayed()) + assertFalse("Camera disabled toggle", RingPage.cameraDisabledToggle.isDisplayed()) + } else { + assertTrue("Camera", RingPage.cameraEnabledToggle.waitDisplayed()) + } } else { assertFalse( "Decline call button", @@ -288,6 +308,30 @@ fun UserRobot.assertOutgoingCall(audioOnly: Boolean = true, isDisplayed: Boolean return this } +/** + * Asserts the presence of the outgoing call notification, which the outgoing call foreground + * service posts with the "Calling..." title (on the ongoing calls channel, see + * getSimpleOngoingCallNotification). The instrumentation runs inside the app process, so the + * check reads NotificationManager.activeNotifications directly instead of matching text in + * the notification shade, where the outgoing screen shows the same "Calling..." text. + * The service start and stop are asynchronous, so both directions poll. + */ +fun UserRobot.assertOutgoingCallNotification(isDisplayed: Boolean): UserRobot { + val title = appContext.getString( + io.getstream.video.android.core.R.string.stream_video_outgoing_call_notification_title, + ) + val notificationManager = appContext.getSystemService(NotificationManager::class.java) + fun displayed() = notificationManager.activeNotifications.any { + it.notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() == title + } + val endTime = System.currentTimeMillis() + defaultTimeout + while (displayed() != isDisplayed && System.currentTimeMillis() < endTime) { + Thread.sleep(250) + } + assertEquals("Outgoing call notification displayed", isDisplayed, displayed()) + return this +} + fun UserRobot.assertConnectingView(): UserRobot { assertEquals("Connecting...", RingPage.callProgressBar.waitToAppear().text) // Connecting covers the same call join round-trip as waitForCallToStart, which can diff --git a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/ReconnectionTests.kt b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/ReconnectionTests.kt index b553c315e96..bef05a23940 100644 --- a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/ReconnectionTests.kt +++ b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/ReconnectionTests.kt @@ -113,9 +113,14 @@ class ReconnectionTests : StreamTestCase() { userRobot.joinCall() } step("AND participant joins the call") { + // The recording window counts from the participant's start request, and the + // composite recorder alone can take 20-30s to start. The window has to outlive + // the drop, the reconnect and the final polling assert on a slow CI emulator, + // otherwise the participant stops the recording on schedule before the assert + // and the test fails on a recording that legitimately ended. participantRobot .setUserCount(participants) - .setCallRecordingDuration(30) + .setCallRecordingDuration(90) .joinCall(callId, actions = arrayOf(Actions.RECORD_CALL)) } step("AND participant starts recording a call") { diff --git a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/RingingTests.kt b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/RingingTests.kt index a9c14548100..27a1502304c 100644 --- a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/RingingTests.kt +++ b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/RingingTests.kt @@ -21,6 +21,7 @@ import io.getstream.video.android.robots.assertAudioCallControls import io.getstream.video.android.robots.assertConnectingView import io.getstream.video.android.robots.assertIncomingCall import io.getstream.video.android.robots.assertOutgoingCall +import io.getstream.video.android.robots.assertOutgoingCallNotification import io.getstream.video.android.robots.assertThatCallIsEnded import io.getstream.video.android.robots.assertVideoCallControls import io.qameta.allure.kotlin.Allure.step @@ -80,12 +81,18 @@ class RingingTests : StreamTestCase() { step("THEN the outgoing call starts") { userRobot.assertOutgoingCall(audioOnly = true, isDisplayed = true) } + step("AND the outgoing call notification is displayed") { + userRobot.assertOutgoingCallNotification(isDisplayed = true) + } step("WHEN user rejects the outgoing call") { userRobot.declineOutgoingCall() } step("THEN the outgoing call ends") { userRobot.assertOutgoingCall(isDisplayed = false) } + step("AND the outgoing call notification is dismissed") { + userRobot.assertOutgoingCallNotification(isDisplayed = false) + } } @AllureId("7776") diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index 78511c4ef3c..f87485fe2e4 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -86,6 +86,7 @@ import io.getstream.video.android.core.call.CallType import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.closedcaptions.ClosedCaptionManager import io.getstream.video.android.core.closedcaptions.ClosedCaptionsSettings +import io.getstream.video.android.core.dispatchers.DispatcherProvider import io.getstream.video.android.core.events.AudioLevelChangedEvent import io.getstream.video.android.core.events.CallEndedSfuEvent import io.getstream.video.android.core.events.ConnectionQualityChangeEvent @@ -131,7 +132,6 @@ import io.getstream.video.android.core.utils.toUser import io.getstream.video.android.model.StreamCallId import io.getstream.video.android.model.User import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.currentCoroutineContext @@ -1354,7 +1354,6 @@ public class CallState( _session.value?.participants?.find { it.user.id == client.userId } != null val outgoingMembersCount = _members.value.filter { it.value.user.id != client.userId }.size val isCallEnded: Boolean = _endedAt.value != null - val createdBySelf = createdBy?.id == client.userId ringingLogger.d { "Current: ${_ringingState.value}, call_id: ${call.cid}" } @@ -1430,6 +1429,11 @@ public class CallState( } else { if (_ringingState.value is RingingState.Incoming && !acceptedOnThisDevice) { RingingState.TimeoutNoAnswer + } else if (isJoinAndRingInProgress.get() && _ringingState.value is RingingState.Outgoing) { + // During join-and-ring the SFU join sets Outgoing before the ring request has + // registered this call in client.state.ringingCall, so hasRingingCall is still + // false here. Falling back to Idle would hide the outgoing ringing UI. + _ringingState.value } else { RingingState.Idle } @@ -1898,12 +1902,12 @@ public class CallState( private fun observeTelecomHold(repo: JetpackTelecomRepository) { telecomHoldObserverJob?.cancel() - telecomHoldObserverJob = scope.launch(Dispatchers.Default) { + telecomHoldObserverJob = scope.launch(DispatcherProvider.Default) { repo.currentCall .map { (it as? TelecomCall.Registered)?.isOnHold == true } .distinctUntilChanged() .filter { it } - .collect { isOnHold -> + .collect { _ -> when (ringingState.value) { is RingingState.Active -> { call.leave(CallLeaveReason.SdkDriven(cause = SdkCause.CALL_ON_HOLD)) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt index 0e46e433cca..b13fc0cf1f9 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt @@ -327,7 +327,15 @@ internal class CallJoinCoordinator( logger.d { "[joinAndRing] Joined #ringing; #track; ring: $members" } apiClient.ring(RingCallRequest(isVideoEnabled(), members)).map { logger.d { "[joinAndRing] Ringed #ringing; #track; ring: $members" } - callRegistry.markRinging() + // registerOutgoingRing registers the ringing call AND starts the outgoing call + // foreground service, like the create-with-ring path does. markRinging alone + // never started the service here, so the caller had no outgoing notification + // (setActiveCall logs "Outgoing call service should already be running"). + callRegistry.registerOutgoingRing() + // An event that arrived before the ring completed (e.g. call.session_started) + // computed the ringing state without the ringing call registered. Recompute so + // the state cannot stay Idle when no further coordinator event arrives. + state.updateRingingState() rtcSession }.onError { logger.e { "[joinAndRing] Ring failed #ringing; #track; error: $it" } @@ -343,10 +351,8 @@ internal class CallJoinCoordinator( } fun isPermanentError(error: Any): Boolean { - if (error is Error.ThrowableError) { - if (error.message.contains("Unable to resolve host")) { - return false - } + if (error is Error.ThrowableError && error.message.contains("Unable to resolve host")) { + return false } return true } @@ -497,17 +503,17 @@ internal class CallJoinCoordinator( } } - if (sfuConnectionResult.cause != SfuConnectFailureCause.TerminalSocketFailure) { - if (!didReconnectSucceed()) { - logger.e { "[_join] Could not recover. Error : $sfuConnectionResult" } - sendJoinErrorAnalytics(sfuConnectionResult) - discardFailedSession(localSession) - return Failure( - Error.GenericError( - sfuConnectionResult.error.message ?: "SFU connection failed", - ), - ) - } + // A terminal failure already returned above, so only recoverable causes + // reach this point and the recovery outcome is the only condition left. + if (!didReconnectSucceed()) { + logger.e { "[_join] Could not recover. Error : $sfuConnectionResult" } + sendJoinErrorAnalytics(sfuConnectionResult) + discardFailedSession(localSession) + return Failure( + Error.GenericError( + sfuConnectionResult.error.message ?: "SFU connection failed", + ), + ) } } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTelecomHoldTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTelecomHoldTest.kt new file mode 100644 index 00000000000..a0479f765fc --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTelecomHoldTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core + +import io.getstream.android.video.generated.models.VideoEvent +import io.getstream.video.android.core.base.TestBase +import io.getstream.video.android.core.base.toResponse +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.JetpackTelecomRepository +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.TelecomCall +import io.getstream.video.android.core.utils.toResponse +import io.getstream.video.android.model.User +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertTrue + +/** + * Verifies the telecom hold observer: when Android Telecom puts the call on hold while it is + * active, the SDK leaves the call with [SdkCause.CALL_ON_HOLD]. + */ +@RunWith(RobolectricTestRunner::class) +internal class CallStateTelecomHoldTest : TestBase() { + + private val scope = CoroutineScope(dispatcherRule.testDispatcher) + + private val user = User(id = "caller", createdAt = nowUtc, updatedAt = nowUtc) + + private val activeCall = MutableStateFlow(null) + private val ringingCall = MutableStateFlow(null) + + private val clientState = mockk(relaxed = true) { + every { activeCall } returns this@CallStateTelecomHoldTest.activeCall + every { ringingCall } returns this@CallStateTelecomHoldTest.ringingCall + } + private val client = mockk(relaxed = true) { + every { userId } returns this@CallStateTelecomHoldTest.user.id + every { state } returns clientState + } + private val call = mockk(relaxed = true) { + every { type } returns "default" + every { id } returns "telecom-hold-test" + every { cid } returns "default:telecom-hold-test" + every { events } returns MutableSharedFlow() + } + + @After + fun tearDownScope() { + scope.cancel() + } + + @Test + fun `putting an active call on hold leaves the call with CALL_ON_HOLD`() { + val callState = CallState(client, call, user, scope) + callState.updateFromResponse(call.toResponse(user.toResponse())) + activeCall.value = call + callState.updateRingingState() + assertTrue(callState.ringingState.value is RingingState.Active) + + val heldCall = mockk { every { isOnHold } returns true } + callState.jetpackTelecomRepository = mockk { + every { currentCall } returns MutableStateFlow(heldCall) + } + + // The observer runs on DispatcherProvider.Default (a real dispatcher here), so the + // verification has to wait for it. + verify(timeout = 5_000L) { + call.leave( + match { + it is CallLeaveReason.SdkDriven && it.cause == SdkCause.CALL_ON_HOLD + }, + ) + } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/RingingStateJoinAndRingTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/RingingStateJoinAndRingTest.kt new file mode 100644 index 00000000000..60edad6114c --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/RingingStateJoinAndRingTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core + +import io.getstream.android.video.generated.models.VideoEvent +import io.getstream.video.android.core.base.TestBase +import io.getstream.video.android.core.base.toResponse +import io.getstream.video.android.core.events.JoinCallResponseEvent +import io.getstream.video.android.core.events.ParticipantCount +import io.getstream.video.android.core.utils.toResponse +import io.getstream.video.android.model.User +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertTrue + +/** + * Regression tests for the ringing state during the join-and-ring flow. + * + * In join-and-ring the SFU join response sets the ringing state to [RingingState.Outgoing] + * directly, but the ring request that registers the call in `client.state.ringingCall` completes + * later. A coordinator event landing in that window (e.g. `call.session_started`) recomputes the + * ringing state with `hasRingingCall = false` and used to downgrade Outgoing back to Idle, leaving + * the caller stuck on the loading UI (AND-1454). + */ +@RunWith(RobolectricTestRunner::class) +internal class RingingStateJoinAndRingTest : TestBase() { + + // Coroutines launched by CallState land here instead of the global uncaught handler, + // where the test framework would attribute them to whichever runTest enters next. + private val uncaughtExceptions = CopyOnWriteArrayList() + private val scope = CoroutineScope( + SupervisorJob() + + dispatcherRule.testDispatcher + + CoroutineExceptionHandler { _, e -> uncaughtExceptions += e }, + ) + + private val user = User(id = "caller", createdAt = nowUtc, updatedAt = nowUtc) + + private val activeCall = MutableStateFlow(null) + private val ringingCall = MutableStateFlow(null) + + private val clientState = mockk(relaxed = true) { + every { activeCall } returns this@RingingStateJoinAndRingTest.activeCall + every { ringingCall } returns this@RingingStateJoinAndRingTest.ringingCall + } + private val client = mockk(relaxed = true) { + every { userId } returns this@RingingStateJoinAndRingTest.user.id + every { state } returns clientState + } + private val call = mockk(relaxed = true) { + every { type } returns "default" + every { id } returns "join-and-ring-test" + every { cid } returns "default:join-and-ring-test" + // A real flow: SharedFlow.collect returns Nothing, so collecting the relaxed + // mock would throw KotlinNothingValueException from CallState's sorter coroutine. + every { events } returns MutableSharedFlow() + } + + @After + fun tearDownScope() { + scope.cancel() + assertTrue( + uncaughtExceptions.isEmpty(), + "CallState coroutines threw: $uncaughtExceptions", + ) + } + + private fun callStateInJoinAndRingWindow(): CallState { + val callState = CallState(client, call, user, scope) + // The call was created by us; nobody accepted or rejected yet. + callState.updateFromResponse(call.toResponse(user.toResponse())) + // joinAndRing() toggled the flag and the join completed (active call registered), + // but the ring request has not completed yet, so ringingCall is still null. + callState.toggleJoinAndRingProgress(true) + activeCall.value = call + // The SFU join response transitions the ringing state to Outgoing directly. + callState.handleEvent( + JoinCallResponseEvent( + callState = stream.video.sfu.models.CallState(), + participantCount = ParticipantCount(total = 1, anonymous = 0), + fastReconnectDeadlineSeconds = 0, + isReconnected = false, + publishOptions = emptyList(), + ), + ) + assertTrue(callState.ringingState.value is RingingState.Outgoing) + return callState + } + + @Test + fun `a recompute before the SFU join sets Outgoing still yields Idle`() { + val callState = CallState(client, call, user, scope) + callState.updateFromResponse(call.toResponse(user.toResponse())) + callState.toggleJoinAndRingProgress(true) + activeCall.value = call + + // Join-and-ring is in progress but nothing set Outgoing yet, so the guard must not + // apply and the state stays Idle (the UI legitimately shows the loading screen). + callState.updateRingingState() + + assertTrue(callState.ringingState.value is RingingState.Idle) + } + + @Test + fun `an event arriving before the ring request completes keeps the Outgoing state`() { + val callState = callStateInJoinAndRingWindow() + + // A coordinator event (e.g. call.session_started) recomputes the ringing state while + // ringingCall is still null. It used to downgrade Outgoing -> Idle. + callState.updateRingingState() + + assertTrue(callState.ringingState.value is RingingState.Outgoing) + } + + @Test + fun `recomputing after the ring request registers the ringing call yields Outgoing`() { + val callState = callStateInJoinAndRingWindow() + // Simulate the clobber the old code produced, so the recovery path is exercised even + // if the guard above changes. + callState.updateRingingState() + + // joinAndRing() registers the ringing call on ring success and recomputes. + ringingCall.value = call + callState.updateRingingState() + + val state = callState.ringingState.value + assertTrue(state is RingingState.Outgoing && !state.acceptedByCallee) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt index 4b288462e6a..1573ec059ff 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt @@ -285,9 +285,11 @@ class CallJoinCoordinatorTest { ) { val coordinator = coordinator() val transient = Error.ThrowableError("Unable to resolve host", Exception("dns")) + val permanentThrowable = Error.ThrowableError("socket reset", Exception("io")) val permanent = Error.GenericError("server error") assertThat(coordinator.isPermanentError(transient)).isFalse() + assertThat(coordinator.isPermanentError(permanentThrowable)).isTrue() assertThat(coordinator.isPermanentError(permanent)).isTrue() } @@ -641,6 +643,28 @@ class CallJoinCoordinatorTest { assertThat(sessionFlow.value).isNull() } + @Test + fun `join succeeds when a recoverable socket failure recovers`() = runTest( + testDispatcher, + ) { + stubJoinCall(Success(mockJoinResponse)) + coEvery { mockSession.connectInternal() } coAnswers { + // The reconnect settled as Connected before recovery was evaluated. + connectionFlow.value = RealtimeConnection.Connected + SfuConnectionResult.Failure( + Exception("recoverable socket failure"), + cause = SfuConnectFailureCause.RecoverableSocketFailure, + ) + } + + val result = coordinator().joinInternal( + joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt), + ) + advanceUntilIdle() + + assertThat(result).isInstanceOf(Success::class.java) + } + @Test fun `joinAndRing joins then rings the members`() = runTest(testDispatcher) { stubJoinCall(Success(mockJoinResponse)) @@ -653,6 +677,9 @@ class CallJoinCoordinatorTest { assertThat(result).isInstanceOf(Success::class.java) coVerify { apiClient.ring(any()) } + // registerOutgoingRing (not markRinging) so the outgoing call foreground service + // starts and the caller gets the outgoing call notification, like create-with-ring. + verify { callRegistry.registerOutgoingRing() } } @Test