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 f769ce0eda9..ad069732525 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 @@ -47,7 +47,6 @@ import io.getstream.chat.android.client.api2.model.dto.DownstreamReminderDto import io.getstream.chat.android.client.api2.model.dto.DownstreamReminderInfoDto import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadDto import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadInfoDto -import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadParticipantDto import io.getstream.chat.android.client.api2.model.dto.DownstreamUserBlockDto import io.getstream.chat.android.client.api2.model.dto.DownstreamUserDto import io.getstream.chat.android.client.api2.model.dto.DownstreamUserGroupDto @@ -135,11 +134,13 @@ import io.getstream.chat.android.network.models.UnreadCountsChannel import io.getstream.chat.android.network.models.UnreadCountsChannelType import io.getstream.chat.android.network.models.UnreadCountsThread import io.getstream.chat.android.network.models.UserGroupResponse +import io.getstream.chat.android.network.models.UserResponse import io.getstream.chat.android.network.models.WrappedUnreadCountsResponse import java.util.Date import io.getstream.chat.android.network.models.Command as CommandDto import io.getstream.chat.android.network.models.FileUploadConfig as UploadConfigDto import io.getstream.chat.android.network.models.Role as RoleDto +import io.getstream.chat.android.network.models.ThreadParticipant as ThreadParticipantDto import io.getstream.chat.android.network.models.UserGroupMember as UserGroupMemberDto @Suppress("TooManyFunctions", "LargeClass") @@ -439,6 +440,26 @@ internal class DomainMapping( extraData = extraData, ) + internal fun UserResponse.toDomain(): User = + User( + id = id, + role = role, + name = name.orEmpty(), + image = image.orEmpty(), + language = language, + banned = banned, + online = online, + createdAt = createdAt, + updatedAt = updatedAt, + lastActive = lastActive, + deactivatedAt = deactivatedAt, + teams = teams, + teamsRole = teamsRole.orEmpty(), + blockedUserIds = blockedUserIds, + avgResponseTime = avgResponseTime?.toLong(), + extraData = custom.mapNotNull { (key, value) -> value?.let { key to it } }.toMap(), + ).let(userTransformer::transform) + internal fun DownstreamLocationDto.toDomain(): Location = Location( cid = channel_cid, @@ -837,11 +858,11 @@ internal class DomainMapping( ) /** - * Transforms [DownstreamThreadParticipantDto] into [ThreadParticipant] + * Transforms [ThreadParticipantDto] into [ThreadParticipant] */ - internal fun DownstreamThreadParticipantDto.toDomain(): ThreadParticipant = ThreadParticipant( - user = user?.toDomain() ?: User(id = user_id), - lastThreadMessageAt = last_thread_message_at, + internal fun ThreadParticipantDto.toDomain(): ThreadParticipant = ThreadParticipant( + user = user?.toDomain() ?: User(id = userId.orEmpty()), + lastThreadMessageAt = lastThreadMessageAt, ) /** 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 10a3e9203a0..cebdf7546fa 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.ThreadParticipant import java.util.Date /** @@ -60,7 +61,7 @@ internal data class DownstreamThreadDto( val participant_count: Int, val read: List?, val reply_count: Int?, - val thread_participants: List?, + val thread_participants: List?, val title: String, val updated_at: Date, val extraData: Map, @@ -98,7 +99,7 @@ internal data class DownstreamThreadInfoDto( val reply_count: Int?, val participant_count: Int?, val active_participant_count: Int?, - val thread_participants: List?, + val thread_participants: List?, val last_message_at: Date?, val created_at: Date, val updated_at: Date, @@ -106,18 +107,3 @@ internal data class DownstreamThreadInfoDto( val title: String, val extraData: Map, ) : ExtraDataDto - -/** - * The DTO for Thread Participant. - * - * @param user_id The ID of the user (thread participant). - * @param user The user as the thread participant. (Note: It is not always delivered, sometimes we only get the ID of - * the user - [user_id]). - * @param last_thread_message_at The date of the last message in the thread at the time of participation. - */ -@JsonClass(generateAdapter = true) -internal data class DownstreamThreadParticipantDto( - val user_id: String, - val user: DownstreamUserDto?, - val last_thread_message_at: Date?, -) 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 f037b5cb971..e4fa2e4df30 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 @@ -46,6 +46,7 @@ import io.getstream.chat.android.client.parser2.adapters.DownstreamUserDtoAdapte import io.getstream.chat.android.client.parser2.adapters.EventAdapterFactory import io.getstream.chat.android.client.parser2.adapters.EventRequestAdapter import io.getstream.chat.android.client.parser2.adapters.ExactDateAdapter +import io.getstream.chat.android.client.parser2.adapters.NullCollectionsAsEmptyFactory import io.getstream.chat.android.client.parser2.adapters.PollOptionInputAdapter import io.getstream.chat.android.client.parser2.adapters.PollOptionRequestAdapter import io.getstream.chat.android.client.parser2.adapters.UpdatePollOptionRequestAdapter @@ -56,6 +57,7 @@ import io.getstream.chat.android.client.parser2.adapters.UpstreamMessageDtoAdapt import io.getstream.chat.android.client.parser2.adapters.UpstreamReactionDtoAdapter import io.getstream.chat.android.client.parser2.adapters.UpstreamUserDtoAdapter import io.getstream.chat.android.client.parser2.adapters.UserRequestAdapter +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 @@ -83,6 +85,7 @@ internal class MoshiChatParser( .add(UpstreamReactionDtoAdapter) .add(DownstreamUserDtoAdapter) .add(UpstreamUserDtoAdapter) + .add(UserResponseAdapter) .add(UserRequestAdapter) .add(DownstreamMemberDtoAdapter) .add(UpstreamMemberDtoAdapter) @@ -107,6 +110,8 @@ internal class MoshiChatParser( UpdatePollRequest.VotingVisibility::class.java, UpdatePollRequest.VotingVisibility.VotingVisibilityAdapter(), ) + // 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/NullCollectionsAsEmptyFactory.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/NullCollectionsAsEmptyFactory.kt new file mode 100644 index 00000000000..8b53844d76f --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/NullCollectionsAsEmptyFactory.kt @@ -0,0 +1,104 @@ +/* + * 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.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.squareup.moshi.rawType +import java.lang.reflect.Type +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.primaryConstructor + +/** + * Reads an explicit `null` as an empty collection for the non-null [List] and [Map] properties of + * the generated network models. + * + * The models are generated from the v2 spec, whose encoder writes nil Go maps and slices as `{}` + * and `[]`, so those properties are non-null. We call the v1 endpoints, whose encoder writes them + * as `null` instead (e.g. `thread_participants[].custom`), which would otherwise fail to parse. + * + * Remove once the client calls the v2 endpoints, where the shape already matches the models. + */ +internal object NullCollectionsAsEmptyFactory : JsonAdapter.Factory { + + private const val GENERATED_MODELS_PACKAGE = "io.getstream.chat.android.network.models" + + override fun create(type: Type, annotations: MutableSet, moshi: Moshi): JsonAdapter<*>? { + if (annotations.isNotEmpty()) return null + val rawType = type.rawType + if (rawType.`package`?.name != GENERATED_MODELS_PACKAGE) return null + + val emptyValues = emptyValuesByJsonName(rawType) + if (emptyValues.isEmpty()) return null + + return NullCollectionsAsEmptyAdapter( + delegate = moshi.nextAdapter(this, type, annotations), + mapAdapter = moshi.adapter( + Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java), + ), + emptyValues = emptyValues, + ) + } + + /** + * Wire names of the non-null collection properties of [rawType], mapped to their empty value. + * + * Relies on the Kotlin metadata of the generated models, which `consumer-proguard-rules.pro` + * keeps. Failures are deliberately not caught: swallowing them would silently stop coercing + * nulls and turn this into a release-only parsing crash. + */ + private fun emptyValuesByJsonName(rawType: Class<*>): Map = + rawType.kotlin.primaryConstructor?.parameters + .orEmpty() + .mapNotNull { parameter -> + if (parameter.type.isMarkedNullable) return@mapNotNull null + val empty: Any = when (parameter.type.classifier) { + List::class -> emptyList() + Map::class -> emptyMap() + else -> return@mapNotNull null + } + val name = parameter.findAnnotation()?.name ?: parameter.name ?: return@mapNotNull null + name to empty + } + .toMap() +} + +private class NullCollectionsAsEmptyAdapter( + private val delegate: JsonAdapter, + private val mapAdapter: JsonAdapter>, + private val emptyValues: Map, +) : JsonAdapter() { + + override fun fromJson(reader: JsonReader): Any? { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull() + } + val map = mapAdapter.fromJson(reader) ?: return null + emptyValues.forEach { (name, empty) -> + if (map.containsKey(name) && map[name] == null) { + map[name] = empty + } + } + return delegate.fromJsonValue(map) + } + + override fun toJson(writer: JsonWriter, value: Any?) = delegate.toJson(writer, value) +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/UserResponseAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/UserResponseAdapter.kt new file mode 100644 index 00000000000..c77240bbc51 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/UserResponseAdapter.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.UserResponse + +// Downstream (read-only) adapter for the generated UserResponse: collects root-level custom fields +// into `custom`, matching the wire's flattened extra data. extraDataPropertyName is its @Json name. +internal object UserResponseAdapter : + CustomObjectDtoAdapter(UserResponse::class, extraDataPropertyName = "custom") { + + @FromJson + fun fromJson( + jsonReader: JsonReader, + mapAdapter: JsonAdapter>, + valueAdapter: JsonAdapter, + ): UserResponse? = parseWithExtraData(jsonReader, mapAdapter, valueAdapter) + + @ToJson + @Suppress("UNUSED_PARAMETER") + fun toJson(jsonWriter: JsonWriter, value: UserResponse): Unit = error("Can't convert this to Json") +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ThreadParticipant.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ThreadParticipant.kt new file mode 100644 index 00000000000..dfb3edc9477 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ThreadParticipant.kt @@ -0,0 +1,59 @@ +/* + * 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 a user that is participating in a thread. + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class ThreadParticipant( + @Json(name = "channel_cid") + internal val channelCid: String, + + @Json(name = "created_at") + internal val createdAt: java.util.Date, + + @Json(name = "last_read_at") + internal val lastReadAt: java.util.Date, + + @Json(name = "custom") + internal val custom: Map = emptyMap(), + + @Json(name = "last_thread_message_at") + internal val lastThreadMessageAt: java.util.Date? = null, + + @Json(name = "left_thread_at") + internal val leftThreadAt: java.util.Date? = null, + + @Json(name = "thread_id") + internal val threadId: String? = null, + + @Json(name = "user_id") + internal val userId: String? = null, + + @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/UserResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/UserResponse.kt new file mode 100644 index 00000000000..d018da2fb43 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/UserResponse.kt @@ -0,0 +1,86 @@ +/* + * 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 + +/** + * User response object + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class UserResponse( + @Json(name = "banned") + internal val banned: Boolean, + + @Json(name = "created_at") + internal val createdAt: java.util.Date, + + @Json(name = "id") + internal val id: String, + + @Json(name = "language") + internal val language: String, + + @Json(name = "online") + internal val online: Boolean, + + @Json(name = "role") + internal val role: String, + + @Json(name = "updated_at") + internal val updatedAt: java.util.Date, + + @Json(name = "blocked_user_ids") + internal val blockedUserIds: List = emptyList(), + + @Json(name = "teams") + internal val teams: List = emptyList(), + + @Json(name = "custom") + internal val custom: Map = emptyMap(), + + @Json(name = "avg_response_time") + internal val avgResponseTime: Int? = null, + + @Json(name = "deactivated_at") + internal val deactivatedAt: java.util.Date? = null, + + @Json(name = "deleted_at") + internal val deletedAt: java.util.Date? = null, + + @Json(name = "image") + internal val image: String? = null, + + @Json(name = "last_active") + internal val lastActive: java.util.Date? = null, + + @Json(name = "name") + internal val name: String? = null, + + @Json(name = "revoke_tokens_issued_before") + internal val revokeTokensIssuedBefore: java.util.Date? = null, + + @Json(name = "teams_role") + internal val teamsRole: Map? = emptyMap(), +) 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 10ec4e593a1..ad4aa39218f 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 @@ -49,7 +49,6 @@ import io.getstream.chat.android.client.api2.model.dto.DownstreamReactionGroupDt import io.getstream.chat.android.client.api2.model.dto.DownstreamReminderDto import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadDto import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadInfoDto -import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadParticipantDto import io.getstream.chat.android.client.api2.model.dto.DownstreamUserBlockDto import io.getstream.chat.android.client.api2.model.dto.DownstreamUserDto import io.getstream.chat.android.client.api2.model.dto.DownstreamUserGroupDto @@ -98,11 +97,13 @@ import io.getstream.chat.android.network.models.BlockUsersResponse import io.getstream.chat.android.network.models.DeviceResponse import io.getstream.chat.android.network.models.FileUploadConfig import io.getstream.chat.android.network.models.GetApplicationResponse +import io.getstream.chat.android.network.models.ThreadParticipant import io.getstream.chat.android.network.models.UnblockUsersResponse import io.getstream.chat.android.network.models.UnreadCountsChannel import io.getstream.chat.android.network.models.UnreadCountsChannelType import io.getstream.chat.android.network.models.UnreadCountsThread import io.getstream.chat.android.network.models.UserGroupResponse +import io.getstream.chat.android.network.models.UserResponse import io.getstream.chat.android.network.models.WrappedUnreadCountsResponse import io.getstream.chat.android.positiveRandomInt import io.getstream.chat.android.randomBoolean @@ -1001,7 +1002,7 @@ internal object Mother { createdByUserId: String = randomString(), createdBy: DownstreamUserDto = randomDownstreamUserDto(id = createdByUserId), participantCount: Int = randomInt(), - threadParticipants: List = emptyList(), + threadParticipants: List = emptyList(), lastMessageAt: Date = randomDate(), createdAt: Date = randomDate(), updatedAt: Date = randomDate(), @@ -1034,14 +1035,31 @@ internal object Mother { extraData = extraData, ) - fun randomDownstreamThreadParticipantDto( + fun randomThreadParticipantDto( userId: String = randomString(), - user: DownstreamUserDto? = randomDownstreamUserDto(id = userId), + user: UserResponse? = randomUserResponse(id = userId), lastThreadMessageAt: Date? = randomDateOrNull(), - ): DownstreamThreadParticipantDto = DownstreamThreadParticipantDto( - user_id = userId, + ): ThreadParticipant = ThreadParticipant( + channelCid = randomCID(), + createdAt = randomDate(), + lastReadAt = randomDate(), + userId = userId, user = user, - last_thread_message_at = lastThreadMessageAt, + lastThreadMessageAt = lastThreadMessageAt, + ) + + fun randomUserResponse( + id: String = randomString(), + role: String = randomString(), + language: String = randomString(), + ): UserResponse = UserResponse( + id = id, + role = role, + language = language, + banned = randomBoolean(), + online = randomBoolean(), + createdAt = randomDate(), + updatedAt = randomDate(), ) fun randomDownstreamThreadInfoDto( @@ -1054,7 +1072,7 @@ internal object Mother { replyCount: Int = randomInt(), participantCount: Int = randomInt(), activeParticipantCount: Int = randomInt(), - threadParticipants: List = emptyList(), + threadParticipants: List = emptyList(), lastMessageAt: Date = randomDate(), createdAt: Date = randomDate(), updatedAt: Date = randomDate(), 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 01ae6b5368d..2bacbdce4e0 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 @@ -48,7 +48,6 @@ import io.getstream.chat.android.client.Mother.randomDownstreamReactionGroupDto import io.getstream.chat.android.client.Mother.randomDownstreamReminderDto import io.getstream.chat.android.client.Mother.randomDownstreamThreadDto import io.getstream.chat.android.client.Mother.randomDownstreamThreadInfoDto -import io.getstream.chat.android.client.Mother.randomDownstreamThreadParticipantDto import io.getstream.chat.android.client.Mother.randomDownstreamUserBlockDto import io.getstream.chat.android.client.Mother.randomDownstreamUserDto import io.getstream.chat.android.client.Mother.randomDownstreamUserGroupDto @@ -60,12 +59,14 @@ import io.getstream.chat.android.client.Mother.randomQueryPollsResponse import io.getstream.chat.android.client.Mother.randomQueryRemindersResponse import io.getstream.chat.android.client.Mother.randomRoleDto import io.getstream.chat.android.client.Mother.randomSearchWarningDto +import io.getstream.chat.android.client.Mother.randomThreadParticipantDto import io.getstream.chat.android.client.Mother.randomUnreadChannelByTypeDto import io.getstream.chat.android.client.Mother.randomUnreadChannelDto import io.getstream.chat.android.client.Mother.randomUnreadDto import io.getstream.chat.android.client.Mother.randomUnreadThreadDto import io.getstream.chat.android.client.Mother.randomUserGroupMemberDto import io.getstream.chat.android.client.Mother.randomUserGroupResponse +import io.getstream.chat.android.client.Mother.randomUserResponse import io.getstream.chat.android.client.api2.mapping.DomainMappingTest.Companion.toSortDomainArguments import io.getstream.chat.android.client.api2.model.dto.DownstreamUserGroupDto import io.getstream.chat.android.client.api2.model.dto.DownstreamUserGroupMemberDto @@ -844,14 +845,14 @@ internal class DomainMappingTest { fun `DownstreamThreadDto is correctly mapped to Thread`() { val user1 = randomDownstreamUserDto(id = "user1") val user2 = randomDownstreamUserDto(id = "user2") - val participant1Dto = randomDownstreamThreadParticipantDto( + val participant1Dto = randomThreadParticipantDto( userId = user1.id, - user = user1, + user = randomUserResponse(id = user1.id), lastThreadMessageAt = Date(2000), ) - val participant2Dto = randomDownstreamThreadParticipantDto( + val participant2Dto = randomThreadParticipantDto( userId = user2.id, - user = user2, + user = randomUserResponse(id = user2.id), lastThreadMessageAt = Date(1000), ) val downstreamThreadDto = randomDownstreamThreadDto( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/NullCollectionsAsEmptyFactoryTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/NullCollectionsAsEmptyFactoryTest.kt new file mode 100644 index 00000000000..c245aba5bf2 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/NullCollectionsAsEmptyFactoryTest.kt @@ -0,0 +1,90 @@ +/* + * 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.client.api2.model.dto.DownstreamUserDto +import io.getstream.chat.android.network.models.ListUserGroupsResponse +import org.amshove.kluent.invoking +import org.amshove.kluent.shouldBeEmpty +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldHaveSize +import org.amshove.kluent.shouldThrow +import org.junit.jupiter.api.Test + +internal class NullCollectionsAsEmptyFactoryTest { + private val parser = ParserFactory.createMoshiChatParser() + + @Test + fun `Explicit null is read as an empty collection`() { + val response = parser.fromJson( + """{"duration": "1ms", "user_groups": null}""", + ListUserGroupsResponse::class.java, + ) + + response.userGroups.shouldBeEmpty() + } + + @Test + fun `Absent collection still falls back to the model default`() { + val response = parser.fromJson("""{"duration": "1ms"}""", ListUserGroupsResponse::class.java) + + response.userGroups.shouldBeEmpty() + } + + @Test + fun `Populated collection is read unchanged`() { + val response = parser.fromJson( + """ + { + "duration": "1ms", + "user_groups": [ + { + "id": "group-1", + "name": "Group One", + "created_at": "2020-06-10T11:04:31.000Z", + "updated_at": "2020-06-10T11:04:31.000Z" + } + ] + } + """.trimIndent(), + ListUserGroupsResponse::class.java, + ) + + response.userGroups shouldHaveSize 1 + response.userGroups.first().id shouldBeEqualTo "group-1" + } + + @Test + fun `Hand-written DTOs are left alone`() { + invoking { + parser.fromJson( + """ + { + "id": "jc", + "role": "user", + "created_at": "2020-06-10T11:04:31.000Z", + "updated_at": "2020-06-10T11:04:31.000Z", + "banned": false, + "online": true, + "teams": null + } + """.trimIndent(), + DownstreamUserDto::class.java, + ) + }.shouldThrow(Exception::class) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/ThreadParticipantParsingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/ThreadParticipantParsingTest.kt new file mode 100644 index 00000000000..f86e4b154b2 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/ThreadParticipantParsingTest.kt @@ -0,0 +1,76 @@ +/* + * 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.ThreadParticipant +import org.amshove.kluent.shouldBeEmpty +import org.amshove.kluent.shouldBeEqualTo +import org.junit.jupiter.api.Test + +internal class ThreadParticipantParsingTest { + private val parser = ParserFactory.createMoshiChatParser() + + @Test + fun `Deserialize a thread participant as sent by the API`() { + val participant = parser.fromJson(THREAD_PARTICIPANT_JSON, ThreadParticipant::class.java) + + participant.userId shouldBeEqualTo "leandro" + participant.channelCid shouldBeEqualTo "messaging:channelId" + participant.threadId shouldBeEqualTo "parentMessageId" + participant.custom.shouldBeEmpty() + } + + @Test + fun `Deserialize the nested user with its custom fields collected`() { + val participant = parser.fromJson(THREAD_PARTICIPANT_JSON, ThreadParticipant::class.java) + + participant.user?.id shouldBeEqualTo "leandro" + participant.user?.language shouldBeEqualTo "pt" + participant.user?.custom shouldBeEqualTo mapOf("birthland" to "Polis Massa") + } + + companion object { + /** + * Captured from `POST /threads`. The API sends `custom` as an explicit `null` here, which only + * parses thanks to [io.getstream.chat.android.client.parser2.adapters.NullCollectionsAsEmptyFactory]. + */ + private const val THREAD_PARTICIPANT_JSON = + """{ + "app_pk": 102398, + "channel_cid": "messaging:channelId", + "last_thread_message_at": "2026-07-03T12:53:32.005047Z", + "thread_id": "parentMessageId", + "user_id": "leandro", + "user": { + "id": "leandro", + "name": "Padmé Amidala", + "language": "pt", + "role": "user", + "teams": [], + "created_at": "2021-07-20T14:17:07.653935Z", + "updated_at": "2026-07-31T11:38:42.46896Z", + "banned": false, + "online": true, + "blocked_user_ids": [], + "birthland": "Polis Massa" + }, + "created_at": "2026-07-03T12:53:25.510355Z", + "last_read_at": "2026-07-03T12:53:32.221127Z", + "custom": null + }""" + } +} 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 0ff891099a8..d51ce977175 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 @@ -18,7 +18,7 @@ package io.getstream.chat.android.client.parser2.testdata import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadDto import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadInfoDto -import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadParticipantDto +import io.getstream.chat.android.network.models.ThreadParticipant import org.intellij.lang.annotations.Language import java.util.Date @@ -44,9 +44,14 @@ internal object ThreadDtoTestData { "reply_count": 10, "thread_participants": [ { + "app_pk": 1, + "channel_cid": "messaging:channelId", + "created_at": "2020-06-10T11:04:31.588Z", + "last_read_at": "2020-06-10T11:04:31.588Z", "user_id": "user1", - "user": ${UserDtoTestData.downstreamJson}, - "last_thread_message_at": null + "user": ${UserDtoTestData.userResponseJson}, + "last_thread_message_at": null, + "custom": null } ], "title": "Thread Title", @@ -84,10 +89,13 @@ internal object ThreadDtoTestData { read = emptyList(), reply_count = 10, thread_participants = listOf( - DownstreamThreadParticipantDto( - user_id = "user1", - user = UserDtoTestData.downstreamUser, - last_thread_message_at = null, + ThreadParticipant( + channelCid = "messaging:channelId", + createdAt = Date(1591787071588), + lastReadAt = Date(1591787071588), + userId = "user1", + user = UserDtoTestData.userResponse, + lastThreadMessageAt = null, ), ), title = "Thread Title", @@ -169,9 +177,14 @@ internal object ThreadDtoTestData { "active_participant_count": 4, "thread_participants": [ { + "app_pk": 1, + "channel_cid": "messaging:channelId", + "created_at": "2020-06-10T11:04:31.588Z", + "last_read_at": "2020-06-10T11:04:31.588Z", "user_id": "user1", - "user": ${UserDtoTestData.downstreamJson}, - "last_thread_message_at": null + "user": ${UserDtoTestData.userResponseJson}, + "last_thread_message_at": null, + "custom": null } ], "last_message_at": "2020-06-10T11:04:31.588Z", @@ -197,10 +210,13 @@ internal object ThreadDtoTestData { participant_count = 8, active_participant_count = 4, thread_participants = listOf( - DownstreamThreadParticipantDto( - user_id = "user1", - user = UserDtoTestData.downstreamUser, - last_thread_message_at = null, + ThreadParticipant( + channelCid = "messaging:channelId", + createdAt = Date(1591787071588), + lastReadAt = Date(1591787071588), + userId = "user1", + user = UserDtoTestData.userResponse, + lastThreadMessageAt = null, ), ), last_message_at = Date(1591787071588), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/UserDtoTestData.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/UserDtoTestData.kt index 3e26cf3dbbb..cd78b753360 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/UserDtoTestData.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/UserDtoTestData.kt @@ -25,6 +25,7 @@ import io.getstream.chat.android.client.api2.model.dto.PrivacySettingsDto import io.getstream.chat.android.client.api2.model.dto.ReadReceiptsDto import io.getstream.chat.android.client.api2.model.dto.TypingIndicatorsDto import io.getstream.chat.android.client.api2.model.dto.UpstreamUserDto +import io.getstream.chat.android.network.models.UserResponse import org.intellij.lang.annotations.Language import java.util.Date @@ -193,6 +194,32 @@ internal object UserDtoTestData { "disabled_until": "2020-06-10T11:04:31.588Z" } }""" + const val userResponseJson = + """{ + "id": "userId", + "role": "owner", + "language": "language", + "banned": false, + "online": true, + "created_at": "2020-06-10T11:04:31.588Z", + "updated_at": "2020-06-10T11:04:31.588Z", + "name": "username", + "image": "image" + }""" + + val userResponse = + UserResponse( + id = "userId", + role = "owner", + language = "language", + banned = false, + online = true, + createdAt = Date(1591787071588), + updatedAt = Date(1591787071588), + name = "username", + image = "image", + ) + val downstreamUser = DownstreamUserDto( banned = false,