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 1b93219cb93..c3dd97513e5 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 @@ -128,7 +128,11 @@ import io.getstream.chat.android.models.querysort.QuerySorter 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.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 @@ -225,6 +229,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]. */ @@ -446,6 +497,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, @@ -718,6 +788,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 e4fa2e4df30..5dcc29cc25f 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 @@ -31,6 +31,8 @@ import io.getstream.chat.android.client.events.ConnectedEvent import io.getstream.chat.android.client.extensions.internal.enrichIfNeeded import io.getstream.chat.android.client.parser.ChatParser import io.getstream.chat.android.client.parser2.adapters.AttachmentDtoAdapter +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 @@ -61,6 +63,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.CreatePollRequest import io.getstream.chat.android.network.models.UpdatePollRequest import retrofit2.Retrofit @@ -87,6 +92,8 @@ internal class MoshiChatParser( .add(UpstreamUserDtoAdapter) .add(UserResponseAdapter) .add(UserRequestAdapter) + .add(ChannelResponseAdapter) + .add(ChannelMemberResponseAdapter) .add(DownstreamMemberDtoAdapter) .add(UpstreamMemberDtoAdapter) .add(UpstreamMemberDataDtoAdapter) @@ -110,6 +117,30 @@ internal class MoshiChatParser( UpdatePollRequest.VotingVisibility::class.java, UpdatePollRequest.VotingVisibility.VotingVisibilityAdapter(), ) + .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/ChatPreferences.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChatPreferences.kt new file mode 100644 index 00000000000..5a181c15fe9 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChatPreferences.kt @@ -0,0 +1,56 @@ +/* + * 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 ChatPreferences( + @Json(name = "channel_mentions") + internal val channelMentions: String? = null, + + @Json(name = "default_preference") + internal val defaultPreference: String? = null, + + @Json(name = "direct_mentions") + internal val directMentions: String? = null, + + @Json(name = "distinct_channel_messages") + internal val distinctChannelMessages: String? = null, + + @Json(name = "group_mentions") + internal val groupMentions: String? = null, + + @Json(name = "here_mentions") + internal val hereMentions: String? = null, + + @Json(name = "role_mentions") + internal val roleMentions: String? = null, + + @Json(name = "thread_replies") + internal val threadReplies: String? = 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 8bb1354de88..c4c34ebd9bc 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 @@ -94,6 +94,7 @@ import io.getstream.chat.android.models.querysort.QuerySortByField 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.ChannelResponse import io.getstream.chat.android.network.models.DeviceResponse import io.getstream.chat.android.network.models.FileUploadConfig import io.getstream.chat.android.network.models.GetApplicationResponse @@ -1002,7 +1003,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(), @@ -1068,9 +1069,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 60cfbadf96d..d2e3c99d948 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 @@ -26,6 +26,7 @@ import io.getstream.chat.android.client.Mother.randomAttachmentDto import io.getstream.chat.android.client.Mother.randomBannedUserResponse import io.getstream.chat.android.client.Mother.randomBlockUsersResponse 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 @@ -304,6 +306,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",