Skip to content

Commit a7be637

Browse files
VelikovPetarPetarVelikov
and
PetarVelikov
authored
[AND-419] Prevent crash when accessing ChatClient.globalState. (#5702)
* [AND-419] Prevent crash when accessing globalState. * [AND-419] Prevent crash when accessing globalState. * [AND-419] Update CHANGELOG.md. * [AND-419] Simplify ChatClient.globalStateFlow extension. * [AND-419] Add warning logs. * [AND-419] Update CHANGELOG.md. --------- Co-authored-by: PetarVelikov <[email protected]>
1 parent 47c9bf3 commit a7be637

File tree

15 files changed

+228
-46
lines changed

15 files changed

+228
-46
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545

4646
### ✅ Added
4747
- Add `GlobalState.channelDraftMessages` and `GlobalState.threadDraftMessages` properties providing access to the draft messages. [#5682](https://github.com/GetStream/stream-chat-android/pull/5682)
48+
- Add `ChatClient.globalStateFlow` flow holding the `GlobalState` object, which emits values only if the user is connected. [#5702](https://github.com/GetStream/stream-chat-android/pull/5702)
4849

4950
### ⚠️ Changed
5051

@@ -65,6 +66,7 @@
6566
## stream-chat-android-ui-components
6667
### 🐞 Fixed
6768
- Fix audio recording attachments not paused when the app goes to the background or the screen is covered with another one. [#5685](https://github.com/GetStream/stream-chat-android/pull/5685)
69+
- Fix crash happening after process death when accessing `GlobalState` from the UI components. [#5702](https://github.com/GetStream/stream-chat-android/pull/5702)
6870

6971
### ⬆️ Improved
7072
- Enable pagination in `MentionListView`. [#5692](https://github.com/GetStream/stream-chat-android/pull/5692)
@@ -74,6 +76,7 @@
7476

7577
### ⚠️ Changed
7678
- 🚨Breaking change: Move `MentionListViewModel` logic and its state to a shared component so they can be reused in Compose. [#5692](https://github.com/GetStream/stream-chat-android/pull/5692)
79+
- 🚨Breaking change: `ChannelListViewModel` now accepts a `Flow<GlobalState>` instead of `GlobalState` for the `globalState` constructor parameter. [#5702](https://github.com/GetStream/stream-chat-android/pull/5702)
7780

7881
### ❌ Removed
7982

@@ -82,6 +85,7 @@
8285
- Fix audio recording attachments not paused when the app goes to the background or the screen is covered with another one. [#5685](https://github.com/GetStream/stream-chat-android/pull/5685)
8386
- Not show deleted poll messages. [#5689](https://github.com/GetStream/stream-chat-android/pull/5689)
8487
- Fix "Thread reply" item shown in the message options menu for messages in a Thread. [#5683](https://github.com/GetStream/stream-chat-android/pull/5683)
88+
- Fix crash happening after process death when accessing `GlobalState` from the UI components. [#5702](https://github.com/GetStream/stream-chat-android/pull/5702)
8589

8690
### ⬆️ Improved
8791

stream-chat-android-compose/api/stream-chat-android-compose.api

+2-2
Original file line numberDiff line numberDiff line change
@@ -3924,8 +3924,8 @@ public final class io/getstream/chat/android/compose/util/KeyValuePair {
39243924

39253925
public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel : androidx/lifecycle/ViewModel {
39263926
public static final field $stable I
3927-
public fun <init> (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;IIILio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZ)V
3928-
public synthetic fun <init> (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;IIILio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZILkotlin/jvm/internal/DefaultConstructorMarker;)V
3927+
public fun <init> (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;IIILio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLkotlinx/coroutines/flow/Flow;)V
3928+
public synthetic fun <init> (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;IIILio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
39293929
public final fun archiveChannel (Lio/getstream/chat/android/models/Channel;)V
39303930
public final fun deleteConversation (Lio/getstream/chat/android/models/Channel;)V
39313931
public final fun dismissChannelAction ()V

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt

+22-4
Original file line numberDiff line numberDiff line change
@@ -41,25 +41,30 @@ import io.getstream.chat.android.models.TypingEvent
4141
import io.getstream.chat.android.models.User
4242
import io.getstream.chat.android.models.querysort.QuerySorter
4343
import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory
44-
import io.getstream.chat.android.state.extensions.globalState
44+
import io.getstream.chat.android.state.extensions.globalStateFlow
4545
import io.getstream.chat.android.state.extensions.queryChannelsAsState
46+
import io.getstream.chat.android.state.plugin.state.global.GlobalState
4647
import io.getstream.chat.android.state.plugin.state.querychannels.ChannelsStateData
4748
import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState
4849
import io.getstream.chat.android.ui.common.state.channels.actions.Cancel
4950
import io.getstream.chat.android.ui.common.state.channels.actions.ChannelAction
5051
import io.getstream.chat.android.uiutils.extension.defaultChannelListFilter
5152
import io.getstream.log.taggedLogger
5253
import io.getstream.result.call.toUnitCall
54+
import kotlinx.coroutines.ExperimentalCoroutinesApi
5355
import kotlinx.coroutines.SupervisorJob
5456
import kotlinx.coroutines.cancelChildren
5557
import kotlinx.coroutines.flow.Flow
5658
import kotlinx.coroutines.flow.MutableStateFlow
59+
import kotlinx.coroutines.flow.SharingStarted
5760
import kotlinx.coroutines.flow.StateFlow
5861
import kotlinx.coroutines.flow.collectLatest
5962
import kotlinx.coroutines.flow.combine
6063
import kotlinx.coroutines.flow.filterNotNull
6164
import kotlinx.coroutines.flow.first
65+
import kotlinx.coroutines.flow.flatMapLatest
6266
import kotlinx.coroutines.flow.map
67+
import kotlinx.coroutines.flow.stateIn
6368
import kotlinx.coroutines.job
6469
import kotlinx.coroutines.launch
6570
import kotlinx.coroutines.plus
@@ -79,7 +84,9 @@ import kotlin.coroutines.cancellation.CancellationException
7984
* @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler].
8085
* @param searchDebounceMs The debounce time for search queries.
8186
* @param isDraftMessageEnabled If the draft message feature is enabled.
87+
* @param globalState A flow emitting the current [GlobalState].
8288
*/
89+
@OptIn(ExperimentalCoroutinesApi::class)
8390
@Suppress("TooManyFunctions")
8491
public class ChannelListViewModel(
8592
public val chatClient: ChatClient,
@@ -91,6 +98,7 @@ public class ChannelListViewModel(
9198
private val chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState),
9299
searchDebounceMs: Long = SEARCH_DEBOUNCE_MS,
93100
private val isDraftMessageEnabled: Boolean,
101+
private val globalState: Flow<GlobalState> = chatClient.globalStateFlow,
94102
) : ViewModel() {
95103

96104
private val logger by taggedLogger("Chat:ChannelListVM")
@@ -182,7 +190,17 @@ public class ChannelListViewModel(
182190
/**
183191
* Gives us the information about the list of channels mutes by the current user.
184192
*/
185-
public val channelMutes: StateFlow<List<ChannelMute>> = chatClient.globalState.channelMutes
193+
public val channelMutes: StateFlow<List<ChannelMute>> = globalState
194+
.flatMapLatest { it.channelMutes }
195+
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
196+
197+
private val typingChannels: StateFlow<Map<String, TypingEvent>> = globalState
198+
.flatMapLatest { it.typingChannels }
199+
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())
200+
201+
private val channelDraftMessages: StateFlow<Map<String, DraftMessage>> = globalState
202+
.flatMapLatest { it.channelDraftMessages }
203+
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())
186204

187205
/**
188206
* Builds the default channel filter, which represents "messaging" channels that the current user is a part of.
@@ -394,8 +412,8 @@ public class ChannelListViewModel(
394412
combine(
395413
queryChannelsState.channelsStateData,
396414
channelMutes,
397-
chatClient.globalState.typingChannels,
398-
chatClient.globalState.channelDraftMessages,
415+
typingChannels,
416+
channelDraftMessages,
399417
) { state, channelMutes, typingChannels, channelDraftMessages ->
400418
when (state) {
401419
ChannelsStateData.NoQueryActive,

stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt

+1
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ internal class ChannelListViewModelTest {
420420
initialFilters = initialFilters,
421421
isDraftMessageEnabled = false,
422422
chatEventHandlerFactory = ChatEventHandlerFactory(clientState),
423+
globalState = MutableStateFlow(globalState),
423424
)
424425
testScope.advanceUntilIdle()
425426
return channelListViewModel

stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt

+3-1
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,8 @@ internal class MessageComposerViewModelTest {
392392
whenever(statePlugin.resolveDependency(eq(StateRegistry::class))) doReturn stateRegistry
393393
whenever(statePlugin.resolveDependency(eq(GlobalState::class))) doReturn globalState
394394
whenever(statePluginFactory.resolveDependency(eq(StatePluginConfig::class))) doReturn statePluginConfig
395+
whenever(globalState.channelDraftMessages) doReturn MutableStateFlow(emptyMap())
396+
whenever(globalState.threadDraftMessages) doReturn MutableStateFlow(emptyMap())
395397
whenever(chatClient.plugins) doReturn listOf(statePlugin)
396398
whenever(chatClient.pluginFactories) doReturn listOf(statePluginFactory)
397399
whenever(chatClient.audioPlayer) doReturn mock()
@@ -439,11 +441,11 @@ internal class MessageComposerViewModelTest {
439441
mediaRecorder = mock(),
440442
userLookupHandler = DefaultUserLookupHandler(chatClient, channelId),
441443
fileToUri = { it.path },
442-
globalState = globalState,
443444
config = MessageComposerController.Config(
444445
maxAttachmentCount = maxAttachmentCount,
445446
),
446447
channelState = MutableStateFlow(channelState),
448+
globalState = MutableStateFlow(globalState),
447449
),
448450
)
449451
}

stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/channels/ChannelListHeader.kt

-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import androidx.compose.ui.Modifier
1616
import io.getstream.chat.android.client.ChatClient
1717
import io.getstream.chat.android.compose.ui.channels.header.ChannelListHeader
1818
import io.getstream.chat.android.compose.ui.theme.ChatTheme
19-
import io.getstream.chat.android.state.extensions.globalState
2019

2120
/**
2221
* [Usage](https://getstream.io/chat/docs/sdk/android/compose/channel-components/channel-list-header/#usage)

stream-chat-android-state/api/stream-chat-android-state.api

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public final class io/getstream/chat/android/state/extensions/ChatClientExtensio
7272
public static final fun cancelEphemeralMessage (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/Message;)Lio/getstream/result/call/Call;
7373
public static final fun downloadAttachment (Lio/getstream/chat/android/client/ChatClient;Landroid/content/Context;Lio/getstream/chat/android/models/Attachment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lio/getstream/result/call/Call;
7474
public static final fun getGlobalState (Lio/getstream/chat/android/client/ChatClient;)Lio/getstream/chat/android/state/plugin/state/global/GlobalState;
75+
public static final fun getGlobalStateFlow (Lio/getstream/chat/android/client/ChatClient;)Lkotlinx/coroutines/flow/Flow;
7576
public static final fun getMessageUsingCache (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;)Lio/getstream/result/call/Call;
7677
public static final fun getRepliesAsState (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;IZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
7778
public static final fun getRepliesAsState (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;IZLkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;

stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt

+17
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,15 @@ import io.getstream.result.call.CoroutineCall
5858
import kotlinx.coroutines.CoroutineScope
5959
import kotlinx.coroutines.Job
6060
import kotlinx.coroutines.coroutineScope
61+
import kotlinx.coroutines.flow.Flow
6162
import kotlinx.coroutines.flow.SharingStarted
6263
import kotlinx.coroutines.flow.StateFlow
6364
import kotlinx.coroutines.flow.combine
6465
import kotlinx.coroutines.flow.distinctUntilChanged
66+
import kotlinx.coroutines.flow.filter
6567
import kotlinx.coroutines.flow.map
6668
import kotlinx.coroutines.flow.mapLatest
69+
import kotlinx.coroutines.flow.onEach
6770
import kotlinx.coroutines.flow.stateIn
6871
import java.text.SimpleDateFormat
6972
import java.util.Date
@@ -90,6 +93,20 @@ public val ChatClient.globalState: GlobalState
9093
@Throws(IllegalArgumentException::class)
9194
get() = resolveDependency<StatePlugin, GlobalState>()
9295

96+
/**
97+
* Retrieves a [Flow] holding the [GlobalState] object, which emits only if the user is connected, and the [ChatClient]
98+
* is in [InitializationState.COMPLETE] state.
99+
*/
100+
public val ChatClient.globalStateFlow: Flow<GlobalState>
101+
get() = clientState.initializationState
102+
.onEach {
103+
if (it == InitializationState.NOT_INITIALIZED) {
104+
StreamLog.w(TAG) { "ChatClient::connectUser() must be called to ensure the globalState is initialized" }
105+
}
106+
}
107+
.filter { it == InitializationState.COMPLETE }
108+
.map { globalState }
109+
93110
/**
94111
* [StatePluginConfig] instance used to configure [io.getstream.chat.android.state.plugin.internal.StatePlugin].
95112
*
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright (c) 2014-2025 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.state.extensions
18+
19+
import app.cash.turbine.test
20+
import io.getstream.chat.android.client.ChatClient
21+
import io.getstream.chat.android.client.setup.state.ClientState
22+
import io.getstream.chat.android.models.InitializationState
23+
import io.getstream.chat.android.state.plugin.internal.StatePlugin
24+
import io.getstream.chat.android.state.plugin.state.global.GlobalState
25+
import kotlinx.coroutines.flow.MutableStateFlow
26+
import kotlinx.coroutines.test.runTest
27+
import org.amshove.kluent.shouldBeEqualTo
28+
import org.junit.jupiter.api.Test
29+
import org.mockito.kotlin.any
30+
import org.mockito.kotlin.doReturn
31+
import org.mockito.kotlin.eq
32+
import org.mockito.kotlin.mock
33+
import org.mockito.kotlin.whenever
34+
35+
internal class ChatClientExtensionsTest {
36+
37+
@Test
38+
fun `Given ChatClient not initialized, When collecting globalStateFlow, There are no emissions()`() = runTest {
39+
// given
40+
val initializationState = InitializationState.NOT_INITIALIZED
41+
val globalState = mock<GlobalState>()
42+
val sut = Fixture().get(initializationState, globalState)
43+
// when
44+
sut.globalStateFlow.test {
45+
// then
46+
expectNoEvents()
47+
}
48+
}
49+
50+
@Test
51+
fun `Given ChatClient initializing, When collecting globalStateFlow, There are no emissions()`() = runTest {
52+
// given
53+
val initializationState = InitializationState.INITIALIZING
54+
val globalState = mock<GlobalState>()
55+
val sut = Fixture().get(initializationState, globalState)
56+
// when / then
57+
sut.globalStateFlow.test {
58+
// then
59+
expectNoEvents()
60+
}
61+
}
62+
63+
@Test
64+
fun `Given ChatClient initialized, When collecting globalStateFlow, Then globalState is emitted()`() = runTest {
65+
// given
66+
val initializationState = InitializationState.COMPLETE
67+
val globalState = mock<GlobalState>()
68+
val sut = Fixture().get(initializationState, globalState)
69+
// when / then
70+
sut.globalStateFlow.test {
71+
// then
72+
val emission = awaitItem()
73+
emission shouldBeEqualTo globalState
74+
expectNoEvents()
75+
}
76+
}
77+
78+
private class Fixture {
79+
80+
fun get(
81+
initializationState: InitializationState,
82+
globalState: GlobalState,
83+
): ChatClient {
84+
val client = mock<ChatClient>()
85+
// Prepare initialization state
86+
val clientState = mock<ClientState>()
87+
whenever(clientState.initializationState)
88+
.thenReturn(MutableStateFlow(initializationState))
89+
whenever(client.awaitInitializationState(any()))
90+
.thenReturn(initializationState)
91+
// Prepare StatePlugin
92+
val statePlugin: StatePlugin = mock()
93+
whenever(statePlugin.resolveDependency(eq(GlobalState::class))) doReturn globalState
94+
whenever(client.plugins).thenReturn(listOf(statePlugin))
95+
whenever(client.clientState).thenReturn(clientState)
96+
return client
97+
}
98+
}
99+
}

0 commit comments

Comments
 (0)