Skip to content

Commit 268830e

Browse files
Merge branch 'develop' into fix/mute-state-sync-cancels-other-tracks
2 parents 616ec1c + 01799d5 commit 268830e

14 files changed

Lines changed: 762 additions & 72 deletions

File tree

demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobotCallAsserts.kt

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,23 @@
1616

1717
package io.getstream.video.android.robots
1818

19+
import android.app.Notification
20+
import android.app.NotificationManager
1921
import androidx.test.uiautomator.BySelector
2022
import io.getstream.video.android.pages.CallPage
2123
import io.getstream.video.android.pages.CallPage.SettingsMenu
2224
import io.getstream.video.android.pages.RingPage
2325
import io.getstream.video.android.robots.UserControls.DISABLE
2426
import io.getstream.video.android.robots.UserControls.ENABLE
27+
import io.getstream.video.android.uiautomator.appContext
2528
import io.getstream.video.android.uiautomator.defaultTimeout
2629
import io.getstream.video.android.uiautomator.device
2730
import io.getstream.video.android.uiautomator.findObject
2831
import io.getstream.video.android.uiautomator.findObjects
2932
import io.getstream.video.android.uiautomator.isDisplayed
3033
import io.getstream.video.android.uiautomator.retryOnStaleObjectException
3134
import io.getstream.video.android.uiautomator.seconds
35+
import io.getstream.video.android.uiautomator.waitDisplayed
3236
import io.getstream.video.android.uiautomator.waitForCount
3337
import io.getstream.video.android.uiautomator.waitForText
3438
import io.getstream.video.android.uiautomator.waitToAppear
@@ -70,15 +74,23 @@ fun UserRobot.assertThatCallIsEnded(): UserRobot {
7074
}
7175

7276
fun UserRobot.assertUserMicrophone(isEnabled: Boolean, videoCall: Boolean = true): UserRobot {
77+
// The participant view icon updates slightly after the control toggle, so both
78+
// checks poll instead of asserting the icon at the instant the toggle appears.
7379
if (isEnabled) {
74-
assertTrue(CallPage.microphoneEnabledToggle.waitToAppear().isDisplayed())
80+
assertTrue("Microphone enabled toggle", CallPage.microphoneEnabledToggle.waitDisplayed())
7581
if (videoCall) {
76-
assertTrue(CallPage.ParticipantView.microphoneEnabledIcon.isDisplayed())
82+
assertTrue(
83+
"Participant microphone enabled icon",
84+
CallPage.ParticipantView.microphoneEnabledIcon.waitDisplayed(),
85+
)
7786
}
7887
} else {
79-
assertTrue(CallPage.microphoneDisabledToggle.waitToAppear().isDisplayed())
88+
assertTrue("Microphone disabled toggle", CallPage.microphoneDisabledToggle.waitDisplayed())
8089
if (videoCall) {
81-
assertTrue(CallPage.ParticipantView.microphoneDisabledIcon.isDisplayed())
90+
assertTrue(
91+
"Participant microphone disabled icon",
92+
CallPage.ParticipantView.microphoneDisabledIcon.waitDisplayed(),
93+
)
8294
}
8395
}
8496
return this
@@ -197,7 +209,12 @@ fun UserRobot.assertRecordingView(isDisplayed: Boolean): UserRobot {
197209
if (isDisplayed) {
198210
// The backend composite recorder can take 20-30s to actually start and emit
199211
// call.recording_started, so the icon needs a longer window than the 5s default.
200-
assertTrue(CallPage.recordingIcon.waitToAppear(timeOutMillis = 30.seconds).isDisplayed())
212+
// waitDisplayed also absorbs stale reads: the node returned by waitToAppear could
213+
// go stale before isDisplayed() and leak a StaleObjectException.
214+
assertTrue(
215+
"Recording icon",
216+
CallPage.recordingIcon.waitDisplayed(timeOutMillis = 30.seconds),
217+
)
201218
// After a network drop the label can briefly read "Reconnecting.." before it settles
202219
// back to "Recording", so poll instead of asserting on the first read.
203220
val callInfoText = CallPage.callInfoView.waitForText(
@@ -271,14 +288,17 @@ fun UserRobot.assertOutgoingCall(audioOnly: Boolean = true, isDisplayed: Boolean
271288
"Decline call button",
272289
RingPage.declineCallButton.waitToAppear(timeOutMillis = 30.seconds).isDisplayed(),
273290
)
274-
assertTrue("Call label", RingPage.outgoingCallLabel.isDisplayed())
275-
assertTrue("Avatar", RingPage.callParticipantAvatar.isDisplayed())
276-
assertTrue("Microphone", RingPage.microphoneEnabledToggle.isDisplayed())
277-
assertEquals(
278-
"Camera should be displayed: ${!audioOnly}",
279-
!audioOnly,
280-
RingPage.cameraEnabledToggle.isDisplayed(),
281-
)
291+
// The control toggles reflect async call state (the microphone can still show the
292+
// muted state right after the screen renders), so poll instead of instant asserts.
293+
assertTrue("Call label", RingPage.outgoingCallLabel.waitDisplayed())
294+
assertTrue("Avatar", RingPage.callParticipantAvatar.waitDisplayed())
295+
assertTrue("Microphone", RingPage.microphoneEnabledToggle.waitDisplayed())
296+
if (audioOnly) {
297+
assertFalse("Camera enabled toggle", RingPage.cameraEnabledToggle.isDisplayed())
298+
assertFalse("Camera disabled toggle", RingPage.cameraDisabledToggle.isDisplayed())
299+
} else {
300+
assertTrue("Camera", RingPage.cameraEnabledToggle.waitDisplayed())
301+
}
282302
} else {
283303
assertFalse(
284304
"Decline call button",
@@ -288,6 +308,30 @@ fun UserRobot.assertOutgoingCall(audioOnly: Boolean = true, isDisplayed: Boolean
288308
return this
289309
}
290310

311+
/**
312+
* Asserts the presence of the outgoing call notification, which the outgoing call foreground
313+
* service posts with the "Calling..." title (on the ongoing calls channel, see
314+
* getSimpleOngoingCallNotification). The instrumentation runs inside the app process, so the
315+
* check reads NotificationManager.activeNotifications directly instead of matching text in
316+
* the notification shade, where the outgoing screen shows the same "Calling..." text.
317+
* The service start and stop are asynchronous, so both directions poll.
318+
*/
319+
fun UserRobot.assertOutgoingCallNotification(isDisplayed: Boolean): UserRobot {
320+
val title = appContext.getString(
321+
io.getstream.video.android.core.R.string.stream_video_outgoing_call_notification_title,
322+
)
323+
val notificationManager = appContext.getSystemService(NotificationManager::class.java)
324+
fun displayed() = notificationManager.activeNotifications.any {
325+
it.notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() == title
326+
}
327+
val endTime = System.currentTimeMillis() + defaultTimeout
328+
while (displayed() != isDisplayed && System.currentTimeMillis() < endTime) {
329+
Thread.sleep(250)
330+
}
331+
assertEquals("Outgoing call notification displayed", isDisplayed, displayed())
332+
return this
333+
}
334+
291335
fun UserRobot.assertConnectingView(): UserRobot {
292336
assertEquals("Connecting...", RingPage.callProgressBar.waitToAppear().text)
293337
// Connecting covers the same call join round-trip as waitForCallToStart, which can

demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/ReconnectionTests.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,14 @@ class ReconnectionTests : StreamTestCase() {
113113
userRobot.joinCall()
114114
}
115115
step("AND participant joins the call") {
116+
// The recording window counts from the participant's start request, and the
117+
// composite recorder alone can take 20-30s to start. The window has to outlive
118+
// the drop, the reconnect and the final polling assert on a slow CI emulator,
119+
// otherwise the participant stops the recording on schedule before the assert
120+
// and the test fails on a recording that legitimately ended.
116121
participantRobot
117122
.setUserCount(participants)
118-
.setCallRecordingDuration(30)
123+
.setCallRecordingDuration(90)
119124
.joinCall(callId, actions = arrayOf(Actions.RECORD_CALL))
120125
}
121126
step("AND participant starts recording a call") {

demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/RingingTests.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import io.getstream.video.android.robots.assertAudioCallControls
2121
import io.getstream.video.android.robots.assertConnectingView
2222
import io.getstream.video.android.robots.assertIncomingCall
2323
import io.getstream.video.android.robots.assertOutgoingCall
24+
import io.getstream.video.android.robots.assertOutgoingCallNotification
2425
import io.getstream.video.android.robots.assertThatCallIsEnded
2526
import io.getstream.video.android.robots.assertVideoCallControls
2627
import io.qameta.allure.kotlin.Allure.step
@@ -80,12 +81,18 @@ class RingingTests : StreamTestCase() {
8081
step("THEN the outgoing call starts") {
8182
userRobot.assertOutgoingCall(audioOnly = true, isDisplayed = true)
8283
}
84+
step("AND the outgoing call notification is displayed") {
85+
userRobot.assertOutgoingCallNotification(isDisplayed = true)
86+
}
8387
step("WHEN user rejects the outgoing call") {
8488
userRobot.declineOutgoingCall()
8589
}
8690
step("THEN the outgoing call ends") {
8791
userRobot.assertOutgoingCall(isDisplayed = false)
8892
}
93+
step("AND the outgoing call notification is dismissed") {
94+
userRobot.assertOutgoingCallNotification(isDisplayed = false)
95+
}
8996
}
9097

9198
@AllureId("7776")

stream-video-android-core/api/stream-video-android-core.api

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9659,7 +9659,13 @@ public final class io/getstream/video/android/core/RingingState$Outgoing : io/ge
96599659
public fun <init> ()V
96609660
public fun <init> (Z)V
96619661
public synthetic fun <init> (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
9662+
public final fun component1 ()Z
9663+
public final fun copy (Z)Lio/getstream/video/android/core/RingingState$Outgoing;
9664+
public static synthetic fun copy$default (Lio/getstream/video/android/core/RingingState$Outgoing;ZILjava/lang/Object;)Lio/getstream/video/android/core/RingingState$Outgoing;
9665+
public fun equals (Ljava/lang/Object;)Z
96629666
public final fun getAcceptedByCallee ()Z
9667+
public fun hashCode ()I
9668+
public fun toString ()Ljava/lang/String;
96639669
}
96649670

96659671
public final class io/getstream/video/android/core/RingingState$RejectedByAll : io/getstream/video/android/core/RingingState {

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ import io.getstream.video.android.core.call.CallType
8686
import io.getstream.video.android.core.call.RtcSession
8787
import io.getstream.video.android.core.closedcaptions.ClosedCaptionManager
8888
import io.getstream.video.android.core.closedcaptions.ClosedCaptionsSettings
89+
import io.getstream.video.android.core.dispatchers.DispatcherProvider
8990
import io.getstream.video.android.core.events.AudioLevelChangedEvent
9091
import io.getstream.video.android.core.events.CallEndedSfuEvent
9192
import io.getstream.video.android.core.events.ConnectionQualityChangeEvent
@@ -131,7 +132,6 @@ import io.getstream.video.android.core.utils.toUser
131132
import io.getstream.video.android.model.StreamCallId
132133
import io.getstream.video.android.model.User
133134
import kotlinx.coroutines.CoroutineScope
134-
import kotlinx.coroutines.Dispatchers
135135
import kotlinx.coroutines.Job
136136
import kotlinx.coroutines.channels.awaitClose
137137
import kotlinx.coroutines.currentCoroutineContext
@@ -1354,7 +1354,6 @@ public class CallState(
13541354
_session.value?.participants?.find { it.user.id == client.userId } != null
13551355
val outgoingMembersCount = _members.value.filter { it.value.user.id != client.userId }.size
13561356
val isCallEnded: Boolean = _endedAt.value != null
1357-
val createdBySelf = createdBy?.id == client.userId
13581357

13591358
ringingLogger.d { "Current: ${_ringingState.value}, call_id: ${call.cid}" }
13601359

@@ -1430,6 +1429,11 @@ public class CallState(
14301429
} else {
14311430
if (_ringingState.value is RingingState.Incoming && !acceptedOnThisDevice) {
14321431
RingingState.TimeoutNoAnswer
1432+
} else if (isJoinAndRingInProgress.get() && _ringingState.value is RingingState.Outgoing) {
1433+
// During join-and-ring the SFU join sets Outgoing before the ring request has
1434+
// registered this call in client.state.ringingCall, so hasRingingCall is still
1435+
// false here. Falling back to Idle would hide the outgoing ringing UI.
1436+
_ringingState.value
14331437
} else {
14341438
RingingState.Idle
14351439
}
@@ -1898,12 +1902,12 @@ public class CallState(
18981902
private fun observeTelecomHold(repo: JetpackTelecomRepository) {
18991903
telecomHoldObserverJob?.cancel()
19001904

1901-
telecomHoldObserverJob = scope.launch(Dispatchers.Default) {
1905+
telecomHoldObserverJob = scope.launch(DispatcherProvider.Default) {
19021906
repo.currentCall
19031907
.map { (it as? TelecomCall.Registered)?.isOnHold == true }
19041908
.distinctUntilChanged()
19051909
.filter { it }
1906-
.collect { isOnHold ->
1910+
.collect { _ ->
19071911
when (ringingState.value) {
19081912
is RingingState.Active -> {
19091913
call.leave(CallLeaveReason.SdkDriven(cause = SdkCause.CALL_ON_HOLD))

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public sealed interface ConnectionState {
5252
public sealed interface RingingState {
5353
public data object Idle : RingingState
5454
public data class Incoming(val acceptedByMe: Boolean = false) : RingingState
55-
public class Outgoing(val acceptedByCallee: Boolean = false) : RingingState
55+
public data class Outgoing(val acceptedByCallee: Boolean = false) : RingingState
5656
public data object Active : RingingState
5757
public data object RejectedByAll : RingingState
5858
public data object TimeoutNoAnswer : RingingState

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt

Lines changed: 65 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -807,45 +807,25 @@ public class RtcSession internal constructor(
807807
}
808808
}
809809

810+
/** Applies [iceHealthTransition] to the current state. Internal for direct testing. */
811+
internal fun evaluateIceHealth() {
812+
val pubIce = publisher.value?.iceState?.value
813+
val subIce = subscriber.value?.iceState?.value
814+
val next = iceHealthTransition(
815+
connection = call.state.connection.value,
816+
sfuSocketConnected = _sfuSfuSocketState.value is SfuSocketState.Connected,
817+
publisherIce = pubIce,
818+
subscriberIce = subIce,
819+
)
820+
if (next != null) {
821+
logger.i { "[iceMonitor] pub=$pubIce, sub=$subIce — marking $next" }
822+
call.state._connection.value = next
823+
}
824+
}
825+
810826
private fun startIceMonitoring() {
811827
if (iceMonitoringJob?.isActive == true) return
812828
iceMonitoringJob = coroutineScope.launch {
813-
val badIceStates = setOf(
814-
PeerConnection.IceConnectionState.DISCONNECTED,
815-
PeerConnection.IceConnectionState.FAILED,
816-
)
817-
val goodIceStates = setOf(
818-
PeerConnection.IceConnectionState.CONNECTED,
819-
PeerConnection.IceConnectionState.COMPLETED,
820-
)
821-
822-
fun evaluateIceHealth() {
823-
val conn = call.state.connection.value
824-
val pubIce = publisher.value?.iceState?.value
825-
val subIce = subscriber.value?.iceState?.value
826-
827-
val pubBad = pubIce != null && pubIce in badIceStates
828-
val subBad = subIce != null && subIce in badIceStates
829-
830-
if ((pubBad || subBad) && conn is RealtimeConnection.Connected) {
831-
logger.w {
832-
"[iceMonitor] ICE degraded (pub=$pubIce, sub=$subIce) — marking Reconnecting"
833-
}
834-
call.state._connection.value = RealtimeConnection.Reconnecting
835-
} else if (conn is RealtimeConnection.Reconnecting &&
836-
_sfuSfuSocketState.value is SfuSocketState.Connected
837-
) {
838-
val pubOk = pubIce == null || pubIce in goodIceStates
839-
val subOk = subIce == null || subIce in goodIceStates
840-
if (pubOk && subOk) {
841-
logger.i {
842-
"[iceMonitor] ICE recovered (pub=$pubIce, sub=$subIce) — marking Connected"
843-
}
844-
call.state._connection.value = RealtimeConnection.Connected
845-
}
846-
}
847-
}
848-
849829
launch {
850830
publisher.collect { pub ->
851831
pub?.iceState?.collect { evaluateIceHealth() }
@@ -856,6 +836,12 @@ public class RtcSession internal constructor(
856836
sub?.iceState?.collect { evaluateIceHealth() }
857837
}
858838
}
839+
// The evaluation is edge-triggered by ICE changes, but after a reconnect the ICE
840+
// states can settle before the SFU socket reports Connected. Re-evaluate on socket
841+
// state changes too, so recovery does not depend on a later ICE transition.
842+
launch {
843+
_sfuSfuSocketState.collect { evaluateIceHealth() }
844+
}
859845
}
860846
}
861847

@@ -2244,7 +2230,49 @@ public class RtcSession internal constructor(
22442230
private fun connectInternalSafetyTimeoutMs(): Long =
22452231
clientImpl.connectionTimeoutInMs * 2 + CONNECT_INTERNAL_SAFETY_GRACE_MS
22462232

2247-
private companion object {
2233+
internal companion object {
2234+
private val badIceStates = setOf(
2235+
PeerConnection.IceConnectionState.DISCONNECTED,
2236+
PeerConnection.IceConnectionState.FAILED,
2237+
)
2238+
2239+
/**
2240+
* Decides the ICE health transition for the realtime connection, or null for no change.
2241+
*
2242+
* Degrades a Connected call when either peer connection reports a bad ICE state.
2243+
* Recovers a Reconnecting call once the SFU socket is connected and no side is bad.
2244+
* NEW and CHECKING count as healthy for the recovery: a peer connection with nothing
2245+
* to negotiate stays NEW forever (e.g. the subscriber right after a reconnect with no
2246+
* inbound tracks), so requiring an established state on both sides deadlocks the
2247+
* recovery and the UI shows "Reconnecting" indefinitely. If a side later fails, the
2248+
* degraded branch marks Reconnecting again.
2249+
*/
2250+
internal fun iceHealthTransition(
2251+
connection: RealtimeConnection,
2252+
sfuSocketConnected: Boolean,
2253+
publisherIce: PeerConnection.IceConnectionState?,
2254+
subscriberIce: PeerConnection.IceConnectionState?,
2255+
): RealtimeConnection? {
2256+
val pubBad = publisherIce != null && publisherIce in badIceStates
2257+
val subBad = subscriberIce != null && subscriberIce in badIceStates
2258+
// CLOSED must also block a recovery: a closed peer connection never emits another
2259+
// ICE event, so recovering past it would lock in a wrong Connected state. It is
2260+
// deliberately not a degrade trigger, because peer connections close during
2261+
// legitimate teardowns and the closing flow owns the connection state there.
2262+
val pubBlocked = pubBad || publisherIce == PeerConnection.IceConnectionState.CLOSED
2263+
val subBlocked = subBad || subscriberIce == PeerConnection.IceConnectionState.CLOSED
2264+
return when {
2265+
(pubBad || subBad) && connection is RealtimeConnection.Connected ->
2266+
RealtimeConnection.Reconnecting
2267+
2268+
connection is RealtimeConnection.Reconnecting && sfuSocketConnected &&
2269+
!pubBlocked && !subBlocked ->
2270+
RealtimeConnection.Connected
2271+
2272+
else -> null
2273+
}
2274+
}
2275+
22482276
private const val CONNECT_INTERNAL_SAFETY_GRACE_MS = 1_000L
22492277
}
22502278
}

0 commit comments

Comments
 (0)