Skip to content

Decouple adding a channel to a channel list from the watch() call - #6660

Draft
VelikovPetar wants to merge 5 commits into
v6from
bug/AND-1447_decouple_watch_from_add_on_new_channel
Draft

Decouple adding a channel to a channel list from the watch() call#6660
VelikovPetar wants to merge 5 commits into
v6from
bug/AND-1447_decouple_watch_from_add_on_new_channel

Conversation

@VelikovPetar

Copy link
Copy Markdown
Contributor

Goal

EventHandlingResult.WatchAndAdd is the only way a channel the client has never seen can enter a channel list. It is produced by notification.added_to_channel, notification.message_new and channel.visible, and it was implemented as:

val result = client.channel(cid = cid).watch().await()
if (result is Result.Success) {
    addChannel(result.value)
}
// no else, no log

This makes list membership conditional on a network round-trip, and silently discards the channel when that call fails.

It is worse than a transient miss, because watch also subscribes the user to the channel server-side. Once subscribed, the server stops sending that user notification.* events for the channel and sends only channel-scoped ones, which cannot introduce a channel the client does not already know about. A failed watch therefore leaves the user subscribed to a channel that is in no list, with no event able to repair it until the next query — i.e. until the app is restarted.

Android is the only SDK that works this way. iOS links the channel into the query straight from the event payload (ChannelListLinker) and treats the watch as a non-gating follow-up whose failure is a warning log; stream-chat-js promotes not-yet-loaded channels (allowNotLoadedChannelPromotionForEvent, all four flags default true); Flutter re-queries the list.

Related: AND-1447.

Implementation

Invert the order — seed the list from the event's own channel payload, then watch as a refresh:

internal suspend fun addAndWatchChannel(cid: String, channel: Channel? = null) {
    if (channel != null) {
        // Add the channel to the list, regardless of the `watch` outcome
        addChannel(channel)
    }
    when (val result = client.channel(cid = cid).watch().await()) {
        is Result.Success -> addChannel(result.value)
        is Result.Failure -> logger.e { "[addAndWatchChannel] failed to watch $cid: ${result.value}; addedFromEvent: ${channel != null}" }
    }
}

The event payload is sufficient to build the list entry — verified over the wire that notification.added_to_channel carries a fully populated channel (config, members, own_capabilities, custom fields).

To reach the payload at the call site, parseChatEventResults now returns List<Pair<ChatEvent, EventHandlingResult>> and EventHandlerSequential.handleChatEvents passes (event as? HasChannel)?.channel. Both are internal, so there is no public API change and no apiDump needed.

All three WatchAndAdd producers implement HasChannel. A custom ChatEventHandler returning WatchAndAdd for some other event still works — channel is null and the previous behaviour applies.

Re-adding the same channel on success is idempotent: QueryChannelsSpec.cids is a Set, rawChannels is keyed by cid, joinMessages ends in distinctBy { it.id } and joinMembers merges via associateBy { getUserId() }.

UI Changes

No UI changes.

Testing

Unit tests added to QueryChannelsLogicTest:

  • the channel from the event payload is added when watch fails
  • nothing is added when watch fails and the event carried no channel
  • both the payload and the watch response are applied on success

:stream-chat-android-state:testDebugUnitTest, :stream-chat-android-state:detekt and :stream-chat-android-state:spotlessCheck all pass.

Manual verification on device with a simulated watch failure (failing at the Result level, so the failure path is actually reached):

  1. Create a channel for another user so a notification.added_to_channel arrives — the channel now appears in the list despite the failed watch. Previously it did not appear at all.
  2. Send a message into it — the preview fills in on the following notification.message_new.

Note when reproducing: forcing the failure with a malformed cid does not exercise this path. ChatClient.channel(cid) calls cidToTypeAndId() eagerly, which check()s the format and throws, so the Result.Failure branch is never reached and the exception aborts the whole event batch.

@VelikovPetar VelikovPetar added the pr:bug Bug fix label Aug 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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.

@VelikovPetar VelikovPetar changed the title Decouple adding a channel to a channel list from the watch() call Decouple adding a channel to a channel list from the watch() call Aug 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.26 MB 5.32 MB 0.05 MB 🟢
stream-chat-android-offline 5.49 MB 5.54 MB 0.04 MB 🟢
stream-chat-android-ui-components 10.64 MB 10.76 MB 0.11 MB 🟢
stream-chat-android-compose 12.87 MB 12.96 MB 0.09 MB 🟢

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

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

See analysis details on SonarQube Cloud

internal suspend fun addAndWatchChannel(cid: String, channel: Channel? = null) {
if (channel != null) {
// Add the channel to the list, regardless of the `watch` outcome
addChannel(channel)

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 be trackChannel rather than addChannel — since addChannel writes the event payload through to shared per-channel state and nulls membership, which GroupAwareChatEventHandler later reads as "not a member" and removes the channel?

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

Labels

pr:bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants