From 7e26496f9fbc3ed93fcef6d3f551fe4d9940a8ae Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:03:36 +0200 Subject: [PATCH 1/4] Clear the thread and mention lists when the user logs out or another user connects --- .../feature/mentions/MentionListController.kt | 12 +++ .../feature/threads/ThreadListController.kt | 31 ++++--- .../mentions/MentionListControllerTest.kt | 88 ++++++++++++++++++- .../threads/ThreadListControllerTest.kt | 64 +++++++++++++- 4 files changed, 180 insertions(+), 15 deletions(-) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListController.kt index d6dc34a19f9b..8801a7dfc748 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListController.kt @@ -19,12 +19,14 @@ package io.getstream.chat.android.ui.common.feature.mentions import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.ui.common.model.MessageResult import io.getstream.chat.android.ui.common.state.mentions.MentionListEvent import io.getstream.chat.android.ui.common.state.mentions.MentionListState +import io.getstream.chat.android.ui.common.utils.extensions.userSessionEnds import io.getstream.log.taggedLogger import io.getstream.result.Error import io.getstream.result.Result @@ -35,6 +37,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn @@ -83,6 +86,13 @@ public class MentionListController( } } .launchIn(scope) + chatClient.clientState.userSessionEnds() + .onEach { + // Drops the previous user's mentions and loads the next user's once they're connected. + _state.value = MentionListState() + loadRequests.tryEmit(Unit) + } + .launchIn(scope) } /** @@ -125,6 +135,8 @@ public class MentionListController( } private suspend fun searchMentions(): Result { + // A user who is still connecting, e.g. right after a switch, can't search yet. + chatClient.clientState.initializationState.first { it == InitializationState.COMPLETE } val currentUser = requireNotNull(chatClient.getCurrentUser()) val channelFilter = Filters.`in`("members", listOf(currentUser.id)) val messageFilter = Filters.contains("mentioned_users.id", currentUser.id) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt index c352e4b17b02..a35520ec127f 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt @@ -25,13 +25,14 @@ import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.state.threads.ThreadListState +import io.getstream.chat.android.ui.common.utils.extensions.userSessionEnds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch @@ -80,22 +81,28 @@ public class ThreadListController( init { scope.launch { queryThreadsState - .filterNotNull() - .flatMapLatest { - combine( - it.threads, - it.loading, - it.loadingMore, - it.unseenThreadIds, - it.loadingError, - ) { threads, loading, loadingMore, unseenThreadIds, loadingError -> - ThreadListState(threads, loading, loadingMore, unseenThreadIds.size, loadingError) - } + // Null while no user is connected: stop following the previous state. + .flatMapLatest { state -> + state?.let { + combine( + it.threads, + it.loading, + it.loadingMore, + it.unseenThreadIds, + it.loadingError, + ) { threads, loading, loadingMore, unseenThreadIds, loadingError -> + ThreadListState(threads, loading, loadingMore, unseenThreadIds.size, loadingError) + } + } ?: emptyFlow() } .collectLatest { _state.value = it } } + scope.launch { + // Drops the previous user's threads; the query runs again once the next user is connected. + chatClient.clientState.userSessionEnds().collect { _state.value = INITIAL_STATE } + } } /** diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListControllerTest.kt index 169020cf8c4a..0c6b8dd985ca 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListControllerTest.kt @@ -19,7 +19,11 @@ package io.getstream.chat.android.ui.common.feature.mentions import app.cash.turbine.test import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.persistance.repository.RepositoryFacade +import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.models.User @@ -29,11 +33,13 @@ import io.getstream.chat.android.test.TestCall import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.model.MessageResult import io.getstream.chat.android.ui.common.state.mentions.MentionListEvent +import io.getstream.chat.android.ui.common.state.mentions.MentionListState import io.getstream.result.Error import io.getstream.result.Result import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -46,10 +52,13 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertInstanceOf import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever internal class MentionListControllerTest { @@ -99,6 +108,63 @@ internal class MentionListControllerTest { } } + @Test + fun `logout clears the mentions`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannels(channels = emptyList()) + .givenSearchMessagesResult(next = null, result = SearchMessagesResult(messages = listOf(Message(cid = CID)))) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + assertEquals(1, sut.state.value.results.size) + + fixture.disconnectUser() + testScheduler.runCurrent() + + assertEquals(MentionListState(), sut.state.value) + } + + @Test + fun `another user connecting loads their mentions`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannels(channels = emptyList()) + .givenSearchMessagesResult(next = null, result = SearchMessagesResult(messages = listOf(Message(cid = CID)))) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + + fixture.disconnectUser() + fixture.connectUser(User(id = "other")) + testScheduler.runCurrent() + + val captor = argumentCaptor() + verify(fixture.chatClient, times(2)).searchMessages( + channelFilter = captor.capture(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = anyOrNull(), + ) + assertEquals(Filters.`in`("members", listOf("other")), captor.lastValue) + assertEquals(1, sut.state.value.results.size) + } + + @Test + fun `no user connected yet waits for one`() = runTest { + val fixture = Fixture() + .givenChannels(channels = emptyList()) + .givenSearchMessagesResult(next = null, result = SearchMessagesResult(messages = listOf(Message(cid = CID)))) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + assertTrue(sut.state.value.isLoading) + + fixture.connectUser(User) + testScheduler.runCurrent() + + assertEquals(1, sut.state.value.results.size) + } + @Test fun `initial load error`() = runTest { val error = Error.GenericError("error") @@ -351,12 +417,30 @@ private class Fixture { private val sort: QuerySorter = mock() private val repositoryFacade: RepositoryFacade = mock() - private val chatClient: ChatClient = mock { + private val userFlow = MutableStateFlow(null) + private val initializationState = MutableStateFlow(InitializationState.NOT_INITIALIZED) + private val clientState: ClientState = mock { + on { user } doReturn userFlow + on { initializationState } doReturn initializationState + } + val chatClient: ChatClient = mock { whenever(mock.repositoryFacade) doReturn repositoryFacade + whenever(mock.clientState) doReturn clientState + whenever(mock.getCurrentUser()) doAnswer { userFlow.value } } fun givenCurrentUser(currentUser: User = User) = apply { - whenever(chatClient.getCurrentUser()) doReturn currentUser + connectUser(currentUser) + } + + fun connectUser(currentUser: User) { + userFlow.value = currentUser + initializationState.value = InitializationState.COMPLETE + } + + fun disconnectUser() { + initializationState.value = InitializationState.NOT_INITIALIZED + userFlow.value = null } fun givenSearchMessagesResult( diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt index f485d03eb4b8..87717cba470b 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt @@ -23,6 +23,8 @@ import io.getstream.chat.android.client.api.state.QueryThreadsState import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.QueryThreadsResult +import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.models.User import io.getstream.chat.android.randomString import io.getstream.chat.android.randomThread import io.getstream.result.call.Call @@ -203,13 +205,58 @@ internal class ThreadListControllerTest { } } + @Test + fun `logout clears the threads`() = runTest { + val threads = MutableStateFlow(listOf(randomThread())) + val fixture = Fixture() + .givenCurrentUser() + .givenQueryThreadsState(threadsState(threads)) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + assertEquals(1, sut.state.value.threads.size) + + fixture.disconnectUser() + testScheduler.runCurrent() + assertTrue(sut.state.value.threads.isEmpty()) + assertTrue(sut.state.value.isLoading) + + threads.value = listOf(randomThread(), randomThread()) + testScheduler.runCurrent() + assertTrue(sut.state.value.threads.isEmpty()) + } + + @Test + fun `the same user connecting again keeps the threads`() = runTest { + val state = threadsState(MutableStateFlow(listOf(randomThread()))) + val fixture = Fixture() + .givenCurrentUser() + .givenQueryThreadsState(state) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + + fixture.reconnect(state) + testScheduler.runCurrent() + + assertEquals(1, sut.state.value.threads.size) + assertFalse(sut.state.value.isLoading) + } + + private fun threadsState(threads: MutableStateFlow>): QueryThreadsState = mock { + on { it.threads } doReturn threads + on { loading } doReturn MutableStateFlow(false) + on { loadingMore } doReturn MutableStateFlow(false) + on { unseenThreadIds } doReturn MutableStateFlow(emptySet()) + on { loadingError } doReturn MutableStateFlow(false) + } + private class Fixture { private val queryThreadsStateFlow = MutableStateFlow(null) + private val userFlow = MutableStateFlow(null) private val mockClientState: ClientState = mock { on { connectionState } doReturn MutableStateFlow(ConnectionState.Connected) - on { user } doReturn MutableStateFlow(null) + on { user } doReturn userFlow } private val mockChatClient: ChatClient = mock { @@ -220,6 +267,21 @@ internal class ThreadListControllerTest { queryThreadsStateFlow.value = state } + fun givenCurrentUser(currentUser: User = User(id = "user")) = apply { + userFlow.value = currentUser + } + + fun disconnectUser() { + // Same order as ChatClient: the query state goes null before the user does. + queryThreadsStateFlow.value = null + userFlow.value = null + } + + fun reconnect(state: QueryThreadsState) { + queryThreadsStateFlow.value = null + queryThreadsStateFlow.value = state + } + fun givenQueryThreadsRequest(query: QueryThreadsRequest) = apply { whenever(mockChatClient.queryThreads(query)) doReturn mock>() From afee65c3f0e3687b6261bf3fabe80d570dada1fc Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:43:45 +0200 Subject: [PATCH 2/4] Keep the thread list running across user sessions so it reloads for the next user --- .../viewmodel/threads/ThreadListViewModel.kt | 5 +++++ .../threads/ThreadListViewModelFactoryTest.kt | 12 ++++++++---- .../feature/threads/ThreadListController.kt | 14 ++++++++++++-- .../threads/ThreadListControllerTest.kt | 18 ++++++++++++++++++ .../viewmodel/threads/ThreadListViewModel.kt | 5 +++++ 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt index 7d2b513260b0..dc36d361385a 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt @@ -62,4 +62,9 @@ public class ThreadListViewModel(private val controller: ThreadListController) : public fun loadNextPage() { controller.loadNextPage() } + + override fun onCleared() { + super.onCleared() + controller.close() + } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelFactoryTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelFactoryTest.kt index 4457a8c1f025..a85491022d13 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelFactoryTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelFactoryTest.kt @@ -19,12 +19,13 @@ package io.getstream.chat.android.compose.viewmodel.threads import androidx.lifecycle.ViewModel import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryThreadsRequest -import kotlinx.coroutines.test.TestScope +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.InitializationState +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Test import org.junit.jupiter.api.assertInstanceOf -import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock @@ -55,9 +56,12 @@ internal class ThreadListViewModelFactoryTest { private class Fixture { + private val mockClientState: ClientState = mock { + on { user } doReturn MutableStateFlow(null) + on { initializationState } doReturn MutableStateFlow(InitializationState.NOT_INITIALIZED) + } private val mockChatClient: ChatClient = mock { - on { clientState } doReturn mock() - on { inheritScope(any()) } doReturn TestScope() + on { clientState } doReturn mockClientState } fun get() = ThreadsViewModelFactory( diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt index a35520ec127f..9c4ce7c6ba4f 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt @@ -28,6 +28,8 @@ import io.getstream.chat.android.ui.common.state.threads.ThreadListState import io.getstream.chat.android.ui.common.utils.extensions.userSessionEnds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest @@ -42,7 +44,8 @@ import kotlinx.coroutines.launch * * @param query The [QueryThreadsRequest] object containing the parameters for querying threads. * @param chatClient The [ChatClient] instance for retrieving the Threads related data. - * @param scope The [CoroutineScope] on which coroutines should be launched. + * @param scope The [CoroutineScope] on which coroutines should be launched. The default one outlives user sessions, so + * the list can follow the next user, and runs one task at a time; [close] cancels it. * @param queryThreadsAsState Function that returns a [StateFlow] of [QueryThreadsState]. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -50,7 +53,7 @@ import kotlinx.coroutines.launch public class ThreadListController( private val query: QueryThreadsRequest, private val chatClient: ChatClient = ChatClient.instance(), - private val scope: CoroutineScope = chatClient.inheritScope { DispatcherProvider.IO }, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + DispatcherProvider.IO.limitedParallelism(1)), queryThreadsAsState: () -> StateFlow = { chatClient.queryThreadsAsState( request = query, @@ -105,6 +108,13 @@ public class ThreadListController( } } + /** + * Cancels the work started by this controller. + */ + public fun close() { + scope.cancel() + } + /** * Force loads the first page of threads. */ diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt index 87717cba470b..bdeda76e4f60 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt @@ -29,7 +29,9 @@ import io.getstream.chat.android.randomString import io.getstream.chat.android.randomThread import io.getstream.result.call.Call import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.job import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -225,6 +227,22 @@ internal class ThreadListControllerTest { assertTrue(sut.state.value.threads.isEmpty()) } + @Test + fun `close stops following the threads`() = runTest { + val threads = MutableStateFlow(listOf(randomThread())) + val fixture = Fixture() + .givenCurrentUser() + .givenQueryThreadsState(threadsState(threads)) + val sut = fixture.get(CoroutineScope(coroutineContext + SupervisorJob(coroutineContext.job))) + testScheduler.runCurrent() + + sut.close() + threads.value = listOf(randomThread(), randomThread()) + testScheduler.runCurrent() + + assertEquals(1, sut.state.value.threads.size) + } + @Test fun `the same user connecting again keeps the threads`() = runTest { val state = threadsState(MutableStateFlow(listOf(randomThread()))) diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt index 46046113f365..1ed7b0edf6b5 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt @@ -51,4 +51,9 @@ public class ThreadListViewModel(private val controller: ThreadListController) : public fun loadNextPage() { controller.loadNextPage() } + + override fun onCleared() { + super.onCleared() + controller.close() + } } From bc3f857eb445c9da0411b2f8d8cd34192318efec Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:43:45 +0200 Subject: [PATCH 3/4] Drop mentions from a session that ended and fail searches with no connected user instead of crashing --- .../feature/mentions/MentionListController.kt | 16 +++---- .../ui/common/utils/extensions/ClientState.kt | 12 +++++ .../mentions/MentionListControllerTest.kt | 44 +++++++++++++++++++ .../ui/viewmodel/search/SearchViewModel.kt | 6 +-- 4 files changed, 65 insertions(+), 13 deletions(-) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListController.kt index 8801a7dfc748..f54348dfb9d2 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListController.kt @@ -19,13 +19,13 @@ package io.getstream.chat.android.ui.common.feature.mentions import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Filters -import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.ui.common.model.MessageResult import io.getstream.chat.android.ui.common.state.mentions.MentionListEvent import io.getstream.chat.android.ui.common.state.mentions.MentionListState +import io.getstream.chat.android.ui.common.utils.extensions.awaitConnectedUser import io.getstream.chat.android.ui.common.utils.extensions.userSessionEnds import io.getstream.log.taggedLogger import io.getstream.result.Error @@ -37,10 +37,8 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update @@ -78,9 +76,9 @@ public class MentionListController( init { loadRequests.onStart { emit(Unit) } // Triggers the initial load - .flatMapLatest { flowOf(searchMentions()) } - .onEach { result -> - when (result) { + // A new load, or the reset when a user session ends, cancels the previous result's handling too. + .mapLatest { + when (val result = searchMentions()) { is Result.Success -> onSuccessResult(result.value) is Result.Failure -> onFailureResult(result.value) } @@ -136,8 +134,8 @@ public class MentionListController( private suspend fun searchMentions(): Result { // A user who is still connecting, e.g. right after a switch, can't search yet. - chatClient.clientState.initializationState.first { it == InitializationState.COMPLETE } - val currentUser = requireNotNull(chatClient.getCurrentUser()) + val currentUser = chatClient.clientState.awaitConnectedUser() + ?: return Result.Failure(Error.GenericError("No user is connected")) val channelFilter = Filters.`in`("members", listOf(currentUser.id)) val messageFilter = Filters.contains("mentioned_users.id", currentUser.id) val currentState = _state.value diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/ClientState.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/ClientState.kt index 04df053f52f4..ecc22c5aa834 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/ClientState.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/ClientState.kt @@ -18,8 +18,11 @@ package io.getstream.chat.android.ui.common.utils.extensions import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.models.InitializationState +import io.getstream.chat.android.models.User import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map @@ -38,3 +41,12 @@ public fun ClientState.userSessionEnds(): Flow = flow { previousUserId = userId } } + +/** + * Waits until the client is ready for a user and returns them, or `null` if they disconnected in the meantime. + */ +@InternalStreamChatApi +public suspend fun ClientState.awaitConnectedUser(): User? { + initializationState.first { it == InitializationState.COMPLETE } + return user.value +} diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListControllerTest.kt index 0c6b8dd985ca..6770049521fc 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListControllerTest.kt @@ -36,6 +36,7 @@ import io.getstream.chat.android.ui.common.state.mentions.MentionListEvent import io.getstream.chat.android.ui.common.state.mentions.MentionListState import io.getstream.result.Error import io.getstream.result.Result +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -55,6 +56,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.times @@ -165,6 +167,39 @@ internal class MentionListControllerTest { assertEquals(1, sut.state.value.results.size) } + @Test + fun `a result still being handled when the user logs out is dropped`() = runTest { + val channels = CompletableDeferred>() + val fixture = Fixture() + .givenCurrentUser() + .givenChannelsWhen(channels) + .givenSearchMessagesResult(next = null, result = SearchMessagesResult(messages = listOf(Message(cid = CID)))) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + + fixture.disconnectUser() + testScheduler.runCurrent() + channels.complete(emptyList()) + testScheduler.runCurrent() + + assertEquals(MentionListState(), sut.state.value) + } + + @Test + fun `no user once the client is ready reports an error`() = runTest { + val fixture = Fixture() + .givenChannels(channels = emptyList()) + .givenSearchMessagesResult(next = null, result = SearchMessagesResult(messages = listOf(Message(cid = CID)))) + val sut = fixture.get(backgroundScope) + + sut.events.test { + fixture.loseUserOnceReady() + testScheduler.runCurrent() + assertInstanceOf(awaitItem()) + } + assertFalse(sut.state.value.isLoading) + } + @Test fun `initial load error`() = runTest { val error = Error.GenericError("error") @@ -472,6 +507,15 @@ private class Fixture { } } + suspend fun givenChannelsWhen(channels: CompletableDeferred>) = apply { + whenever(repositoryFacade.selectChannels(channelCIDs = any())) doSuspendableAnswer { channels.await() } + } + + fun loseUserOnceReady() { + userFlow.value = null + initializationState.value = InitializationState.COMPLETE + } + suspend fun givenChannels(channels: List) = apply { whenever(repositoryFacade.selectChannels(channelCIDs = any())) doReturn channels } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt index 88c9a4e3b8b6..d75645b9698a 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt @@ -23,9 +23,9 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.state.Event import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.Filters -import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.ui.common.model.MessageResult +import io.getstream.chat.android.ui.common.utils.extensions.awaitConnectedUser import io.getstream.chat.android.ui.common.utils.extensions.userSessionEnds import io.getstream.log.taggedLogger import io.getstream.result.Error @@ -33,7 +33,6 @@ import io.getstream.result.Result import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** @@ -183,8 +182,7 @@ public class SearchViewModel( private suspend fun searchMessages(query: String, next: String?): Result { logger.d { "Searching for \"$query\" with next: $next" } // A user who is still connecting, e.g. right after a switch, can't search yet. - chatClient.clientState.initializationState.first { it == InitializationState.COMPLETE } - val currentUser = chatClient.clientState.user.value + val currentUser = chatClient.clientState.awaitConnectedUser() ?: return Result.Failure(Error.GenericError("No user is connected")) return chatClient .searchMessages( From b5b2ba796066bf16290c7ab30c8dda688e6bf851 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:12:06 +0200 Subject: [PATCH 4/4] Close only the thread list controller's own work, and test reloading it for the next user --- .../viewmodel/threads/ThreadListViewModel.kt | 7 +- .../threads/ThreadListViewModelTest.kt | 23 ++++++ .../feature/threads/ThreadListController.kt | 30 ++++--- .../threads/ThreadListControllerTest.kt | 79 +++++++++++++++++-- .../viewmodel/threads/ThreadListViewModel.kt | 7 +- .../threads/ThreadListViewModelTest.kt | 71 +++++++++++++++++ 6 files changed, 187 insertions(+), 30 deletions(-) create mode 100644 stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/threads/ThreadListViewModelTest.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt index dc36d361385a..7147e8b7e133 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt @@ -29,7 +29,7 @@ import kotlinx.coroutines.flow.StateFlow * @param controller The [ThreadListController] handling the business logic and the state management for the threads * list. */ -public class ThreadListViewModel(private val controller: ThreadListController) : ViewModel() { +public class ThreadListViewModel(private val controller: ThreadListController) : ViewModel(controller) { /** * The current thread list state. @@ -62,9 +62,4 @@ public class ThreadListViewModel(private val controller: ThreadListController) : public fun loadNextPage() { controller.loadNextPage() } - - override fun onCleared() { - super.onCleared() - controller.close() - } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelTest.kt index cf3325baf11a..2dfb54b0ff0b 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelTest.kt @@ -16,6 +16,9 @@ package io.getstream.chat.android.compose.viewmodel.threads +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore import app.cash.turbine.test import io.getstream.chat.android.randomBoolean import io.getstream.chat.android.randomInt @@ -71,6 +74,17 @@ internal class ThreadListViewModelTest { fixture.verifyLoadNextPage() } + @Test + fun `clearing the view model closes the controller`() { + val fixture = Fixture() + val store = ViewModelStore() + ViewModelProvider(store, fixture.factory())[ThreadListViewModel::class.java] + + store.clear() + + fixture.verifyClose() + } + private class Fixture { private val mockController: ThreadListController = mock() @@ -86,6 +100,15 @@ internal class ThreadListViewModelTest { verify(mockController).loadNextPage() } + fun verifyClose() = apply { + verify(mockController).close() + } + + fun factory() = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = get() as T + } + fun get() = ThreadListViewModel( controller = mockController, ) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt index 9c4ce7c6ba4f..690bc9b628d0 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt @@ -28,6 +28,7 @@ import io.getstream.chat.android.ui.common.state.threads.ThreadListState import io.getstream.chat.android.ui.common.utils.extensions.userSessionEnds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow @@ -44,23 +45,26 @@ import kotlinx.coroutines.launch * * @param query The [QueryThreadsRequest] object containing the parameters for querying threads. * @param chatClient The [ChatClient] instance for retrieving the Threads related data. - * @param scope The [CoroutineScope] on which coroutines should be launched. The default one outlives user sessions, so - * the list can follow the next user, and runs one task at a time; [close] cancels it. - * @param queryThreadsAsState Function that returns a [StateFlow] of [QueryThreadsState]. + * @param scope The [CoroutineScope] the controller's work runs under, in a child scope that [close] cancels; the given + * scope itself is never cancelled. The default one outlives user sessions, so the list can follow the next user, and + * runs one task at a time. + * @param queryThreadsAsState Function that returns a [StateFlow] of [QueryThreadsState], built in the given scope. */ @OptIn(ExperimentalCoroutinesApi::class) @InternalStreamChatApi public class ThreadListController( private val query: QueryThreadsRequest, private val chatClient: ChatClient = ChatClient.instance(), - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + DispatcherProvider.IO.limitedParallelism(1)), - queryThreadsAsState: () -> StateFlow = { + scope: CoroutineScope = CoroutineScope(DispatcherProvider.IO.limitedParallelism(1)), + queryThreadsAsState: (CoroutineScope) -> StateFlow = { stateScope -> chatClient.queryThreadsAsState( request = query, - coroutineScope = scope, + coroutineScope = stateScope, ) }, -) { +) : AutoCloseable { + + private val ownScope = CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job])) /** * Exposes the current thread list state. @@ -79,10 +83,10 @@ public class ThreadListController( */ public val user: StateFlow = chatClient.clientState.user - private val queryThreadsState = queryThreadsAsState() + private val queryThreadsState = queryThreadsAsState(ownScope) init { - scope.launch { + ownScope.launch { queryThreadsState // Null while no user is connected: stop following the previous state. .flatMapLatest { state -> @@ -102,17 +106,17 @@ public class ThreadListController( _state.value = it } } - scope.launch { + ownScope.launch { // Drops the previous user's threads; the query runs again once the next user is connected. chatClient.clientState.userSessionEnds().collect { _state.value = INITIAL_STATE } } } /** - * Cancels the work started by this controller. + * Cancels the work started by this controller, without cancelling the scope it was given. */ - public fun close() { - scope.cancel() + override fun close() { + ownScope.cancel() } /** diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt index bdeda76e4f60..f94e4b451dbc 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt @@ -21,6 +21,7 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.api.state.QueryThreadsState import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.QueryThreadsResult import io.getstream.chat.android.models.Thread @@ -30,13 +31,17 @@ import io.getstream.chat.android.randomThread import io.getstream.result.call.Call import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.job +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -233,7 +238,8 @@ internal class ThreadListControllerTest { val fixture = Fixture() .givenCurrentUser() .givenQueryThreadsState(threadsState(threads)) - val sut = fixture.get(CoroutineScope(coroutineContext + SupervisorJob(coroutineContext.job))) + val callerScope = CoroutineScope(coroutineContext + SupervisorJob(coroutineContext.job)) + val sut = fixture.get(callerScope) testScheduler.runCurrent() sut.close() @@ -241,6 +247,48 @@ internal class ThreadListControllerTest { testScheduler.runCurrent() assertEquals(1, sut.state.value.threads.size) + assertTrue(callerScope.isActive) + callerScope.cancel() + } + + @Test + fun `another user connecting loads their threads`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenQueryThreadsState(threadsState(MutableStateFlow(listOf(randomThread())))) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + + fixture.disconnectUser() + testScheduler.runCurrent() + fixture.connectUser(User(id = "other"), threadsState(MutableStateFlow(listOf(randomThread(), randomThread())))) + testScheduler.runCurrent() + + assertEquals(2, sut.state.value.threads.size) + assertFalse(sut.state.value.isLoading) + } + + @Test + fun `with the default scope another user connecting loads their threads`() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + DispatcherProvider.set(testDispatcher, testDispatcher) + try { + val fixture = Fixture() + .givenCurrentUser() + .givenQueryThreadsState(threadsState(MutableStateFlow(listOf(randomThread())))) + val sut = fixture.getWithDefaultScope() + testScheduler.runCurrent() + + fixture.disconnectUser() + testScheduler.runCurrent() + fixture.connectUser(User(id = "other"), threadsState(MutableStateFlow(listOf(randomThread(), randomThread())))) + testScheduler.runCurrent() + + assertEquals(2, sut.state.value.threads.size) + sut.close() + } finally { + DispatcherProvider.reset() + } } @Test @@ -252,7 +300,13 @@ internal class ThreadListControllerTest { val sut = fixture.get(backgroundScope) testScheduler.runCurrent() - fixture.reconnect(state) + // The client's state goes null and back while the same user connects again; the user may be re-emitted. + fixture.publishQueryState(null) + testScheduler.runCurrent() + assertEquals(1, sut.state.value.threads.size) + assertFalse(sut.state.value.isLoading) + fixture.givenCurrentUser(User(id = "user", name = "renamed")) + fixture.publishQueryState(state) testScheduler.runCurrent() assertEquals(1, sut.state.value.threads.size) @@ -277,8 +331,12 @@ internal class ThreadListControllerTest { on { user } doReturn userFlow } + // Stands in for the client's per-user scope, which disconnect cancels. + private val userSessionScope = CoroutineScope(SupervisorJob()) + private val mockChatClient: ChatClient = mock { on { clientState } doReturn mockClientState + on { inheritScope(any()) } doReturn userSessionScope } fun givenQueryThreadsState(state: QueryThreadsState) = apply { @@ -290,13 +348,18 @@ internal class ThreadListControllerTest { } fun disconnectUser() { - // Same order as ChatClient: the query state goes null before the user does. + // Same order as ChatClient: the query state goes null, the user's scope is cancelled, then the user goes. queryThreadsStateFlow.value = null + userSessionScope.cancel() userFlow.value = null } - fun reconnect(state: QueryThreadsState) { - queryThreadsStateFlow.value = null + fun connectUser(newUser: User, state: QueryThreadsState) { + userFlow.value = newUser + queryThreadsStateFlow.value = state + } + + fun publishQueryState(state: QueryThreadsState?) { queryThreadsStateFlow.value = state } @@ -322,5 +385,11 @@ internal class ThreadListControllerTest { scope = scope, queryThreadsAsState = { queryThreadsStateFlow }, ) + + fun getWithDefaultScope() = ThreadListController( + query = QueryThreadsRequest(), + chatClient = mockChatClient, + queryThreadsAsState = { queryThreadsStateFlow }, + ) } } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt index 1ed7b0edf6b5..9a922b4f2622 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt @@ -28,7 +28,7 @@ import io.getstream.chat.android.ui.common.state.threads.ThreadListState * @param controller The [ThreadListController] handling the business logic and the state management for the threads * list. */ -public class ThreadListViewModel(private val controller: ThreadListController) : ViewModel() { +public class ThreadListViewModel(private val controller: ThreadListController) : ViewModel(controller) { /** * The current thread list state. @@ -51,9 +51,4 @@ public class ThreadListViewModel(private val controller: ThreadListController) : public fun loadNextPage() { controller.loadNextPage() } - - override fun onCleared() { - super.onCleared() - controller.close() - } } diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/threads/ThreadListViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/threads/ThreadListViewModelTest.kt new file mode 100644 index 000000000000..ddbab351bebb --- /dev/null +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/threads/ThreadListViewModelTest.kt @@ -0,0 +1,71 @@ +/* + * 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-chat-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.chat.android.ui.viewmodels.threads + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import io.getstream.chat.android.test.InstantTaskExecutorExtension +import io.getstream.chat.android.test.TestCoroutineExtension +import io.getstream.chat.android.ui.common.feature.threads.ThreadListController +import io.getstream.chat.android.ui.common.state.threads.ThreadListState +import io.getstream.chat.android.ui.viewmodel.threads.ThreadListViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +internal class ThreadListViewModelTest { + + @Test + fun `clearing the view model closes the controller`() { + val controller: ThreadListController = mock { + on { state } doReturn MutableStateFlow( + ThreadListState( + threads = emptyList(), + isLoading = true, + isLoadingMore = false, + unseenThreadsCount = 0, + ), + ) + } + val store = ViewModelStore() + ViewModelProvider( + store, + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = ThreadListViewModel(controller) as T + }, + )[ThreadListViewModel::class.java] + + store.clear() + + verify(controller).close() + } + + companion object { + @JvmField + @RegisterExtension + val testCoroutines: TestCoroutineExtension = TestCoroutineExtension() + + @JvmField + @RegisterExtension + val instantExecutorExtension: InstantTaskExecutorExtension = InstantTaskExecutorExtension() + } +}