Skip to content

Commit b44f0c9

Browse files
[AND-956] Fix an issue where Android SDK may end up in a reconnect loop (#1584)
* Do not create publisher if the user is not allowed to publish * Spotless
1 parent 4f46892 commit b44f0c9

File tree

4 files changed

+144
-10
lines changed

4 files changed

+144
-10
lines changed

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -744,8 +744,9 @@ public class RtcSession internal constructor(
744744
}
745745
}
746746
// step 6 - onNegotiationNeeded will trigger and complete the setup using SetPublisherRequest
747-
listenToMediaChanges()
748-
747+
publisher?.let {
748+
listenToMediaChanges()
749+
}
749750
// subscribe to the tracks of other participants
750751
setVideoSubscriptions(true)
751752
return
@@ -820,6 +821,15 @@ public class RtcSession internal constructor(
820821
// Note: Executor cleanup is handled by Call cleanup
821822
}
822823

824+
private fun hasPublishCapability(): Boolean {
825+
val capabilities = call.state.ownCapabilities.value
826+
return capabilities.any {
827+
it == OwnCapability.SendAudio ||
828+
it == OwnCapability.SendVideo ||
829+
it == OwnCapability.Screenshare
830+
}
831+
}
832+
823833
internal val muteState = MutableStateFlow(
824834
mapOf(
825835
TrackType.TRACK_TYPE_AUDIO to false,
@@ -1071,12 +1081,14 @@ public class RtcSession internal constructor(
10711081
call.state.replaceParticipants(participantStates)
10721082
sfuConnectionModule.socketConnection.whenConnected {
10731083
logger.d { "JoinCallResponseEvent sfuConnectionModule.socketConnection.whenConnected" }
1074-
if (publisher == null) {
1084+
if (publisher == null && hasPublishCapability()) {
10751085
publisher = createPublisher(event.publishOptions)
10761086
}
10771087
connectRtc()
10781088
processPendingSubscriberEvents()
1079-
processPendingPublisherEvents()
1089+
publisher?.let {
1090+
processPendingPublisherEvents()
1091+
}
10801092
}
10811093
}
10821094

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,8 @@ internal class Publisher(
188188
trackInfos.joinToString(separator = ";") { it.toString() },
189189
)
190190
if (trackInfos.isEmpty()) {
191-
logger.e { ("rejoin cause, Can't negotiate without announcing any tracks") }
192-
rejoin.invoke()
191+
logger.d { "No local tracks to publish, skipping publisher negotiate" }
192+
return@submit
193193
}
194194
logger.i { "Negotiating with tracks: $trackInfos" }
195195
logger.i { "Offer: ${offer.description}" }

stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/PublisherTest.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,15 @@ class PublisherTest {
275275
coVerify { publisher.negotiate(any()) }
276276
}
277277

278+
@Test
279+
fun `negotiate skips setPublisher when no tracks are announced`() = runTest(coroutineContext) {
280+
every { publisher.getAnnouncedTracks(any(), any()) } returns emptyList()
281+
282+
publisher.negotiate()
283+
284+
coVerify(exactly = 0) { mockSignalServerService.setPublisher(any()) }
285+
}
286+
278287
@Test
279288
fun `close with stopTracks = true stops publishing and closes connection`() = runTest {
280289
val mockVideoTrack = mockk<VideoTrack>(relaxed = true) {

stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/RtcSessionTest2.kt

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,22 @@ package io.getstream.video.android.core.rtc
1919
import android.graphics.ColorSpace.match
2020
import android.os.PowerManager
2121
import androidx.lifecycle.Lifecycle
22+
import io.getstream.android.video.generated.models.OwnCapability
2223
import io.getstream.video.android.core.Call
2324
import io.getstream.video.android.core.CallState
2425
import io.getstream.video.android.core.MediaManagerImpl
26+
import io.getstream.video.android.core.ParticipantState
2527
import io.getstream.video.android.core.StreamVideo
2628
import io.getstream.video.android.core.StreamVideoClient
2729
import io.getstream.video.android.core.call.RtcSession
2830
import io.getstream.video.android.core.call.connection.Publisher
2931
import io.getstream.video.android.core.events.ICETrickleEvent
32+
import io.getstream.video.android.core.events.JoinCallResponseEvent
3033
import io.getstream.video.android.core.events.SubscriberOfferEvent
3134
import io.getstream.video.android.core.internal.module.SfuConnectionModule
3235
import io.getstream.video.android.core.model.IceServer
36+
import io.getstream.video.android.core.socket.sfu.SfuSocketConnection
37+
import io.getstream.video.android.core.socket.sfu.state.SfuSocketState
3338
import io.mockk.MockKAnnotations
3439
import io.mockk.coEvery
3540
import io.mockk.coJustRun
@@ -40,26 +45,36 @@ import io.mockk.impl.annotations.RelaxedMockK
4045
import io.mockk.mockk
4146
import io.mockk.spyk
4247
import io.mockk.unmockkAll
48+
import io.mockk.verify
4349
import junit.framework.TestCase.assertEquals
4450
import junit.framework.TestCase.assertNotNull
4551
import junit.framework.TestCase.assertNull
4652
import junit.framework.TestCase.assertTrue
53+
import kotlinx.coroutines.delay
54+
import kotlinx.coroutines.flow.MutableStateFlow
55+
import kotlinx.coroutines.launch
4756
import kotlinx.coroutines.test.StandardTestDispatcher
4857
import kotlinx.coroutines.test.TestScope
4958
import kotlinx.coroutines.test.UnconfinedTestDispatcher
5059
import kotlinx.coroutines.test.runTest
5160
import org.junit.After
5261
import org.junit.Before
53-
import org.junit.Ignore
5462
import org.junit.Test
5563
import org.webrtc.SessionDescription
64+
import stream.video.sfu.models.ParticipantCount
5665
import stream.video.sfu.models.PeerType
66+
import stream.video.sfu.models.PublishOption
67+
import stream.video.sfu.models.TrackType
68+
import stream.video.sfu.models.VideoDimension
5769
import stream.video.sfu.signal.SendAnswerResponse
5870

5971
class RtcSessionTest2 {
6072

6173
private val testDispatcher = StandardTestDispatcher()
62-
private val testScope = TestScope(UnconfinedTestDispatcher())
74+
private val testScope = TestScope(testDispatcher)
75+
private val ownCapabilitiesFlow = MutableStateFlow<List<OwnCapability>>(emptyList())
76+
private val participantsFlow = MutableStateFlow<List<ParticipantState>>(emptyList())
77+
private val remoteParticipantsFlow = MutableStateFlow<List<ParticipantState>>(emptyList())
6378

6479
@MockK
6580
private lateinit var mockPowerManager: PowerManager
@@ -99,6 +114,10 @@ class RtcSessionTest2 {
99114
)
100115
} returns mockk(relaxed = true) {}
101116
}
117+
every { mockCallState.ownCapabilities } returns ownCapabilitiesFlow
118+
every { mockCallState.participants } returns participantsFlow
119+
every { mockCallState.remoteParticipants } returns remoteParticipantsFlow
120+
every { mockCallState.replaceParticipants(any()) } answers { }
102121

103122
// We can stub out other pieces
104123
every { mockCallState.me.value } returns null
@@ -255,10 +274,17 @@ class RtcSessionTest2 {
255274
}
256275

257276
// TODO: Test is broken because socket connection is not established in this test.
258-
@Ignore
259277
@Test
260278
fun `handleIceTrickle adds event to publisherPendingEvents if publisher is null`() = runTest {
261279
// Given an RtcSession with no publisher set (publisher = null by default until fully joined)
280+
val mockSocket = mockk<SfuSocketConnection>()
281+
val mockConnectedEvent = mockk<JoinCallResponseEvent>(relaxed = true)
282+
val socketStateFlow =
283+
MutableStateFlow<SfuSocketState>(SfuSocketState.Connected(mockConnectedEvent))
284+
every { mockSocket.state() } returns socketStateFlow
285+
val mockModule = mockk<SfuConnectionModule>(relaxed = true) {
286+
every { socketConnection } returns mockSocket
287+
}
262288
val rtcSession = RtcSession(
263289
client = mockStreamVideo,
264290
powerManager = mockPowerManager,
@@ -271,8 +297,9 @@ class RtcSessionTest2 {
271297
sfuToken = "fake-sfu-token",
272298
clientImpl = mockVideoClient,
273299
coroutineScope = testScope,
300+
rtcSessionScope = testScope,
274301
remoteIceServers = emptyList(),
275-
sfuConnectionModuleProvider = { mockk(relaxed = true) },
302+
sfuConnectionModuleProvider = { mockModule },
276303
)
277304
// Confirm publisher is null
278305
assertNull(rtcSession.publisher)
@@ -290,6 +317,7 @@ class RtcSessionTest2 {
290317

291318
// When
292319
rtcSession.handleIceTrickle(event)
320+
testScope.testScheduler.advanceUntilIdle()
293321

294322
// Then
295323
// The event should be added to publisherPendingEvents
@@ -380,9 +408,94 @@ class RtcSessionTest2 {
380408
coVerify { publisher.close(any()) }
381409
}
382410

411+
@Test
412+
fun `join response without publish capability skips publisher creation`() = runTest {
413+
ownCapabilitiesFlow.value = emptyList()
414+
val (rtcSession, _) = createRtcSessionSpyWithMockSocket()
415+
val event = fakeJoinResponseEvent(samplePublishOptions())
416+
417+
rtcSession.handleEvent(event)
418+
testScope.testScheduler.advanceUntilIdle()
419+
420+
assertNull(rtcSession.publisher)
421+
verify(exactly = 0) { rtcSession["createPublisher"](any<List<PublishOption>>()) }
422+
}
423+
383424
private fun <T> RtcSession.fieldValue(name: String): T? {
384425
val field = RtcSession::class.java.getDeclaredField(name)
385426
field.isAccessible = true
386427
return field.get(this) as? T
387428
}
429+
430+
private fun samplePublishOptions(): List<PublishOption> = listOf(
431+
PublishOption(
432+
track_type = TrackType.TRACK_TYPE_VIDEO,
433+
bitrate = 1_000_000,
434+
fps = 30,
435+
max_spatial_layers = 1,
436+
max_temporal_layers = 1,
437+
video_dimension = VideoDimension(width = 1280, height = 720),
438+
id = 1,
439+
),
440+
)
441+
442+
private fun fakeJoinResponseEvent(
443+
publishOptions: List<PublishOption>,
444+
): JoinCallResponseEvent {
445+
val protoCallState = mockk<stream.video.sfu.models.CallState>(relaxed = true) {
446+
every { participants } returns emptyList()
447+
}
448+
val count = mockk<io.getstream.video.android.core.events.ParticipantCount>(relaxed = true)
449+
return mockk(relaxed = true) {
450+
every { callState } returns protoCallState
451+
every { participantCount } returns count
452+
every { fastReconnectDeadlineSeconds } returns 0
453+
every { isReconnected } returns false
454+
every { this@mockk.publishOptions } returns publishOptions
455+
}
456+
}
457+
458+
private fun createRtcSessionSpyWithMockSocket(): Pair<RtcSession, Publisher> {
459+
val mockSocket = mockk<SfuSocketConnection>()
460+
val mockConnectedEvent = mockk<JoinCallResponseEvent>(relaxed = true)
461+
val socketStateFlow =
462+
MutableStateFlow<SfuSocketState>(SfuSocketState.Connected(mockConnectedEvent))
463+
every { mockSocket.state() } returns socketStateFlow
464+
every { mockSocket.whenConnected(any<Long>(), any()) } answers {
465+
val callback = secondArg<suspend (String) -> Unit>()
466+
// Launch the callback in backgroundScope to simulate the real whenConnected behavior
467+
// The real impl launches in socket's scope; we launch in background scope for control
468+
testScope.backgroundScope.launch {
469+
delay(500) // Simulate the real whenConnected delay
470+
callback("connection-id")
471+
}
472+
Unit // Return Unit since whenConnected returns Unit
473+
}
474+
val mockModule = mockk<SfuConnectionModule>(relaxed = true) {
475+
every { socketConnection } returns mockSocket
476+
every { api } returns mockk(relaxed = true)
477+
}
478+
val rtcSession = spyk(
479+
RtcSession(
480+
client = mockStreamVideo,
481+
powerManager = mockPowerManager,
482+
call = mockCall,
483+
sessionId = "session-id",
484+
apiKey = "api-key",
485+
lifecycle = mockLifecycle,
486+
sfuUrl = "https://test-sfu.stream.com",
487+
sfuWsUrl = "wss://test-sfu.stream.com",
488+
sfuToken = "fake-sfu-token",
489+
clientImpl = mockVideoClient,
490+
coroutineScope = testScope,
491+
rtcSessionScope = testScope,
492+
remoteIceServers = emptyList(),
493+
sfuConnectionModuleProvider = { mockModule },
494+
),
495+
recordPrivateCalls = true,
496+
)
497+
val publisherMock = mockk<Publisher>(relaxed = true)
498+
every { rtcSession["createPublisher"](any<List<PublishOption>>()) } returns publisherMock
499+
return rtcSession to publisherMock
500+
}
388501
}

0 commit comments

Comments
 (0)