From 2d1e74d74bfe99d3ec274583a840b3e16ff395dc Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:21:07 +0200 Subject: [PATCH 1/5] Migrate the thread channel to the generated ChannelResponse model --- .../client/api2/mapping/DomainMapping.kt | 104 +++++++ .../client/api2/model/dto/ThreadDtos.kt | 5 +- .../android/client/parser2/MoshiChatParser.kt | 31 ++ .../adapters/ChannelMemberResponseAdapter.kt | 41 +++ .../adapters/ChannelResponseAdapter.kt | 45 +++ .../network/models/BlockListOptions.kt | 77 +++++ .../network/models/ChannelConfigWithInfo.kt | 285 ++++++++++++++++++ .../network/models/ChannelMemberResponse.kt | 92 ++++++ .../network/models/ChannelOwnCapability.kt | 144 +++++++++ .../android/network/models/ChannelResponse.kt | 116 +++++++ .../android/network/models/LabelThresholds.kt | 38 +++ .../chat/android/network/models/Thresholds.kt | 41 +++ .../getstream/chat/android/client/Mother.kt | 20 +- .../client/api2/mapping/DomainMappingTest.kt | 91 ++++++ .../parser2/CustomAdapterCoverageTest.kt | 74 +++++ .../parser2/testdata/ChannelDtoTestData.kt | 143 +++++++++ .../parser2/testdata/ThreadDtoTestData.kt | 12 +- 17 files changed, 1349 insertions(+), 10 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelMemberResponseAdapter.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelResponseAdapter.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/BlockListOptions.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelConfigWithInfo.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelMemberResponse.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelOwnCapability.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelResponse.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/LabelThresholds.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/Thresholds.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/CustomAdapterCoverageTest.kt diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index 1b5230e2966..ae0831dd9a2 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -130,7 +130,11 @@ import io.getstream.chat.android.models.querysort.SortDirection import io.getstream.chat.android.network.models.AppResponseFields import io.getstream.chat.android.network.models.BlockUsersResponse import io.getstream.chat.android.network.models.BlockedUserResponse +import io.getstream.chat.android.network.models.ChannelConfigWithInfo +import io.getstream.chat.android.network.models.ChannelMemberResponse +import io.getstream.chat.android.network.models.ChannelOwnCapability import io.getstream.chat.android.network.models.ChannelPushPreferencesResponse +import io.getstream.chat.android.network.models.ChannelResponse import io.getstream.chat.android.network.models.ChatPreferencesResponse import io.getstream.chat.android.network.models.DeviceResponse import io.getstream.chat.android.network.models.GetApplicationResponse @@ -228,6 +232,53 @@ internal class DomainMapping( image = image, ) + /** + * Transforms [ChannelResponse] into [Channel]. Channel-level wire fields only: the type carries + * no `messages`, `watchers`, `read`, `pinned_messages`, `membership` or `active_live_locations`, + * so those stay empty. `name` and `image` are custom data on the wire. + */ + internal fun ChannelResponse.toDomain(): Channel = + Channel( + id = id, + type = type, + name = custom["name"] as? String ?: "", + image = custom["image"] as? String ?: "", + filterTags = filterTags.orEmpty(), + frozen = frozen, + createdAt = createdAt, + deletedAt = deletedAt, + updatedAt = updatedAt, + memberCount = memberCount ?: 0, + members = members.orEmpty().map { it.toDomain() }, + hidden = hidden, + hiddenMessagesBefore = hideMessagesBefore, + truncatedAt = truncatedAt, + disabled = disabled, + blocked = blocked, + config = config?.toDomain() ?: Config(), + createdBy = createdBy?.toDomain() ?: User(), + team = team.orEmpty(), + cooldown = cooldown ?: 0, + ownCapabilities = ownCapabilities.orEmpty().mapTo(mutableSetOf(), ChannelOwnCapability::value), + messageCount = messageCount, + lastMessageAt = lastMessageAt, + extraData = custom.mapNotNull { (key, value) -> value?.let { key to it } } + .toMap() + .minus(listOf("name", "image")) + .toMutableMap(), + ).syncUnreadCountWithReads(currentUserIdProvider()) + .let(channelTransformer::transform) + + internal fun ChannelResponse.toChannelInfo(): ChannelInfo = + ChannelInfo( + cid = cid, + id = id, + memberCount = memberCount ?: 0, + name = custom["name"] as? String, + type = type, + image = custom["image"] as? String, + ) + /** * Transforms [DownstreamMessageDto] to [Message]. */ @@ -451,6 +502,25 @@ internal class DomainMapping( extraData = extraData, ) + internal fun ChannelMemberResponse.toDomain(): Member = + Member( + user = user?.toDomain() ?: User(id = userId.orEmpty()), + createdAt = createdAt, + updatedAt = updatedAt, + isInvited = invited, + inviteAcceptedAt = inviteAcceptedAt, + inviteRejectedAt = inviteRejectedAt, + shadowBanned = shadowBanned, + banned = banned, + channelRole = channelRole, + notificationsMuted = notificationsMuted, + status = status, + banExpires = banExpires, + pinnedAt = pinnedAt, + archivedAt = archivedAt, + extraData = custom.mapNotNull { (key, value) -> value?.let { key to it } }.toMap(), + ) + internal fun UserResponse.toDomain(): User = User( id = id, @@ -748,6 +818,40 @@ internal class DomainMapping( pushLevel = push_level, ) + /** + * Transforms [ChannelConfigWithInfo] to [Config]. The wire carries `message_retention`, but Go + * tags it `openapi:"-"` so it is absent from the generated model; we keep the domain default + * until the spec describes it. + */ + internal fun ChannelConfigWithInfo.toDomain(): Config = Config( + createdAt = createdAt, + updatedAt = updatedAt, + name = name, + typingEventsEnabled = typingEvents, + readEventsEnabled = readEvents, + deliveryEventsEnabled = deliveryEvents, + connectEventsEnabled = connectEvents, + searchEnabled = search, + isReactionsEnabled = reactions, + isThreadEnabled = replies, + muteEnabled = mutes, + uploadsEnabled = uploads, + urlEnrichmentEnabled = urlEnrichment, + customEventsEnabled = customEvents, + pushNotificationsEnabled = pushNotifications, + skipLastMsgUpdateForSystemMsgs = skipLastMsgUpdateForSystemMsgs, + pollsEnabled = polls, + maxMessageLength = maxMessageLength, + automod = automod.value, + automodBehavior = automodBehavior.value, + blocklistBehavior = blocklistBehavior?.value.orEmpty(), + commands = commands.map { it.toDomain() }, + messageRemindersEnabled = userMessageReminders, + sharedLocationsEnabled = sharedLocations, + markMessagesPending = markMessagesPending, + pushLevel = pushLevel?.value, + ) + /** * Transforms [DeviceResponse] to [Device]. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ThreadDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ThreadDtos.kt index cebdf7546fa..e93476d06f4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ThreadDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ThreadDtos.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.api2.model.dto import com.squareup.moshi.JsonClass +import io.getstream.chat.android.network.models.ChannelResponse import io.getstream.chat.android.network.models.ThreadParticipant import java.util.Date @@ -47,7 +48,7 @@ import java.util.Date @JsonClass(generateAdapter = true) internal data class DownstreamThreadDto( val active_participant_count: Int?, - val channel: DownstreamChannelDto?, + val channel: ChannelResponse?, val channel_cid: String, val created_at: Date, val created_by: DownstreamUserDto?, @@ -91,7 +92,7 @@ internal data class DownstreamThreadDto( @JsonClass(generateAdapter = true) internal data class DownstreamThreadInfoDto( val channel_cid: String, - val channel: DownstreamChannelDto?, + val channel: ChannelResponse?, val parent_message_id: String, val parent_message: DownstreamMessageDto?, val created_by_user_id: String, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt index b50b569de2f..6bb80a02de2 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt @@ -34,6 +34,8 @@ import io.getstream.chat.android.client.parser2.adapters.AttachmentDtoAdapter import io.getstream.chat.android.client.parser2.adapters.AttachmentRequestAdapter import io.getstream.chat.android.client.parser2.adapters.ChannelInputRequestAdapter import io.getstream.chat.android.client.parser2.adapters.ChannelMemberRequestAdapter +import io.getstream.chat.android.client.parser2.adapters.ChannelMemberResponseAdapter +import io.getstream.chat.android.client.parser2.adapters.ChannelResponseAdapter import io.getstream.chat.android.client.parser2.adapters.CreatePollOptionRequestAdapter import io.getstream.chat.android.client.parser2.adapters.CreatePollRequestAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamChannelDtoAdapter @@ -67,6 +69,9 @@ import io.getstream.chat.android.client.parser2.adapters.UserResponseAdapter import io.getstream.chat.android.client.socket.ErrorResponse import io.getstream.chat.android.client.socket.SocketErrorMessage import io.getstream.chat.android.network.infrastructure.Serializer +import io.getstream.chat.android.network.models.BlockListOptions +import io.getstream.chat.android.network.models.ChannelConfigWithInfo +import io.getstream.chat.android.network.models.ChannelOwnCapability import io.getstream.chat.android.network.models.ConfigOverridesRequest import io.getstream.chat.android.network.models.CreatePollRequest import io.getstream.chat.android.network.models.MessageRequest @@ -100,6 +105,8 @@ internal class MoshiChatParser( .add(MessageRequestAdapter) .add(ChannelMemberRequestAdapter) .add(ChannelInputRequestAdapter) + .add(ChannelResponseAdapter) + .add(ChannelMemberResponseAdapter) .add(DownstreamMemberDtoAdapter) .add(DownstreamMemberInfoDtoAdapter) .add(UpstreamMemberDtoAdapter) @@ -141,6 +148,30 @@ internal class MoshiChatParser( TranslateMessageRequest.Language::class.java, TranslateMessageRequest.Language.LanguageAdapter(), ) + .add( + ChannelOwnCapability::class.java, + ChannelOwnCapability.ChannelOwnCapabilityAdapter(), + ) + .add( + ChannelConfigWithInfo.Automod::class.java, + ChannelConfigWithInfo.Automod.AutomodAdapter(), + ) + .add( + ChannelConfigWithInfo.AutomodBehavior::class.java, + ChannelConfigWithInfo.AutomodBehavior.AutomodBehaviorAdapter(), + ) + .add( + ChannelConfigWithInfo.BlocklistBehavior::class.java, + ChannelConfigWithInfo.BlocklistBehavior.BlocklistBehaviorAdapter(), + ) + .add( + ChannelConfigWithInfo.PushLevel::class.java, + ChannelConfigWithInfo.PushLevel.PushLevelAdapter(), + ) + .add( + BlockListOptions.Behavior::class.java, + BlockListOptions.Behavior.BehaviorAdapter(), + ) // Registered last so the model-specific adapters above keep precedence and delegate into it. .add(NullCollectionsAsEmptyFactory) .build() diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelMemberResponseAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelMemberResponseAdapter.kt new file mode 100644 index 00000000000..f28780b7058 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelMemberResponseAdapter.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.parser2.adapters + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import io.getstream.chat.android.network.models.ChannelMemberResponse + +// Downstream (read-only) adapter for the generated ChannelMemberResponse: collects root-level custom fields +// into `custom`, matching the wire's flattened extra data. extraDataPropertyName is its @Json name. +internal object ChannelMemberResponseAdapter : + CustomObjectDtoAdapter(ChannelMemberResponse::class, extraDataPropertyName = "custom") { + + @FromJson + fun fromJson( + jsonReader: JsonReader, + mapAdapter: JsonAdapter>, + valueAdapter: JsonAdapter, + ): ChannelMemberResponse? = parseWithExtraData(jsonReader, mapAdapter, valueAdapter) + + @ToJson + @Suppress("UNUSED_PARAMETER") + fun toJson(jsonWriter: JsonWriter, value: ChannelMemberResponse): Unit = error("Can't convert this to Json") +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelResponseAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelResponseAdapter.kt new file mode 100644 index 00000000000..4ea5d44eab4 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelResponseAdapter.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.parser2.adapters + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import io.getstream.chat.android.network.models.ChannelResponse + +// Downstream (read-only) adapter for the generated ChannelResponse: collects root-level custom fields +// into `custom`, matching the wire's flattened extra data. extraDataPropertyName is its @Json name. +internal object ChannelResponseAdapter : + CustomObjectDtoAdapter( + ChannelResponse::class, + extraDataPropertyName = "custom", + alsoKeepInExtraData = LEGACY_CHANNEL_EXTRA_DATA_KEYS, + ) { + + @FromJson + fun fromJson( + jsonReader: JsonReader, + mapAdapter: JsonAdapter>, + valueAdapter: JsonAdapter, + ): ChannelResponse? = parseWithExtraData(jsonReader, mapAdapter, valueAdapter) + + @ToJson + @Suppress("UNUSED_PARAMETER") + fun toJson(jsonWriter: JsonWriter, value: ChannelResponse): Unit = error("Can't convert this to Json") +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/BlockListOptions.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/BlockListOptions.kt new file mode 100644 index 00000000000..29c1d09dc4c --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/BlockListOptions.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.FromJson +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson + +/** + * + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class BlockListOptions( + @Json(name = "behavior") + internal val behavior: Behavior, + + @Json(name = "blocklist") + internal val blocklist: String, +) { + + /** + * Behavior Enum + */ + internal sealed class Behavior(internal val value: String) { + override fun toString(): String = value + + internal companion object { + internal fun fromString(s: String): Behavior = when (s) { + "block" -> Block + "flag" -> Flag + "shadow_block" -> ShadowBlock + else -> Unknown(s) + } + } + internal object Block : Behavior("block") + internal object Flag : Behavior("flag") + internal object ShadowBlock : Behavior("shadow_block") + internal data class Unknown(val unknownValue: String) : Behavior(unknownValue) + + internal class BehaviorAdapter : JsonAdapter() { + @FromJson + override fun fromJson(reader: JsonReader): Behavior? { + val s = reader.nextString() ?: return null + return Behavior.fromString(s) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: Behavior?) { + writer.value(value?.value) + } + } + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelConfigWithInfo.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelConfigWithInfo.kt new file mode 100644 index 00000000000..190003acaa8 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelConfigWithInfo.kt @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.FromJson +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson + +/** + * + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class ChannelConfigWithInfo( + @Json(name = "automod") + internal val automod: Automod, + + @Json(name = "automod_behavior") + internal val automodBehavior: AutomodBehavior, + + @Json(name = "connect_events") + internal val connectEvents: Boolean, + + @Json(name = "count_messages") + internal val countMessages: Boolean, + + @Json(name = "created_at") + internal val createdAt: java.util.Date, + + @Json(name = "custom_events") + internal val customEvents: Boolean, + + @Json(name = "delivery_events") + internal val deliveryEvents: Boolean, + + @Json(name = "mark_messages_pending") + internal val markMessagesPending: Boolean, + + @Json(name = "max_message_length") + internal val maxMessageLength: Int, + + @Json(name = "mutes") + internal val mutes: Boolean, + + @Json(name = "name") + internal val name: String, + + @Json(name = "polls") + internal val polls: Boolean, + + @Json(name = "push_notifications") + internal val pushNotifications: Boolean, + + @Json(name = "quotes") + internal val quotes: Boolean, + + @Json(name = "reactions") + internal val reactions: Boolean, + + @Json(name = "read_events") + internal val readEvents: Boolean, + + @Json(name = "reminders") + internal val reminders: Boolean, + + @Json(name = "replies") + internal val replies: Boolean, + + @Json(name = "search") + internal val search: Boolean, + + @Json(name = "shared_locations") + internal val sharedLocations: Boolean, + + @Json(name = "skip_last_msg_update_for_system_msgs") + internal val skipLastMsgUpdateForSystemMsgs: Boolean, + + @Json(name = "typing_events") + internal val typingEvents: Boolean, + + @Json(name = "updated_at") + internal val updatedAt: java.util.Date, + + @Json(name = "uploads") + internal val uploads: Boolean, + + @Json(name = "url_enrichment") + internal val urlEnrichment: Boolean, + + @Json(name = "user_message_reminders") + internal val userMessageReminders: Boolean, + + @Json(name = "commands") + internal val commands: List = emptyList(), + + @Json(name = "blocklist") + internal val blocklist: String? = null, + + @Json(name = "blocklist_behavior") + internal val blocklistBehavior: BlocklistBehavior? = null, + + @Json(name = "partition_size") + internal val partitionSize: Int? = null, + + @Json(name = "partition_ttl") + internal val partitionTtl: String? = null, + + @Json(name = "push_level") + internal val pushLevel: PushLevel? = null, + + @Json(name = "allowed_flag_reasons") + internal val allowedFlagReasons: List? = emptyList(), + + @Json(name = "blocklists") + internal val blocklists: List? = emptyList(), + + @Json(name = "automod_thresholds") + internal val automodThresholds: Thresholds? = null, + + @Json(name = "chat_preferences") + internal val chatPreferences: ChatPreferences? = null, + + @Json(name = "grants") + internal val grants: Map>? = emptyMap(), +) { + + /** + * Automod Enum + */ + internal sealed class Automod(internal val value: String) { + override fun toString(): String = value + + internal companion object { + internal fun fromString(s: String): Automod = when (s) { + "AI" -> AI + "disabled" -> Disabled + "simple" -> Simple + else -> Unknown(s) + } + } + internal object AI : Automod("AI") + internal object Disabled : Automod("disabled") + internal object Simple : Automod("simple") + internal data class Unknown(val unknownValue: String) : Automod(unknownValue) + + internal class AutomodAdapter : JsonAdapter() { + @FromJson + override fun fromJson(reader: JsonReader): Automod? { + val s = reader.nextString() ?: return null + return Automod.fromString(s) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: Automod?) { + writer.value(value?.value) + } + } + } + + /** + * AutomodBehavior Enum + */ + internal sealed class AutomodBehavior(internal val value: String) { + override fun toString(): String = value + + internal companion object { + internal fun fromString(s: String): AutomodBehavior = when (s) { + "block" -> Block + "flag" -> Flag + "shadow_block" -> ShadowBlock + else -> Unknown(s) + } + } + internal object Block : AutomodBehavior("block") + internal object Flag : AutomodBehavior("flag") + internal object ShadowBlock : AutomodBehavior("shadow_block") + internal data class Unknown(val unknownValue: String) : AutomodBehavior(unknownValue) + + internal class AutomodBehaviorAdapter : JsonAdapter() { + @FromJson + override fun fromJson(reader: JsonReader): AutomodBehavior? { + val s = reader.nextString() ?: return null + return AutomodBehavior.fromString(s) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: AutomodBehavior?) { + writer.value(value?.value) + } + } + } + + /** + * BlocklistBehavior Enum + */ + internal sealed class BlocklistBehavior(internal val value: String) { + override fun toString(): String = value + + internal companion object { + internal fun fromString(s: String): BlocklistBehavior = when (s) { + "block" -> Block + "flag" -> Flag + "shadow_block" -> ShadowBlock + else -> Unknown(s) + } + } + internal object Block : BlocklistBehavior("block") + internal object Flag : BlocklistBehavior("flag") + internal object ShadowBlock : BlocklistBehavior("shadow_block") + internal data class Unknown(val unknownValue: String) : BlocklistBehavior(unknownValue) + + internal class BlocklistBehaviorAdapter : JsonAdapter() { + @FromJson + override fun fromJson(reader: JsonReader): BlocklistBehavior? { + val s = reader.nextString() ?: return null + return BlocklistBehavior.fromString(s) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: BlocklistBehavior?) { + writer.value(value?.value) + } + } + } + + /** + * PushLevel Enum + */ + internal sealed class PushLevel(internal val value: String) { + override fun toString(): String = value + + internal companion object { + internal fun fromString(s: String): PushLevel = when (s) { + "all" -> All + "all_mentions" -> AllMentions + "direct_mentions" -> DirectMentions + "mentions" -> Mentions + "none" -> None + else -> Unknown(s) + } + } + internal object All : PushLevel("all") + internal object AllMentions : PushLevel("all_mentions") + internal object DirectMentions : PushLevel("direct_mentions") + internal object Mentions : PushLevel("mentions") + internal object None : PushLevel("none") + internal data class Unknown(val unknownValue: String) : PushLevel(unknownValue) + + internal class PushLevelAdapter : JsonAdapter() { + @FromJson + override fun fromJson(reader: JsonReader): PushLevel? { + val s = reader.nextString() ?: return null + return PushLevel.fromString(s) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: PushLevel?) { + writer.value(value?.value) + } + } + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelMemberResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelMemberResponse.kt new file mode 100644 index 00000000000..16d4576b7a4 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelMemberResponse.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.Json + +/** + * + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class ChannelMemberResponse( + @Json(name = "banned") + internal val banned: Boolean, + + @Json(name = "channel_role") + internal val channelRole: String, + + @Json(name = "created_at") + internal val createdAt: java.util.Date, + + @Json(name = "notifications_muted") + internal val notificationsMuted: Boolean, + + @Json(name = "shadow_banned") + internal val shadowBanned: Boolean, + + @Json(name = "updated_at") + internal val updatedAt: java.util.Date, + + @Json(name = "custom") + internal val custom: Map = emptyMap(), + + @Json(name = "archived_at") + internal val archivedAt: java.util.Date? = null, + + @Json(name = "ban_expires") + internal val banExpires: java.util.Date? = null, + + @Json(name = "deleted_at") + internal val deletedAt: java.util.Date? = null, + + @Json(name = "invite_accepted_at") + internal val inviteAcceptedAt: java.util.Date? = null, + + @Json(name = "invite_rejected_at") + internal val inviteRejectedAt: java.util.Date? = null, + + @Json(name = "invited") + internal val invited: Boolean? = null, + + @Json(name = "is_moderator") + internal val isModerator: Boolean? = null, + + @Json(name = "pinned_at") + internal val pinnedAt: java.util.Date? = null, + + @Json(name = "role") + internal val role: String? = null, + + @Json(name = "status") + internal val status: String? = null, + + @Json(name = "user_id") + internal val userId: String? = null, + + @Json(name = "deleted_messages") + internal val deletedMessages: List? = emptyList(), + + @Json(name = "user") + internal val user: io.getstream.chat.android.network.models.UserResponse? = null, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelOwnCapability.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelOwnCapability.kt new file mode 100644 index 00000000000..c19fdce9576 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelOwnCapability.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson + +/** + * [All possibility of string to use] + */ +/** + * ChannelOwnCapability Enum + */ +internal sealed class ChannelOwnCapability(internal val value: String) { + override fun toString(): String = value + + internal companion object { + internal fun fromString(s: String): ChannelOwnCapability = when (s) { + "ban-channel-members" -> BanChannelMembers + "cast-poll-vote" -> CastPollVote + "connect-events" -> ConnectEvents + "create-attachment" -> CreateAttachment + "create-mention" -> CreateMention + "delete-any-message" -> DeleteAnyMessage + "delete-channel" -> DeleteChannel + "delete-own-message" -> DeleteOwnMessage + "delivery-events" -> DeliveryEvents + "flag-message" -> FlagMessage + "freeze-channel" -> FreezeChannel + "join-channel" -> JoinChannel + "leave-channel" -> LeaveChannel + "mute-channel" -> MuteChannel + "notify-channel" -> NotifyChannel + "notify-group" -> NotifyGroup + "notify-here" -> NotifyHere + "notify-role" -> NotifyRole + "pin-message" -> PinMessage + "query-poll-votes" -> QueryPollVotes + "quote-message" -> QuoteMessage + "read-events" -> ReadEvents + "search-messages" -> SearchMessages + "send-custom-events" -> SendCustomEvents + "send-links" -> SendLinks + "send-message" -> SendMessage + "send-poll" -> SendPoll + "send-reaction" -> SendReaction + "send-reply" -> SendReply + "send-restricted-visibility-message" -> SendRestrictedVisibilityMessage + "send-typing-events" -> SendTypingEvents + "set-channel-cooldown" -> SetChannelCooldown + "share-location" -> ShareLocation + "skip-slow-mode" -> SkipSlowMode + "slow-mode" -> SlowMode + "typing-events" -> TypingEvents + "update-any-message" -> UpdateAnyMessage + "update-channel" -> UpdateChannel + "update-channel-members" -> UpdateChannelMembers + "update-own-message" -> UpdateOwnMessage + "update-thread" -> UpdateThread + "upload-file" -> UploadFile + else -> Unknown(s) + } + } + internal object BanChannelMembers : ChannelOwnCapability("ban-channel-members") + internal object CastPollVote : ChannelOwnCapability("cast-poll-vote") + internal object ConnectEvents : ChannelOwnCapability("connect-events") + internal object CreateAttachment : ChannelOwnCapability("create-attachment") + internal object CreateMention : ChannelOwnCapability("create-mention") + internal object DeleteAnyMessage : ChannelOwnCapability("delete-any-message") + internal object DeleteChannel : ChannelOwnCapability("delete-channel") + internal object DeleteOwnMessage : ChannelOwnCapability("delete-own-message") + internal object DeliveryEvents : ChannelOwnCapability("delivery-events") + internal object FlagMessage : ChannelOwnCapability("flag-message") + internal object FreezeChannel : ChannelOwnCapability("freeze-channel") + internal object JoinChannel : ChannelOwnCapability("join-channel") + internal object LeaveChannel : ChannelOwnCapability("leave-channel") + internal object MuteChannel : ChannelOwnCapability("mute-channel") + internal object NotifyChannel : ChannelOwnCapability("notify-channel") + internal object NotifyGroup : ChannelOwnCapability("notify-group") + internal object NotifyHere : ChannelOwnCapability("notify-here") + internal object NotifyRole : ChannelOwnCapability("notify-role") + internal object PinMessage : ChannelOwnCapability("pin-message") + internal object QueryPollVotes : ChannelOwnCapability("query-poll-votes") + internal object QuoteMessage : ChannelOwnCapability("quote-message") + internal object ReadEvents : ChannelOwnCapability("read-events") + internal object SearchMessages : ChannelOwnCapability("search-messages") + internal object SendCustomEvents : ChannelOwnCapability("send-custom-events") + internal object SendLinks : ChannelOwnCapability("send-links") + internal object SendMessage : ChannelOwnCapability("send-message") + internal object SendPoll : ChannelOwnCapability("send-poll") + internal object SendReaction : ChannelOwnCapability("send-reaction") + internal object SendReply : ChannelOwnCapability("send-reply") + internal object SendRestrictedVisibilityMessage : ChannelOwnCapability("send-restricted-visibility-message") + internal object SendTypingEvents : ChannelOwnCapability("send-typing-events") + internal object SetChannelCooldown : ChannelOwnCapability("set-channel-cooldown") + internal object ShareLocation : ChannelOwnCapability("share-location") + internal object SkipSlowMode : ChannelOwnCapability("skip-slow-mode") + internal object SlowMode : ChannelOwnCapability("slow-mode") + internal object TypingEvents : ChannelOwnCapability("typing-events") + internal object UpdateAnyMessage : ChannelOwnCapability("update-any-message") + internal object UpdateChannel : ChannelOwnCapability("update-channel") + internal object UpdateChannelMembers : ChannelOwnCapability("update-channel-members") + internal object UpdateOwnMessage : ChannelOwnCapability("update-own-message") + internal object UpdateThread : ChannelOwnCapability("update-thread") + internal object UploadFile : ChannelOwnCapability("upload-file") + internal data class Unknown(val unknownValue: String) : ChannelOwnCapability(unknownValue) + + internal class ChannelOwnCapabilityAdapter : JsonAdapter() { + @FromJson + override fun fromJson(reader: JsonReader): ChannelOwnCapability? { + val s = reader.nextString() ?: return null + return ChannelOwnCapability.fromString(s) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: ChannelOwnCapability?) { + writer.value(value?.value) + } + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelResponse.kt new file mode 100644 index 00000000000..faae78b4aa2 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelResponse.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.Json + +/** + * Represents channel in chat + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class ChannelResponse( + @Json(name = "cid") + internal val cid: String, + + @Json(name = "created_at") + internal val createdAt: java.util.Date, + + @Json(name = "disabled") + internal val disabled: Boolean, + + @Json(name = "frozen") + internal val frozen: Boolean, + + @Json(name = "id") + internal val id: String, + + @Json(name = "type") + internal val type: String, + + @Json(name = "updated_at") + internal val updatedAt: java.util.Date, + + @Json(name = "custom") + internal val custom: Map = emptyMap(), + + @Json(name = "auto_translation_enabled") + internal val autoTranslationEnabled: Boolean? = null, + + @Json(name = "auto_translation_language") + internal val autoTranslationLanguage: String? = null, + + @Json(name = "blocked") + internal val blocked: Boolean? = null, + + @Json(name = "cooldown") + internal val cooldown: Int? = null, + + @Json(name = "deleted_at") + internal val deletedAt: java.util.Date? = null, + + @Json(name = "hidden") + internal val hidden: Boolean? = null, + + @Json(name = "hide_messages_before") + internal val hideMessagesBefore: java.util.Date? = null, + + @Json(name = "last_message_at") + internal val lastMessageAt: java.util.Date? = null, + + @Json(name = "member_count") + internal val memberCount: Int? = null, + + @Json(name = "message_count") + internal val messageCount: Int? = null, + + @Json(name = "mute_expires_at") + internal val muteExpiresAt: java.util.Date? = null, + + @Json(name = "muted") + internal val muted: Boolean? = null, + + @Json(name = "team") + internal val team: String? = null, + + @Json(name = "truncated_at") + internal val truncatedAt: java.util.Date? = null, + + @Json(name = "filter_tags") + internal val filterTags: List? = emptyList(), + + @Json(name = "members") + internal val members: List? = emptyList(), + + @Json(name = "own_capabilities") + internal val ownCapabilities: List? = emptyList(), + + @Json(name = "config") + internal val config: ChannelConfigWithInfo? = null, + + @Json(name = "created_by") + internal val createdBy: UserResponse? = null, + + @Json(name = "truncated_by") + internal val truncatedBy: UserResponse? = null, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/LabelThresholds.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/LabelThresholds.kt new file mode 100644 index 00000000000..a863c313141 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/LabelThresholds.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.Json + +/** + * + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class LabelThresholds( + @Json(name = "block") + internal val block: Float? = null, + + @Json(name = "flag") + internal val flag: Float? = null, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/Thresholds.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/Thresholds.kt new file mode 100644 index 00000000000..81bb6657deb --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/Thresholds.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.Json + +/** + * Sets thresholds for AI moderation + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class Thresholds( + @Json(name = "explicit") + internal val explicit: LabelThresholds? = null, + + @Json(name = "spam") + internal val spam: LabelThresholds? = null, + + @Json(name = "toxic") + internal val toxic: LabelThresholds? = null, +) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt index 9b54a69da1a..3ad193aa658 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt @@ -92,6 +92,7 @@ import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.network.models.AppResponseFields import io.getstream.chat.android.network.models.BlockUsersResponse import io.getstream.chat.android.network.models.BlockedUserResponse +import io.getstream.chat.android.network.models.ChannelResponse import io.getstream.chat.android.network.models.CreateGuestResponse import io.getstream.chat.android.network.models.DeviceResponse import io.getstream.chat.android.network.models.FileUploadConfig @@ -1012,7 +1013,7 @@ internal object Mother { fun randomDownstreamThreadDto( activeParticipantCount: Int = randomInt(), channelCid: String = randomString(), - channel: DownstreamChannelDto = randomDownstreamChannelDto(id = channelCid), + channel: ChannelResponse? = randomChannelResponse(id = channelCid), parentMessageId: String = randomString(), parentMessage: DownstreamMessageDto = randomDownstreamMessageDto(), createdByUserId: String = randomString(), @@ -1078,9 +1079,24 @@ internal object Mother { updatedAt = randomDate(), ) + fun randomChannelResponse( + id: String = randomString(), + type: String = randomString(), + custom: Map = emptyMap(), + ): ChannelResponse = ChannelResponse( + cid = "$type:$id", + id = id, + type = type, + disabled = randomBoolean(), + frozen = randomBoolean(), + createdAt = randomDate(), + updatedAt = randomDate(), + custom = custom, + ) + fun randomDownstreamThreadInfoDto( channelCid: String = randomString(), - channel: DownstreamChannelDto? = randomDownstreamChannelDto(id = channelCid), + channel: ChannelResponse? = randomChannelResponse(id = channelCid), parentMessageId: String = randomString(), parentMessage: DownstreamMessageDto = randomDownstreamMessageDto(id = parentMessageId), createdByUserId: String = randomString(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt index 43ecb6a3c9f..2d8b9614971 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -27,6 +27,7 @@ import io.getstream.chat.android.client.Mother.randomBannedUserResponse import io.getstream.chat.android.client.Mother.randomBlockUsersResponse import io.getstream.chat.android.client.Mother.randomBlockedUserResponse import io.getstream.chat.android.client.Mother.randomChannelInfoDto +import io.getstream.chat.android.client.Mother.randomChannelResponse import io.getstream.chat.android.client.Mother.randomCommandDto import io.getstream.chat.android.client.Mother.randomConfigDto import io.getstream.chat.android.client.Mother.randomDeviceResponse @@ -72,6 +73,7 @@ import io.getstream.chat.android.client.api2.model.dto.DownstreamUserGroupDto import io.getstream.chat.android.client.api2.model.dto.DownstreamUserGroupMemberDto import io.getstream.chat.android.client.api2.model.response.MessageResponse import io.getstream.chat.android.client.extensions.internal.sortedByLastReply +import io.getstream.chat.android.client.parser2.testdata.ChannelDtoTestData import io.getstream.chat.android.models.Answer import io.getstream.chat.android.models.App import io.getstream.chat.android.models.AppSettings @@ -309,6 +311,95 @@ internal class DomainMappingTest { assertEquals(transformedChannel, result) } + @Test + fun `ChannelResponse promotes name and image out of custom and keeps the rest as extraData`() { + val channelResponse = randomChannelResponse( + custom = mapOf( + "name" to "channelName", + "image" to "channelImage", + "customKey" to "customValue", + "nullKey" to null, + ), + ) + val sut = Fixture().get() + + val channel = with(sut) { channelResponse.toDomain() } + + assertEquals("channelName", channel.name) + assertEquals("channelImage", channel.image) + assertEquals(mapOf("customKey" to "customValue"), channel.extraData) + } + + @Test + fun `ChannelResponse is correctly mapped to Channel`() { + val channelResponse = ChannelDtoTestData.channelResponse + val sut = Fixture().get() + + val channel = with(sut) { channelResponse.toDomain() } + + assertEquals(channelResponse.id, channel.id) + assertEquals(channelResponse.type, channel.type) + assertEquals(channelResponse.frozen, channel.frozen) + assertEquals(channelResponse.createdAt, channel.createdAt) + assertEquals(channelResponse.updatedAt, channel.updatedAt) + assertEquals(channelResponse.memberCount, channel.memberCount) + assertEquals(setOf("connect-events", "pin-message"), channel.ownCapabilities) + assertEquals(channelResponse.hidden, channel.hidden) + assertEquals(channelResponse.hideMessagesBefore, channel.hiddenMessagesBefore) + assertEquals(with(sut) { channelResponse.config?.toDomain() }, channel.config) + } + + @Test + fun `ChannelResponse maps the channel state to properties and keeps it in extraData`() { + val channelResponse = ChannelDtoTestData.channelResponse + val sut = Fixture().get() + + val channel = with(sut) { channelResponse.toDomain() } + + channel.disabled shouldBeEqualTo true + channel.blocked shouldBeEqualTo true + channel.truncatedAt shouldBeEqualTo Date(1591787071588) + channel.hidden shouldBeEqualTo true + channel.hiddenMessagesBefore shouldBeEqualTo Date(1591787071588) + // Still reachable through extraData, matching the hand-written channel path. + channel.extraData["disabled"] shouldBeEqualTo true + channel.extraData["blocked"] shouldBeEqualTo true + channel.extraData["truncated_at"] shouldBeEqualTo "2020-06-10T11:04:31.588Z" + } + + @Test + fun `ChannelResponse is correctly mapped to ChannelInfo`() { + val channelResponse = ChannelDtoTestData.channelResponse + val sut = Fixture().get() + + val channelInfo = with(sut) { channelResponse.toChannelInfo() } + + assertEquals( + ChannelInfo( + cid = channelResponse.cid, + id = channelResponse.id, + memberCount = 2, + name = "channelName", + type = channelResponse.type, + image = "channelImage", + ), + channelInfo, + ) + } + + @Test + fun `ChannelConfigWithInfo keeps the domain messageRetention the wire field is absent from the model`() { + val sut = Fixture().get() + + val config = with(sut) { ChannelDtoTestData.channelResponse.config!!.toDomain() } + + assertEquals("infinite", config.messageRetention) + assertEquals("disabled", config.automod) + assertEquals("flag", config.automodBehavior) + assertEquals("block", config.blocklistBehavior) + assertEquals(500, config.maxMessageLength) + } + @Test fun `DownstreamChannelDto is correctly mapped to Channel`() { val downstreamChannelDto = randomDownstreamChannelDto() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/CustomAdapterCoverageTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/CustomAdapterCoverageTest.kt new file mode 100644 index 00000000000..5bdd91cfa99 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/CustomAdapterCoverageTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.parser2 + +import org.junit.jupiter.api.Test +import java.io.File + +/** + * Guards against silently dropping custom data. + * + * The v1 endpoints flatten custom data to the root of the enclosing object, so a generated model that + * declares `custom` needs a [io.getstream.chat.android.client.parser2.adapters.CustomObjectDtoAdapter] + * registered in [MoshiChatParser] to collect it. Without one the field stays empty and the data is lost + * with no error, which is how member `extraData` was dropped by the queryMembers slice. + * + * Models whose `custom` is genuinely a nested object on the wire (the Go struct declares a plain map + * rather than `jsonextra.ExtraFields`) must be listed in [NESTED_CUSTOM] with a reason instead. + */ +internal class CustomAdapterCoverageTest { + + @Test + fun `every generated model declaring custom is either adapted or documented as nested`() { + val declaring = modelsDeclaringCustom() + check(declaring.isNotEmpty()) { "Found no generated models declaring `custom`; is $MODELS_DIR correct?" } + + val registered = registeredAdapterTargets() + val unprotected = declaring - registered - NESTED_CUSTOM.keys + + check(unprotected.isEmpty()) { + "These generated models declare `custom` but have no adapter registered in MoshiChatParser:\n" + + unprotected.sorted().joinToString("\n") { " - $it" } + + "\n\nAdd a CustomObjectDtoAdapter (extraDataPropertyName = \"custom\") and register it, or " + + "list the model in NESTED_CUSTOM if its custom really is a nested object on the wire." + } + } + + private fun modelsDeclaringCustom(): Set = + File(MODELS_DIR).listFiles { f -> f.extension == "kt" }.orEmpty() + .filter { it.readText().contains(CUSTOM_PROPERTY) } + .map { it.nameWithoutExtension } + .toSet() + + private fun registeredAdapterTargets(): Set = + Regex("""\.add\((\w+)Adapter\)""").findAll(File(PARSER_FILE).readText()) + .map { it.groupValues[1] } + .toSet() + + private companion object { + private const val MODELS_DIR = "src/main/java/io/getstream/chat/android/network/models" + private const val PARSER_FILE = "src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt" + + /** The colon matters: without it this also matches unrelated properties like `customEvents`. */ + private const val CUSTOM_PROPERTY = "internal val custom:" + + /** Models whose `custom` is a nested object on the wire, so no collecting adapter applies. */ + private val NESTED_CUSTOM = mapOf( + "ThreadParticipant" to "Go declares a plain map, not jsonextra.ExtraFields, so custom stays nested", + ) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt index 6895cbb314c..b54c77a7608 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt @@ -21,6 +21,9 @@ import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelDto import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelUserRead import io.getstream.chat.android.client.api2.model.dto.DownstreamMemberDto import io.getstream.chat.android.models.ChannelCapabilities +import io.getstream.chat.android.network.models.ChannelConfigWithInfo +import io.getstream.chat.android.network.models.ChannelOwnCapability +import io.getstream.chat.android.network.models.ChannelResponse import org.intellij.lang.annotations.Language import java.util.Date import io.getstream.chat.android.network.models.Command as CommandDto @@ -287,6 +290,146 @@ internal object ChannelDtoTestData { extraData = emptyMap(), ) + /** + * The config as sent for a generated [io.getstream.chat.android.network.models.ChannelResponse]. + * `message_retention` is on the wire but absent from the model, which drops it. + */ + @Language("JSON") + private val channelResponseConfigJson = + """{ + "created_at": "2020-06-10T11:04:31.000Z", + "updated_at": "2020-06-10T11:04:31.588Z", + "name": "config1", + "typing_events": true, + "read_events": true, + "delivery_events": true, + "connect_events": true, + "search": false, + "reactions": true, + "replies": true, + "quotes": true, + "mutes": true, + "uploads": true, + "url_enrichment": false, + "custom_events": false, + "push_notifications": true, + "reminders": false, + "count_messages": true, + "skip_last_msg_update_for_system_msgs": false, + "polls": true, + "message_retention": "retention", + "max_message_length": 500, + "automod": "disabled", + "automod_behavior": "flag", + "blocklist_behavior": "block", + "commands": [ + { + "name": "giphy", + "description": "gif", + "args": "empty", + "set": "none" + } + ], + "user_message_reminders": false, + "shared_locations": true, + "mark_messages_pending": false + } + """.withoutWhitespace() + + private val channelResponseConfig = ChannelConfigWithInfo( + createdAt = Date(1591787071000), + updatedAt = Date(1591787071588), + name = "config1", + typingEvents = true, + readEvents = true, + deliveryEvents = true, + connectEvents = true, + search = false, + reactions = true, + replies = true, + quotes = true, + mutes = true, + uploads = true, + urlEnrichment = false, + customEvents = false, + pushNotifications = true, + reminders = false, + countMessages = true, + skipLastMsgUpdateForSystemMsgs = false, + polls = true, + maxMessageLength = 500, + automod = ChannelConfigWithInfo.Automod.Disabled, + automodBehavior = ChannelConfigWithInfo.AutomodBehavior.Flag, + blocklistBehavior = ChannelConfigWithInfo.BlocklistBehavior.Block, + commands = listOf( + CommandDto( + name = "giphy", + description = "gif", + args = "empty", + set = "none", + ), + ), + userMessageReminders = false, + sharedLocations = true, + markMessagesPending = false, + ) + + /** + * A channel as embedded in a thread: `name` and `image` are custom data, and the type carries no + * messages, watchers or reads. + */ + @Language("JSON") + val channelResponseJson = + """{ + "cid": "channelType:channelId", + "id": "channelId", + "type": "channelType", + "name": "channelName", + "image": "channelImage", + "disabled": true, + "blocked": true, + "truncated_at": "2020-06-10T11:04:31.588Z", + "frozen": false, + "created_at": "2020-06-10T11:04:31.0Z", + "updated_at": "2020-06-10T11:04:31.588Z", + "member_count": 2, + "hidden": true, + "hide_messages_before": "2020-06-10T11:04:31.588Z", + "own_capabilities": ["connect-events", "pin-message"], + "config": $channelResponseConfigJson, + "customKey1": "customVal1" + } + """.withoutWhitespace() + + val channelResponse = ChannelResponse( + cid = "channelType:channelId", + id = "channelId", + type = "channelType", + disabled = true, + blocked = true, + truncatedAt = Date(1591787071588), + frozen = false, + createdAt = Date(1591787071000), + updatedAt = Date(1591787071588), + memberCount = 2, + hidden = true, + hideMessagesBefore = Date(1591787071588), + ownCapabilities = listOf( + ChannelOwnCapability.ConnectEvents, + ChannelOwnCapability.PinMessage, + ), + config = channelResponseConfig, + custom = mapOf( + "name" to "channelName", + "image" to "channelImage", + // Kept in the overflow map as well, so they stay reachable through Channel.extraData. + "disabled" to true, + "blocked" to true, + "truncated_at" to "2020-06-10T11:04:31.588Z", + "customKey1" to "customVal1", + ), + ) + @Language("JSON") val downstreamJsonWithoutNameAndImage = """{ diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ThreadDtoTestData.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ThreadDtoTestData.kt index d51ce977175..cf41ecb85e5 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ThreadDtoTestData.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ThreadDtoTestData.kt @@ -28,7 +28,7 @@ internal object ThreadDtoTestData { val downstreamThreadJson = """{ "active_participant_count": 3, - "channel": ${ChannelDtoTestData.downstreamJsonWithoutExtraData}, + "channel": ${ChannelDtoTestData.channelResponseJson}, "channel_cid": "messaging:123", "created_at": "2020-06-10T11:04:31.000Z", "created_by": ${UserDtoTestData.downstreamJson}, @@ -74,7 +74,7 @@ internal object ThreadDtoTestData { val downstreamThread = DownstreamThreadDto( active_participant_count = 3, - channel = ChannelDtoTestData.downstreamChannelWithoutExtraData, + channel = ChannelDtoTestData.channelResponse, channel_cid = "messaging:123", created_at = Date(1591787071000), created_by = UserDtoTestData.downstreamUser, @@ -122,7 +122,7 @@ internal object ThreadDtoTestData { val downstreamThreadJsonWithoutExtraData = """{ "active_participant_count": 2, - "channel": ${ChannelDtoTestData.downstreamJsonWithoutExtraData}, + "channel": ${ChannelDtoTestData.channelResponseJson}, "channel_cid": "messaging:456", "created_at": "2020-06-10T11:04:31.000Z", "created_by": ${UserDtoTestData.downstreamJson}, @@ -143,7 +143,7 @@ internal object ThreadDtoTestData { val downstreamThreadWithoutExtraData = DownstreamThreadDto( active_participant_count = 2, - channel = ChannelDtoTestData.downstreamChannelWithoutExtraData, + channel = ChannelDtoTestData.channelResponse, channel_cid = "messaging:456", created_at = Date(1591787071000), created_by = UserDtoTestData.downstreamUser, @@ -167,7 +167,7 @@ internal object ThreadDtoTestData { val downstreamThreadInfoJson = """{ "channel_cid": "messaging:789", - "channel": ${ChannelDtoTestData.downstreamJsonWithoutExtraData}, + "channel": ${ChannelDtoTestData.channelResponseJson}, "parent_message_id": "parent_msg_id_3", "parent_message": ${MessageDtoTestData.downstreamJsonWithoutExtraData}, "created_by_user_id": "user3", @@ -201,7 +201,7 @@ internal object ThreadDtoTestData { val downstreamThreadInfo = DownstreamThreadInfoDto( channel_cid = "messaging:789", - channel = ChannelDtoTestData.downstreamChannelWithoutExtraData, + channel = ChannelDtoTestData.channelResponse, parent_message_id = "parent_msg_id_3", parent_message = MessageDtoTestData.downstreamMessageWithoutExtraData, created_by_user_id = "user3", From edfd32201bab738925d01c8e5f7afa7ca84c9706 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:23:53 +0200 Subject: [PATCH 2/5] Keep the generated channel and member wire keys in extraData --- .../adapters/ChannelMemberResponseAdapter.kt | 20 +++- .../adapters/ChannelResponseAdapter.kt | 17 ++- .../parser2/GeneratedExtraDataParityTest.kt | 102 ++++++++++++++++++ .../parser2/testdata/ChannelDtoTestData.kt | 2 + 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedExtraDataParityTest.kt diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelMemberResponseAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelMemberResponseAdapter.kt index f28780b7058..8dbe2a85134 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelMemberResponseAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelMemberResponseAdapter.kt @@ -23,10 +23,28 @@ import com.squareup.moshi.JsonWriter import com.squareup.moshi.ToJson import io.getstream.chat.android.network.models.ChannelMemberResponse +/** + * Keys `ChannelMemberResponse` declares that `DownstreamMemberDto` did not, so they used to reach + * `Member.extraData` and would otherwise stop doing so. Only `user_id` is read at all, as the fallback + * for the user id when the payload carries no user, and that does not restore the map an app may already + * read. Drop with AND-1375. + */ +internal val GENERATED_MEMBER_EXTRA_DATA_KEYS = setOf( + "deleted_at", + "deleted_messages", + "is_moderator", + "role", + "user_id", +) + // Downstream (read-only) adapter for the generated ChannelMemberResponse: collects root-level custom fields // into `custom`, matching the wire's flattened extra data. extraDataPropertyName is its @Json name. internal object ChannelMemberResponseAdapter : - CustomObjectDtoAdapter(ChannelMemberResponse::class, extraDataPropertyName = "custom") { + CustomObjectDtoAdapter( + ChannelMemberResponse::class, + extraDataPropertyName = "custom", + alsoKeepInExtraData = GENERATED_MEMBER_EXTRA_DATA_KEYS, + ) { @FromJson fun fromJson( diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelResponseAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelResponseAdapter.kt index 4ea5d44eab4..23195140764 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelResponseAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelResponseAdapter.kt @@ -23,13 +23,28 @@ import com.squareup.moshi.JsonWriter import com.squareup.moshi.ToJson import io.getstream.chat.android.network.models.ChannelResponse +/** + * Keys `ChannelResponse` declares that `DownstreamChannelDto` did not, so they used to reach + * `Channel.extraData` and would otherwise stop doing so. Kept there as well as mapped, matching how + * [LEGACY_CHANNEL_EXTRA_DATA_KEYS] treats the keys the hand-written DTO declared. Drop with AND-1375. + */ +internal val GENERATED_CHANNEL_EXTRA_DATA_KEYS = setOf( + "auto_translation_enabled", + "auto_translation_language", + "hidden", + "hide_messages_before", + "mute_expires_at", + "muted", + "truncated_by", +) + // Downstream (read-only) adapter for the generated ChannelResponse: collects root-level custom fields // into `custom`, matching the wire's flattened extra data. extraDataPropertyName is its @Json name. internal object ChannelResponseAdapter : CustomObjectDtoAdapter( ChannelResponse::class, extraDataPropertyName = "custom", - alsoKeepInExtraData = LEGACY_CHANNEL_EXTRA_DATA_KEYS, + alsoKeepInExtraData = LEGACY_CHANNEL_EXTRA_DATA_KEYS + GENERATED_CHANNEL_EXTRA_DATA_KEYS, ) { @FromJson diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedExtraDataParityTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedExtraDataParityTest.kt new file mode 100644 index 00000000000..7aab440d3f6 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedExtraDataParityTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.parser2 + +import io.getstream.chat.android.network.models.ChannelMemberResponse +import io.getstream.chat.android.network.models.ChannelResponse +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldContainAll +import org.junit.jupiter.api.Test + +/** + * The generated models declare keys their hand-written predecessors did not. Declaring a key removes it + * from the collected overflow map, so without an explicit keep set an app reading + * `channel.extraData["muted"]` (or the member equivalent) silently starts getting nothing. + */ +internal class GeneratedExtraDataParityTest { + private val parser = ParserFactory.createMoshiChatParser() + + @Test + fun `A channel keeps the keys the hand-written DTO did not declare`() { + val channel = parser.fromJson( + """ + { + "id": "c1", "type": "messaging", "cid": "messaging:c1", + "created_at": "2026-08-14T10:00:00.000Z", "updated_at": "2026-08-14T10:00:00.000Z", + "frozen": false, "disabled": true, "blocked": true, + "truncated_at": "2026-08-14T09:00:00.000Z", "truncated_by_id": "u1", + "truncated_by": { + "id": "u1", "role": "user", "language": "en", "banned": false, "online": false, + "created_at": "2026-08-14T10:00:00.000Z", "updated_at": "2026-08-14T10:00:00.000Z" + }, + "auto_translation_enabled": true, "auto_translation_language": "it", + "muted": true, "mute_expires_at": "2026-08-14T11:00:00.000Z", + "hidden": true, "hide_messages_before": "2026-08-14T08:00:00.000Z", + "sentinel": "keep-me" + } + """.trimIndent(), + ChannelResponse::class.java, + ) + + // Genuine custom data, plus every declared key that used to land here. + channel.custom.keys shouldContainAll setOf( + "sentinel", + "disabled", + "blocked", + "truncated_at", + "auto_translation_enabled", + "auto_translation_language", + "muted", + "mute_expires_at", + "hidden", + "hide_messages_before", + "truncated_by", + ) + channel.custom["muted"] shouldBeEqualTo true + channel.custom["auto_translation_language"] shouldBeEqualTo "it" + // The only nested value in the set: it has to survive as the whole object, not just as a marker. + (channel.custom["truncated_by"] as Map<*, *>)["id"] shouldBeEqualTo "u1" + // Still parsed into their own fields, not only kept in the map. + channel.muted shouldBeEqualTo true + channel.hidden shouldBeEqualTo true + } + + @Test + fun `A channel member keeps the keys the hand-written DTO did not declare`() { + val member = parser.fromJson( + """ + { + "created_at": "2026-08-14T10:00:00.000Z", "updated_at": "2026-08-14T10:00:00.000Z", + "banned": false, "shadow_banned": false, "notifications_muted": false, + "channel_role": "channel_member", + "user_id": "u1", "role": "member", "is_moderator": true, + "deleted_messages": [], "deleted_at": "2026-08-14T12:00:00.000Z", + "sentinel": "keep-me" + } + """.trimIndent(), + ChannelMemberResponse::class.java, + ) + + member.custom.keys shouldContainAll setOf( + "sentinel", "user_id", "role", "is_moderator", "deleted_messages", "deleted_at", + ) + member.custom["role"] shouldBeEqualTo "member" + member.custom["is_moderator"] shouldBeEqualTo true + member.custom["deleted_at"] shouldBeEqualTo "2026-08-14T12:00:00.000Z" + member.role shouldBeEqualTo "member" + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt index b54c77a7608..0b3e124ec92 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt @@ -426,6 +426,8 @@ internal object ChannelDtoTestData { "disabled" to true, "blocked" to true, "truncated_at" to "2020-06-10T11:04:31.588Z", + "hidden" to true, + "hide_messages_before" to "2020-06-10T11:04:31.588Z", "customKey1" to "customVal1", ), ) From b127160f1124c00ea2c43f3efece4eadf2cc93b0 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:27:55 +0200 Subject: [PATCH 3/5] Point the extraData keep sets at the ticket that removes them --- .../client/parser2/adapters/ChannelMemberResponseAdapter.kt | 2 +- .../android/client/parser2/adapters/ChannelResponseAdapter.kt | 2 +- .../client/parser2/adapters/DownstreamChannelDtoAdapter.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelMemberResponseAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelMemberResponseAdapter.kt index 8dbe2a85134..b95d559ea88 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelMemberResponseAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelMemberResponseAdapter.kt @@ -27,7 +27,7 @@ import io.getstream.chat.android.network.models.ChannelMemberResponse * Keys `ChannelMemberResponse` declares that `DownstreamMemberDto` did not, so they used to reach * `Member.extraData` and would otherwise stop doing so. Only `user_id` is read at all, as the fallback * for the user id when the payload carries no user, and that does not restore the map an app may already - * read. Drop with AND-1375. + * read. Drop with AND-1398. */ internal val GENERATED_MEMBER_EXTRA_DATA_KEYS = setOf( "deleted_at", diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelResponseAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelResponseAdapter.kt index 23195140764..1c5d83506b9 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelResponseAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelResponseAdapter.kt @@ -26,7 +26,7 @@ import io.getstream.chat.android.network.models.ChannelResponse /** * Keys `ChannelResponse` declares that `DownstreamChannelDto` did not, so they used to reach * `Channel.extraData` and would otherwise stop doing so. Kept there as well as mapped, matching how - * [LEGACY_CHANNEL_EXTRA_DATA_KEYS] treats the keys the hand-written DTO declared. Drop with AND-1375. + * [LEGACY_CHANNEL_EXTRA_DATA_KEYS] treats the keys the hand-written DTO declared. Drop with AND-1398. */ internal val GENERATED_CHANNEL_EXTRA_DATA_KEYS = setOf( "auto_translation_enabled", diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/DownstreamChannelDtoAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/DownstreamChannelDtoAdapter.kt index 25fb2f811f2..3b8ac3715ba 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/DownstreamChannelDtoAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/DownstreamChannelDtoAdapter.kt @@ -44,6 +44,6 @@ internal object DownstreamChannelDtoAdapter : /** * Channel fields that were only reachable through `Channel.extraData` before they became declared properties. * - * TODO(AND-1375): drop in the next major, along with the [CustomObjectDtoAdapter.alsoKeepInExtraData] plumbing. + * TODO(AND-1398): drop in the next major, along with the [CustomObjectDtoAdapter.alsoKeepInExtraData] plumbing. */ internal val LEGACY_CHANNEL_EXTRA_DATA_KEYS = setOf("disabled", "blocked", "truncated_at") From 64b89afac71e946fb53739c75e176e25b0810767 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:20:08 +0200 Subject: [PATCH 4/5] Read the adapted model from the adapter's declared type parameter --- .../client/parser2/CustomAdapterCoverageTest.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/CustomAdapterCoverageTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/CustomAdapterCoverageTest.kt index 5bdd91cfa99..9185e7b210f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/CustomAdapterCoverageTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/CustomAdapterCoverageTest.kt @@ -37,8 +37,7 @@ internal class CustomAdapterCoverageTest { val declaring = modelsDeclaringCustom() check(declaring.isNotEmpty()) { "Found no generated models declaring `custom`; is $MODELS_DIR correct?" } - val registered = registeredAdapterTargets() - val unprotected = declaring - registered - NESTED_CUSTOM.keys + val unprotected = declaring - adaptedModels() - NESTED_CUSTOM.keys check(unprotected.isEmpty()) { "These generated models declare `custom` but have no adapter registered in MoshiChatParser:\n" + @@ -54,14 +53,24 @@ internal class CustomAdapterCoverageTest { .map { it.nameWithoutExtension } .toSet() - private fun registeredAdapterTargets(): Set = - Regex("""\.add\((\w+)Adapter\)""").findAll(File(PARSER_FILE).readText()) + private fun adaptedModels(): Set { + val registered = Regex("""\.add\((\w+)\)""").findAll(File(PARSER_FILE).readText()) .map { it.groupValues[1] } .toSet() + return File(ADAPTERS_DIR).listFiles { f -> f.extension == "kt" }.orEmpty() + .flatMap { file -> ADAPTER_DECLARATION.findAll(file.readText()).toList() } + .filter { it.groupValues[1] in registered } + .map { it.groupValues[2] } + .toSet() + } private companion object { private const val MODELS_DIR = "src/main/java/io/getstream/chat/android/network/models" private const val PARSER_FILE = "src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt" + private const val ADAPTERS_DIR = "src/main/java/io/getstream/chat/android/client/parser2/adapters" + + /** Read from the type parameter, since an adapter's name need not match the model it adapts. */ + private val ADAPTER_DECLARATION = Regex("""object\s+(\w+)\s*:\s*CustomObjectDtoAdapter<(\w+)>""") /** The colon matters: without it this also matches unrelated properties like `customEvents`. */ private const val CUSTOM_PROPERTY = "internal val custom:" From 9d89df64aa9387406811a46642b073908e4ba884 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:38:06 +0200 Subject: [PATCH 5/5] Map the channel config message retention now that the spec declares it --- .../chat/android/client/api2/mapping/DomainMapping.kt | 5 ++--- .../chat/android/network/models/ChannelConfigWithInfo.kt | 3 +++ .../chat/android/client/api2/mapping/DomainMappingTest.kt | 4 ++-- .../android/client/parser2/testdata/ChannelDtoTestData.kt | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index ae0831dd9a2..1927b76df6b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -819,9 +819,7 @@ internal class DomainMapping( ) /** - * Transforms [ChannelConfigWithInfo] to [Config]. The wire carries `message_retention`, but Go - * tags it `openapi:"-"` so it is absent from the generated model; we keep the domain default - * until the spec describes it. + * Transforms [ChannelConfigWithInfo] to [Config]. */ internal fun ChannelConfigWithInfo.toDomain(): Config = Config( createdAt = createdAt, @@ -841,6 +839,7 @@ internal class DomainMapping( pushNotificationsEnabled = pushNotifications, skipLastMsgUpdateForSystemMsgs = skipLastMsgUpdateForSystemMsgs, pollsEnabled = polls, + messageRetention = messageRetention, maxMessageLength = maxMessageLength, automod = automod.value, automodBehavior = automodBehavior.value, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelConfigWithInfo.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelConfigWithInfo.kt index 190003acaa8..d2ef8b473e4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelConfigWithInfo.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelConfigWithInfo.kt @@ -62,6 +62,9 @@ internal data class ChannelConfigWithInfo( @Json(name = "max_message_length") internal val maxMessageLength: Int, + @Json(name = "message_retention") + internal val messageRetention: String, + @Json(name = "mutes") internal val mutes: Boolean, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt index 2d8b9614971..4cbb5c4843a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -388,12 +388,12 @@ internal class DomainMappingTest { } @Test - fun `ChannelConfigWithInfo keeps the domain messageRetention the wire field is absent from the model`() { + fun `ChannelConfigWithInfo is correctly mapped to Config`() { val sut = Fixture().get() val config = with(sut) { ChannelDtoTestData.channelResponse.config!!.toDomain() } - assertEquals("infinite", config.messageRetention) + assertEquals("retention", config.messageRetention) assertEquals("disabled", config.automod) assertEquals("flag", config.automodBehavior) assertEquals("block", config.blocklistBehavior) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt index 0b3e124ec92..e50a96fc135 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt @@ -292,7 +292,6 @@ internal object ChannelDtoTestData { /** * The config as sent for a generated [io.getstream.chat.android.network.models.ChannelResponse]. - * `message_retention` is on the wire but absent from the model, which drops it. */ @Language("JSON") private val channelResponseConfigJson = @@ -372,6 +371,7 @@ internal object ChannelDtoTestData { userMessageReminders = false, sharedLocations = true, markMessagesPending = false, + messageRetention = "retention", ) /**