Skip to content

Commit 9425b52

Browse files
committed
Fix syncing with custom limits.
1 parent 8c7278d commit 9425b52

15 files changed

Lines changed: 599 additions & 17 deletions

File tree

stream-chat-android-client/api/stream-chat-android-client.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3404,6 +3404,8 @@ public abstract interface class io/getstream/chat/android/client/plugin/listener
34043404
}
34053405

34063406
public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener {
3407+
public fun onQueryGroupedChannelsRequest (Ljava/lang/Integer;Ljava/util/Map;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
3408+
public static synthetic fun onQueryGroupedChannelsRequest$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener;Ljava/lang/Integer;Ljava/util/Map;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
34073409
public abstract fun onQueryGroupedChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/util/Map;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
34083410
}
34093411

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3171,6 +3171,11 @@ internal constructor(
31713171
presence: Boolean = false,
31723172
): Call<GroupedChannels> {
31733173
return api.queryGroupedChannels(limit = limit, groups = groups, watch = watch, presence = presence)
3174+
.doOnStart(userScope) {
3175+
plugins.forEach { plugin ->
3176+
plugin.onQueryGroupedChannelsRequest(limit, groups, watch, presence)
3177+
}
3178+
}
31743179
.doOnResult(userScope) { result ->
31753180
plugins.forEach { plugin ->
31763181
plugin.onQueryGroupedChannelsResult(result, limit, groups, watch, presence)

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,24 @@ import io.getstream.result.Result
2525
*/
2626
public interface QueryGroupedChannelsListener {
2727

28+
/**
29+
* Called before the query grouped channels request is dispatched to the API. Fires regardless
30+
* of whether the call later succeeds or fails, so the state plugin can persist the request
31+
* parameters early and recover them even when the response never lands.
32+
*
33+
* @param limit The request-level default per-group limit, or `null` for the server default.
34+
* @param groups The per-group request options being sent, or `null` when the request asks for
35+
* the server-defined default set of groups.
36+
* @param watch Whether watching was requested.
37+
* @param presence Whether presence was requested.
38+
*/
39+
public suspend fun onQueryGroupedChannelsRequest(
40+
limit: Int?,
41+
groups: Map<String, GroupedChannelsGroupQuery>?,
42+
watch: Boolean,
43+
presence: Boolean,
44+
) { /* No-Op */ }
45+
2846
/**
2947
* Called when the query grouped channels request completes.
3048
*

stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import org.junit.jupiter.api.Test
3434
import org.mockito.kotlin.any
3535
import org.mockito.kotlin.anyOrNull
3636
import org.mockito.kotlin.eq
37+
import org.mockito.kotlin.inOrder
3738
import org.mockito.kotlin.mock
3839
import org.mockito.kotlin.verify
3940
import org.mockito.kotlin.whenever
@@ -81,6 +82,75 @@ internal class ChatClientGroupedChannelsApiTests : BaseChatClientTest() {
8182
verifyNetworkError(result, errorCode)
8283
}
8384

85+
@Test
86+
fun `queryGroupedChannels dispatches request to plugin listeners before issuing the call`() = runTest {
87+
// given
88+
val plugin: Plugin = mock()
89+
plugins.add(plugin)
90+
val groupedChannels = GroupedChannels(
91+
groups = mapOf(
92+
"direct" to GroupedChannelsGroup(
93+
groupKey = "direct",
94+
channels = listOf(randomChannel()),
95+
),
96+
),
97+
)
98+
val sut = Fixture()
99+
.givenQueryGroupedChannelsResult(RetroSuccess(groupedChannels).toRetrofitCall())
100+
.get()
101+
val groupsParam = mapOf("direct" to GroupedChannelsGroupQuery(limit = 25))
102+
// when
103+
sut.queryGroupedChannels(
104+
limit = 30,
105+
groups = groupsParam,
106+
watch = true,
107+
presence = false,
108+
).await()
109+
// then - the request hook fires BEFORE the result hook
110+
inOrder(plugin) {
111+
verify(plugin).onQueryGroupedChannelsRequest(
112+
limit = eq(30),
113+
groups = eq(groupsParam),
114+
watch = eq(true),
115+
presence = eq(false),
116+
)
117+
verify(plugin).onQueryGroupedChannelsResult(
118+
result = any(),
119+
limit = eq(30),
120+
groups = eq(groupsParam),
121+
watch = eq(true),
122+
presence = eq(false),
123+
)
124+
}
125+
}
126+
127+
@Test
128+
fun `queryGroupedChannels dispatches request hook even when the call fails`() = runTest {
129+
// given
130+
val plugin: Plugin = mock()
131+
plugins.add(plugin)
132+
val errorCode = positiveRandomInt()
133+
val sut = Fixture()
134+
.givenQueryGroupedChannelsResult(RetroError<GroupedChannels>(errorCode).toRetrofitCall())
135+
.get()
136+
val groupsParam = mapOf("direct" to GroupedChannelsGroupQuery(limit = 25))
137+
// when
138+
sut.queryGroupedChannels(
139+
limit = 30,
140+
groups = groupsParam,
141+
watch = true,
142+
presence = false,
143+
).await()
144+
// then - request hook ran, giving the state plugin a chance to capture the config
145+
// before the result hook reports the failure
146+
verify(plugin).onQueryGroupedChannelsRequest(
147+
limit = eq(30),
148+
groups = eq(groupsParam),
149+
watch = eq(true),
150+
presence = eq(false),
151+
)
152+
}
153+
84154
@Test
85155
fun `queryGroupedChannels dispatches result to plugin listeners`() = runTest {
86156
// given

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -826,9 +826,18 @@ public class ChannelListViewModel internal constructor(
826826
logger.v { "[loadMoreGroupedChannels] rejected (already loading more)" }
827827
return
828828
}
829+
val config = state.groupedQueryConfig.value
829830
channelsState = channelsState.copy(isLoadingMore = true)
830831
val result = chatClient.queryGroupedChannels(
831-
groups = mapOf(groupKey to GroupedChannelsGroupQuery(next = cursor)),
832+
limit = config?.limit,
833+
groups = mapOf(
834+
groupKey to GroupedChannelsGroupQuery(
835+
limit = config?.pageSize,
836+
next = cursor,
837+
),
838+
),
839+
watch = config?.watch ?: false,
840+
presence = config?.presence ?: false,
832841
).await()
833842
if (result.isSuccess) {
834843
logger.v { "[loadMoreGroupedChannels] completed (listener applied)" }

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import io.getstream.chat.android.models.Channel
2929
import io.getstream.chat.android.models.ChannelMute
3030
import io.getstream.chat.android.models.FilterObject
3131
import io.getstream.chat.android.models.Filters
32+
import io.getstream.chat.android.models.GroupedChannels
33+
import io.getstream.chat.android.models.GroupedChannelsGroupQuery
3234
import io.getstream.chat.android.models.InitializationState
3335
import io.getstream.chat.android.models.Message
3436
import io.getstream.chat.android.models.OrFilterObject
@@ -43,6 +45,7 @@ import io.getstream.chat.android.state.plugin.internal.StatePlugin
4345
import io.getstream.chat.android.state.plugin.state.StateRegistry
4446
import io.getstream.chat.android.state.plugin.state.global.GlobalState
4547
import io.getstream.chat.android.state.plugin.state.querychannels.ChannelsStateData
48+
import io.getstream.chat.android.state.plugin.state.querychannels.GroupedQueryConfig
4649
import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState
4750
import io.getstream.chat.android.test.TestCoroutineExtension
4851
import io.getstream.chat.android.test.asCall
@@ -507,6 +510,72 @@ internal class ChannelListViewModelTest {
507510
verify(chatClient, times(0)).queryChannels(any())
508511
}
509512

513+
@Test
514+
fun `Given grouped ViewModel with captured config When loading more Should reuse limit pageSize watch and presence`() =
515+
runTest {
516+
val chatClient: ChatClient = mock()
517+
val capturedConfig = GroupedQueryConfig(
518+
limit = 20,
519+
pageSize = 5,
520+
watch = true,
521+
presence = false,
522+
)
523+
val viewModel = Fixture(chatClient)
524+
.givenCurrentUser()
525+
.givenChannelsState(
526+
channelsStateData = ChannelsStateData.Result(listOf(channel1)),
527+
channels = listOf(channel1),
528+
loading = false,
529+
nextCursor = "cursor-1",
530+
groupedQueryConfig = capturedConfig,
531+
)
532+
.givenChannelMutes()
533+
.givenGroupedChannelsQuery()
534+
.get(this, groupKey = "team-a")
535+
536+
viewModel.loadMore()
537+
advanceUntilIdle()
538+
539+
verify(chatClient).queryGroupedChannels(
540+
limit = 20,
541+
groups = mapOf(
542+
"team-a" to GroupedChannelsGroupQuery(limit = 5, next = "cursor-1"),
543+
),
544+
watch = true,
545+
presence = false,
546+
)
547+
}
548+
549+
@Test
550+
fun `Given grouped ViewModel with no captured config When loading more Should fall back to method defaults`() =
551+
runTest {
552+
val chatClient: ChatClient = mock()
553+
val viewModel = Fixture(chatClient)
554+
.givenCurrentUser()
555+
.givenChannelsState(
556+
channelsStateData = ChannelsStateData.Result(listOf(channel1)),
557+
channels = listOf(channel1),
558+
loading = false,
559+
nextCursor = "cursor-2",
560+
groupedQueryConfig = null,
561+
)
562+
.givenChannelMutes()
563+
.givenGroupedChannelsQuery()
564+
.get(this, groupKey = "team-a")
565+
566+
viewModel.loadMore()
567+
advanceUntilIdle()
568+
569+
verify(chatClient).queryGroupedChannels(
570+
limit = null,
571+
groups = mapOf(
572+
"team-a" to GroupedChannelsGroupQuery(limit = null, next = "cursor-2"),
573+
),
574+
watch = false,
575+
presence = false,
576+
)
577+
}
578+
510579
private class Fixture(
511580
private val chatClient: ChatClient = mock(),
512581
private val channelClient: ChannelClient = mock(),
@@ -559,6 +628,19 @@ internal class ChannelListViewModelTest {
559628
whenever(chatClient.queryChannels(any())) doReturn channels.asCall()
560629
}
561630

631+
fun givenGroupedChannelsQuery(
632+
result: GroupedChannels = GroupedChannels(groups = emptyMap()),
633+
) = apply {
634+
whenever(
635+
chatClient.queryGroupedChannels(
636+
limit = anyOrNull(),
637+
groups = anyOrNull(),
638+
watch = any(),
639+
presence = any(),
640+
),
641+
) doReturn result.asCall()
642+
}
643+
562644
fun givenDeleteChannel() = apply {
563645
whenever(channelClient.delete()) doReturn Channel().asCall()
564646
}
@@ -593,6 +675,7 @@ internal class ChannelListViewModelTest {
593675
endOfChannels: Boolean = false,
594676
nextPageRequest: QueryChannelsRequest? = null,
595677
nextCursor: String? = null,
678+
groupedQueryConfig: GroupedQueryConfig? = null,
596679
) = apply {
597680
val queryChannelsState: QueryChannelsState = mock {
598681
whenever(it.channelsStateData) doReturn MutableStateFlow(channelsStateData)
@@ -602,6 +685,7 @@ internal class ChannelListViewModelTest {
602685
whenever(it.endOfChannels) doReturn MutableStateFlow(endOfChannels)
603686
whenever(it.nextPageRequest) doReturn MutableStateFlow(nextPageRequest)
604687
whenever(it.nextCursor) doReturn MutableStateFlow(nextCursor)
688+
whenever(it.groupedQueryConfig) doReturn MutableStateFlow(groupedQueryConfig)
605689
}
606690
whenever(stateRegistry.queryChannels(any(), any())) doReturn queryChannelsState
607691
whenever(

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,13 +219,31 @@ public final class io/getstream/chat/android/state/plugin/state/querychannels/Ch
219219
public fun toString ()Ljava/lang/String;
220220
}
221221

222+
public final class io/getstream/chat/android/state/plugin/state/querychannels/GroupedQueryConfig {
223+
public fun <init> (Ljava/lang/Integer;Ljava/lang/Integer;ZZ)V
224+
public final fun component1 ()Ljava/lang/Integer;
225+
public final fun component2 ()Ljava/lang/Integer;
226+
public final fun component3 ()Z
227+
public final fun component4 ()Z
228+
public final fun copy (Ljava/lang/Integer;Ljava/lang/Integer;ZZ)Lio/getstream/chat/android/state/plugin/state/querychannels/GroupedQueryConfig;
229+
public static synthetic fun copy$default (Lio/getstream/chat/android/state/plugin/state/querychannels/GroupedQueryConfig;Ljava/lang/Integer;Ljava/lang/Integer;ZZILjava/lang/Object;)Lio/getstream/chat/android/state/plugin/state/querychannels/GroupedQueryConfig;
230+
public fun equals (Ljava/lang/Object;)Z
231+
public final fun getLimit ()Ljava/lang/Integer;
232+
public final fun getPageSize ()Ljava/lang/Integer;
233+
public final fun getPresence ()Z
234+
public final fun getWatch ()Z
235+
public fun hashCode ()I
236+
public fun toString ()Ljava/lang/String;
237+
}
238+
222239
public abstract interface class io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState {
223240
public abstract fun getChannels ()Lkotlinx/coroutines/flow/StateFlow;
224241
public abstract fun getChannelsStateData ()Lkotlinx/coroutines/flow/StateFlow;
225242
public abstract fun getChatEventHandlerFactory ()Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;
226243
public abstract fun getCurrentRequest ()Lkotlinx/coroutines/flow/StateFlow;
227244
public abstract fun getEndOfChannels ()Lkotlinx/coroutines/flow/StateFlow;
228245
public abstract fun getFilter ()Lio/getstream/chat/android/models/FilterObject;
246+
public abstract fun getGroupedQueryConfig ()Lkotlinx/coroutines/flow/StateFlow;
229247
public abstract fun getLoading ()Lkotlinx/coroutines/flow/StateFlow;
230248
public abstract fun getLoadingMore ()Lkotlinx/coroutines/flow/StateFlow;
231249
public abstract fun getNextCursor ()Lkotlinx/coroutines/flow/StateFlow;

stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,38 @@ import io.getstream.chat.android.models.GroupedChannels
2222
import io.getstream.chat.android.models.GroupedChannelsGroupQuery
2323
import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry
2424
import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState
25+
import io.getstream.chat.android.state.plugin.state.querychannels.GroupedQueryConfig
2526
import io.getstream.result.Result
2627

2728
internal class QueryGroupedChannelsListenerState(
2829
private val logic: LogicRegistry,
2930
private val globalState: MutableGlobalState,
3031
) : QueryGroupedChannelsListener {
3132

33+
override suspend fun onQueryGroupedChannelsRequest(
34+
limit: Int?,
35+
groups: Map<String, GroupedChannelsGroupQuery>?,
36+
watch: Boolean,
37+
presence: Boolean,
38+
) {
39+
// Capture config for every explicitly named group BEFORE the network call so that a
40+
// failed request still leaves enough state for SyncManager to retry with the same
41+
// parameters. When `groups == null` the caller is relying on the server's default group
42+
// set — we don't know the keys until the response, so we defer to the result-side capture
43+
// (which only fires on success, but in that case there is no failure to recover from).
44+
groups?.forEach { (key, groupQuery) ->
45+
logic.queryChannels(QueryChannelsIdentifier.Grouped(key))
46+
.setGroupedQueryConfig(
47+
GroupedQueryConfig(
48+
limit = limit,
49+
pageSize = groupQuery.limit,
50+
watch = watch,
51+
presence = presence,
52+
),
53+
)
54+
}
55+
}
56+
3257
override suspend fun onQueryGroupedChannelsResult(
3358
result: Result<GroupedChannels>,
3459
limit: Int?,
@@ -45,13 +70,24 @@ internal class QueryGroupedChannelsListenerState(
4570
val merged = globalState.groupedUnreadChannels.value + returnedUnreadCounts
4671
globalState.setGroupedUnreadChannels(merged)
4772

48-
// Route each returned group's channels into the per-group state.
73+
// Route each returned group's channels into the per-group state. The captured config lets
74+
// both ChannelListViewModel.loadMoreGroupedChannels and SyncManager.updateGroupedQueryChannels
75+
// reuse the caller's original parameters on paginated and recovery calls respectively.
4976
result.value.groups.forEach { (key, group) ->
5077
// A request without a `next` cursor for this key (or no per-group query at all) is
5178
// a first-page request → replace channels. With a `next` cursor → paginated → append.
5279
val isFirstPage = groups?.get(key)?.next == null
53-
logic.queryChannels(QueryChannelsIdentifier.Grouped(key))
54-
.applyGroupedResult(group, isFirstPage = isFirstPage)
80+
val perGroupLimit = groups?.get(key)?.limit
81+
val queryLogic = logic.queryChannels(QueryChannelsIdentifier.Grouped(key))
82+
queryLogic.setGroupedQueryConfig(
83+
GroupedQueryConfig(
84+
limit = limit,
85+
pageSize = perGroupLimit,
86+
watch = watch,
87+
presence = presence,
88+
),
89+
)
90+
queryLogic.applyGroupedResult(group, isFirstPage = isFirstPage)
5591
}
5692
}
5793
}

stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import io.getstream.chat.android.models.GroupedChannelsGroup
3030
import io.getstream.chat.android.models.User
3131
import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult
3232
import io.getstream.chat.android.state.model.querychannels.pagination.internal.toOfflinePaginationRequest
33+
import io.getstream.chat.android.state.plugin.state.querychannels.GroupedQueryConfig
3334
import io.getstream.log.taggedLogger
3435
import io.getstream.result.Result
3536
import kotlinx.coroutines.flow.StateFlow
@@ -140,6 +141,12 @@ internal class QueryChannelsLogic(
140141

141142
internal fun groupKey(): String? = (identifier as? QueryChannelsIdentifier.Grouped)?.group
142143

144+
internal fun groupedQueryConfig(): GroupedQueryConfig? = queryChannelsStateLogic.getGroupedQueryConfig()
145+
146+
internal fun setGroupedQueryConfig(config: GroupedQueryConfig) {
147+
queryChannelsStateLogic.setGroupedQueryConfig(config)
148+
}
149+
143150
internal fun currentRequest(): QueryChannelsRequest? = queryChannelsStateLogic.getState().currentRequest.value
144151

145152
internal fun recoveryNeeded(): StateFlow<Boolean> {

0 commit comments

Comments
 (0)