Skip to content

Add GroupedQueryChannels and grouped unread counts#6437

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

Add GroupedQueryChannels and grouped unread counts#6437
VelikovPetar wants to merge 52 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.

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.30 MB 0.04 MB 🟢
stream-chat-android-offline 5.49 MB 5.52 MB 0.03 MB 🟢
stream-chat-android-ui-components 10.64 MB 10.73 MB 0.09 MB 🟢
stream-chat-android-compose 12.87 MB 12.93 MB 0.06 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 and others added 16 commits May 14, 2026 19:56
- Guard GroupAwareChatEventHandlerFactory auto-install in LogicRegistry to
  be idempotent (preserve any factory already installed on the state).
- Defensively reset channelsOffset on first-page applyGroupedResult.
- Rename QueryChannelsIdentifier.Grouped.group to .groupKey for naming
  consistency with the rest of the grouped surface.
- Add grouped-channels tests: QueryChannelsLogicGroupedTest covers
  applyGroupedResult and loadOfflineGroupedChannels; extend LogicRegistryTest
  with Grouped identifier coverage.
- Doc tweaks: SyncManager.updateGroupedQueryChannels assumption,
  DefaultChannelGroupResolver currentGroup intent, drop stale
  @Suppress("LongMethod") on observeQueryChannelsInternal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt
Copy link
Copy Markdown
Contributor

@gpunto gpunto left a comment

Choose a reason for hiding this comment

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

Leaving a batch of early comments from a first pass since I'm out of office tomorrow and I don't want to block you.

ConcurrentHashMap()
private val threads: ConcurrentHashMap<String, ThreadMutableState> = ConcurrentHashMap()

private val watchedChannelRecords = mutableListOf<WeakReference<WatchedChannelRecord>>()
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.

Do we need WatchedChannelRecord? Maybe we can use a weak map here with the flow as a key and the cid as value:

private val watchedChannelFlows: MutableMap<WatchedChannelStateFlow, String> = Collections.synchronizedMap(WeakHashMap())

This should simplify the implementation a bit as the map handles the weak references for us and getting the cids is just watchedChannelFlows.values.toSet(). It also eliminates the unused property, which I worry could be optimized away by R8.

I also considered using the original flow directly as the key, but that would couple us to a coroutines implementation detail. So I think we should still keep the wrapper which we guarantee is a fresh instance with its own equality semantics.

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.

I like the suggestion - let me test it to make it works properly, and I will simplify it!

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.

Comment on lines +448 to +449
* Assumes all active grouped queries share the same request-level `limit`, `watch`, and
* `presence` flags — the first captured config wins.
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.

Do we know that there's always at most one set of limit + watch + presence? What if an integrator calls chatClient.queryGroupedChannels twice for different groups with different params?

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.

Well the idea is that GroupedQueryChannels would replace multiple QueryChannels invocations - at the moment it is not intended to neither have more than one call of GroupedQueryChannels, nor GroupedQueryChannels + QueryChannels.
Do you think we should re-build the request by keeping track of each invocation (and argument)?

public data class QueryChannelsSpec(
val filter: FilterObject,
val querySort: QuerySorter<Channel>,
var cids: Set<String> = emptySet(),
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.

Do we have to move this to the constructor? Given that it is a data class, all constructor properties are going to be used for equality/hash code and having a var makes them unstable in time, e.g. if QueryChannelsSpec is used as key in a map, the hash code can change while the item is in the map so trying to reinsert the same spec would duplicate it in the map.

Ideally we would make it a val, but that's a breaking change 😅 Should we move it back in the body?

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.

Yes OK, I can move it back to the body (the PredefinedFilters PR would also be affected by this)

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.

result.value.groups.forEach { (key, group) ->
// A request without a `next` cursor for this key (or no per-group query at all) is
// a first-page request → replace channels. With a `next` cursor → paginated → append.
val isFirstPage = groups?.get(key)?.next == null
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.

Should we check also prev? We do that at line 69 above.

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.

Ah yes, I missed that (although we prev is currently never used)

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.

* Replaces channels on the first page, appends on subsequent pages.
* Updates the next-page cursor and persists fresh data to the local database.
*/
internal suspend fun applyGroupedResult(group: GroupedChannelsGroup, isFirstPage: Boolean) {
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.

I wonder if we need some kind of synchronization here. We are mutating queryChannelsStateLogic via several functions so the whole function is not atomic. Maybe having a mutex would help. Do you think it's a realistic worry?

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.

Hmm it is probably a good idea to add synchronisation - at least to prevent sync bugs if the related methods are misused. Let me see what we can do.

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.

Comment on lines +414 to +415
val hasGroupedQueries = allLogics.any { it.groupKey() != null }
val hasStandardQueries = allLogics.any { it.groupKey() == null }
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.

The previous code was always calling updateActiveChannels, now we do only if we have elements in the standard queries list. Could there be cases where the list is empty but we still have to call it. I see it calls getActiveChannelStates which reads from StateRegistry.channels. Could that be not empty?

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.

For the GroupedQueryChannels - we explicitly want to re-watch ONLY the channels which are currently active/opened (that's why I introduced the weak-reference tracker). This is a bit different from the current QueryChannels logic, where we would re-watch ALL channels that have been instantiated.

So I made this like this intentionally, but to be honest I am not 100% it is correct approach.

Although there is another point about getActiveChannelStates - it might not be fully correct, because it results in a QueryChannels call with arbitrary CIDs, which might not be expected and optimised on the BE. (I think we should eventually revisit the whole SyncManager, we might be doing some not optimised calls here)

public data class GroupedChannelsGroup(
public val groupKey: String,
public val channels: List<Channel>,
public val unreadChannels: Int = 0,
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.

Should this maybe be unreadChannelsCount? The current name suggests we return channels

Copy link
Copy Markdown
Contributor Author

@VelikovPetar VelikovPetar May 26, 2026

Choose a reason for hiding this comment

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

I followed the API naming, but let me actually align this naming with iOS, because this is part of the public API which should be aligned.

If it is OK with you, I would keep it as unreadChannels so that we are aligned with iOS: https://github.com/GetStream/stream-chat-swift/pull/4076/changes#diff-2d936aad31714a18b3ad01e719db554b74b31681975075c230c5652d23fd6ffeR23

@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

public abstract fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun insertQueryChannels (Lio/getstream/chat/android/client/query/QueryChannelsSpec;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun selectBy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun selectBy (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Copy link
Copy Markdown

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 adding an abstract method to QueryChannelsRepository.

selectBy(String, ...) makes this public interface incompatible for any custom repository implementation supplied through RepositoryFactory. A default interface implementation or a new extension interface would preserve existing implementers.

🤖 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` at 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.

Comment on lines +3162 to +3172
public fun queryGroupedChannels(
groups: List<String>,
limit: Int? = null,
watch: Boolean = false,
presence: Boolean = false,
): Call<GroupedChannels> = queryGroupedChannelsInternal(
limit = limit,
groups = groups.associateWith { GroupedChannelsGroupQuery() },
watch = watch,
presence = presence,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Enforce non-empty groups precondition.

Line 3163 allows an empty list even though the KDoc says at least one group is required. Right now this sends groups = emptyMap(), which can produce an avoidable invalid request path.

Suggested fix
 `@CheckResult`
 public fun queryGroupedChannels(
     groups: List<String>,
     limit: Int? = null,
     watch: Boolean = false,
     presence: Boolean = false,
-): Call<GroupedChannels> = queryGroupedChannelsInternal(
-    limit = limit,
-    groups = groups.associateWith { GroupedChannelsGroupQuery() },
-    watch = watch,
-    presence = presence,
-)
+): Call<GroupedChannels> {
+    if (groups.isEmpty()) {
+        return ErrorCall(
+            userScope,
+            Error.GenericError("groups must contain at least one group name."),
+        )
+    }
+    return queryGroupedChannelsInternal(
+        limit = limit,
+        groups = groups.associateWith { GroupedChannelsGroupQuery() },
+        watch = watch,
+        presence = presence,
+    )
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public fun queryGroupedChannels(
groups: List<String>,
limit: Int? = null,
watch: Boolean = false,
presence: Boolean = false,
): Call<GroupedChannels> = queryGroupedChannelsInternal(
limit = limit,
groups = groups.associateWith { GroupedChannelsGroupQuery() },
watch = watch,
presence = presence,
)
`@CheckResult`
public fun queryGroupedChannels(
groups: List<String>,
limit: Int? = null,
watch: Boolean = false,
presence: Boolean = false,
): Call<GroupedChannels> {
if (groups.isEmpty()) {
return ErrorCall(
userScope,
Error.GenericError("groups must contain at least one group name."),
)
}
return queryGroupedChannelsInternal(
limit = limit,
groups = groups.associateWith { GroupedChannelsGroupQuery() },
watch = watch,
presence = presence,
)
}
🤖 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 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.

draft = randomDownstreamDraftDto(),
),
),
unread_channels = randomInt(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use a non-negative value for unread_channels in success fixtures.

unread_channels is a count; using randomInt() can generate invalid negative values in the “success” path fixture. Prefer a non-negative generator.

Proposed tweak
-                            unread_channels = randomInt(),
+                            unread_channels = kotlin.math.abs(randomInt()),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
unread_channels = randomInt(),
unread_channels = kotlin.math.abs(randomInt()),
🤖 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/MoshiChatApiTestArguments.kt`
at 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.

public abstract fun getChannelMutes ()Lkotlinx/coroutines/flow/StateFlow;
public abstract fun getChannelUnreadCount ()Lkotlinx/coroutines/flow/StateFlow;
public abstract fun getCurrentUserActiveLiveLocations ()Lkotlinx/coroutines/flow/StateFlow;
public abstract fun getGroupedUnreadChannels ()Lkotlinx/coroutines/flow/StateFlow;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Is adding a new abstract method to an existing public Kotlin interface on JVM binary compatible for already compiled implementations?

💡 Result:

No, adding a new abstract method to an existing public Kotlin interface is not binary compatible for already compiled implementations [1][2]. While the JVM specification allows for adding abstract methods to interfaces without causing immediate link-time failures [3][1], this change is binary incompatible at runtime for any existing class that implements that interface [2]. When an existing implementation (compiled against the old version of the interface) is loaded by the JVM and used, it will lack an implementation for the newly added abstract method [1][2]. If the code attempts to call this method, or if the class is instantiated and used in a way that requires the interface to be fully satisfied, the JVM will throw an AbstractMethodError [1][2]. To maintain binary compatibility, library authors should avoid adding abstract methods to interfaces that are intended to be implemented by consumers (e.g., listeners, callbacks, or extensible service interfaces) [2]. If a new method is required, consider the following alternatives: 1. Add a default method instead of an abstract method [3][1]. While this is generally binary compatible, it may still cause issues if multiple interfaces provide conflicting default implementations, potentially leading to IncompatibleClassChangeError [3][1]. 2. Create a new sub-interface that extends the original interface and includes the new abstract method [4]. Existing implementations can continue to use the original interface, while new or updated code can use the sub-interface. 3. Use the Kotlin binary compatibility validator plugin to monitor and prevent accidental binary-incompatible changes in your public API [5][6].

Citations:


Avoid breaking binary compatibility by adding new abstract interface members

New abstract getters added to existing public interfaces (GlobalState at line 212, QueryChannelsState at lines 273 and 276) are binary incompatible for already-compiled third-party implementations on JVM and can lead to AbstractMethodError at runtime. Prefer alternatives like default-backed methods, or introducing a new derived sub-interface for the added API.

Also ensure stream-chat-android-state/api/stream-chat-android-state.api is regenerated via ./gradlew apiDump after making the public API change (don’t hand-edit *.api files).

🤖 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/api/stream-chat-android-state.api` at 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.

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

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.

Comment on lines +208 to +213
coroutineScope.launch {
runCatching {
clientState.initializationState.first { it == InitializationState.COMPLETE }
state.trackWatchedChannel(watchedFlow)
}
}
Copy link
Copy Markdown

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 strongly capturing watchedFlow while waiting for initialization

Line 210 can wait indefinitely, and Line 211 keeps watchedFlow strongly referenced the whole time. If callers drop the returned flow before init completes, it still can’t be collected.

💡 Proposed fix
-    coroutineScope.launch {
-        runCatching {
-            clientState.initializationState.first { it == InitializationState.COMPLETE }
-            state.trackWatchedChannel(watchedFlow)
-        }
-    }
+    val watchedFlowRef = java.lang.ref.WeakReference(watchedFlow)
+    coroutineScope.launch {
+        runCatching {
+            clientState.initializationState.first { it == InitializationState.COMPLETE }
+            watchedFlowRef.get()?.let(state::trackWatchedChannel)
+        }
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
coroutineScope.launch {
runCatching {
clientState.initializationState.first { it == InitializationState.COMPLETE }
state.trackWatchedChannel(watchedFlow)
}
}
val watchedFlowRef = java.lang.ref.WeakReference(watchedFlow)
coroutineScope.launch {
runCatching {
clientState.initializationState.first { it == InitializationState.COMPLETE }
watchedFlowRef.get()?.let(state::trackWatchedChannel)
}
}
🤖 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/extensions/ChatClient.kt`
around lines 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.

Comment on lines +469 to +476
val groupsParam = activeGroupedLogics
.mapNotNull { logic ->
val key = logic.groupKey() ?: return@mapNotNull null
val cfg = logic.groupedQueryConfig() ?: return@mapNotNull null
key to GroupedChannelsGroupQuery(limit = cfg.pageSize)
}
.toMap()
.takeIf { it.isNotEmpty() }
Copy link
Copy Markdown

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

Groups without captured config are silently skipped during recovery.

Lines 471-473 skip a group if groupedQueryConfig() returns null, but the comment at lines 465-467 states that an empty {} per-group entry is meaningful and "filtering it out would cause re-sync to skip that group entirely." This contradicts the current logic.

If a grouped logic was initialized but its config was never captured (e.g., due to an early disconnect), recovery will skip that group. Consider using a default GroupedChannelsGroupQuery() when cfg is null:

Proposed fix
         val groupsParam = activeGroupedLogics
             .mapNotNull { logic ->
                 val key = logic.groupKey() ?: return@mapNotNull null
-                val cfg = logic.groupedQueryConfig() ?: return@mapNotNull null
-                key to GroupedChannelsGroupQuery(limit = cfg.pageSize)
+                val cfg = logic.groupedQueryConfig()
+                key to GroupedChannelsGroupQuery(limit = cfg?.pageSize)
             }
             .toMap()
             .takeIf { it.isNotEmpty() }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val groupsParam = activeGroupedLogics
.mapNotNull { logic ->
val key = logic.groupKey() ?: return@mapNotNull null
val cfg = logic.groupedQueryConfig() ?: return@mapNotNull null
key to GroupedChannelsGroupQuery(limit = cfg.pageSize)
}
.toMap()
.takeIf { it.isNotEmpty() }
val groupsParam = activeGroupedLogics
.mapNotNull { logic ->
val key = logic.groupKey() ?: return@mapNotNull null
val cfg = logic.groupedQueryConfig()
key to GroupedChannelsGroupQuery(limit = cfg?.pageSize)
}
.toMap()
.takeIf { it.isNotEmpty() }
🤖 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/sync/internal/SyncManager.kt`
around lines 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.

Comment on lines +87 to +88
@Suppress("LargeClass")
@OptIn(InternalStreamChatApi::class)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Document or remove the new @Suppress("LargeClass").

This suppression was introduced without rationale. Please add a short justification comment near it (or remove it) to keep suppression usage auditable.

As per coding guidelines: "Use @OptIn annotations explicitly in Kotlin code; avoid suppressions unless documented".

🤖 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/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt`
around lines 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.

@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
71.8% 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

Labels

pr:new-feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants