Skip to content

Add GroupedQueryChannels and grouped unread counts#6437

Open
VelikovPetar wants to merge 65 commits into
v6from
feature/grouped-channels-endpoint
Open

Add GroupedQueryChannels and grouped unread counts#6437
VelikovPetar wants to merge 65 commits into
v6from
feature/grouped-channels-endpoint

Conversation

@VelikovPetar
Copy link
Copy Markdown
Contributor

@VelikovPetar VelikovPetar commented May 13, 2026

Goal

Add support for the server-driven grouped-channels API (POST /channels/grouped), where the backend partitions the channel list into named groups (e.g. direct, support) and returns per-group channels, pagination cursors, and unread counts. Surface those grouped unread counts on relevant chat events, and provide a Compose ChannelListViewModel path that drives a UI off a group key without the consumer needing to know about filter/sort.

Implementation

  • Endpoint: new ChatClient.queryGroupedChannels(limit, groups, watch, presence) returning GroupedChannels (per-group channels + unreadChannels + next/prev cursors). Per-group request options via GroupedChannelsGroupQuery. Backed by POST /channels/grouped (ChannelApi).
  • Plugin contract: new QueryGroupedChannelsListener; the StatePlugin implementation merges returned per-group unread counts into GlobalState.groupedUnreadChannels and routes each returned group into a state keyed by a new sealed QueryChannelsIdentifier:
    • QueryChannelsIdentifier.Standard(filter, sort) — existing offset-paginated path
    • QueryChannelsIdentifier.Grouped(groupKey) — new cursor-paginated path
  • Logic: QueryChannelsLogic branches on identifier. applyGroupedResult replaces channels on the first page (resetting channelsOffset defensively to keep the Standard offset paginator from picking up stale state), appends on subsequent pages (driven off the request's next cursor), and persists per-group state under a groupKey-derived DB key.
  • Events: new HasGroupedUnreadChannels marker on NewMessageEvent, NotificationMessageNewEvent, NotificationMarkReadEvent, NotificationMarkUnreadEvent, NotificationChannelDeletedEvent, NotificationChannelTruncatedEvent. EventHandlerSequential updates GlobalState.groupedUnreadChannels whenever an inbound event carries the map. GroupedUnreadChannelsUpdater is the single calculator: events with a non-null map replace the current state, channel.updated/channel.updated_by_user events migrate per-group counts when the channel's group field changes, and queryGroupedChannels results merge per-group counts.
  • Group-aware event routing: new GroupAwareChatEventHandler classifies channel-bearing events using a pluggable ChannelGroupResolver. The default resolver reads channel.extraData["group"] and always includes an "all" sentinel. Channels are routed Add/Remove/Skip per inbound group. The LogicRegistry auto-install of the default factory is idempotent — it won't clobber a factory another caller has already installed on the state. Member/CID events delegate to DefaultChatEventHandler unchanged.
  • Compose: new ChannelListViewModel(chatClient, groupKey, ...) constructor + matching ChannelViewModelFactory(chatClient, groupKey, ...). Wires the VM to the identifier-keyed state via initGroupedQueryChannelsAsState, with a group-aware event handler factory keyed on groupKey. Pagination uses cursor-based queryGroupedChannels(groups = mapOf(groupKey to GroupedChannelsGroupQuery(next = cursor))). The Standard path is untouched.
  • Sync/recovery: SyncManager.restoreActiveChannels() splits standard vs grouped reconnect paths. Grouped queries are refreshed via a single queryGroupedChannels() call; manually-watched channels are re-watched via WatchedChannelRecord/WatchedChannelStateFlow (weak-referenced from StateRegistry). Recovery assumes all active grouped queries share the same request-level limit/watch/presence flags — the first captured config wins.
  • QueryChannelsSpec: new optional groupKey field for grouped identity. cids remains a mutable var for backward compatibility with prior versions; the two-arg constructor and 2-arg copy are preserved for source/binary compat.
  • DB: schema bumped to 99 for the new groupKey column on QueryChannelsEntity. Uses the existing fallbackToDestructiveMigration strategy.

Testing

Unit-test coverage added for each layer:

  • Endpoint dispatch + plugin notification: ChatClientGroupedChannelsApiTests
  • Moshi serialization (request/response): MoshiChatApiTest, QueryGroupedChannelsResponseAdapterTest
  • Event mapping (new grouped_unread_channels field): EventMappingTestArguments
  • Group-aware event routing: GroupAwareChatEventHandlerTest, DefaultChannelGroupResolverTest
  • Grouped unread counts calculator: GroupedUnreadChannelsUpdaterTest
  • Listener state merge / first-page vs paginated detection / failure path: QueryGroupedChannelsListenerStateTest
  • Sync recovery split: SyncManagerTest
  • Identifier-keyed state registry: StateRegistryTest, QueryChannelsMutableStateTest
  • Logic registry Grouped identifier handling (creation, idempotent retrieval, auto-installed factory): LogicRegistryTest
  • QueryChannelsLogic grouped behavior: QueryChannelsLogicGroupedTest covers applyGroupedResult (first-page replace, subsequent-page append, cursor/end-of-channels, DB persistence, defensive channelsOffset reset, no-op on Standard) and loadOfflineGroupedChannels (cache load, race-condition guard, null cache, no-op on Standard)
  • Compose grouped init: ChatClientStateCallsTest
  • Global state groupedUnreadChannels propagation: EventHandlerSequentialTest

Manually verified the Compose sample app in both Standard and Grouped modes: initial render, cursor pagination, event-driven Add/Remove/Skip across groups, reconnect/recovery, and grouped unread counts updating from inbound events.

Patch for testing
Subject: [PATCH] Prevent double updates in GroupedUnreadChannelsUpdater.kt.
---
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/LeaveChannelBottomSheet.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/LeaveChannelBottomSheet.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/LeaveChannelBottomSheet.kt
new file mode 100644
--- /dev/null	(date 1780407669798)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/LeaveChannelBottomSheet.kt	(date 1780407669798)
@@ -0,0 +1,105 @@
+/*
+ * 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.compose.sample.feature.groupedchannels.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Logout
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import io.getstream.chat.android.models.Channel
+import kotlinx.coroutines.launch
+
+/**
+ * Bottom sheet shown when the user long-presses a channel item. Exposes destructive channel
+ * actions (currently: leave + freeze) for the given [channel].
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun LeaveChannelBottomSheet(
+    channel: Channel,
+    onDismiss: () -> Unit,
+    onLeaveAndFreeze: (Channel) -> Unit,
+) {
+    val sheetState = rememberModalBottomSheetState()
+    val scope = rememberCoroutineScope()
+
+    ModalBottomSheet(
+        onDismissRequest = onDismiss,
+        sheetState = sheetState,
+    ) {
+        Column(modifier = Modifier.navigationBarsPadding()) {
+            Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+                Text(
+                    text = channel.name.ifBlank { channel.cid },
+                    style = MaterialTheme.typography.titleMedium,
+                    fontWeight = FontWeight.SemiBold,
+                    color = MaterialTheme.colorScheme.onSurface,
+                )
+                Spacer(Modifier.height(2.dp))
+                Text(
+                    text = "Channel actions",
+                    style = MaterialTheme.typography.bodySmall,
+                    color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+                )
+            }
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .clickable {
+                        scope.launch { sheetState.hide() }
+                            .invokeOnCompletion {
+                                onLeaveAndFreeze(channel)
+                                onDismiss()
+                            }
+                    }
+                    .padding(horizontal = 16.dp, vertical = 16.dp),
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                Icon(
+                    imageVector = Icons.AutoMirrored.Filled.Logout,
+                    contentDescription = null,
+                    tint = MaterialTheme.colorScheme.error,
+                )
+                Spacer(Modifier.size(16.dp))
+                Text(
+                    text = "Leave and freeze channel",
+                    style = MaterialTheme.typography.bodyLarge,
+                    color = MaterialTheme.colorScheme.error,
+                )
+            }
+        }
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/MainActivity.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/MainActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/MainActivity.kt
new file mode 100644
--- /dev/null	(date 1780407722526)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/MainActivity.kt	(date 1780407722526)
@@ -0,0 +1,395 @@
+/*
+ * 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.compose.sample.feature.groupedchannels
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material3.Badge
+import androidx.compose.material3.BadgedBox
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.ViewModelProvider
+import io.getstream.chat.android.client.ChatClient
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.ChannelGroupMenu
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.CreateChannelMenu
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.LeaveChannelBottomSheet
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.theme.GroupedChannelsSampleTheme
+import io.getstream.chat.android.compose.ui.channels.list.ChannelItem
+import io.getstream.chat.android.compose.ui.channels.list.ChannelList
+import io.getstream.chat.android.compose.ui.theme.ChatTheme
+import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModel
+import io.getstream.chat.android.compose.viewmodel.channels.ChannelViewModelFactory
+import io.getstream.chat.android.models.Channel
+import io.getstream.chat.android.state.extensions.globalStateFlow
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flatMapLatest
+
+class MainActivity : ComponentActivity() {
+
+    private val allFactory by lazy {
+        ChannelViewModelFactory(groupKey = ChannelGroup.ALL.key)
+    }
+    private val newFactory by lazy {
+        ChannelViewModelFactory(groupKey = ChannelGroup.NEW.key)
+    }
+    private val currentFactory by lazy {
+        ChannelViewModelFactory(groupKey = ChannelGroup.CURRENT.key)
+    }
+    private val oldFactory by lazy {
+        ChannelViewModelFactory(groupKey = ChannelGroup.OLD.key)
+    }
+
+    private val allViewModel: ChannelListViewModel by lazy {
+        ViewModelProvider(this, allFactory)[ChannelGroup.ALL.key, ChannelListViewModel::class.java]
+    }
+    private val newViewModel: ChannelListViewModel by lazy {
+        ViewModelProvider(this, newFactory)[ChannelGroup.NEW.key, ChannelListViewModel::class.java]
+    }
+    private val currentViewModel: ChannelListViewModel by lazy {
+        ViewModelProvider(this, currentFactory)[ChannelGroup.CURRENT.key, ChannelListViewModel::class.java]
+    }
+    private val oldViewModel: ChannelListViewModel by lazy {
+        ViewModelProvider(this, oldFactory)[ChannelGroup.OLD.key, ChannelListViewModel::class.java]
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        enableEdgeToEdge()
+
+        ChatManager.prefillGroupedChannels()
+
+        setContent {
+            GroupedChannelsSampleTheme {
+                var selected by rememberSaveable { mutableStateOf(ChannelGroup.ALL) }
+
+                @OptIn(ExperimentalCoroutinesApi::class)
+                val unreadByTab by remember {
+                    ChatClient.instance()
+                        .globalStateFlow
+                        .flatMapLatest { it.groupedUnreadChannels }
+                }.collectAsState(initial = emptyMap())
+
+                Box(
+                    Modifier
+                        .fillMaxSize()
+                        .background(MaterialTheme.colorScheme.background),
+                ) {
+                    Column(Modifier.fillMaxSize()) {
+                        val currentUser by remember {
+                            ChatClient.instance().clientState.user
+                        }.collectAsState(initial = null)
+                        val availableUsers = remember(currentUser) {
+                            LoginUser.all.filter { it.id != currentUser?.id }
+                        }
+                        TopBar(
+                            title = "Grouped Channels",
+                            onMarkAllRead = ChatManager::markAllRead,
+                            availableUsers = availableUsers,
+                            onCreateChannelWith = ChatManager::createChannelWith,
+                        )
+
+                        val vm = when (selected) {
+                            ChannelGroup.ALL -> allViewModel
+                            ChannelGroup.NEW -> newViewModel
+                            ChannelGroup.CURRENT -> currentViewModel
+                            ChannelGroup.OLD -> oldViewModel
+                        }
+                        Box(modifier = Modifier.weight(1f)) {
+                            ChatTheme {
+                                key(selected) {
+                                    val openChannel: (Channel) -> Unit = { channel ->
+                                        startActivity(
+                                            ChannelActivity.createIntent(
+                                                this@MainActivity,
+                                                channel.cid,
+                                            ),
+                                        )
+                                    }
+                                    var leaveSheetChannel by remember { mutableStateOf<Channel?>(null) }
+                                    ChannelList(
+                                        modifier = Modifier.fillMaxSize(),
+                                        viewModel = vm,
+                                        onChannelClick = openChannel,
+                                        channelContent = { itemState ->
+                                            val user by vm.user.collectAsState()
+                                            ChannelItem(
+                                                modifier = Modifier.animateItem(),
+                                                channelItem = itemState,
+                                                currentUser = user,
+                                                onChannelClick = openChannel,
+                                                onChannelLongClick = { leaveSheetChannel = it },
+                                                trailingContent = { state ->
+                                                    with(ChatTheme.componentFactory) {
+                                                        ChannelItemTrailingContent(
+                                                            channelItem = state,
+                                                            currentUser = user,
+                                                        )
+                                                    }
+                                                    ChannelGroupMenu(
+                                                        channel = state.channel,
+                                                        onMoveTo = { group ->
+                                                            ChatManager.moveChannelToGroup(
+                                                                state.channel,
+                                                                group.key,
+                                                            )
+                                                        },
+                                                    )
+                                                },
+                                            )
+                                        },
+                                    )
+                                    leaveSheetChannel?.let { channel ->
+                                        LeaveChannelBottomSheet(
+                                            channel = channel,
+                                            onDismiss = { leaveSheetChannel = null },
+                                            onLeaveAndFreeze = ChatManager::leaveExpireAndFreezeChannel,
+                                        )
+                                    }
+                                }
+                            }
+                        }
+
+                        BottomTabBar(
+                            tabs = ChannelGroup.entries,
+                            selected = selected,
+                            unreadByTab = unreadByTab,
+                            onSelect = { selected = it },
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+private val UnreadBadgeColor = Color(0xFFFF3B30)
+
+// region Composables
+
+@Composable
+private fun TopBar(
+    title: String,
+    onMarkAllRead: () -> Unit,
+    availableUsers: List<LoginUser>,
+    onCreateChannelWith: (LoginUser) -> Unit,
+) {
+    var createMenuExpanded by remember { mutableStateOf(false) }
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .statusBarsPadding()
+            .padding(horizontal = 16.dp, vertical = 12.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        Text(
+            text = title,
+            modifier = Modifier.weight(1f),
+            fontSize = 22.sp,
+            fontWeight = FontWeight.Bold,
+            color = MaterialTheme.colorScheme.onBackground,
+        )
+        Surface(
+            shape = RoundedCornerShape(50),
+            color = MaterialTheme.colorScheme.surface,
+            shadowElevation = 2.dp,
+        ) {
+            Row(verticalAlignment = Alignment.CenterVertically) {
+                IconButton(onClick = onMarkAllRead) {
+                    Icon(
+                        imageVector = Icons.Filled.CheckCircle,
+                        contentDescription = "Mark all read",
+                        tint = MaterialTheme.colorScheme.onSurface,
+                    )
+                }
+                Box {
+                    IconButton(
+                        onClick = { createMenuExpanded = true },
+                        enabled = availableUsers.isNotEmpty(),
+                    ) {
+                        Icon(
+                            imageVector = Icons.Filled.Add,
+                            contentDescription = "Create new channel",
+                            tint = if (availableUsers.isNotEmpty()) {
+                                MaterialTheme.colorScheme.onSurface
+                            } else {
+                                MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f)
+                            },
+                        )
+                    }
+                    CreateChannelMenu(
+                        expanded = createMenuExpanded,
+                        users = availableUsers,
+                        onDismiss = { createMenuExpanded = false },
+                        onUserSelected = {
+                            createMenuExpanded = false
+                            onCreateChannelWith(it)
+                        },
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun BottomTabBar(
+    tabs: List<ChannelGroup>,
+    selected: ChannelGroup,
+    unreadByTab: Map<String, Int>,
+    onSelect: (ChannelGroup) -> Unit,
+) {
+    Surface(
+        modifier = Modifier.fillMaxWidth(),
+        color = MaterialTheme.colorScheme.surface,
+        shadowElevation = 8.dp,
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .navigationBarsPadding()
+                .padding(horizontal = 8.dp, vertical = 8.dp),
+            horizontalArrangement = Arrangement.SpaceEvenly,
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            tabs.forEach { tab ->
+                BottomTabItem(
+                    tab = tab,
+                    selected = tab == selected,
+                    unread = unreadByTab[tab.key] ?: 0,
+                    onClick = { onSelect(tab) },
+                    modifier = Modifier.weight(1f),
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun BottomTabItem(
+    tab: ChannelGroup,
+    selected: Boolean,
+    unread: Int,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val containerColor by animateColorAsState(
+        targetValue = if (selected) {
+            MaterialTheme.colorScheme.primaryContainer
+        } else {
+            Color.Transparent
+        },
+        label = "tab-bg",
+    )
+    val contentColor by animateColorAsState(
+        targetValue = if (selected) {
+            MaterialTheme.colorScheme.primary
+        } else {
+            MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+        },
+        label = "tab-fg",
+    )
+
+    Box(
+        modifier = modifier
+            .clickable(onClick = onClick)
+            .padding(vertical = 4.dp),
+        contentAlignment = Alignment.Center,
+    ) {
+        Column(horizontalAlignment = Alignment.CenterHorizontally) {
+            Surface(
+                shape = RoundedCornerShape(50),
+                color = containerColor,
+            ) {
+                Box(
+                    modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    BadgedBox(
+                        badge = {
+                            if (unread > 0) {
+                                Badge(
+                                    containerColor = UnreadBadgeColor,
+                                    contentColor = Color.White,
+                                ) {
+                                    Text(
+                                        text = if (unread > 99) "99+" else unread.toString(),
+                                        fontSize = 10.sp,
+                                        fontWeight = FontWeight.Bold,
+                                    )
+                                }
+                            }
+                        },
+                    ) {
+                        Icon(
+                            imageVector = tab.icon,
+                            contentDescription = tab.label,
+                            tint = contentColor,
+                            modifier = Modifier.size(22.dp),
+                        )
+                    }
+                }
+            }
+            Spacer(Modifier.height(4.dp))
+            Text(
+                text = tab.label,
+                fontSize = 12.sp,
+                fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
+                color = contentColor,
+            )
+        }
+    }
+}
+
+// endregion
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/ChannelGroupMenu.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/ChannelGroupMenu.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/ChannelGroupMenu.kt
new file mode 100644
--- /dev/null	(date 1780407218873)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/ChannelGroupMenu.kt	(date 1780407218873)
@@ -0,0 +1,118 @@
+/*
+ * 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.compose.sample.feature.groupedchannels.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.outlined.GridView
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ChannelGroup
+import io.getstream.chat.android.models.Channel
+import io.getstream.chat.android.models.ChannelCapabilities
+
+/**
+ * Trailing-content icon button on a channel item that opens a dropdown for moving the channel to
+ * a different group. The button is disabled (and dimmed) when the current user lacks the
+ * `update-channel` capability.
+ */
+@Composable
+internal fun ChannelGroupMenu(
+    channel: Channel,
+    onMoveTo: (ChannelGroup) -> Unit,
+) {
+    var expanded by rememberSaveable(channel.cid) { mutableStateOf(false) }
+    val canUpdate = ChannelCapabilities.UPDATE_CHANNEL in channel.ownCapabilities
+    val currentGroup = channel.extraData["group"] as? String
+
+    Box {
+        IconButton(
+            onClick = { expanded = true },
+            enabled = canUpdate,
+        ) {
+            Icon(
+                imageVector = Icons.Outlined.GridView,
+                contentDescription = "Move to group",
+                tint = if (canUpdate) {
+                    MaterialTheme.colorScheme.primary
+                } else {
+                    MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f)
+                },
+            )
+        }
+        DropdownMenu(
+            expanded = expanded,
+            onDismissRequest = { expanded = false },
+        ) {
+            Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+                Text(
+                    text = "Move to group",
+                    style = MaterialTheme.typography.titleSmall,
+                    fontWeight = FontWeight.SemiBold,
+                    color = MaterialTheme.colorScheme.onSurface,
+                )
+                Spacer(Modifier.height(2.dp))
+                Text(
+                    text = "Pick which group this channel belongs to",
+                    style = MaterialTheme.typography.bodySmall,
+                    color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+                )
+            }
+            HorizontalDivider()
+            ChannelGroup.entries
+                .filter { it != ChannelGroup.ALL }
+                .forEach { group ->
+                    DropdownMenuItem(
+                        text = { Text(group.label) },
+                        leadingIcon = {
+                            if (group.key == currentGroup) {
+                                Icon(
+                                    imageVector = Icons.Filled.Check,
+                                    contentDescription = null,
+                                )
+                            } else {
+                                Spacer(Modifier.size(24.dp))
+                            }
+                        },
+                        onClick = {
+                            expanded = false
+                            onMoveTo(group)
+                        },
+                    )
+                }
+        }
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/Avatar.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/Avatar.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/Avatar.kt
new file mode 100644
--- /dev/null	(date 1780407218855)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/Avatar.kt	(date 1780407218855)
@@ -0,0 +1,75 @@
+/*
+ * 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.compose.sample.feature.groupedchannels.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun Avatar(
+    seed: String,
+    initials: String,
+    modifier: Modifier = Modifier,
+    fontSize: TextUnit = 16.sp,
+) {
+    val palette = remember(seed) { avatarGradient(seed) }
+    Box(
+        modifier = modifier.background(brush = palette, shape = CircleShape),
+        contentAlignment = Alignment.Center,
+    ) {
+        Text(
+            text = initials,
+            color = Color.White,
+            fontSize = fontSize,
+            fontWeight = FontWeight.Bold,
+        )
+    }
+}
+
+private val AvatarPalettes: List<Pair<Color, Color>> = listOf(
+    Color(0xFF7F7FD5) to Color(0xFF86A8E7),
+    Color(0xFFFF6A88) to Color(0xFFFFB199),
+    Color(0xFF11998E) to Color(0xFF38EF7D),
+    Color(0xFFFF9A9E) to Color(0xFFFAD0C4),
+    Color(0xFF4776E6) to Color(0xFF8E54E9),
+    Color(0xFFF7971E) to Color(0xFFFFD200),
+)
+
+private fun avatarGradient(seed: String): Brush {
+    val (start, end) = AvatarPalettes[(seed.hashCode().toUInt() % AvatarPalettes.size.toUInt()).toInt()]
+    return Brush.linearGradient(listOf(start, end))
+}
+
+fun String.initials(): String {
+    val parts = split('_', '-', ' ').filter { it.isNotBlank() }
+    return when {
+        parts.isEmpty() -> "?"
+        parts.size == 1 -> parts[0].take(2).uppercase()
+        else -> (parts[0].take(1) + parts[1].take(1)).uppercase()
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelActivity.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelActivity.kt
new file mode 100644
--- /dev/null	(date 1780407218884)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelActivity.kt	(date 1780407218884)
@@ -0,0 +1,65 @@
+/*
+ * 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.compose.sample.feature.groupedchannels
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import io.getstream.chat.android.compose.ui.messages.MessagesScreen
+import io.getstream.chat.android.compose.ui.theme.ChatTheme
+import io.getstream.chat.android.compose.viewmodel.messages.MessagesViewModelFactory
+
+/**
+ * Minimum-feature Channel screen.
+ */
+class ChannelActivity : ComponentActivity() {
+
+    private val cid: String by lazy {
+        requireNotNull(intent.getStringExtra(KEY_CHANNEL_ID)) { "Channel ID must be provided" }
+    }
+
+    private val factory by lazy {
+        MessagesViewModelFactory(
+            context = this,
+            channelId = cid,
+        )
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContent {
+            ChatTheme {
+                MessagesScreen(
+                    viewModelFactory = factory,
+                    onBackPressed = { finish() },
+                )
+            }
+        }
+    }
+
+    companion object {
+        private const val KEY_CHANNEL_ID = "channelId"
+
+        fun createIntent(context: Context, channelId: String): Intent {
+            return Intent(context, ChannelActivity::class.java).apply {
+                putExtra(KEY_CHANNEL_ID, channelId)
+            }
+        }
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/CreateChannelMenu.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/CreateChannelMenu.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/CreateChannelMenu.kt
new file mode 100644
--- /dev/null	(date 1780407218878)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/CreateChannelMenu.kt	(date 1780407218878)
@@ -0,0 +1,81 @@
+/*
+ * 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.compose.sample.feature.groupedchannels.ui
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.LoginUser
+
+/**
+ * Dropdown anchored to the "+" action in the top bar that lets the user pick a peer to start
+ * a 1:1 channel with. The current user is expected to be filtered out of [users] by the caller.
+ */
+@Composable
+internal fun CreateChannelMenu(
+    expanded: Boolean,
+    users: List<LoginUser>,
+    onDismiss: () -> Unit,
+    onUserSelected: (LoginUser) -> Unit,
+) {
+    DropdownMenu(
+        expanded = expanded,
+        onDismissRequest = onDismiss,
+    ) {
+        Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+            Text(
+                text = "New channel",
+                style = MaterialTheme.typography.titleSmall,
+                fontWeight = FontWeight.SemiBold,
+                color = MaterialTheme.colorScheme.onSurface,
+            )
+            Spacer(Modifier.height(2.dp))
+            Text(
+                text = "Pick a user to start a 1:1 channel with",
+                style = MaterialTheme.typography.bodySmall,
+                color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+            )
+        }
+        HorizontalDivider()
+        users.forEach { user ->
+            DropdownMenuItem(
+                text = { Text(user.name) },
+                leadingIcon = {
+                    Avatar(
+                        seed = user.id,
+                        initials = user.name.initials(),
+                        modifier = Modifier.size(28.dp),
+                        fontSize = 11.sp,
+                    )
+                },
+                onClick = { onUserSelected(user) },
+            )
+        }
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChatManager.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChatManager.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChatManager.kt
new file mode 100644
--- /dev/null	(date 1780409537766)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChatManager.kt	(date 1780409537766)
@@ -0,0 +1,186 @@
+/*
+ * 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.compose.sample.feature.groupedchannels
+
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import io.getstream.chat.android.client.ChatClient
+import io.getstream.chat.android.client.logger.ChatLogLevel
+import io.getstream.chat.android.models.Channel
+import io.getstream.chat.android.models.User
+import io.getstream.chat.android.offline.plugin.factory.StreamOfflinePluginFactory
+import io.getstream.chat.android.state.plugin.config.StatePluginConfig
+import io.getstream.chat.android.state.plugin.factory.StreamStatePluginFactory
+import io.getstream.result.call.enqueue
+import java.time.LocalTime
+import java.time.format.DateTimeFormatter
+
+/**
+ * Handles ChatClient initialization, connection, and the channel operations used by the sample.
+ */
+object ChatManager {
+
+    private const val TAG = "ChatManager"
+    private const val API_KEY = "vrvdwv6pk4yz"
+
+    /**
+     * Initializes the ChatClient with offline and state plugins, then connects the user.
+     */
+    fun initializeAndConnect(
+        appContext: Context,
+        loginUser: LoginUser,
+        onComplete: () -> Unit,
+        onError: () -> Unit,
+    ) {
+        val state = StreamStatePluginFactory(
+            config = StatePluginConfig(),
+            appContext = appContext,
+        )
+        val offline = StreamOfflinePluginFactory(
+            appContext = appContext,
+        )
+        val chatClient = ChatClient.Builder(API_KEY, appContext)
+            .withPlugins(state, offline)
+            .logLevel(ChatLogLevel.ALL)
+            .build()
+        chatClient.connectUser(
+            user = User(id = loginUser.id, name = loginUser.name),
+            token = loginUser.token,
+        ).enqueue(
+            onSuccess = { onComplete() },
+            onError = { onError() },
+        )
+    }
+
+    /**
+     * Prefills the local state/db with grouped channels for the current user.
+     */
+    fun prefillGroupedChannels() {
+        ChatClient.instance()
+            .queryGroupedChannels(
+                groups = ChannelGroup.entries.map { it.name.lowercase() },
+                watch = true,
+            )
+            .enqueue(
+                onSuccess = { grouped ->
+                    // No action needed, state/db is prefilled automatically
+                    Log.d(TAG, "Prefill grouped channels: ${grouped.groups.keys}")
+                },
+                onError = {
+                    Log.e(TAG, "Failed to query grouped channels for prefill")
+                },
+            )
+    }
+
+    /**
+     * Marks all channels as read for the current user.
+     */
+    fun markAllRead() {
+        ChatClient.instance()
+            .markAllRead()
+            .enqueue(
+                onSuccess = {
+                    Log.d(TAG, "Marked all channels as read")
+                },
+                onError = {
+                    Log.e(TAG, "Failed to mark all channels as read")
+                },
+            )
+    }
+
+    /**
+     * Creates a new 1:1 channel between the current user and [otherUser], starting in the "new" group.
+     */
+    fun createChannelWith(otherUser: LoginUser) {
+        val client = ChatClient.instance()
+        val currentUserId = client.getCurrentUser()?.id ?: return
+        val id = "new-channel-${System.currentTimeMillis()}"
+        val name = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            "New Channel ${DateTimeFormatter.ISO_LOCAL_TIME.format(LocalTime.now())}"
+        } else {
+            "New Channel ${System.currentTimeMillis()}"
+        }
+        client.channel("messaging", id)
+            .create(
+                memberIds = listOf(currentUserId, otherUser.id),
+                extraData = mapOf(
+                    "name" to name,
+                    "group" to "new",
+                ),
+            ).enqueue(
+                onSuccess = { channel ->
+                    Log.d(TAG, "Created channel ${channel.cid} with ${otherUser.id}")
+                },
+                onError = {
+                    Log.e(TAG, "Failed to create channel: $it")
+                },
+            )
+    }
+
+    /**
+     * Moves [channel] to the given [groupKey] via a partial channel update.
+     */
+    fun moveChannelToGroup(channel: Channel, groupKey: String) {
+        ChatClient.instance()
+            .updateChannelPartial(
+                channelType = channel.type,
+                channelId = channel.id,
+                set = mapOf("group" to groupKey),
+                unset = emptyList(),
+            )
+            .enqueue(
+                onSuccess = {
+                    Log.d(TAG, "Channel ${channel.cid} moved to '$groupKey'")
+                },
+                onError = {
+                    Log.e(TAG, "Failed to move channel ${channel.cid}: $it")
+                },
+            )
+    }
+
+    fun leaveExpireAndFreezeChannel(channel: Channel) {
+        val client = ChatClient.instance()
+        val currentUserId = client.getCurrentUser()?.id ?: ""
+        val channelClient = client.channel(channel.cid)
+
+        val expireAndFreezeChannel: () -> Unit =  {
+            channelClient
+                .updatePartial(set = mapOf("group" to "old"))
+                .enqueue(
+                    onSuccess = {
+                        Log.d("X_PETAR", "successfully p.updated channel")
+                    },
+                    onError = {
+                        Log.d("X_PETAR", "failed to p.update channel $it")
+                    }
+                )
+        }
+
+        expireAndFreezeChannel()
+        channelClient.removeMembers(listOf(currentUserId))
+            .enqueue(
+                onSuccess = {
+                    Log.d("X_PETAR", "successfully left channel")
+                    // expireAndFreezeChannel()
+                },
+                onError = {
+                    Log.d("X_PETAR", "failed to leave channel")
+                }
+            )
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelGroup.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelGroup.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelGroup.kt
new file mode 100644
--- /dev/null	(date 1780407218889)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelGroup.kt	(date 1780407218889)
@@ -0,0 +1,41 @@
+/*
+ * 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.compose.sample.feature.groupedchannels
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Chat
+import androidx.compose.material.icons.filled.Archive
+import androidx.compose.material.icons.filled.AutoAwesome
+import androidx.compose.material.icons.filled.Inbox
+import androidx.compose.ui.graphics.vector.ImageVector
+
+/**
+ * The buckets a channel can be sorted into in the sample app.
+ *
+ * The [key] is the value stored in the channel's `extraData["group"]` and used by the backend's
+ * `queryGroupedChannels` endpoint.
+ */
+internal enum class ChannelGroup(
+    val key: String,
+    val label: String,
+    val icon: ImageVector,
+) {
+    ALL("all", "All", Icons.Filled.Inbox),
+    NEW("new", "New", Icons.Filled.AutoAwesome),
+    CURRENT("current", "Current", Icons.AutoMirrored.Filled.Chat),
+    OLD("old", "Old", Icons.Filled.Archive),
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginUser.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginUser.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginUser.kt
new file mode 100644
--- /dev/null	(date 1780407219223)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginUser.kt	(date 1780407219223)
@@ -0,0 +1,48 @@
+/*
+ * 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.compose.sample.feature.groupedchannels
+
+/**
+ * A user that can be selected on the login screen.
+ */
+data class LoginUser(
+    val id: String,
+    val name: String,
+    val token: String,
+) {
+    companion object {
+        val member01 = LoginUser(
+            id = "member_01",
+            name = "member_01",
+            token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibWVtYmVyXzAxIn0.JEXL5-mvLcz96EG-CUSbdYgY-hex3iqktL75uSi_Uoo",
+        )
+
+        val member02 = LoginUser(
+            id = "member_02",
+            name = "member_02",
+            token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibWVtYmVyXzAyIn0.z9gG7F9u-td_It3WA2kGOkI_Li5TtrcFh3YAi4AxgT0",
+        )
+
+        val member03 = LoginUser(
+            id = "member_03",
+            name = "member_03",
+            token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibWVtYmVyXzAzIn0.G5e_HucwuVmWKB6NjuE-izAltTxH_k-AyY5RlAo-2VY",
+        )
+
+        val all: List<LoginUser> = listOf(member01, member02, member03)
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginActivity.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginActivity.kt
new file mode 100644
--- /dev/null	(date 1780407219214)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginActivity.kt	(date 1780407219214)
@@ -0,0 +1,318 @@
+/*
+ * 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.compose.sample.feature.groupedchannels
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.Avatar
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.initials
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.theme.GroupedChannelsSampleTheme
+import kotlinx.coroutines.launch
+
+class LoginActivity : ComponentActivity() {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        enableEdgeToEdge()
+        setContent {
+            GroupedChannelsSampleTheme {
+                LoginScreen(
+                    users = LoginUser.all,
+                    onLoginSuccess = {
+                        startActivity(Intent(this, MainActivity::class.java))
+                        finish()
+                    },
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun LoginScreen(
+    users: List<LoginUser>,
+    onLoginSuccess: () -> Unit,
+) {
+    val context = LocalContext.current
+    val scope = rememberCoroutineScope()
+    val snackbarHostState = remember { SnackbarHostState() }
+
+    var selected by remember { mutableStateOf(users.firstOrNull()) }
+    var connecting by remember { mutableStateOf(false) }
+
+    Scaffold(
+        snackbarHost = { SnackbarHost(snackbarHostState) },
+        containerColor = MaterialTheme.colorScheme.background,
+    ) { padding ->
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .padding(padding)
+                .background(
+                    Brush.verticalGradient(
+                        colors = listOf(
+                            MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f),
+                            MaterialTheme.colorScheme.background,
+                        ),
+                    ),
+                )
+                .systemBarsPadding(),
+        ) {
+            Column(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(horizontal = 24.dp),
+                horizontalAlignment = Alignment.CenterHorizontally,
+            ) {
+                Spacer(Modifier.height(48.dp))
+                Text(
+                    text = "Stream Chat",
+                    fontSize = 32.sp,
+                    fontWeight = FontWeight.Bold,
+                    color = MaterialTheme.colorScheme.onBackground,
+                )
+                Spacer(Modifier.height(8.dp))
+                Text(
+                    text = "Choose a user to continue",
+                    fontSize = 16.sp,
+                    color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
+                )
+                Spacer(Modifier.height(32.dp))
+
+                LazyColumn(
+                    modifier = Modifier
+                        .weight(1f)
+                        .fillMaxWidth(),
+                    verticalArrangement = Arrangement.spacedBy(12.dp),
+                    contentPadding = PaddingValues(vertical = 4.dp),
+                ) {
+                    items(users, key = { it.id }) { user ->
+                        UserCard(
+                            user = user,
+                            selected = selected?.id == user.id,
+                            enabled = !connecting,
+                            onClick = { selected = user },
+                        )
+                    }
+                }
+
+                Spacer(Modifier.height(16.dp))
+
+                Button(
+                    onClick = {
+                        val user = selected ?: return@Button
+                        connecting = true
+                        ChatManager.initializeAndConnect(
+                            appContext = context.applicationContext,
+                            loginUser = user,
+                            onComplete = {
+                                connecting = false
+                                onLoginSuccess()
+                            },
+                            onError = {
+                                connecting = false
+                                scope.launch {
+                                    snackbarHostState.showSnackbar(
+                                        "Failed to connect as ${user.name}. Please try again.",
+                                    )
+                                }
+                            },
+                        )
+                    },
+                    enabled = selected != null && !connecting,
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .height(56.dp),
+                    shape = RoundedCornerShape(16.dp),
+                    colors = ButtonDefaults.buttonColors(
+                        containerColor = MaterialTheme.colorScheme.primary,
+                    ),
+                ) {
+                    if (connecting) {
+                        CircularProgressIndicator(
+                            modifier = Modifier.size(22.dp),
+                            color = MaterialTheme.colorScheme.onPrimary,
+                            strokeWidth = 2.dp,
+                        )
+                    } else {
+                        Text(
+                            text = "Continue",
+                            fontSize = 16.sp,
+                            fontWeight = FontWeight.SemiBold,
+                        )
+                    }
+                }
+
+                Spacer(Modifier.height(24.dp))
+            }
+
+            // Subtle overlay during connection to block taps
+            AnimatedVisibility(
+                visible = connecting,
+                enter = fadeIn(),
+                exit = fadeOut(),
+            ) {
+                Box(
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .background(Color.Black.copy(alpha = 0.05f)),
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun UserCard(
+    user: LoginUser,
+    selected: Boolean,
+    enabled: Boolean,
+    onClick: () -> Unit,
+) {
+    val elevation by animateDpAsState(
+        targetValue = if (selected) 6.dp else 1.dp,
+        label = "card-elevation",
+    )
+    val borderWidth by animateDpAsState(
+        targetValue = if (selected) 2.dp else 0.dp,
+        label = "card-border",
+    )
+    val alpha by animateFloatAsState(
+        targetValue = if (enabled) 1f else 0.6f,
+        label = "card-alpha",
+    )
+
+    Surface(
+        modifier = Modifier
+            .fillMaxWidth()
+            .shadow(elevation, RoundedCornerShape(20.dp))
+            .border(
+                width = borderWidth,
+                color = MaterialTheme.colorScheme.primary,
+                shape = RoundedCornerShape(20.dp),
+            )
+            .clickable(enabled = enabled, onClick = onClick),
+        shape = RoundedCornerShape(20.dp),
+        color = MaterialTheme.colorScheme.surface,
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(16.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Avatar(
+                seed = user.id,
+                initials = user.name.initials(),
+                modifier = Modifier
+                    .size(48.dp),
+            )
+            Spacer(Modifier.width(16.dp))
+            Column(modifier = Modifier.weight(1f)) {
+                Text(
+                    text = user.name,
+                    fontSize = 16.sp,
+                    fontWeight = FontWeight.SemiBold,
+                    color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha),
+                )
+                Spacer(Modifier.height(2.dp))
+                Text(
+                    text = "ID: ${user.id}",
+                    fontSize = 13.sp,
+                    color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f * alpha),
+                )
+            }
+            AnimatedVisibility(
+                visible = selected,
+                enter = fadeIn(),
+                exit = fadeOut(),
+            ) {
+                Box(
+                    modifier = Modifier
+                        .size(28.dp)
+                        .background(
+                            color = MaterialTheme.colorScheme.primary,
+                            shape = CircleShape,
+                        ),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    Icon(
+                        imageVector = Icons.Default.Check,
+                        contentDescription = "Selected",
+                        tint = MaterialTheme.colorScheme.onPrimary,
+                        modifier = Modifier.size(18.dp),
+                    )
+                }
+            }
+        }
+    }
+}
Index: stream-chat-android-compose-sample/src/demo/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/demo/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt b/stream-chat-android-compose-sample/src/demo/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt
--- a/stream-chat-android-compose-sample/src/demo/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt	(revision f942ff2104d74d1998bc3da6fba8be0da783c6d1)
+++ b/stream-chat-android-compose-sample/src/demo/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt	(date 1780407218834)
@@ -32,6 +32,7 @@
 import io.getstream.chat.android.models.InitializationState
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.LoginActivity as GroupedChannelsLoginActivity
 
 /**
  * An Activity without UI responsible for startup routing. It navigates the user to
@@ -48,6 +49,13 @@
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
 
+        // Intercept: route to the grouped-channels demo. Comment out to restore the normal flow.
+        if (USE_GROUPED_CHANNELS_DEMO) {
+            startActivity(Intent(this, GroupedChannelsLoginActivity::class.java))
+            finish()
+            return
+        }
+
         lifecycleScope.launch {
             val userCredentials = ChatApp.credentialsRepository.loadUserCredentials()
             if (userCredentials != null && !BuildConfig.BENCHMARK) {
@@ -100,6 +108,9 @@
     }
 
     companion object {
+        /** Flip to `false` to restore the standard sample flow. */
+        private const val USE_GROUPED_CHANNELS_DEMO = true
+
         private const val KEY_CHANNEL_ID = "channelId"
         private const val KEY_MESSAGE_ID = "messageId"
         private const val KEY_PARENT_MESSAGE_ID = "parentMessageId"
Index: stream-chat-android-compose-sample/src/main/AndroidManifest.xml
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/AndroidManifest.xml b/stream-chat-android-compose-sample/src/main/AndroidManifest.xml
--- a/stream-chat-android-compose-sample/src/main/AndroidManifest.xml	(revision f942ff2104d74d1998bc3da6fba8be0da783c6d1)
+++ b/stream-chat-android-compose-sample/src/main/AndroidManifest.xml	(date 1780407219250)
@@ -98,6 +98,19 @@
             android:windowSoftInputMode="adjustResize"
             />
         <activity android:name=".feature.reminders.MessageRemindersActivity" />
+        <activity
+            android:name=".feature.groupedchannels.LoginActivity"
+            android:exported="false"
+            />
+        <activity
+            android:name=".feature.groupedchannels.MainActivity"
+            android:exported="false"
+            />
+        <activity
+            android:name=".feature.groupedchannels.ChannelActivity"
+            android:exported="false"
+            android:windowSoftInputMode="adjustResize"
+            />
         <activity android:name=".ui.profile.UserProfileActivity" />
         <activity android:name=".ui.channel.attachments.ChannelFilesAttachmentsActivity" />
         <activity android:name=".ui.channel.attachments.ChannelMediaAttachmentsActivity" />
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Color.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Color.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Color.kt
new file mode 100644
--- /dev/null	(date 1780407218838)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Color.kt	(date 1780407218838)
@@ -0,0 +1,27 @@
+/*
+ * 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.compose.sample.feature.groupedchannels.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Type.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Type.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Type.kt
new file mode 100644
--- /dev/null	(date 1780407218849)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Type.kt	(date 1780407218849)
@@ -0,0 +1,34 @@
+/*
+ * 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.compose.sample.feature.groupedchannels.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+    bodyLarge = TextStyle(
+        fontFamily = FontFamily.Default,
+        fontWeight = FontWeight.Normal,
+        fontSize = 16.sp,
+        lineHeight = 24.sp,
+        letterSpacing = 0.5.sp,
+    ),
+)
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Theme.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Theme.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Theme.kt
new file mode 100644
--- /dev/null	(date 1780407218843)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Theme.kt	(date 1780407218843)
@@ -0,0 +1,63 @@
+/*
+ * 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.compose.sample.feature.groupedchannels.ui.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+    primary = Purple80,
+    secondary = PurpleGrey80,
+    tertiary = Pink80,
+)
+
+private val LightColorScheme = lightColorScheme(
+    primary = Purple40,
+    secondary = PurpleGrey40,
+    tertiary = Pink40,
+)
+
+@Composable
+fun GroupedChannelsSampleTheme(
+    darkTheme: Boolean = isSystemInDarkTheme(),
+    // Dynamic color is available on Android 12+
+    dynamicColor: Boolean = true,
+    content: @Composable () -> Unit,
+) {
+    val colorScheme = when {
+        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+            val context = LocalContext.current
+            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+        }
+
+        darkTheme -> DarkColorScheme
+        else -> LightColorScheme
+    }
+
+    MaterialTheme(
+        colorScheme = colorScheme,
+        typography = Typography,
+        content = content,
+    )
+}

Scenarios to test

  1. Create a channel using the "+" button => The channel should be visible in both "All" / "New" tabs
  2. Receive a message => The unread count should be increased on both "All" / The other tab to which the channel belongs
  3. Read an unread channel => The unread counts should be decreased on both "All" / The other tab to which the channel belongs
  4. Mark a channel as unread => The unread count should be increased on both "All" / The other tab to which the channel belongs
  5. Move a channel to a different group (tap on the channel training icon, and select the group to which the channel will move) => The channel should be moved, and unread counts updated. (note: The channel will ALWAYS stay in the "All" group)
  6. Leave a channel (long press on channel) => Unread counts should be updated
  7. Everything in the Channel (Message List) View should work as usual

Summary by CodeRabbit

Release Notes

  • New Features
    • Added support for querying channels organized into server-defined groups with per-group pagination and cursor-based navigation
    • Introduced grouped unread channel counts tracking per group, exposed via global state for UI display
    • New grouped channel list view model supporting group-aware filtering, search, and automatic channel migration between groups
    • Extended event system to propagate grouped unread channel data across notification and message events

Review Change Stack

# Conflicts:
#	stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt
#	stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 13, 2026

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled (or ignored for dependabot PRs).

🎉 Great job! This PR is ready for review.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 13, 2026

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.26 MB 5.31 MB 0.05 MB 🟢
stream-chat-android-offline 5.49 MB 5.53 MB 0.04 MB 🟢
stream-chat-android-ui-components 10.64 MB 10.75 MB 0.10 MB 🟢
stream-chat-android-compose 12.87 MB 12.94 MB 0.07 MB 🟢

@github-actions
Copy link
Copy Markdown
Contributor

DB Entities have been updated. Do we need to upgrade DB Version?
Modified Entities :

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt

@VelikovPetar VelikovPetar added the pr:new-feature New feature label May 14, 2026
@VelikovPetar VelikovPetar marked this pull request as ready for review May 26, 2026 13:16
@VelikovPetar VelikovPetar requested a review from a team as a code owner May 26, 2026 13:16
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 26, 2026

Walkthrough

Adds grouped channel query APIs, models, and Retrofit endpoint; propagates grouped unread counts in events; extends state engine with grouped identifiers, logic, persistence, and recovery; integrates grouped mode in Compose ChannelListViewModel/Factory; updates global state; and adds comprehensive unit tests and fixtures.

Changes

Client API, identifiers, and core models

Layer / File(s) Summary
Grouped channels models and endpoints
.../models/GroupedChannels.kt, .../model/requests/QueryGroupedChannelsRequest.kt, .../model/response/QueryGroupedChannelsResponse.kt, .../api2/endpoint/ChannelApi.kt
Introduces GroupedChannels request/response models and Retrofit POST /channels/grouped.
ChatClient and plugin listener wiring
.../client/ChatClient.kt, .../api/ChatApi.kt, .../api2/MoshiChatApi.kt, .../plugin/*
Adds ChatClient grouped query methods and plugin listener callbacks/defaults.
Identifiers, specs, repositories (client)
.../internal/state/plugin/QueryChannelsIdentifier.kt, .../query/QueryChannelsSpec.kt, .../persistance/repository/*
Adds identifiers (Standard/Grouped), extends QueryChannelsSpec with groupKey, and repository selectBy(groupKey).
Public API declarations update
stream-chat-android-client.api
Updates API surface for new methods/interfaces/specs.

State engine, persistence, and recovery

Layer / File(s) Summary
Logic registry and identifier-based routing
.../logic/internal/LogicRegistry.kt
Keys caches by identifier; installs group-aware handlers for grouped.
QueryChannelsLogic grouped paths and helpers
.../logic/querychannels/internal/QueryChannelsLogic.kt
Adds grouped offline load, applyGroupedResult, and tracking.
State logic & pagination mapping
.../logic/querychannels/internal/QueryChannelsStateLogic.kt, .../pagination/internal/Mapper.kt
Adds grouped cursors/config and QueryChannelsRequest.toOfflinePaginationRequest.
DB repository/entity and cache fetch for grouped
.../offline/.../DatabaseQueryChannelsRepository.kt, QueryChannelsEntity.kt, ChatDatabase.kt, .../QueryChannelsDatabaseLogic.kt
Persists groupKey, generates grouped IDs, and fetches grouped cache.
Group-aware event handling and resolvers
.../grouped/internal/*
Adds Channel.group, resolver, and group-aware event handler/factory.
GroupedUnreadChannelsUpdater and sequential handler
.../GroupedUnreadChannelsUpdater.kt, .../EventHandlerSequential.kt
Computes and updates grouped unread counts in global state.
State plugin factory, plugin, and grouped listener
.../factory/StreamStatePluginFactory.kt, .../internal/StatePlugin.kt, .../listener/internal/QueryGroupedChannelsListenerState.kt, .../listener/internal/QueryChannelsListenerState.kt
Wires updater; implements grouped listener state handling.
StateRegistry identifiers and watched tracking
.../state/StateRegistry.kt
Stores by identifier and tracks watched channel flows.
ChatClient extensions for grouped state and watched flows
.../extensions/ChatClient.kt, .../state/internal/WatchedChannelStateFlow.kt
Adds initGroupedQueryChannelsAsState and delayed watch tracking.
GlobalState grouped unread flows
.../state/global/*
Exposes groupedUnreadChannels and setter.
State .api surface updates
stream-chat-android-state.api
Adds GroupedQueryConfig and grouped state flows.
SyncManager grouped recovery path
.../sync/internal/SyncManager.kt
Adds grouped reconnect path using queryGroupedChannelsInternal.
ChatClientStateCalls grouped init
.../state/internal/ChatClientStateCalls.kt
Initializes grouped query state without remote call.

Events and DTOs: grouped unread propagation

Layer / File(s) Summary
Event contracts and mappings
.../events/ChatEvent.kt, .../mapping/EventMapping.kt
Adds HasGroupedUnreadChannels and propagates groupedUnreadChannels.
Event DTOs with grouped_unread_channels
.../model/dto/EventDtos.kt
Extends DTOs with grouped_unread_channels and nullable fields.
Mother.kt and JSON fixtures
.../test/Mother.kt, .../EventChatJsonProvider.kt
Adds event factory and grouped field in JSON payloads.
Parser event arguments and mapping tests
.../mapping/EventMappingTestArguments.kt, .../parser/EventArguments.kt
Wires grouped unread into fixtures and domain mappings.

Compose ChannelListViewModel and factory (grouped mode)

Layer / File(s) Summary
ChannelListViewModel grouped mode
.../compose/viewmodel/channels/ChannelListViewModel.kt
Adds grouped mode with cursor pagination and search fallback.
ChannelViewModelFactory modes and wiring
.../compose/viewmodel/channels/ChannelViewModelFactory.kt
Adds Standard/Grouped constructors and wiring.
Compose .api updates
stream-chat-android-compose.api
Updates public constructors for VM and factory.
Compose ViewModel grouped tests
.../compose/viewmodel/channels/ChannelListViewModelTest.kt
Validates grouped init and pagination behavior.

Client grouped query tests and adapters

Layer / File(s) Summary
ChatClient grouped query tests
.../ChatClientGroupedChannelsApiTests.kt
Verifies success/failure, hooks, and delegation.
Moshi API and arguments
.../MoshiChatApiTest.kt, .../MoshiChatApiTestArguments.kt
Checks request payload and provides fixtures.
Grouped response adapter tests
.../parser2/QueryGroupedChannelsResponseAdapterTest.kt
Asserts JSON parsing with/without unread counters.

State logic tests for grouped features

Layer / File(s) Summary
Resolver and handler tests
.../DefaultChannelGroupResolverTest.kt, .../GroupAwareChatEventHandlerTest.kt
Covers resolver behavior and event routing.
GroupedUnreadChannelsUpdater tests
.../GroupedUnreadChannelsUpdaterTest.kt
Covers replacement/merge and migration semantics.
Sequential handler and SyncManager tests
.../EventHandlerSequentialTest.kt, .../SyncManagerTest.kt
Validates grouped unread updates and reconnect flows.
QueryGroupedChannelsListenerState tests
.../QueryGroupedChannelsListenerStateTest.kt
Validates first-page merging and config capture.
Logic/State grouped tests
.../QueryChannelsLogicGroupedTest.kt, .../QueryChannelsLogicTest.kt
Tests grouped logic apply/offline and standard offline.
State logic/mutable and registry tests
.../QueryChannelsStateLogicTest.kt, .../StateRegistryTest.kt, .../ChatClientStateCallsTest.kt, .../QueryChannelsMutableStateTest.kt
Covers setCids, sorting, flows, and watched tracking.

Sequence Diagram(s)

sequenceDiagram
  participant VM as ChannelListViewModel (Grouped)
  participant Client as ChatClient
  participant API as ChatApi
  participant HTTP as ChannelApi
  VM->>Client: queryGroupedChannelsInternal(limit, groups, watch, presence)
  Client->>API: queryGroupedChannels(...)
  API->>HTTP: POST /channels/grouped (body: groups, cursors)
  HTTP-->>API: QueryGroupedChannelsResponse
  API-->>Client: GroupedChannels
  Client-->>VM: Result<GroupedChannels>
  VM->>VM: applyGroupedResult, update cursors/state
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

pr:test

Suggested reviewers

  • andremion
  • gpunto

Poem

A rabbit groups the burrows, neat and new,
Counts each unread whisper, one and two.
Hops through cursors, next then prev,
Keeps the state in tidy weave.
Compose shows lanes where channels run—
All ears tuned: grouped chats begun! 🐇✨

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/grouped-channels-endpoint

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
stream-chat-android-client/api/stream-chat-android-client.api (1)

1788-1812: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preserve constructor/copy ABI for existing event types.

Adding groupedUnreadChannels directly to these public event data classes changes their constructor, copy, and componentN signatures. That is a source/binary breaking API change for consumers who instantiate or clone these events. Please expose grouped unread counts additively instead of changing the shape of existing public event types in place.

Also applies to: 1854-1875, 1901-1922, 2010-2035, 2050-2078, 2094-2116

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stream-chat-android-client/api/stream-chat-android-client.api` around lines
1788 - 1812, You changed the public data-class shape for events like
NewMessageEvent (its constructor, copy and componentN signatures), which is a
binary-breaking change; revert adding groupedUnreadChannels as a constructor
parameter and instead expose it additively — implement getGroupedUnreadChannels
as a derived accessor that reads the value from existing extraData/metadata (or
provide a new wrapper/subclass like NewMessageEventWithGroupedUnreadChannels or
a helper extension function) so the original NewMessageEvent
constructor/copy/componentN remain untouched; update usages to call the new
accessor/wrapper rather than changing the event primary constructor or copy
methods.
stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt (1)

538-552: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a null-count NotificationMarkUnread fixture to validate fallback mapping.

Line 1216 and Line 1217 added ?: 0, but the only fixture (Line 549 and Line 550) still provides non-null counts, so this new behavior is currently untested in arguments().

✅ Suggested test fixture addition
+    private val notificationMarkUnreadDtoWithNullCounts = NotificationMarkUnreadEventDto(
+        type = EventType.NOTIFICATION_MARK_UNREAD,
+        created_at = EXACT_DATE,
+        user = USER,
+        cid = CID,
+        channel_type = CHANNEL_TYPE,
+        channel_id = CHANNEL_ID,
+        first_unread_message_id = FIRST_UNREAD_MESSAGE_ID,
+        last_read_message_id = LAST_READ_MESSAGE_ID,
+        last_read_at = EXACT_DATE,
+        unread_messages = UNREAD_MESSAGES,
+        total_unread_count = null,
+        unread_channels = null,
+        grouped_unread_channels = GROUPED_UNREAD_CHANNELS,
+    )
+
+    private val notificationMarkUnreadWithNullCounts = NotificationMarkUnreadEvent(
+        type = notificationMarkUnreadDtoWithNullCounts.type,
+        createdAt = notificationMarkUnreadDtoWithNullCounts.created_at.date,
+        rawCreatedAt = notificationMarkUnreadDtoWithNullCounts.created_at.rawDate,
+        user = with(domainMapping) { notificationMarkUnreadDtoWithNullCounts.user.toDomain() },
+        cid = notificationMarkUnreadDtoWithNullCounts.cid,
+        channelType = notificationMarkUnreadDtoWithNullCounts.channel_type,
+        channelId = notificationMarkUnreadDtoWithNullCounts.channel_id,
+        firstUnreadMessageId = notificationMarkUnreadDtoWithNullCounts.first_unread_message_id,
+        lastReadMessageId = notificationMarkUnreadDtoWithNullCounts.last_read_message_id,
+        lastReadMessageAt = notificationMarkUnreadDtoWithNullCounts.last_read_at.date,
+        unreadMessages = notificationMarkUnreadDtoWithNullCounts.unread_messages,
+        totalUnreadCount = 0,
+        unreadChannels = 0,
+        groupedUnreadChannels = notificationMarkUnreadDtoWithNullCounts.grouped_unread_channels,
+    )
...
+        Arguments.of(notificationMarkUnreadDtoWithNullCounts, notificationMarkUnreadWithNullCounts),

Also applies to: 1204-1219, 1559-1626

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt`
around lines 538 - 552, The tests added a fallback mapping using the Elvis
operator (?: 0) for unread counts but the existing
NotificationMarkUnreadEventDto fixture (notificationMarkUnreadDto) still
supplies non-null counts, so arguments() doesn't exercise the null-to-zero
fallback; add a new fixture instance of NotificationMarkUnreadEventDto with
first_unread_message_id, last_read_message_id, unread_messages,
total_unread_count, and unread_channels set to null (or omit them) and include
it in the arguments() list used by EventMappingTestArguments to validate the
mapping fallback for notificationMarkUnreadDto; repeat the same pattern for
similar fixtures referenced around the other affected ranges.
🧹 Nitpick comments (8)
stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt (1)

131-155: ⚡ Quick win

Assert failure path dispatches the result hook too.

This test currently verifies only onQueryGroupedChannelsRequest. Add an assertion that onQueryGroupedChannelsResult is also invoked on failure so regressions in failure-result propagation are caught.

Proposed test extension
     fun `queryGroupedChannelsInternal dispatches request hook even when the call fails`() = runTest {
@@
         verify(plugin).onQueryGroupedChannelsRequest(
             limit = eq(30),
             groups = eq(groupsParam),
             watch = eq(true),
             presence = eq(false),
         )
+        verify(plugin).onQueryGroupedChannelsResult(
+            result = any(),
+            limit = eq(30),
+            groups = eq(groupsParam),
+            watch = eq(true),
+            presence = eq(false),
+        )
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt`
around lines 131 - 155, The test currently only verifies the request hook;
extend it to also verify that plugin.onQueryGroupedChannelsResult(...) is
invoked when the call fails by adding a verify(plugin) call after the await that
checks onQueryGroupedChannelsResult was called with the same limit, groups,
watch, and presence arguments and a failure/result indicating the RetroError (or
use an argument matcher like any() for the error payload if exact type is hard
to match). Update the test method `queryGroupedChannelsInternal dispatches
request hook even when the call fails` to include this verification so failure
result propagation is asserted.
stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt (1)

1914-1923: ⚡ Quick win

Add a non-null groups case to validate nested request mapping.

This test only checks groups = null. Please add a case with groups containing per-group limit/next/prev and assert the exact QueryGroupedChannelsRequest mapping sent to ChannelApi.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt`
around lines 1914 - 1923, Add a new test similar to the existing null-groups
case that calls sut.queryGroupedChannels with a non-null groups map (e.g.,
mapOf("team" to ChannelPaginationParams(limit=5, next="n", prev="p"), "project"
to ChannelPaginationParams(limit=3, next=null, prev="x")) and await the result;
build an expected QueryGroupedChannelsRequest with the same groups structure and
other params (limit/watch/presence) and assert the result type equals expected,
then verify(api, times(1)).queryGroupedChannels(connectionId, expectedPayload)
to ensure the nested mapping is sent to ChannelApi.
stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/QueryGroupedChannelsResponseAdapterTest.kt (1)

33-87: ⚡ Quick win

Include and assert group next/prev cursors in the JSON fixture.

Pagination cursors are part of grouped query behavior; asserting them here would harden parser coverage against regressions.

Also applies to: 145-181

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/QueryGroupedChannelsResponseAdapterTest.kt`
around lines 33 - 87, Add pagination cursors to the JSON fixture under the
grouped entry and assert them in the test: extend the "groups" -> "all-open"
object to include "next" and "prev" cursor fields with sample values, then
update the assertions in QueryGroupedChannelsResponseAdapterTest (where the
variable json is used) to verify parsedGroup.next and parsedGroup.prev (or the
actual properties returned by the parser) match those sample values; ensure both
the primary test and the additional case referenced (lines ~145-181) include the
same cursors and assertions to cover regressions.
stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt (1)

51-55: ⚡ Quick win

Enforce next/prev mutual exclusivity in constructor

The KDoc documents these as mutually exclusive, but the model currently allows both to be set. Add an init guard to fail fast on invalid requests.

Proposed change
 public data class GroupedChannelsGroupQuery(
     public val limit: Int? = null,
     public val next: String? = null,
     public val prev: String? = null,
-)
+) {
+    init {
+        require(next == null || prev == null) {
+            "GroupedChannelsGroupQuery: `next` and `prev` are mutually exclusive."
+        }
+    }
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt`
around lines 51 - 55, The GroupedChannelsGroupQuery data class allows both next
and prev simultaneously despite KDoc saying they are mutually exclusive; add an
init block in GroupedChannelsGroupQuery that checks if next != null && prev !=
null and throw an IllegalArgumentException (or similar) with a clear message to
fail fast; update the init to reference the class name GroupedChannelsGroupQuery
and validate the constructor parameters (limit can remain unchanged).
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt (1)

3150-3160: ⚡ Quick win

Expand KDoc with thread and state expectations for the new public API.

The new grouped-query KDocs describe params/behavior, but they don’t state threading and state expectations (for example, calling thread guarantees and required client/socket state when watch/presence are enabled).

As per coding guidelines stream-chat-android-client/src/main/**/*.kt: “Document public APIs with KDoc, including thread expectations and state notes”.

Also applies to: 3174-3189

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt`
around lines 3150 - 3160, Update the KDoc for the public grouped-query API
(ChatClient.queryGroupedChannels and its overloads) to explicitly state thread
and state expectations: say whether the method is safe to call from background
threads or must be called on the main thread, which thread/coroutine context
results/callbacks are delivered on, and that when watch==true or presence==true
the ChatClient must be connected/authenticated and the socket open (or document
that the call will attempt to open the socket and what guarantees that gives);
add the same notes to the second KDoc block referenced (around the other
overload at lines ~3174-3189) so both public docs mention calling-thread,
callback-thread, and required client/socket state for watch/presence.
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt (1)

36-39: ⚡ Quick win

Expand KDoc for selectBy(groupKey) with threading/state expectations.

Please add a short note about expected coroutine/threading usage and state semantics (e.g., nullable when absent) to keep public repository APIs consistently documented.

As per coding guidelines "Document public APIs with KDoc, including thread expectations and state notes".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt`
around lines 36 - 39, Add KDoc to the public suspend function selectBy(groupKey:
String) in QueryChannelsRepository describing coroutine/threading and state
semantics: note that it is a suspending call executed on the caller's coroutine
context (not confined to a specific thread), should be safe to call from any
coroutine (including Main), and that the returned QueryChannelsSpec? is nullable
to indicate the spec is absent in storage (not a live/observable state update).
Mention any thread-safety expectations (e.g., repository handles internal
synchronization) if applicable and keep the note concise.
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt (1)

23-62: ⚡ Quick win

Add explicit callback thread/coroutine-context expectations in KDoc.

The new listener docs explain state behavior, but they don’t state where callbacks execute (thread/coroutine context). Please document that explicitly for both callbacks so plugin implementations can safely handle side effects.

As per coding guidelines "Document public APIs with KDoc, including thread expectations and state notes".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt`
around lines 23 - 62, Update the KDoc for both onQueryGroupedChannelsRequest and
onQueryGroupedChannelsResult to explicitly state the coroutine/thread context
where implementations will be invoked (e.g., "Invoked on the ChatClient's
internal coroutine context/dispatcher; not guaranteed to be Main/UI — switch to
Dispatchers.Main for UI work" or the exact client dispatcher used). Mention any
guarantees (serial/in-order invocation) if applicable and advise callers to
switch context for side effects. Ensure these sentences are added to each
function’s KDoc so plugin authors know where to run UI or blocking operations.
stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt (1)

663-667: 💤 Low value

Consider guarding against null user in grouped search filter.

If user.value is null when optimizedChannelSearchFilter is called, orEmpty() produces an empty string, resulting in a filter that matches no channels. While unlikely during normal operation, this could occur during logout races. The search would silently return no results.

You may want to add an early return in buildQueryChannelsRequest for Grouped mode similar to the Standard mode's null-filter check, or document that this is expected behavior.

🔧 Optional defensive guard
        is QueryMode.Grouped -> QueryChannelsRequest(
-           filter = optimizedChannelSearchFilter(searchQuery),
+           filter = user.value?.id?.let { optimizedChannelSearchFilter(searchQuery, it) } ?: return null,
            limit = channelLimit,
            messageLimit = messageLimit,
            memberLimit = memberLimit,
        )
    }

-   private fun optimizedChannelSearchFilter(text: String): FilterObject =
+   private fun optimizedChannelSearchFilter(text: String, userId: String): FilterObject =
        Filters.and(
            Filters.autocomplete("name", text),
-           Filters.`in`("members", user.value?.id.orEmpty()),
+           Filters.`in`("members", userId),
        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt`
around lines 663 - 667, The grouped-search path can call
optimizedChannelSearchFilter when user.value is null, producing an empty-string
member filter that matches nothing; update buildQueryChannelsRequest (the
grouped-mode branch) to guard against a null user.value.id the same way the
Standard mode does—if user.value?.id is null, return early (e.g., empty
query/response) or skip applying the member filter instead of calling
optimizedChannelSearchFilter; alternatively adjust optimizedChannelSearchFilter
to return a safe FilterObject (or throw) when user.value?.id is absent. Ensure
you reference optimizedChannelSearchFilter and buildQueryChannelsRequest when
making the change so the grouped search no longer silently returns no results
during logout races.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@stream-chat-android-client/api/stream-chat-android-client.api`:
- Line 3092: An abstract method selectBy(String, Continuation) was added to
QueryChannelsRepository which breaks binary compatibility for custom
implementations supplied via RepositoryFactory; remove the new abstract
declaration from QueryChannelsRepository and instead provide either a default
implementation on QueryChannelsRepository (e.g., a default selectBy(...) body)
or declare a new extension/sub-interface that contains selectBy so existing
implementers aren’t forced to change; update usages to call the
default/extension method while leaving RepositoryFactory and existing
implementers untouched.

In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt`:
- Around line 3162-3172: The public API queryGroupedChannels currently accepts
an empty groups list (sending groups = emptyMap()), violating the KDoc
precondition; add a guard at the start of queryGroupedChannels that validates
groups.isNotEmpty() and throws an IllegalArgumentException (or uses require())
with a clear message like "groups must contain at least one group" before
calling queryGroupedChannelsInternal, so callers cannot send an empty map;
reference the public function queryGroupedChannels and the internal helper
queryGroupedChannelsInternal when making the change.

In
`@stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt`:
- Line 459: The success fixture sets unread_channels = randomInt(), which can
produce negative counts; update the generator to ensure a non-negative value
(e.g., use a non-negative helper or bounded call instead of randomInt()). Locate
the unread_channels assignment in MoshiChatApiTestArguments and replace
randomInt() with a non-negative generator such as randomInt(max = N) or a
dedicated randomNonNegativeInt()/abs(randomInt()) so unread_channels is always
>= 0.

In `@stream-chat-android-state/api/stream-chat-android-state.api`:
- Line 212: You added new abstract getters (getGroupedUnreadChannels) to public
interfaces (GlobalState and QueryChannelsState) which breaks binary
compatibility; instead, add a default implementation or introduce a new derived
sub-interface: provide a default-backed method implementation in the interface
(e.g., default getGroupedUnreadChannels() returning an empty StateFlow or a
pre-existing fallback) or create a new interface (e.g.,
GlobalStateWithGroupedUnread / QueryChannelsStateWithGroupedUnread) that extends
the original and declares getGroupedUnreadChannels, update usages to the new
type, and then regenerate the public API dump with ./gradlew apiDump so
stream-chat-android-state.api is updated rather than hand-editing it.

In
`@stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt`:
- Around line 438-449: The grouped-unread update is being applied twice when an
event carries authoritative groupedUnreadChannels: currently
HasGroupedUnreadChannels is processed first and then
ChannelUpdatedEvent/ChannelUpdatedByUserEvent apply delta changes again; reorder
the logic so the delta handlers (ChannelUpdatedEvent and
ChannelUpdatedByUserEvent) run before the authoritative handler
(HasGroupedUnreadChannels) to ensure authoritative groupedUnreadChannels
provided by the event (variable groupedUnreadChannels and updater
groupedUnreadChannelsUpdater) overwrite deltas rather than being mutated
afterwards.

In
`@stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt`:
- Around line 208-213: The coroutine currently holds a strong reference to
watchedFlow while waiting for initialization (coroutineScope.launch {
clientState.initializationState.first { ... };
state.trackWatchedChannel(watchedFlow) }), which can keep the flow alive
indefinitely; change the API to accept a provider/lambda (e.g.,
watchedFlowProvider: () -> Flow<...>) and update this code to wait for
clientState.initializationState.first { it == InitializationState.COMPLETE }
first and only then invoke state.trackWatchedChannel(watchedFlowProvider()), so
the actual Flow instance is created/retrieved after init completes and not
strongly captured during the wait.

In
`@stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt`:
- Around line 469-476: The current mapping over activeGroupedLogics skips a
group when logic.groupedQueryConfig() returns null, which causes recovery to
omit groups whose config wasn't captured; update the logic building groupsParam
so that for each activeGroupedLogics entry with a non-null groupKey() you always
include an entry using groupedQueryConfig() when present otherwise fallback to a
default GroupedChannelsGroupQuery() (e.g., new/default instance) instead of
returning null; adjust the mapNotNull block around activeGroupedLogics to only
filter out entries with null groupKey() but never filter when cfg is null, and
construct GroupedChannelsGroupQuery(limit = cfg.pageSize) when cfg != null or
GroupedChannelsGroupQuery() when cfg == null.

In
`@stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt`:
- Around line 87-88: The `@Suppress`("LargeClass") usage was added without
rationale; either remove that suppression or add a one-line justification
comment directly above it explaining why the test class (SyncManagerTest) is
intentionally large (e.g., "test class aggregates many scenarios for SyncManager
integration, keeping related tests together") so suppression is auditable; keep
the `@OptIn`(InternalStreamChatApi::class) as-is and ensure the justification
comment references the suppression symbol so reviewers can see why
`@Suppress`("LargeClass") is necessary.

---

Outside diff comments:
In `@stream-chat-android-client/api/stream-chat-android-client.api`:
- Around line 1788-1812: You changed the public data-class shape for events like
NewMessageEvent (its constructor, copy and componentN signatures), which is a
binary-breaking change; revert adding groupedUnreadChannels as a constructor
parameter and instead expose it additively — implement getGroupedUnreadChannels
as a derived accessor that reads the value from existing extraData/metadata (or
provide a new wrapper/subclass like NewMessageEventWithGroupedUnreadChannels or
a helper extension function) so the original NewMessageEvent
constructor/copy/componentN remain untouched; update usages to call the new
accessor/wrapper rather than changing the event primary constructor or copy
methods.

In
`@stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt`:
- Around line 538-552: The tests added a fallback mapping using the Elvis
operator (?: 0) for unread counts but the existing
NotificationMarkUnreadEventDto fixture (notificationMarkUnreadDto) still
supplies non-null counts, so arguments() doesn't exercise the null-to-zero
fallback; add a new fixture instance of NotificationMarkUnreadEventDto with
first_unread_message_id, last_read_message_id, unread_messages,
total_unread_count, and unread_channels set to null (or omit them) and include
it in the arguments() list used by EventMappingTestArguments to validate the
mapping fallback for notificationMarkUnreadDto; repeat the same pattern for
similar fixtures referenced around the other affected ranges.

---

Nitpick comments:
In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt`:
- Around line 3150-3160: Update the KDoc for the public grouped-query API
(ChatClient.queryGroupedChannels and its overloads) to explicitly state thread
and state expectations: say whether the method is safe to call from background
threads or must be called on the main thread, which thread/coroutine context
results/callbacks are delivered on, and that when watch==true or presence==true
the ChatClient must be connected/authenticated and the socket open (or document
that the call will attempt to open the socket and what guarantees that gives);
add the same notes to the second KDoc block referenced (around the other
overload at lines ~3174-3189) so both public docs mention calling-thread,
callback-thread, and required client/socket state for watch/presence.

In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt`:
- Around line 36-39: Add KDoc to the public suspend function selectBy(groupKey:
String) in QueryChannelsRepository describing coroutine/threading and state
semantics: note that it is a suspending call executed on the caller's coroutine
context (not confined to a specific thread), should be safe to call from any
coroutine (including Main), and that the returned QueryChannelsSpec? is nullable
to indicate the spec is absent in storage (not a live/observable state update).
Mention any thread-safety expectations (e.g., repository handles internal
synchronization) if applicable and keep the note concise.

In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt`:
- Around line 23-62: Update the KDoc for both onQueryGroupedChannelsRequest and
onQueryGroupedChannelsResult to explicitly state the coroutine/thread context
where implementations will be invoked (e.g., "Invoked on the ChatClient's
internal coroutine context/dispatcher; not guaranteed to be Main/UI — switch to
Dispatchers.Main for UI work" or the exact client dispatcher used). Mention any
guarantees (serial/in-order invocation) if applicable and advise callers to
switch context for side effects. Ensure these sentences are added to each
function’s KDoc so plugin authors know where to run UI or blocking operations.

In
`@stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt`:
- Around line 1914-1923: Add a new test similar to the existing null-groups case
that calls sut.queryGroupedChannels with a non-null groups map (e.g.,
mapOf("team" to ChannelPaginationParams(limit=5, next="n", prev="p"), "project"
to ChannelPaginationParams(limit=3, next=null, prev="x")) and await the result;
build an expected QueryGroupedChannelsRequest with the same groups structure and
other params (limit/watch/presence) and assert the result type equals expected,
then verify(api, times(1)).queryGroupedChannels(connectionId, expectedPayload)
to ensure the nested mapping is sent to ChannelApi.

In
`@stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt`:
- Around line 131-155: The test currently only verifies the request hook; extend
it to also verify that plugin.onQueryGroupedChannelsResult(...) is invoked when
the call fails by adding a verify(plugin) call after the await that checks
onQueryGroupedChannelsResult was called with the same limit, groups, watch, and
presence arguments and a failure/result indicating the RetroError (or use an
argument matcher like any() for the error payload if exact type is hard to
match). Update the test method `queryGroupedChannelsInternal dispatches request
hook even when the call fails` to include this verification so failure result
propagation is asserted.

In
`@stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/QueryGroupedChannelsResponseAdapterTest.kt`:
- Around line 33-87: Add pagination cursors to the JSON fixture under the
grouped entry and assert them in the test: extend the "groups" -> "all-open"
object to include "next" and "prev" cursor fields with sample values, then
update the assertions in QueryGroupedChannelsResponseAdapterTest (where the
variable json is used) to verify parsedGroup.next and parsedGroup.prev (or the
actual properties returned by the parser) match those sample values; ensure both
the primary test and the additional case referenced (lines ~145-181) include the
same cursors and assertions to cover regressions.

In
`@stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt`:
- Around line 663-667: The grouped-search path can call
optimizedChannelSearchFilter when user.value is null, producing an empty-string
member filter that matches nothing; update buildQueryChannelsRequest (the
grouped-mode branch) to guard against a null user.value.id the same way the
Standard mode does—if user.value?.id is null, return early (e.g., empty
query/response) or skip applying the member filter instead of calling
optimizedChannelSearchFilter; alternatively adjust optimizedChannelSearchFilter
to return a safe FilterObject (or throw) when user.value?.id is absent. Ensure
you reference optimizedChannelSearchFilter and buildQueryChannelsRequest when
making the change so the grouped search no longer silently returns no results
during logout races.

In
`@stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt`:
- Around line 51-55: The GroupedChannelsGroupQuery data class allows both next
and prev simultaneously despite KDoc saying they are mutually exclusive; add an
init block in GroupedChannelsGroupQuery that checks if next != null && prev !=
null and throw an IllegalArgumentException (or similar) with a clear message to
fail fast; update the init to reference the class name GroupedChannelsGroupQuery
and validate the constructor parameters (limit can remain unchanged).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 922bbeab-6322-4345-ac89-07eeac429ee4

📥 Commits

Reviewing files that changed from the base of the PR and between 82ccae3 and 997b2ee.

📒 Files selected for processing (73)
  • stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt
  • stream-chat-android-client/api/stream-chat-android-client.api
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryGroupedChannelsRequest.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryGroupedChannelsResponse.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/QueryGroupedChannelsResponseAdapterTest.kt
  • stream-chat-android-compose/api/stream-chat-android-compose.api
  • stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt
  • stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt
  • stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt
  • stream-chat-android-core/api/stream-chat-android-core.api
  • stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt
  • stream-chat-android-state/api/stream-chat-android-state.api
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/ChannelGroupExtensions.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/ChannelGroupResolver.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/DefaultChannelGroupResolver.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/model/querychannels/pagination/internal/Mapper.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/factory/StreamStatePluginFactory.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerState.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/GlobalState.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/internal/MutableGlobalState.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelStateFlow.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/GroupedQueryConfig.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt
  • stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/DefaultChannelGroupResolverTest.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistryTest.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/StateRegistryTest.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt
  • stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt

Comment thread stream-chat-android-client/api/stream-chat-android-client.api Outdated
Comment thread stream-chat-android-state/api/stream-chat-android-state.api
Comment on lines +438 to +449
(event as? HasGroupedUnreadChannels)?.let { e ->
groupedUnreadChannels = groupedUnreadChannelsUpdater
.calculateUpdatedCounts(groupedUnreadChannels, e)
}
(event as? ChannelUpdatedEvent)?.let { e ->
groupedUnreadChannels = groupedUnreadChannelsUpdater
.calculateUpdatedCounts(groupedUnreadChannels, e)
}
(event as? ChannelUpdatedByUserEvent)?.let { e ->
groupedUnreadChannels = groupedUnreadChannelsUpdater
.calculateUpdatedCounts(groupedUnreadChannels, e)
}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid double-applying grouped unread updates on channel update events.

On Line 438 you first apply HasGroupedUnreadChannels (authoritative replacement), then on Line 442 and Line 446 you apply ChannelUpdated* delta migration again. If a channel update event includes non-null groupedUnreadChannels, this second step mutates already-authoritative counts and can drift state.

💡 Proposed fix (process channel deltas first, authoritative payload last)
-                (event as? HasGroupedUnreadChannels)?.let { e ->
-                    groupedUnreadChannels = groupedUnreadChannelsUpdater
-                        .calculateUpdatedCounts(groupedUnreadChannels, e)
-                }
                 (event as? ChannelUpdatedEvent)?.let { e ->
                     groupedUnreadChannels = groupedUnreadChannelsUpdater
                         .calculateUpdatedCounts(groupedUnreadChannels, e)
                 }
                 (event as? ChannelUpdatedByUserEvent)?.let { e ->
                     groupedUnreadChannels = groupedUnreadChannelsUpdater
                         .calculateUpdatedCounts(groupedUnreadChannels, e)
                 }
+                (event as? HasGroupedUnreadChannels)?.let { e ->
+                    groupedUnreadChannels = groupedUnreadChannelsUpdater
+                        .calculateUpdatedCounts(groupedUnreadChannels, e)
+                }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt`
around lines 438 - 449, The grouped-unread update is being applied twice when an
event carries authoritative groupedUnreadChannels: currently
HasGroupedUnreadChannels is processed first and then
ChannelUpdatedEvent/ChannelUpdatedByUserEvent apply delta changes again; reorder
the logic so the delta handlers (ChannelUpdatedEvent and
ChannelUpdatedByUserEvent) run before the authoritative handler
(HasGroupedUnreadChannels) to ensure authoritative groupedUnreadChannels
provided by the event (variable groupedUnreadChannels and updater
groupedUnreadChannelsUpdater) overwrite deltas rather than being mutated
afterwards.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VelikovPetar is this a valid concern?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very likely, I will revisit (already did some work around this, but not yet pushed)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gpunto I've implemented a workaround for this in this commit. This complicates the logic quite a bit, but I am not sure how to solve this in a different way. The way the batched event processing works without updating the state in-between the events in the batch, makes this pretty difficult to solve.

Comment on lines +438 to +449
(event as? HasGroupedUnreadChannels)?.let { e ->
groupedUnreadChannels = groupedUnreadChannelsUpdater
.calculateUpdatedCounts(groupedUnreadChannels, e)
}
(event as? ChannelUpdatedEvent)?.let { e ->
groupedUnreadChannels = groupedUnreadChannelsUpdater
.calculateUpdatedCounts(groupedUnreadChannels, e)
}
(event as? ChannelUpdatedByUserEvent)?.let { e ->
groupedUnreadChannels = groupedUnreadChannelsUpdater
.calculateUpdatedCounts(groupedUnreadChannels, e)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VelikovPetar is this a valid concern?

VelikovPetar and others added 10 commits May 28, 2026 20:47
Brings PR #6426 (Predefined Filters) into the branch alongside the existing
Grouped Channels work. Both implementations coexist as sibling variants of
`QueryChannelsIdentifier` (Standard | Predefined | Grouped); registries,
repositories, state, and the Compose ChannelListViewModel/Factory absorb both
surfaces additively.

Key resolution decisions:
- `QueryChannelsSpec.cids` stays as a body `var` (pre-v6 baseline) to preserve
  binary compatibility with the data-class-generated members. `setCids`
  mutates the var in place.
- `QueryChannelsMutableState` keeps the simpler `(identifier, scope, ...)`
  constructor; initial filter/sort and the spec shape are derived inside the
  state from the identifier, so `StateRegistry` is just a registry-cache lookup.
- `QueryChannelsLogic.fetchChannelsFromCache` adopts v6's identifier-keyed
  signature returning `CachedQueryChannels`; the Grouped offline path
  (`loadOfflineGroupedChannels`) goes through the same signature and unwraps
  `.channels`.
- `@JvmOverloads` dropped on the Compose Factory's Predefined and Grouped
  constructors because their synthesized `(ChatClient, String)` overloads
  clash — see task #23 for the proper fix.

Verification: builds clean across client/state/offline/compose/ui-components,
apiCheck passes, all unit tests on touched modules pass, detekt clean except
for three intentional TODO-marker comments left for follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Bump ChatDatabase version 99 -> 100 for the QueryChannelsEntity.groupKey
  column added on top of main's PredefinedFilters schema.
- Document the 1..10 limit range on queryGroupedChannels[Internal].
- Lock loadOfflineGroupedChannels' read-and-seed under groupedResultMutex
  to prevent interleaving with a concurrent applyGroupedResult.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Jun 1, 2026

Quality Gate Failed Quality Gate failed

Failed conditions
77.0% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants