Skip to content

Commit 4fe1b3e

Browse files
Merge branch 'develop' into chore/merge-develop-into-develop-v2
Bring in the NC signal test hardening, join-after-relogin wait, and stopService null-check from develop, keeping the v2 StreamClient cleanup tests. Co-authored-by: Cursor <cursoragent@cursor.com>
2 parents f191600 + 622f5ed commit 4fe1b3e

3 files changed

Lines changed: 92 additions & 11 deletions

File tree

demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinViewModel.kt

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import io.getstream.video.android.model.User
3131
import io.getstream.video.android.model.mapper.isValidCallCid
3232
import io.getstream.video.android.model.mapper.toTypeAndId
3333
import io.getstream.video.android.tooling.util.StreamBuildFlavorUtil
34+
import io.getstream.video.android.util.InitializedState
3435
import io.getstream.video.android.util.NetworkMonitor
3536
import io.getstream.video.android.util.StreamVideoInitHelper
3637
import io.getstream.video.android.util.fcmToken
@@ -41,6 +42,7 @@ import kotlinx.coroutines.flow.SharedFlow
4142
import kotlinx.coroutines.flow.SharingStarted
4243
import kotlinx.coroutines.flow.collectLatest
4344
import kotlinx.coroutines.flow.filterNotNull
45+
import kotlinx.coroutines.flow.first
4446
import kotlinx.coroutines.flow.flatMapLatest
4547
import kotlinx.coroutines.flow.flowOf
4648
import kotlinx.coroutines.flow.shareIn
@@ -69,7 +71,11 @@ class CallJoinViewModel @Inject constructor(
6971
}
7072
is CallJoinEvent.JoinCall -> {
7173
val call = joinCall(event.callId)
72-
flowOf(CallJoinUiState.JoinCompleted(callId = call.cid))
74+
if (call != null) {
75+
flowOf(CallJoinUiState.JoinCompleted(callId = call.cid))
76+
} else {
77+
flowOf(CallJoinUiState.GoBackToLogin)
78+
}
7379
}
7480
is CallJoinEvent.JoinCompleted -> flowOf(
7581
CallJoinUiState.JoinCompleted(event.callId),
@@ -110,8 +116,18 @@ class CallJoinViewModel @Inject constructor(
110116
viewModelScope.launch { this@CallJoinViewModel.event.emit(event) }
111117
}
112118

113-
private fun joinCall(callId: String? = null): Call {
114-
val streamVideo = StreamVideo.instance()
119+
private suspend fun joinCall(callId: String? = null): Call? {
120+
// A fast re-login lands on this screen while StreamVideoInitHelper.loadSdk, started in
121+
// init, is still rebuilding the SDK, so the instance may not be installed yet at tap
122+
// time. loadSdk returns early when another initialization is already in flight, so the
123+
// helper's terminal state must be awaited before reading the instance.
124+
if (!StreamVideo.isInstalled) {
125+
StreamVideoInitHelper.loadSdk(dataStore = dataStore)
126+
StreamVideoInitHelper.initializedState.first {
127+
it == InitializedState.FINISHED || it == InitializedState.FAILED
128+
}
129+
}
130+
val streamVideo = StreamVideo.instanceOrNull() ?: return null
115131
val newCallId = callId ?: "default:${UUID.randomUUID()}"
116132
val (type, id) = if (newCallId.isValidCallCid()) {
117133
newCallId.toTypeAndId()

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

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@ import org.junit.runner.RunWith
3232
import org.robolectric.RobolectricTestRunner
3333
import stream.video.sfu.signal.StartNoiseCancellationResponse
3434
import stream.video.sfu.signal.StopNoiseCancellationResponse
35+
import java.util.concurrent.CopyOnWriteArrayList
3536
import kotlin.test.assertEquals
3637

3738
@RunWith(RobolectricTestRunner::class)
3839
class NoiseCancellationSignalTest : IntegrationTestBase(connectCoordinatorWS = false) {
3940

4041
private companion object {
4142
const val SIGNAL_TIMEOUT_MS = 5_000L
43+
const val SIGNAL_POLL_MS = 10L
4244
}
4345

4446
/**
@@ -80,8 +82,8 @@ class NoiseCancellationSignalTest : IntegrationTestBase(connectCoordinatorWS = f
8082
}
8183

8284
/** Records the state of every signal the SFU receives, in the order it receives them. */
83-
private fun record(session: RtcSession): MutableList<Boolean> {
84-
val signalled = mutableListOf<Boolean>()
85+
private fun record(session: RtcSession): List<Boolean> {
86+
val signalled = CopyOnWriteArrayList<Boolean>()
8587
coEvery { session.startNoiseCancellation() } coAnswers {
8688
signalled += true
8789
Result.Success(mockk<StartNoiseCancellationResponse>(relaxed = true))
@@ -93,6 +95,29 @@ class NoiseCancellationSignalTest : IntegrationTestBase(connectCoordinatorWS = f
9395
return signalled
9496
}
9597

98+
/**
99+
* MockK records the mocked call before the answer body runs, and the append to the list
100+
* happens on the signal coroutine, so right after a passing coVerify the recorded signals
101+
* may not be visible to the test thread yet. Polls until they match instead of asserting
102+
* on a snapshot.
103+
*/
104+
private fun awaitSignalled(signalled: List<Boolean>, expected: List<Boolean>) {
105+
val deadline = System.currentTimeMillis() + SIGNAL_TIMEOUT_MS
106+
while (signalled != expected && System.currentTimeMillis() < deadline) {
107+
Thread.sleep(SIGNAL_POLL_MS)
108+
}
109+
assertEquals(expected, signalled.toList())
110+
}
111+
112+
/** Same visibility caveat as [awaitSignalled], for a test where only the final state matters. */
113+
private fun awaitFinalSignal(signalled: List<Boolean>, expected: Boolean) {
114+
val deadline = System.currentTimeMillis() + SIGNAL_TIMEOUT_MS
115+
while (signalled.lastOrNull() != expected && System.currentTimeMillis() < deadline) {
116+
Thread.sleep(SIGNAL_POLL_MS)
117+
}
118+
assertEquals(expected, signalled.lastOrNull())
119+
}
120+
96121
@Test
97122
fun `enabling audio processing signals the SFU that noise cancellation started`() = runTest {
98123
val (call, session) = callWithProcessor()
@@ -158,7 +183,7 @@ class NoiseCancellationSignalTest : IntegrationTestBase(connectCoordinatorWS = f
158183
call.injectSession(session)
159184

160185
coVerify(timeout = SIGNAL_TIMEOUT_MS) { session.startNoiseCancellation() }
161-
assertEquals(listOf(true), signalled)
186+
awaitSignalled(signalled, listOf(true))
162187
}
163188

164189
@Test
@@ -174,7 +199,7 @@ class NoiseCancellationSignalTest : IntegrationTestBase(connectCoordinatorWS = f
174199
call.injectSession(replacement)
175200

176201
coVerify(timeout = SIGNAL_TIMEOUT_MS) { replacement.startNoiseCancellation() }
177-
assertEquals(listOf(true), signalled)
202+
awaitSignalled(signalled, listOf(true))
178203
}
179204

180205
@Test
@@ -190,7 +215,7 @@ class NoiseCancellationSignalTest : IntegrationTestBase(connectCoordinatorWS = f
190215

191216
// Waiting on the stop means every signal before it has already landed.
192217
coVerify(timeout = SIGNAL_TIMEOUT_MS) { session.stopNoiseCancellation() }
193-
assertEquals(false, signalled.last())
218+
awaitFinalSignal(signalled, expected = false)
194219
}
195220

196221
@Test
@@ -212,6 +237,6 @@ class NoiseCancellationSignalTest : IntegrationTestBase(connectCoordinatorWS = f
212237
call.injectSession(session)
213238

214239
coVerify(timeout = SIGNAL_TIMEOUT_MS) { session.startNoiseCancellation() }
215-
assertEquals(listOf(true), signalled)
240+
awaitSignalled(signalled, listOf(true))
216241
}
217242
}

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

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

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

19+
import android.app.ActivityManager
20+
import android.content.ComponentName
1921
import android.content.Context
2022
import androidx.lifecycle.Lifecycle
2123
import io.getstream.android.core.api.StreamClient
2224
import io.getstream.android.core.api.model.connection.StreamConnectionState
2325
import io.getstream.android.core.api.subscribe.StreamSubscription
2426
import io.getstream.video.android.core.internal.module.CoordinatorConnectionModule
27+
import io.getstream.video.android.core.notifications.internal.service.CallService
2528
import io.getstream.video.android.model.User
2629
import io.getstream.video.android.model.UserType
2730
import io.mockk.coEvery
2831
import io.mockk.coVerify
2932
import io.mockk.every
3033
import io.mockk.mockk
3134
import io.mockk.unmockkAll
35+
import io.mockk.verify
3236
import kotlinx.coroutines.delay
3337
import kotlinx.coroutines.flow.MutableStateFlow
3438
import kotlinx.coroutines.isActive
@@ -44,7 +48,8 @@ import kotlin.test.assertTrue
4448
/**
4549
* Runs under Robolectric so a real main looper exists and the test thread is the main thread,
4650
* which is exactly the situation an app is in when it calls StreamVideo.removeClient() from
47-
* a logout button handler.
51+
* a logout button handler. The stop intent inside cleanup() is also a real Intent instead of
52+
* a stubbed one that returns null from every builder call.
4853
*/
4954
@RunWith(RobolectricTestRunner::class)
5055
class StreamVideoClientCleanupTest {
@@ -74,11 +79,20 @@ class StreamVideoClientCleanupTest {
7479
)
7580
}
7681

82+
private fun mockContext(runningServices: List<ActivityManager.RunningServiceInfo>): Context {
83+
val context = mockk<Context>(relaxed = true)
84+
val activityManager = mockk<ActivityManager>()
85+
every { context.getSystemService(Context.ACTIVITY_SERVICE) } returns activityManager
86+
every { activityManager.getRunningServices(any()) } returns runningServices
87+
return context
88+
}
89+
7790
private fun buildClient(
7891
streamClient: StreamClient,
92+
context: Context = mockk(relaxed = true),
7993
cleanupDisconnectTimeoutMs: Long = 10_000L,
8094
): StreamVideoClient = StreamVideoClient(
81-
context = mockk<Context>(relaxed = true),
95+
context = context,
8296
initialUser = User(id = "user-1", type = UserType.Authenticated),
8397
apiKey = "apikey",
8498
token = "token",
@@ -205,4 +219,30 @@ class StreamVideoClientCleanupTest {
205219
// timeout branch ran instead of waiting for the disconnect.
206220
awaitScopeCancelled(client)
207221
}
222+
223+
// buildStopIntent returns null when the call service is not running. Passing the null
224+
// through to stopService used to throw a NullPointerException, swallowed by safeCall,
225+
// on every logout without a running call service. AND-1466 / #1794.
226+
@Test
227+
fun `cleanup does not call stopService when the call service is not running`() {
228+
val context = mockContext(runningServices = emptyList())
229+
val client = buildClient(mockStreamClient(), context)
230+
231+
client.cleanup()
232+
233+
verify(exactly = 0) { context.stopService(any()) }
234+
}
235+
236+
@Test
237+
fun `cleanup stops the call service when it is running`() {
238+
val runningService = ActivityManager.RunningServiceInfo().apply {
239+
service = ComponentName("io.getstream.video.android", CallService::class.java.name)
240+
}
241+
val context = mockContext(runningServices = listOf(runningService))
242+
val client = buildClient(mockStreamClient(), context)
243+
244+
client.cleanup()
245+
246+
verify(exactly = 1) { context.stopService(any()) }
247+
}
208248
}

0 commit comments

Comments
 (0)