Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,23 @@

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
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}" }

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
rahul-lohra marked this conversation as resolved.
rtcSession
}.onError {
logger.e { "[joinAndRing] Ring failed #ringing; #track; error: $it" }
Expand All @@ -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
}
Expand Down Expand Up @@ -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",
),
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Call?>(null)
private val ringingCall = MutableStateFlow<Call?>(null)

private val clientState = mockk<ClientState>(relaxed = true) {
every { activeCall } returns this@CallStateTelecomHoldTest.activeCall
every { ringingCall } returns this@CallStateTelecomHoldTest.ringingCall
}
private val client = mockk<StreamVideoClient>(relaxed = true) {
every { userId } returns this@CallStateTelecomHoldTest.user.id
every { state } returns clientState
}
private val call = mockk<Call>(relaxed = true) {
every { type } returns "default"
every { id } returns "telecom-hold-test"
every { cid } returns "default:telecom-hold-test"
every { events } returns MutableSharedFlow<VideoEvent>()
}

@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<TelecomCall.Registered> { every { isOnHold } returns true }
callState.jetpackTelecomRepository = mockk<JetpackTelecomRepository> {
every { currentCall } returns MutableStateFlow<TelecomCall>(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<CallLeaveReason> {
it is CallLeaveReason.SdkDriven && it.cause == SdkCause.CALL_ON_HOLD
},
)
}
}
}
Loading
Loading