Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
cdb1d0a
Check the cutoff only for listed channels and move group badges for c…
andremion Oct 20, 2026
47687e6
Keep disabled channels out of grouped lists
andremion Oct 20, 2026
ea8e4f1
Keep hidden channels hidden for messages that do not un-hide them on …
andremion Oct 20, 2026
bb1a7fb
Watch partially updated channels without a local copy only for groupe…
andremion Oct 20, 2026
e87d01c
Reconnect once before scrolling in the grouped reconnect e2e test
andremion Oct 20, 2026
582fff4
Open the grouped tab online in the reconnection e2e test
andremion Oct 20, 2026
acb9635
Place partially updated grouped channels by the last message time of …
andremion Oct 20, 2026
c72041c
Add stored channels without watching them for grouped lists queried w…
andremion Oct 20, 2026
ff2953b
Wait for the second reconnection in the grouped reconnection e2e test
andremion Oct 20, 2026
3168f60
Keep the stored hidden and blocked state on notification.message_new
andremion Oct 20, 2026
c14a274
Turn on Wi-Fi before mobile data in the e2e network helper
andremion Oct 20, 2026
e51088f
Keep the later last message time in the channel state on partial updates
andremion Oct 21, 2026
3f2256c
Do not move group badges for hidden or disabled channels
andremion Oct 21, 2026
50dddd7
Create the channel state for channels that events add to a list
andremion Oct 21, 2026
ead6f14
Keep the stored hidden and blocked state on channel updates and trunc…
andremion Oct 21, 2026
4434611
Reset grouped lists queried with presence after a reconnect
andremion Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ internal class DomainMapping(
frozen = frozen,
createdAt = created_at,
updatedAt = updated_at,
lastMessageAt = last_message_at,
team = team,
extraData = (extraData + (CHANNEL_DISABLED_FIELD to disabled)).toMutableMap(),
).let(channelTransformer::transform)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
val image: String?,
val created_at: Date?,
val updated_at: Date?,
val last_message_at: Date? = null,

Check warning on line 65 in stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this parameter to match the regular expression ^`?[_a-z][a-zA-Z0-9]*`?$

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-android&issues=AaFZnT1gYf03tCDbAF8U&open=AaFZnT1gYf03tCDbAF8U&pullRequest=6855
Comment thread
gpunto marked this conversation as resolved.
val frozen: Boolean,
val disabled: Boolean,
val team: String = "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ internal fun createNotificationChannelPartialUpdatedEventStringJson() =
"cid": "channelType:channelId",
"created_at": "2020-06-29T06:14:28.000Z",
"updated_at": "2020-06-29T06:14:28.000Z",
"last_message_at": "2020-06-29T06:14:28.000Z",
"frozen": true,
"disabled": false,
"team": "team1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,28 @@ internal class DomainMappingTest {
assertEquals("", result.image)
}

@Test
fun `Partially updated channel keeps the last message time of the event`() {
val lastMessageAt = randomDate()
val dto = DownstreamChannelPartialUpdateDto(
cid = "messaging:123",
id = "123",
type = "messaging",
name = null,
image = null,
created_at = null,
updated_at = null,
last_message_at = lastMessageAt,
frozen = false,
disabled = false,
extraData = emptyMap(),
)

val result = with(Fixture().get()) { dto.toDomain() }

assertEquals(lastMessageAt, result.lastMessageAt)
}

@Test
fun `DownstreamChannelDto is correctly mapped to Channel`() {
val downstreamChannelDto = randomDownstreamChannelDto()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ internal object EventArguments {
frozen = true,
createdAt = date,
updatedAt = date,
lastMessageAt = date,
team = "team1",
extraData = mutableMapOf("group" to "current", "disabled" to false),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,19 @@ class GroupedChannelListTests : StreamTestCase() {
step("GIVEN backend has two pages of new channels") {
seedNewChannels(channelsCount = 20)
}
step("AND user logs in and reconnects once, so the app has a last sync date") {
// After a fresh login, the first sync date is stored by the first reconnection. Pages are kept only after
// a sync that starts from a known date.
userRobot.login().waitForChannelListToLoad()
device.disableInternetConnection()
userRobot.sleep(2.seconds)
device.enableInternetConnection()
userRobot.sleep(3.seconds)
}
step("AND user scrolls to the end of the second page of the new tab") {
userRobot
.login()
.waitForChannelListToLoad()
// Opened online: a tab asks for its next page when it is shown, and a request rejected while offline
// is not sent again.
.openGroupTab("new")
// The first scroll loads the second page, the second one reaches its end.
.scrollChannelListDown()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,11 @@ public fun UiDevice.goToForeground() {
}

public fun UiDevice.enableInternetConnection() {
executeShellCommand("svc data enable")
// Wi-Fi goes first. When mobile data connects first, Wi-Fi takes over the default network a few seconds later and
// slows down the requests the app has in flight.
executeShellCommand("svc wifi enable")
waitForInternetConnection()
executeShellCommand("svc data enable")
}

public fun UiDevice.disableInternetConnection() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import io.getstream.chat.android.state.event.handler.chat.DefaultChatEventHandle
import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult
import io.getstream.chat.android.state.utils.internal.calculateNewLastMessageAt
import io.getstream.chat.android.state.utils.internal.mergePartialUpdate
import io.getstream.chat.android.state.utils.internal.unhidesChannel
import kotlinx.coroutines.flow.StateFlow
import java.util.Date

Expand All @@ -54,6 +55,7 @@ internal class GroupAwareChatEventHandler(
private val resolver: ChannelGroupResolver,
channels: StateFlow<Map<String, Channel>?>,
clientState: ClientState,
private val blockedUserIds: () -> Collection<String>,
private val now: () -> Date = ::Date,
) : DefaultChatEventHandler(channels, clientState) {

Expand All @@ -69,14 +71,16 @@ internal class GroupAwareChatEventHandler(

is NotificationChannelPartialUpdatedEvent -> routePartialUpdate(event, cachedChannel, filter)

is NotificationMessageNewEvent ->
if ((cachedChannel ?: event.channel).staysHiddenAfter(event.message)) {
EventHandlingResult.Skip
} else {
watchAndAddIfBelongsHere(event.channel, filter)
}

is NotificationAddedToChannelEvent,
is NotificationMessageNewEvent,
is ChannelVisibleEvent,
-> if (channelBelongsHere(event.channel, filter)) {
EventHandlingResult.WatchAndAdd(event.cid)
} else {
EventHandlingResult.Skip
}
-> watchAndAddIfBelongsHere(event.channel, filter)

else -> super.handleChatEvent(event, filter, cachedChannel)
}
Expand All @@ -87,12 +91,24 @@ internal class GroupAwareChatEventHandler(
filter: FilterObject,
cachedChannel: Channel?,
): EventHandlingResult {
val defaultResult = super.handleCidEvent(event, filter, cachedChannel)
// The cached channel can predate the new message, which may bring it back within a cutoff.
// The cached channel can predate the new message, which may bring it back within a cutoff or un-hide it.
val newMessage = (event as? NewMessageEvent)?.message
if (newMessage != null && cachedChannel?.staysHiddenAfter(newMessage) == true) return EventHandlingResult.Skip
val defaultResult = super.handleCidEvent(event, filter, cachedChannel)
return filterResultByGroup(defaultResult, cachedChannel, filter, newMessage)
}

private fun watchAndAddIfBelongsHere(channel: Channel, filter: FilterObject): EventHandlingResult =
if (channelBelongsHere(channel, filter)) {
EventHandlingResult.WatchAndAdd(channel.cid)
} else {
EventHandlingResult.Skip
}

/** Whether this channel is hidden and the new [message] keeps it hidden on the server. */
private fun Channel.staysHiddenAfter(message: Message): Boolean =
hidden == true && !message.unhidesChannel(extraData, blockedUserIds())

/**
* The membership guard prevents a `ChannelUpdatedEvent` from (re-)adding a channel the user
* has already left — relies on [cachedChannel] (in-memory state after `member.removed` is
Expand All @@ -117,41 +133,45 @@ internal class GroupAwareChatEventHandler(

/**
* Same checks as [routeByGroup], against [cachedChannel] with the partial update applied. The event's channel
* lacks data such as members and the last message time, so a channel that is not in the list yet is watched to
* get its full data instead of being added from the event.
* lacks data such as members, so a channel that is not in the list yet is watched to get its full data instead of
* being added from the event.
*
* Without [cachedChannel], only the group is checked: the last activity is unknown, so the cutoff is left to the
* list, which checks the watched channel before showing it. The event is only sent to members, and a hidden
* channel is dropped after the watch. The query skips the watch when the channel sorts below its loaded pages.
* The cutoff is checked when the channel's last activity is known: the channel is in the list, or it has a local
* copy and the event carries its last message time. For any other channel only the group is checked, and the
* cutoff is left to the list, which checks the watched channel before showing it. The event is only sent to
* members, and a hidden channel is dropped after the watch. The query skips the watch when the channel sorts below
* its loaded pages, and does not watch it when the list was not queried with `watch`.
*/
private fun routePartialUpdate(
event: NotificationChannelPartialUpdatedEvent,
cachedChannel: Channel?,
filter: FilterObject,
): EventHandlingResult {
val belongsHere = if (cachedChannel == null) {
groupMatches(event.channel, filter)
} else {
channelBelongsHere(cachedChannel.mergePartialUpdate(event.channel), filter) &&
isCurrentUserMember(cachedChannel) &&
cachedChannel.hidden != true
}
val channel = cachedChannel?.mergePartialUpdate(event.channel) ?: event.channel
val isInList = channels.value?.containsKey(event.cid) == true
// A stored copy can miss recent messages, so its last activity is known only with the event's value.
val isLastActivityKnown = isInList || (cachedChannel != null && event.channel.lastMessageAt != null)
val matches = if (isLastActivityKnown) channelBelongsHere(channel, filter) else groupMatches(channel, filter)
val belongsHere = matches &&
(cachedChannel == null || (isCurrentUserMember(cachedChannel) && cachedChannel.hidden != true))
return when {
belongsHere && !isInList -> EventHandlingResult.WatchAndAdd(event.cid)
!belongsHere && isInList -> EventHandlingResult.Remove(event.cid)
else -> EventHandlingResult.Skip
}
}

/** Whether [channel] belongs in this group. A disabled channel never does, since the backend leaves it out. */
private fun channelBelongsHere(channel: Channel, filter: FilterObject): Boolean =
resolver.resolve(channel).contains(groupKey) &&
!channel.isDisabled &&
resolver.resolve(channel).contains(groupKey) &&
filter.matchesGroupedChannel(group = channel.group, queryTimestamp = channel.queryTimestamp, now = now())

/** [channelBelongsHere] with the cutoff passed, for a channel whose last activity is unknown. */
/** [channelBelongsHere] with the cutoff passed, for a channel whose last activity is unknown or may be stale. */
private fun groupMatches(channel: Channel, filter: FilterObject): Boolean {
val checkedAt = now()
return resolver.resolve(channel).contains(groupKey) &&
return !channel.isDisabled &&
resolver.resolve(channel).contains(groupKey) &&
filter.matchesGroupedChannel(group = channel.group, queryTimestamp = checkedAt, now = checkedAt)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import io.getstream.chat.android.core.internal.InternalStreamChatApi
import io.getstream.chat.android.models.Channel
import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler
import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory
import io.getstream.chat.android.state.extensions.globalState
import kotlinx.coroutines.flow.StateFlow

/**
Expand All @@ -34,6 +35,7 @@ internal class GroupAwareChatEventHandlerFactory(
private val groupKey: String,
private val resolver: ChannelGroupResolver = DefaultChannelGroupResolver(),
private val clientState: ClientState = ChatClient.instance().clientState,
private val blockedUserIds: () -> Collection<String> = { ChatClient.instance().globalState.blockedUserIds.value },
) : ChatEventHandlerFactory(clientState) {

override fun chatEventHandler(channels: StateFlow<Map<String, Channel>?>): ChatEventHandler =
Expand All @@ -42,6 +44,7 @@ internal class GroupAwareChatEventHandlerFactory(
resolver = resolver,
channels = channels,
clientState = clientState,
blockedUserIds = blockedUserIds,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ internal fun Channel.isUnreadInGroupFor(userId: String): Boolean =
hidden != true && !isDisabled && currentUserUnreadCount(userId) > 0

// The model has no field for it, so the server's `disabled` flag arrives in the extra data.
private val Channel.isDisabled: Boolean
internal val Channel.isDisabled: Boolean
get() = extraData[DISABLED_FIELD_NAME] == true

private val queryTimestampDescSort = QuerySortByField.descByName<Channel>(QUERY_TIMESTAMP_FIELD_NAME)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import io.getstream.chat.android.client.events.NotificationMarkReadEvent
import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent
import io.getstream.chat.android.client.events.NotificationMessageNewEvent
import io.getstream.chat.android.client.extensions.cidToTypeAndId
import io.getstream.chat.android.client.extensions.currentUserUnreadCount
import io.getstream.chat.android.models.Channel
import io.getstream.chat.android.models.GroupedChannels
import io.getstream.chat.android.models.UserId
Expand Down Expand Up @@ -96,7 +95,7 @@ internal class GroupedUnreadChannelsUpdater(
current: Map<String, Int>,
batchId: Int,
event: ChannelUpdatedEvent,
): Map<String, Int> = applyDelta(current, batchId, event.cid, event.channel)
): Map<String, Int> = applyDelta(current, batchId, event.cid, event.channel, cachedChannel(event.cid))

/**
* `channel.updated_by_user` delta. Same semantics as the [ChannelUpdatedEvent] overload.
Expand All @@ -105,17 +104,21 @@ internal class GroupedUnreadChannelsUpdater(
current: Map<String, Int>,
batchId: Int,
event: ChannelUpdatedByUserEvent,
): Map<String, Int> = applyDelta(current, batchId, event.cid, event.channel)
): Map<String, Int> = applyDelta(current, batchId, event.cid, event.channel, cachedChannel(event.cid))

/**
* `notification.channel_partial_updated` delta. Same semantics as the [ChannelUpdatedEvent] overload. The
* event's channel carries the custom data, so it holds the new group.
* event's channel carries the custom data, so it holds the new group. The event goes to members who are not
* watching the channel, so it usually has no active state: its old group and unread state then come from
* [storedChannel], which is only read in that case.
*/
fun calculateUpdatedCounts(
suspend fun calculateUpdatedCounts(
current: Map<String, Int>,
batchId: Int,
event: NotificationChannelPartialUpdatedEvent,
): Map<String, Int> = applyDelta(current, batchId, event.cid, event.channel)
storedChannel: suspend () -> Channel?,
): Map<String, Int> =
applyDelta(current, batchId, event.cid, event.channel, cachedChannel(event.cid) ?: storedChannel())
Comment thread
gpunto marked this conversation as resolved.

/**
* Records that channel [cid] has been removed within [batchId] — either deleted, or the
Expand Down Expand Up @@ -147,10 +150,11 @@ internal class GroupedUnreadChannelsUpdater(
batchId: Int,
cid: String,
newChannel: Channel,
oldChannel: Channel?,
): Map<String, Int> {
rotateBatchIfNeeded(batchId)
if (cid in removedCids || cid in processedCids) return current
val oldChannel = cachedChannel(cid) ?: return current
if (oldChannel == null) return current
val oldGroup = oldChannel.group
val newGroup = newChannel.group
if (oldGroup == newGroup) return current
Expand All @@ -167,7 +171,7 @@ internal class GroupedUnreadChannelsUpdater(
private fun hadUnreadFor(cid: String, oldChannel: Channel): Boolean = when {
cid in hadUnreadOverride -> hadUnreadOverride.getValue(cid)
markAllReadApplied -> false
else -> oldChannel.currentUserUnreadCount(currentUserId) > 0
else -> oldChannel.isUnreadInGroupFor(currentUserId)
}

private fun rotateBatchIfNeeded(batchId: Int) {
Expand Down
Loading
Loading