diff --git a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/pages/DirectCallPage.kt b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/pages/DirectCallPage.kt index 7463a6b98f3..ee9ce5aced0 100644 --- a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/pages/DirectCallPage.kt +++ b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/pages/DirectCallPage.kt @@ -22,6 +22,7 @@ class DirectCallPage { companion object { val participantName = By.res("Stream_DirectCallUserName") + val joinAndRingCheckbox = By.res("Stream_JoinAndRingCheckbox") val audioCallButton = By.res("Stream_AudioCallButton") val videoCallButton = By.res("Stream_VideoCallButton") } diff --git a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobot.kt b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobot.kt index 08284259268..54c783f22a3 100644 --- a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobot.kt +++ b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobot.kt @@ -74,9 +74,14 @@ class UserRobot { return this } - fun directCall(audioOnly: Boolean): UserRobot { + fun directCall(audioOnly: Boolean, joinAndRing: Boolean = false): UserRobot { CallDetailsPage.wheelIcon.waitToAppearAndClick() CallDetailsPage.directCallButton.waitToAppearAndClick() + DirectCallPage.joinAndRingCheckbox.waitToAppear().let { checkbox -> + if (checkbox.isChecked != joinAndRing) { + checkbox.click() + } + } DirectCallPage.participantName.waitToAppearAndClick() val callButton = if (audioOnly) audioCallButton else videoCallButton callButton.waitToAppearAndClick() 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 2e4b20e062e..703ec4cbbce 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 @@ -292,12 +292,20 @@ fun UserRobot.assertOutgoingCall(audioOnly: Boolean = true, isDisplayed: Boolean // 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()) + assertTrue( + "Microphone", + RingPage.microphoneEnabledToggle.waitDisplayed() || + RingPage.microphoneDisabledToggle.waitDisplayed(), + ) if (audioOnly) { assertFalse("Camera enabled toggle", RingPage.cameraEnabledToggle.isDisplayed()) assertFalse("Camera disabled toggle", RingPage.cameraDisabledToggle.isDisplayed()) } else { - assertTrue("Camera", RingPage.cameraEnabledToggle.waitDisplayed()) + assertTrue( + "Camera", + RingPage.cameraEnabledToggle.waitDisplayed() || + RingPage.cameraDisabledToggle.waitDisplayed(), + ) } } else { assertFalse( 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 27a1502304c..0526b444cc7 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 @@ -16,6 +16,10 @@ package io.getstream.video.android.tests +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.robots.UserControls.DISABLE import io.getstream.video.android.robots.assertAudioCallControls import io.getstream.video.android.robots.assertConnectingView @@ -24,12 +28,114 @@ 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.getstream.video.android.uiautomator.seconds import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Test class RingingTests : StreamTestCase() { + @Test + fun testJoinAndRingRemainsOutgoingAfterFastReconnectBeforeCalleeAccepts() { + startUnansweredJoinAndRingCall() + + step("WHEN the caller completes a fast reconnect before the callee accepts") { + reconnectOutgoingCall(expectSessionReplacement = false) { + fastReconnect("E2E: outgoing join-and-ring") + } + } + step("THEN the outgoing call screen is still displayed") { + userRobot.assertOutgoingCall(audioOnly = false, isDisplayed = true) + } + + userRobot.declineOutgoingCall() + } + + @Test + fun testJoinAndRingRemainsOutgoingAfterRejoinBeforeCalleeAccepts() { + startUnansweredJoinAndRingCall() + + step("WHEN the caller completes a rejoin before the callee accepts") { + reconnectOutgoingCall(expectSessionReplacement = true) { + rejoin("E2E: outgoing join-and-ring") + } + } + step("THEN the outgoing call screen is still displayed") { + userRobot.assertOutgoingCall(audioOnly = false, isDisplayed = true) + } + + userRobot.declineOutgoingCall() + } + + private fun startUnansweredJoinAndRingCall() { + step("GIVEN the caller joins first and rings a callee who has not accepted") { + // Do not start the participant robot: the selected callee remains unanswered. + userRobot + .logout() + .loginAsRandomUser() + .directCall(audioOnly = false, joinAndRing = true) + .assertOutgoingCall(audioOnly = false, isDisplayed = true) + } + } + + private fun reconnectOutgoingCall( + expectSessionReplacement: Boolean, + reconnect: suspend Call.() -> Unit, + ) { + val call = requireNotNull(StreamVideo.instance().state.ringingCall.value) { + "Expected the join-and-ring call to be registered as the ringing call" + } + assertTrue(call.state.ringingState.value is RingingState.Outgoing) + assertEquals(RealtimeConnection.Connected, call.state.connection.value) + val sessionIdBeforeReconnect = call.sessionId + + runBlocking { + // Subscribe before triggering the reconnect so a fast Reconnecting transition + // cannot be missed by a polling assertion. + val enteredReconnecting = async(start = CoroutineStart.UNDISPATCHED) { + call.state.connection.first { it is RealtimeConnection.Reconnecting } + } + val reconnectCompleted = async { + reconnect(call) + } + + val reconnectingState = withTimeoutOrNull(30.seconds) { + enteredReconnecting.await() + } + assertNotNull( + "Expected the connection to enter Reconnecting within 30 seconds, " + + "but its current state is ${call.state.connection.value}", + reconnectingState, + ) + withTimeout(60.seconds) { reconnectCompleted.await() } + val connectedState = withTimeoutOrNull(30.seconds) { + call.state.connection.first { it is RealtimeConnection.Connected } + } + assertNotNull( + "Expected the connection to recover to Connected within 30 seconds, " + + "but its current state is ${call.state.connection.value}", + connectedState, + ) + } + + if (expectSessionReplacement) { + assertNotEquals(sessionIdBeforeReconnect, call.sessionId) + } else { + assertEquals(sessionIdBeforeReconnect, call.sessionId) + } + assertTrue(call.state.ringingState.value is RingingState.Outgoing) + } + @AllureId("7774") @Test fun testParticipantRejectsTheOutgoingCall() { diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt index 85d45f2f018..d234884fe44 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt @@ -305,7 +305,9 @@ private fun Body( Text(stringResource(id = R.string.join_first), color = Color.White) Checkbox( callerJoinsFirst, - modifier = Modifier.offset(x = 10.dp), + modifier = Modifier + .offset(x = 10.dp) + .testTag("Stream_JoinAndRingCheckbox"), colors = CheckboxDefaults.colors( uncheckedColor = Color.White, // Border color when unchecked checkedColor = Color.White, // Fill color when checked