Skip to content

Support local unread counts for channels with read events disabled - #6609

Draft
gpunto wants to merge 21 commits into
developfrom
feat/local-unread-count
Draft

Support local unread counts for channels with read events disabled#6609
gpunto wants to merge 21 commits into
developfrom
feat/local-unread-count

Conversation

@gpunto

@gpunto gpunto commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Goal

Closes AND-1358

Provide an unread mechanism for channels where server-side read events are disabled (e.g. livestreams). Android port of stream-chat-swift#4102.

Adds an opt-in ChatClientConfig.isLocalUnreadCountEnabled flag (default false). When on, channels with readEventsEnabled = false get a per-channel unread count tracked entirely on-device. The user-level total unread count is unaffected.

Implementation

  • Increment: for a locally tracked channel, an incoming message creates the current user's read on the fly (the server never sends one) and increments it. All existing skip rules (own/muted/silent/shadowed/thread-reply/outdated) still apply, plus a skip for messages already in the channel state so a sync-replayed event after a restart is not counted twice.
  • Mark read: markRead on a locally tracked channel resets the count in state without a network request (markRead() returns a new MarkReadResult describing whether a remote request is still needed) and refreshes the channel-list rows, since no server read event will follow.
  • Server-data guard: the in-memory read merge and the DB-layer merge both keep the locally tracked read; server payloads only contribute user info and delivered-receipt fields. A recency check alone is not enough because server reads carry lastReceivedEventDate = last_message_at, which ties the local value.
  • Persistence: local reads are written to Room through a new internal ChannelRepository.upsertChannelReads (default no-op) that bypasses the server-data merge, so the count survives an app restart.
  • Both the new and legacy channel-logic paths are covered.

Demo app

The compose sample gets a "Local unread count" toggle (advanced login options). When enabled it also includes livestream channels in the channel list so the feature is testable.

Testing

  • spotlessCheck, detekt, apiCheck (only additive: the new ChatClientConfig/StateRegistry trailing flag; source-compatible), and the full client testDebugUnitTest suite pass.
  • New unit tests cover the increment, local mark-read, the server-data guard (including the tying-event-date and cold-start cases), the restart replay skip, and the channel-list refresh, on both channel-logic paths.
  • Manually QAed on two devices against a livestream channel: unread badge increments from another user's messages, clears on opening the channel with no /read request, survives a sync and an app restart, and does not double-count offline messages on relaunch.

Summary by CodeRabbit

  • New Features

    • Added an optional local unread-count setting for channels where server read events are disabled.
    • Local unread counts now persist across offline updates and synchronize with channel state.
    • Added sample-app controls and localized descriptions for enabling the feature.
  • Bug Fixes

    • Improved read-state merging, duplicate message handling, and channel-list refresh behavior.
    • Channel read actions now correctly distinguish remote, local, and unnecessary operations.

@gpunto gpunto added the pr:new-feature New feature label Jul 31, 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 the PR is bot-authored.
  • An issue is linked (Linear ticket or GitHub issue), or the PR is bot-authored.

🎉 Great job! This PR is ready for review.

@gpunto

gpunto commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.96 MB 5.95 MB -0.01 MB 🚀
stream-chat-android-ui-components 11.22 MB 11.23 MB 0.01 MB 🟢
stream-chat-android-compose 12.71 MB 12.71 MB 0.00 MB 🟢

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

ChatClientConfig adds optional local unread-count tracking. Channel state now distinguishes remote, local, and unnecessary read operations, preserves local read data, and persists updates. The Compose sample exposes the setting and adjusts initialization and channel queries.

Changes

Local unread-count support

Layer / File(s) Summary
Configuration and state wiring
stream-chat-android-client/api/..., stream-chat-android-client/src/main/java/.../api/..., stream-chat-android-client/src/main/java/.../state/..., stream-chat-android-client/src/main/java/.../factory/...
The new isLocalUnreadCountEnabled flag flows from ChatClientConfig to StateRegistry, channel states, and EventHandlerSequential.
Read-state outcomes and channel logic
stream-chat-android-client/src/main/java/.../logic/channel/..., stream-chat-android-client/src/main/java/.../state/channel/..., stream-chat-android-client/src/main/java/.../listener/..., stream-chat-android-client/src/main/java/.../querychannels/...
MarkReadResult replaces Boolean read results. Current and legacy channel states support local read updates, unread increments, duplicate prevention, local-read merging, and configuration-before-read ordering.
Read persistence and event integration
stream-chat-android-client/src/main/java/.../offline/repository/..., stream-chat-android-client/src/main/java/.../persistance/repository/..., stream-chat-android-client/src/main/java/.../state/event/...
Channel repositories merge and persist reads with cache and database synchronization. Event handling persists locally tracked reads for eligible new-message channels.
Validation and Compose sample controls
stream-chat-android-client/src/test/..., stream-chat-android-compose-sample/src/main/..., stream-chat-android-compose-sample/src/main/res/...
Tests cover read outcomes, merge behavior, persistence, event ordering, and local unread counts. The sample stores the flag, exposes it in login settings, reinitializes the client when it changes, and applies matching channel filters.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested labels: released

Suggested reviewers: andremion, velikovpetar

Poem

A rabbit counts each message bright,
Saves local reads by moonlit night.
Remote or local, the result is clear,
New sample switches now appear.
Hop, hop—unread states persevere! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: local unread counts for channels with read events disabled.
Description check ✅ Passed The description covers the goal, implementation, demo-app changes, testing, issue link, and manual QA; non-critical template sections are omitted.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/local-unread-count

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

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

⚠️ Outside diff range comments (1)
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt (1)

1188-1213: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Mark-read outcomes are not in parity between the two channel-state implementations. The same mark-read decision table is implemented twice, and the two copies disagree on the empty-message case and on the unreadMessages check. A channel behaves differently depending on which state implementation is active.

  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt#L1188-L1213: add || currentUserRead.unreadMessages > 0 to the final condition, and align the empty-message-list outcome with the legacy implementation.
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt#L595-L624: keep the unreadMessages check, and align the empty-message-list outcome with the chosen behavior. Add a test for each branch in both implementations.
🤖 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/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt`
around lines 1188 - 1213, Align mark-read decision logic across
ChannelStateImpl.markRead and ChannelStateLegacyImpl.markRead: include
currentUserRead.unreadMessages > 0 in the final condition, and make both
empty-message branches return the same chosen outcome. Add coverage for each
decision branch in both implementations. Update ChannelStateImpl.kt lines
1188-1213 and ChannelStateLegacyImpl.kt lines 595-624; tests should cover both
implementations and all affected branches.
🧹 Nitpick comments (3)
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt (1)

250-265: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Local-read persistence is implemented twice. Both channel-logic implementations read the current reads, skip empty lists, and launch upsertChannelReads on MarkReadResult.HandledLocally. Neither handles a persistence failure, so a failed upsert silently loses the local reset.

  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt#L250-L265: extract the persistence rule into one shared helper and log a failure from the launched coroutine.
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt#L203-L215: call the shared helper instead of repeating the block inline.
🤖 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/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt`
around lines 250 - 265, Local-read persistence is duplicated and silently
ignores upsert failures. In ChannelLogicImpl.kt lines 250-265, extract the
reads-empty check and coroutine upsert into a shared helper that logs failures
from the launched coroutine; in ChannelLogicLegacyImpl.kt lines 203-215, replace
the inline persistence block with a call to that helper while preserving the
HandledLocally behavior.
stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt (1)

109-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the duplicated channel-list filter/sort construction into a shared helper.

Both activities build the same ChannelListViewModelFactory with identical KDoc, the same isLocalUnreadCountEnabled branch, and the same Filters/QuerySortByField configuration. Any future change to this filter (for example, adding another channel type) must be made in both places, and the two copies can drift.

  • stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt#L109-L140: replace this block with a call to a shared helper function that takes chatClient and settings and returns the built ChannelListViewModelFactory.
  • stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt#L137-L168: replace this block with the same shared helper call.
♻️ Suggested shared helper
fun buildSampleChannelListViewModelFactory(
    chatClient: ChatClient,
    settings: CustomSettings,
): ChannelListViewModelFactory {
    val currentUserId = chatClient.getCurrentUser()?.id ?: ""
    return if (settings.isLocalUnreadCountEnabled) {
        ChannelListViewModelFactory(
            chatClient = chatClient,
            querySort = QuerySortByField<Channel>().desc("pinned_at").desc("last_updated"),
            filters = Filters.and(
                Filters.`in`("type", listOf("messaging", "livestream")),
                Filters.`in`("members", listOf(currentUserId)),
                Filters.or(Filters.notExists("draft"), Filters.eq("draft", false)),
            ),
            chatEventHandlerFactory = CustomChatEventHandlerFactory(),
        )
    } else {
        ChannelListViewModelFactory(
            chatClient = chatClient,
            predefinedFilterName = "android_sample_filter",
            filterValues = mapOf(
                "channel_type" to "messaging",
                "user_id" to currentUserId,
            ),
            chatEventHandlerFactory = CustomChatEventHandlerFactory(),
        )
    }
}
🤖 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-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt`
around lines 109 - 140, Extract the duplicated channel-list factory construction
into a shared buildSampleChannelListViewModelFactory helper accepting ChatClient
and CustomSettings, preserving the existing local-unread filter/sort and
predefined-filter branches. In
stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt
lines 109-140 and
stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt
lines 137-168, replace each duplicated block with a call to this helper using
the activity’s chatClient and settings.
stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt (1)

424-473: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the NotificationMessageNewEvent branch.

These two tests cover persistLocallyTrackedReads only through randomNewMessageEvent. The production when block in persistLocallyTrackedReads also matches NotificationMessageNewEvent. Add a test using randomNotificationMessageNewEvent to confirm that branch also triggers repos.upsertChannelReads.

🤖 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/internal/state/event/handler/internal/EventHandlerSequentialTest.kt`
around lines 424 - 473, Add a test alongside the existing local unread tracking
tests that invokes persistLocallyTrackedReads through
randomNotificationMessageNewEvent, with local unread tracking enabled and
locally tracked reads configured, then verify repos.upsertChannelReads receives
the channel ID and reads. Keep the setup and assertions consistent with the
existing randomNewMessageEvent coverage.
🤖 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/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt`:
- Around line 71-98: Move all fallback DB/user resolution out of
cacheMutex.withLock: preload the required channel rows and resolved reads before
the lock in preserveLocallyTrackedReads and the upsertChannelReads flow,
including selectChannel. Pass the preloaded in-memory data into the locked merge
so cacheMutex only protects synchronous cache updates; preserve existing merge
behavior and avoid any channelDao.select or getUser calls while the lock is
held.

In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt`:
- Around line 45-55: Update the HandledLocally branch in
ChannelMarkReadListenerState to return Result.Success(Unit) after refreshing
active query-channel state. Preserve the existing refreshChannelState call and
ensure callers treat a locally completed mark-read as successful rather than
returning Error.GenericError.

---

Outside diff comments:
In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt`:
- Around line 1188-1213: Align mark-read decision logic across
ChannelStateImpl.markRead and ChannelStateLegacyImpl.markRead: include
currentUserRead.unreadMessages > 0 in the final condition, and make both
empty-message branches return the same chosen outcome. Add coverage for each
decision branch in both implementations. Update ChannelStateImpl.kt lines
1188-1213 and ChannelStateLegacyImpl.kt lines 595-624; tests should cover both
implementations and all affected branches.

---

Nitpick comments:
In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt`:
- Around line 250-265: Local-read persistence is duplicated and silently ignores
upsert failures. In ChannelLogicImpl.kt lines 250-265, extract the reads-empty
check and coroutine upsert into a shared helper that logs failures from the
launched coroutine; in ChannelLogicLegacyImpl.kt lines 203-215, replace the
inline persistence block with a call to that helper while preserving the
HandledLocally behavior.

In
`@stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt`:
- Around line 424-473: Add a test alongside the existing local unread tracking
tests that invokes persistLocallyTrackedReads through
randomNotificationMessageNewEvent, with local unread tracking enabled and
locally tracked reads configured, then verify repos.upsertChannelReads receives
the channel ID and reads. Keep the setup and assertions consistent with the
existing randomNewMessageEvent coverage.

In
`@stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt`:
- Around line 109-140: Extract the duplicated channel-list factory construction
into a shared buildSampleChannelListViewModelFactory helper accepting ChatClient
and CustomSettings, preserving the existing local-unread filter/sort and
predefined-filter branches. In
stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt
lines 109-140 and
stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt
lines 137-168, replace each duplicated block with a call to this helper using
the activity’s chatClient and settings.
🪄 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 Plus

Run ID: 742011a2-2f22-40a3-b7b8-24b4e4b64927

📥 Commits

Reviewing files that changed from the base of the PR and between f0a5137 and 31957ed.

📒 Files selected for processing (37)
  • stream-chat-android-client/api/stream-chat-android-client.api
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatClientConfig.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/StateRegistry.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/factory/internal/DatabaseRepositoryFactory.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogic.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MarkReadResult.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/ChannelRepository.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelRepositoryImplTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/TotalUnreadCountTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialUserMessagesDeletedTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerStateTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplReadReceiptsTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplLocalUnreadCountTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplTest.kt
  • stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt
  • stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt
  • stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt
  • stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt
  • stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt
  • stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/UserLoginActivity.kt
  • stream-chat-android-compose-sample/src/main/res/values/strings.xml

Comment on lines +71 to +98
val channelsToInsert = cacheMutex.withLock {
val updatedChannels = channels
.map { channelCache[it.cid]?.let { cachedChannel -> it.combine(cachedChannel) } ?: it }
.map { if (it.config.readEventsEnabled) it else it.preserveLocallyTrackedReads() }
updatedChannels
.filter { channelCache[it.cid] != it }
.also { cacheChannel(updatedChannels) }
}
scope.launchWithMutex(dbMutex) {
logger.v {
"[insertChannels] inserting ${channelToInsert.size} entities on DB, " +
"updated ${updatedChannels.size} on cache"
}
channelToInsert
logger.v { "[insertChannels] inserting ${channelsToInsert.size} entities on DB" }
channelsToInsert
.takeUnless { it.isEmpty() }
// Re-read the cache at write time: DAO writes are not guaranteed to run in launch
// order, so the last write must persist the latest merged state, not its snapshot
?.map { channel -> (channelCache[channel.cid] ?: channel).toEntity() }
?.let { channelDao.insertMany(it) }
}
}

/**
* Keeps the stored current user read of read-events-disabled channels: it is tracked on-device and
* must never be overwritten by server data (a recency check is not enough - server reads carry
* lastReceivedEventDate = last_message_at, tying it). Other users' reads are merged by recency.
*/
private suspend fun Channel.preserveLocallyTrackedReads(): Channel {
val storedReads = channelCache[cid]?.read
?: channelDao.select(cid)?.reads?.values?.map { it.toModel(getUser) }
?: return this

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Avoid DB reads while holding cacheMutex.

preserveLocallyTrackedReads() (line 97) and selectChannel() inside upsertChannelReads (line 126) can call channelDao.select, and preserveLocallyTrackedReads() additionally resolves users via getUser for each stored read. Both run inside cacheMutex.withLock.

cacheMutex serializes every cache mutation, including insertChannels and upsertChannelReads. Holding it across suspending DB and user-resolution calls blocks unrelated channel/read updates for the duration of that I/O. In insertChannels, this happens sequentially for every cache-miss channel in the batch, which is most costly during cold start when the cache is empty and many channels load at once — exactly the scenario this PR targets.

Fetch the fallback data (DB rows, users) before acquiring cacheMutex, then perform the merge with only in-memory data under the lock.

Also applies to: 119-137

🤖 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/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt`
around lines 71 - 98, Move all fallback DB/user resolution out of
cacheMutex.withLock: preload the required channel rows and resolved reads before
the lock in preserveLocallyTrackedReads and the upsertChannelReads flow,
including selectChannel. Pass the preloaded in-memory data into the locked merge
so cacheMutex only protects synchronous cache updates; preserve existing merge
behavior and avoid any channelDao.select or getUser calls while the lock is
held.

@gpunto
gpunto force-pushed the feat/local-unread-count branch from a5e0cc0 to e77e987 Compare July 31, 2026 13:26
@sonarqubecloud

Copy link
Copy Markdown

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.

1 participant