diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt index 24d30ae97eb..19ecc836a8a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt @@ -71,6 +71,7 @@ import io.getstream.chat.android.client.notifications.handler.NotificationConfig import io.getstream.chat.android.client.notifications.handler.NotificationHandler import io.getstream.chat.android.client.notifications.handler.NotificationHandlerFactory import io.getstream.chat.android.client.parser.ChatParser +import io.getstream.chat.android.client.parser2.DirectEventParser import io.getstream.chat.android.client.parser2.MoshiChatParser import io.getstream.chat.android.client.plugins.requests.ApiRequestsAnalyser import io.getstream.chat.android.client.scope.ClientScope @@ -164,10 +165,19 @@ constructor( } private val eventMapping by lazy { EventMapping(domainMapping) } + private val directEventParser by lazy { + DirectEventParser( + currentUserIdProvider = currentUserIdProvider, + messageTransformer = apiModelTransformers.incomingMessageTransformer, + userTransformer = apiModelTransformers.incomingUserTransformer, + ) + } + private val moshiParser: ChatParser by lazy { MoshiChatParser( eventMapping = eventMapping, dtoMapping = dtoMapping, + directEventParser = directEventParser, ) } private val socketFactory: SocketFactory by lazy { SocketFactory(moshiParser, tokenManager, headersUtil) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChatEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChatEvent.kt index 473971417e7..7e2a8f14dac 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChatEvent.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChatEvent.kt @@ -24,6 +24,7 @@ import io.getstream.chat.android.client.events.MessageDeletedEvent import io.getstream.chat.android.client.events.MessageUpdatedEvent import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationMessageNewEvent +import io.getstream.chat.android.client.events.NotificationThreadMessageNewEvent import io.getstream.chat.android.client.events.ReactionDeletedEvent import io.getstream.chat.android.client.events.ReactionNewEvent import io.getstream.chat.android.client.events.ReactionUpdateEvent @@ -43,5 +44,6 @@ public fun ChatEvent.enrichIfNeeded(): ChatEvent = when (this) { is ChannelTruncatedEvent -> copy(message = message?.enrichWithCid(cid)) is ChannelUpdatedByUserEvent -> copy(message = message?.enrichWithCid(cid)) is NotificationMessageNewEvent -> copy(message = message.enrichWithCid(cid)) + is NotificationThreadMessageNewEvent -> copy(message = message.enrichWithCid(cid)) else -> this } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/DirectEventParser.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/DirectEventParser.kt new file mode 100644 index 00000000000..535cee67e43 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/DirectEventParser.kt @@ -0,0 +1,147 @@ +/* + * 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 com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.Moshi +import io.getstream.chat.android.client.events.ChatEvent +import io.getstream.chat.android.client.parser2.adapters.DateAdapter +import io.getstream.chat.android.client.parser2.direct.AttachmentAdapter +import io.getstream.chat.android.client.parser2.direct.ChannelInfoAdapter +import io.getstream.chat.android.client.parser2.direct.DeviceAdapter +import io.getstream.chat.android.client.parser2.direct.LocationAdapter +import io.getstream.chat.android.client.parser2.direct.MessageAdapter +import io.getstream.chat.android.client.parser2.direct.MessageModerationDetailsAdapter +import io.getstream.chat.android.client.parser2.direct.MessageReminderInfoAdapter +import io.getstream.chat.android.client.parser2.direct.ModerationAdapter +import io.getstream.chat.android.client.parser2.direct.NewMessageEventAdapter +import io.getstream.chat.android.client.parser2.direct.OptionAdapter +import io.getstream.chat.android.client.parser2.direct.PollAdapter +import io.getstream.chat.android.client.parser2.direct.PrivacySettingsAdapter +import io.getstream.chat.android.client.parser2.direct.ReactionAdapter +import io.getstream.chat.android.client.parser2.direct.ReactionGroupAdapter +import io.getstream.chat.android.client.parser2.direct.UserAdapter +import io.getstream.chat.android.models.EventType +import io.getstream.chat.android.models.MessageTransformer +import io.getstream.chat.android.models.UserId +import io.getstream.chat.android.models.UserTransformer +import okio.Buffer +import java.util.Date + +/** + * Routes incoming JSON events to hand-written [JsonAdapter]s that parse directly into domain models, + * bypassing the DTO intermediate layer. Returns `null` for unsupported event types so the caller + * can fall back to the existing DTO path. + */ +internal class DirectEventParser( + private val currentUserIdProvider: () -> UserId?, + private val messageTransformer: MessageTransformer, + private val userTransformer: UserTransformer, +) { + + // region Leaf adapters + + private val moshi by lazy { Moshi.Builder().add(Date::class.java, DateAdapter()).build() } + private val dateAdapter by lazy { moshi.adapter(Date::class.java) } + private val deviceAdapter by lazy { DeviceAdapter() } + private val privacySettingsAdapter by lazy { PrivacySettingsAdapter() } + private val attachmentAdapter by lazy { AttachmentAdapter() } + private val channelInfoAdapter by lazy { ChannelInfoAdapter() } + private val moderationDetailsAdapter by lazy { MessageModerationDetailsAdapter() } + private val moderationAdapter by lazy { ModerationAdapter() } + private val optionAdapter by lazy { OptionAdapter() } + private val locationAdapter by lazy { LocationAdapter(dateAdapter) } + private val reactionGroupAdapter by lazy { ReactionGroupAdapter(dateAdapter) } + + // endregion + + // region Composed adapters + + private val userAdapter by lazy { + UserAdapter(deviceAdapter, privacySettingsAdapter, dateAdapter, userTransformer) + } + private val reactionAdapter by lazy { ReactionAdapter(userAdapter, dateAdapter) } + private val pollAdapter by lazy { + PollAdapter(userAdapter, optionAdapter, dateAdapter, currentUserIdProvider) + } + private val reminderAdapter by lazy { MessageReminderInfoAdapter(dateAdapter) } + private val messageAdapter by lazy { + MessageAdapter( + attachmentAdapter, channelInfoAdapter, reactionAdapter, + reactionGroupAdapter, userAdapter, moderationDetailsAdapter, moderationAdapter, + pollAdapter, reminderAdapter, locationAdapter, dateAdapter, messageTransformer, + ) + } + + // endregion + + // region Event adapters + + private val newMessageEventAdapter by lazy { + NewMessageEventAdapter(messageAdapter, userAdapter) + } + + // endregion + + /** Registry mapping event type strings to their direct adapters. */ + private val adapterMap: Map> by lazy { + mapOf(EventType.MESSAGE_NEW to newMessageEventAdapter) + } + + /** + * Attempts to parse [raw] JSON into a [ChatEvent] using a direct adapter. + * Returns `null` if the event type is not supported by any direct adapter. + */ + fun parse(raw: String): ChatEvent? { + val type = extractType(raw) ?: return null + val adapter = adapterMap[type] ?: return null + return adapter.fromJson(raw) + } + + companion object { + /** + * Extracts the `"type"` field value from the top level of a JSON object + * using a streaming [JsonReader]. Stops as soon as the field is found. + */ + @Suppress("NestedBlockDepth") + internal fun extractType(raw: String): String? { + if (raw.isBlank()) return null + val reader = JsonReader.of(Buffer().writeUtf8(raw)) + return try { + reader.use { + if (it.peek() != JsonReader.Token.BEGIN_OBJECT) return null + it.beginObject() + while (it.hasNext()) { + if (it.nextName() == "type") { + return if (it.peek() == JsonReader.Token.NULL) { + it.nextNull() + } else { + it.nextString() + } + } else { + it.skipValue() + } + } + null + } + } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception) { + null + } + } + } +} 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 b1c0abb4185..048ebf76480 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 @@ -59,6 +59,7 @@ import retrofit2.converter.moshi.MoshiConverterFactory internal class MoshiChatParser( private val eventMapping: EventMapping, private val dtoMapping: DtoMapping, + private val directEventParser: DirectEventParser, ) : ChatParser { private val moshi: Moshi by lazy { @@ -141,8 +142,12 @@ internal class MoshiChatParser( private val chatEventDtoAdapter = moshi.adapter(ChatEventDto::class.java) - private fun parseAndProcessEvent(raw: String): ChatEvent = with(eventMapping) { - val event = chatEventDtoAdapter.fromJson(raw)!!.toDomain() - return event.enrichIfNeeded() + private fun parseAndProcessEvent(raw: String): ChatEvent { + val directEvent = directEventParser.parse(raw) + if (directEvent != null) { + // Direct adapters handle enrichment inline — no enrichIfNeeded() needed. + return directEvent + } + return with(eventMapping) { chatEventDtoAdapter.fromJson(raw)!!.toDomain() }.enrichIfNeeded() } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/AttachmentAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/AttachmentAdapter.kt new file mode 100644 index 00000000000..c3a44615ec5 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/AttachmentAdapter.kt @@ -0,0 +1,100 @@ +/* + * 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.direct + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import io.getstream.chat.android.models.Attachment + +internal class AttachmentAdapter : JsonAdapter() { + + @Suppress("LongMethod") + override fun fromJson(reader: JsonReader): Attachment? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + + reader.beginObject() + var assetUrl: String? = null + var authorName: String? = null + var authorLink: String? = null + var fallback: String? = null + var fileSize: Int = 0 + var image: String? = null + var imageUrl: String? = null + var mimeType: String? = null + var name: String? = null + var ogScrapeUrl: String? = null + var text: String? = null + var thumbUrl: String? = null + var title: String? = null + var titleLink: String? = null + var type: String? = null + var originalHeight: Int? = null + var originalWidth: Int? = null + var extraData: MutableMap? = null + + while (reader.hasNext()) { + val key = reader.nextName() + when (key) { + "asset_url" -> assetUrl = JsonParsingUtils.readNullableString(reader) + "author_name" -> authorName = JsonParsingUtils.readNullableString(reader) + "author_link" -> authorLink = JsonParsingUtils.readNullableString(reader) + "fallback" -> fallback = JsonParsingUtils.readNullableString(reader) + "file_size" -> fileSize = reader.nextInt() + "image" -> image = JsonParsingUtils.readNullableString(reader) + "image_url" -> imageUrl = JsonParsingUtils.readNullableString(reader) + "mime_type" -> mimeType = JsonParsingUtils.readNullableString(reader) + "name" -> name = JsonParsingUtils.readNullableString(reader) + "og_scrape_url" -> ogScrapeUrl = JsonParsingUtils.readNullableString(reader) + "text" -> text = JsonParsingUtils.readNullableString(reader) + "thumb_url" -> thumbUrl = JsonParsingUtils.readNullableString(reader) + "title" -> title = JsonParsingUtils.readNullableString(reader) + "title_link" -> titleLink = JsonParsingUtils.readNullableString(reader) + "type" -> type = JsonParsingUtils.readNullableString(reader) + "original_height" -> originalHeight = JsonParsingUtils.readNullableInt(reader) + "original_width" -> originalWidth = JsonParsingUtils.readNullableInt(reader) + else -> extraData = JsonParsingUtils.accumulateExtraData(key, reader, extraData) + } + } + reader.endObject() + + return Attachment( + assetUrl = assetUrl, + authorName = authorName, + authorLink = authorLink, + fallback = fallback, + fileSize = fileSize, + image = image, + imageUrl = imageUrl, + mimeType = mimeType, + name = name, + ogUrl = ogScrapeUrl, + text = text, + thumbUrl = thumbUrl, + title = title, + titleLink = titleLink, + type = type, + originalHeight = originalHeight, + originalWidth = originalWidth, + extraData = extraData?.toMutableMap() ?: mutableMapOf(), + ) + } + + override fun toJson(p0: JsonWriter, p1: Attachment?) { + error("Serialization not supported for direct-to-domain path") + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/ChannelInfoAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/ChannelInfoAdapter.kt new file mode 100644 index 00000000000..36bf16d8df3 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/ChannelInfoAdapter.kt @@ -0,0 +1,62 @@ +/* + * 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.direct + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import io.getstream.chat.android.models.ChannelInfo + +internal class ChannelInfoAdapter : JsonAdapter() { + override fun fromJson(reader: JsonReader): ChannelInfo? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + + reader.beginObject() + var cid: String? = null + var id: String? = null + var memberCount: Int = 0 + var name: String? = null + var type: String? = null + var image: String? = null + + while (reader.hasNext()) { + when (reader.nextName()) { + "cid" -> cid = JsonParsingUtils.readNullableString(reader) + "id" -> id = JsonParsingUtils.readNullableString(reader) + "member_count" -> memberCount = reader.nextInt() + "name" -> name = JsonParsingUtils.readNullableString(reader) + "type" -> type = JsonParsingUtils.readNullableString(reader) + "image" -> image = JsonParsingUtils.readNullableString(reader) + else -> reader.skipValue() + } + } + reader.endObject() + + return ChannelInfo( + cid = cid, + id = id, + memberCount = memberCount, + name = name, + type = type, + image = image, + ) + } + + override fun toJson(p0: JsonWriter, p1: ChannelInfo?) { + error("Serialization not supported for direct-to-domain path") + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/DeviceAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/DeviceAdapter.kt new file mode 100644 index 00000000000..3159961e689 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/DeviceAdapter.kt @@ -0,0 +1,57 @@ +/* + * 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.direct + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import io.getstream.chat.android.models.Device +import io.getstream.chat.android.models.PushProvider + +internal class DeviceAdapter : JsonAdapter() { + override fun fromJson(reader: JsonReader): Device? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + + reader.beginObject() + var id: String? = null + var pushProvider: String? = null + var pushProviderName: String? = null + + while (reader.hasNext()) { + when (reader.nextName()) { + "id" -> id = reader.nextString() + "push_provider" -> pushProvider = reader.nextString() + "push_provider_name" -> pushProviderName = JsonParsingUtils.readNullableString(reader) + else -> reader.skipValue() + } + } + reader.endObject() + + JsonParsingUtils.requireField(id, "id", reader) + JsonParsingUtils.requireField(pushProvider, "push_provider", reader) + + return Device( + token = id, + pushProvider = PushProvider.fromKey(pushProvider), + providerName = pushProviderName, + ) + } + + override fun toJson(p0: JsonWriter, p1: Device?) { + error("Serialization not supported for direct-to-domain path") + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/JsonParsingUtils.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/JsonParsingUtils.kt new file mode 100644 index 00000000000..f69c034adc6 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/JsonParsingUtils.kt @@ -0,0 +1,161 @@ +/* + * 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.direct + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonReader +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +/** + * Utility functions for parsing JSON with consistent null-handling. + */ +internal object JsonParsingUtils { + + /** + * Throws [JsonDataException] if [value] is null. Enables smart-cast via contract. + * The error format matches Moshi codegen's `Util.missingProperty` when invoked through `fromJsonValue`, + * ensuring parity between the DTO and direct parsing paths. + */ + @OptIn(ExperimentalContracts::class) + fun requireField(value: T?, fieldName: String, reader: JsonReader): T { + contract { returns() implies (value != null) } + return value ?: throw JsonDataException( + "Required value '$fieldName' missing at ${reader.path}", + ) + } + + /** Reads a nullable Int (returns null if JSON value is null). */ + fun readNullableInt(reader: JsonReader): Int? { + return if (reader.peek() == JsonReader.Token.NULL) reader.nextNull() else reader.nextInt() + } + + /** Reads a nullable String (returns null if JSON value is null). */ + fun readNullableString(reader: JsonReader): String? { + return if (reader.peek() == JsonReader.Token.NULL) reader.nextNull() else reader.nextString() + } + + /** Reads a nullable Boolean (returns null if JSON value is null). */ + fun readNullableBoolean(reader: JsonReader): Boolean? { + return if (reader.peek() == JsonReader.Token.NULL) reader.nextNull() else reader.nextBoolean() + } + + /** Reads a nullable Long (returns null if JSON value is null). */ + fun readNullableLong(reader: JsonReader): Long? { + return if (reader.peek() == JsonReader.Token.NULL) reader.nextNull() else reader.nextLong() + } + + /** Accumulates a key-value pair into an extra data map, lazily creating it if needed. */ + fun accumulateExtraData( + key: String, + reader: JsonReader, + extraData: MutableMap?, + ): MutableMap? { + val value = reader.readJsonValue() ?: return extraData + val map = extraData ?: mutableMapOf() + map[key] = value + return map + } + + /** + * Parses a JSON array of strings. + * + * @param reader The JsonReader positioned at the array field. + * @return A list of strings, or null if the JSON value is null, missing, or not an array. + */ + fun parseStringList(reader: JsonReader): List? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + if (reader.peek() != JsonReader.Token.BEGIN_ARRAY) { + reader.skipValue() + return null + } + reader.beginArray() + val list = mutableListOf() + while (reader.hasNext()) { + list.add(reader.nextString()) + } + reader.endArray() + return list + } + + /** + * Parses a JSON array of objects using the provided adapter. + * + * @param reader The JsonReader positioned at the array field. + * @param adapter The JsonAdapter to parse individual array elements. + * @return A list of parsed objects, or null if the JSON value is null, missing, or not an array. + */ + fun parseList(reader: JsonReader, adapter: JsonAdapter): List? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + if (reader.peek() != JsonReader.Token.BEGIN_ARRAY) { + reader.skipValue() + return null + } + reader.beginArray() + val list = mutableListOf() + while (reader.hasNext()) { + adapter.fromJson(reader)?.let { list.add(it) } + } + reader.endArray() + return list + } + + /** + * Parses a JSON object into a Map. + * + * @param reader The JsonReader positioned at the object field. + * @return A map of string key-value pairs, or null if the JSON value is null, missing, or not an object. + */ + fun parseStringMap(reader: JsonReader): Map? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue() + return null + } + reader.beginObject() + val map = mutableMapOf() + while (reader.hasNext()) { + val key = reader.nextName() + map[key] = reader.nextString() + } + reader.endObject() + return map + } + + /** + * Parses a JSON object into a Map. + * + * @param reader The JsonReader positioned at the object field. + * @return A map of string keys to integer values, or null if the JSON value is null, missing, or not an object. + */ + fun parseIntMap(reader: JsonReader): Map? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue() + return null + } + reader.beginObject() + val map = mutableMapOf() + while (reader.hasNext()) { + val key = reader.nextName() + map[key] = reader.nextInt() + } + reader.endObject() + return map + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/LocationAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/LocationAdapter.kt new file mode 100644 index 00000000000..d8fe94c94c4 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/LocationAdapter.kt @@ -0,0 +1,75 @@ +/* + * 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.direct + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import io.getstream.chat.android.models.Location +import java.util.Date + +internal class LocationAdapter( + private val dateAdapter: JsonAdapter, +) : JsonAdapter() { + override fun fromJson(reader: JsonReader): Location? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + + reader.beginObject() + var channelCid: String? = null + var messageId: String? = null + var userId: String? = null + var latitude: Double? = null + var longitude: Double? = null + var createdByDeviceId: String? = null + var endAt: Date? = null + + while (reader.hasNext()) { + when (reader.nextName()) { + "channel_cid" -> channelCid = reader.nextString() + "message_id" -> messageId = reader.nextString() + "user_id" -> userId = reader.nextString() + "latitude" -> latitude = reader.nextDouble() + "longitude" -> longitude = reader.nextDouble() + "created_by_device_id" -> createdByDeviceId = reader.nextString() + "end_at" -> endAt = dateAdapter.fromJson(reader) + else -> reader.skipValue() + } + } + reader.endObject() + + JsonParsingUtils.requireField(channelCid, "channel_cid", reader) + JsonParsingUtils.requireField(messageId, "message_id", reader) + JsonParsingUtils.requireField(userId, "user_id", reader) + JsonParsingUtils.requireField(latitude, "latitude", reader) + JsonParsingUtils.requireField(longitude, "longitude", reader) + JsonParsingUtils.requireField(createdByDeviceId, "created_by_device_id", reader) + + return Location( + cid = channelCid, + messageId = messageId, + userId = userId, + latitude = latitude, + longitude = longitude, + deviceId = createdByDeviceId, + endAt = endAt, + ) + } + + override fun toJson(p0: JsonWriter, p1: Location?) { + error("Serialization not supported for direct-to-domain path") + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/MessageAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/MessageAdapter.kt new file mode 100644 index 00000000000..40fa5b3daa1 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/MessageAdapter.kt @@ -0,0 +1,254 @@ +/* + * 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.direct + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.models.ChannelInfo +import io.getstream.chat.android.models.Location +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageModerationDetails +import io.getstream.chat.android.models.MessageReminderInfo +import io.getstream.chat.android.models.MessageTransformer +import io.getstream.chat.android.models.Moderation +import io.getstream.chat.android.models.Poll +import io.getstream.chat.android.models.Reaction +import io.getstream.chat.android.models.ReactionGroup +import io.getstream.chat.android.models.User +import java.util.Date + +@Suppress("LongParameterList") +internal class MessageAdapter( + private val attachmentAdapter: JsonAdapter, + private val channelInfoAdapter: JsonAdapter, + private val reactionAdapter: JsonAdapter, + private val reactionGroupAdapter: ReactionGroupAdapter, + private val userAdapter: JsonAdapter, + private val moderationDetailsAdapter: JsonAdapter, + private val moderationAdapter: JsonAdapter, + private val pollAdapter: JsonAdapter, + private val reminderAdapter: JsonAdapter, + private val locationAdapter: JsonAdapter, + private val dateAdapter: JsonAdapter, + private val messageTransformer: MessageTransformer, +) : JsonAdapter() { + + override fun fromJson(reader: JsonReader): Message? { + return fromJson(reader, fallbackChannelInfo = null) + } + + @Suppress("LongMethod", "ThrowsCount") + fun fromJson(reader: JsonReader, fallbackChannelInfo: ChannelInfo?): Message? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + + reader.beginObject() + + var attachments: List? = null + var channel: ChannelInfo? = null + var cid: String? = null + var command: String? = null + var createdAt: Date? = null + var deletedAt: Date? = null + var html: String? = null + var i18n: Map? = null + var id: String? = null + var latestReactions: List? = null + var mentionedUsers: List? = null + var ownReactions: List? = null + var parentId: String? = null + var pinExpires: Date? = null + var pinned: Boolean? = null + var pinnedAt: Date? = null + var messageTextUpdatedAt: Date? = null + var pinnedBy: User? = null + var quotedMessage: Message? = null + var quotedMessageId: String? = null + var reactionCounts: Map? = null + var reactionScores: Map? = null + var reactionGroups: Map? = null + var replyCount: Int? = null + var deletedReplyCount: Int? = null + var shadowed: Boolean? = null + var showInChannel: Boolean? = null + var silent: Boolean? = null + var text: String? = null + var threadParticipants: List? = null + var type: String? = null + var updatedAt: Date? = null + var user: User? = null + var moderationDetails: MessageModerationDetails? = null + var moderation: Moderation? = null + var poll: Poll? = null + var reminder: MessageReminderInfo? = null + var sharedLocation: Location? = null + var channelRole: String? = null + var deletedForMe: Boolean? = null + var extraData: MutableMap? = null + + while (reader.hasNext()) { + val key = reader.nextName() + when (key) { + "attachments" -> attachments = JsonParsingUtils.parseList(reader, attachmentAdapter) + "channel" -> channel = channelInfoAdapter.fromJson(reader) + "cid" -> cid = reader.nextString() + "command" -> command = JsonParsingUtils.readNullableString(reader) + "created_at" -> createdAt = dateAdapter.fromJson(reader) + "deleted_at" -> deletedAt = dateAdapter.fromJson(reader) + "html" -> html = reader.nextString() + "i18n" -> i18n = JsonParsingUtils.parseStringMap(reader) + "id" -> id = reader.nextString() + "latest_reactions" -> latestReactions = JsonParsingUtils.parseList(reader, reactionAdapter) + "mentioned_users" -> mentionedUsers = JsonParsingUtils.parseList(reader, userAdapter) + "own_reactions" -> ownReactions = JsonParsingUtils.parseList(reader, reactionAdapter) + "parent_id" -> parentId = JsonParsingUtils.readNullableString(reader) + "pin_expires" -> pinExpires = dateAdapter.fromJson(reader) + "pinned" -> pinned = reader.nextBoolean() + "pinned_at" -> pinnedAt = dateAdapter.fromJson(reader) + "message_text_updated_at" -> messageTextUpdatedAt = dateAdapter.fromJson(reader) + "pinned_by" -> pinnedBy = userAdapter.fromJson(reader) + "quoted_message" -> { + // Recursive parsing: pass the fallback channel info along + val resolvedChannelInfo = channel ?: fallbackChannelInfo + quotedMessage = fromJson(reader, resolvedChannelInfo) + } + + "quoted_message_id" -> quotedMessageId = JsonParsingUtils.readNullableString(reader) + "reaction_counts" -> reactionCounts = JsonParsingUtils.parseIntMap(reader) + "reaction_scores" -> reactionScores = JsonParsingUtils.parseIntMap(reader) + "reaction_groups" -> reactionGroups = reactionGroupAdapter.parseReactionGroupsMap(reader) + "reply_count" -> replyCount = reader.nextInt() + "deleted_reply_count" -> deletedReplyCount = reader.nextInt() + "shadowed" -> shadowed = reader.nextBoolean() + "show_in_channel" -> showInChannel = reader.nextBoolean() + "silent" -> silent = reader.nextBoolean() + "text" -> text = reader.nextString() + "thread_participants" -> threadParticipants = JsonParsingUtils.parseList(reader, userAdapter) + "type" -> type = reader.nextString() + "updated_at" -> updatedAt = dateAdapter.fromJson(reader) + "user" -> user = userAdapter.fromJson(reader) + "moderation_details" -> moderationDetails = moderationDetailsAdapter.fromJson(reader) + "moderation" -> moderation = moderationAdapter.fromJson(reader) + "poll" -> poll = pollAdapter.fromJson(reader) + "reminder" -> reminder = reminderAdapter.fromJson(reader) + "shared_location" -> sharedLocation = locationAdapter.fromJson(reader) + "member" -> channelRole = parseMemberChannelRole(reader) + "deleted_for_me" -> deletedForMe = JsonParsingUtils.readNullableBoolean(reader) + else -> extraData = JsonParsingUtils.accumulateExtraData(key, reader, extraData) + } + } + reader.endObject() + + JsonParsingUtils.requireField(attachments, "attachments", reader) + JsonParsingUtils.requireField(cid, "cid", reader) + JsonParsingUtils.requireField(createdAt, "created_at", reader) + JsonParsingUtils.requireField(html, "html", reader) + JsonParsingUtils.requireField(id, "id", reader) + JsonParsingUtils.requireField(latestReactions, "latest_reactions", reader) + JsonParsingUtils.requireField(mentionedUsers, "mentioned_users", reader) + JsonParsingUtils.requireField(ownReactions, "own_reactions", reader) + JsonParsingUtils.requireField(replyCount, "reply_count", reader) + JsonParsingUtils.requireField(deletedReplyCount, "deleted_reply_count", reader) + JsonParsingUtils.requireField(silent, "silent", reader) + JsonParsingUtils.requireField(text, "text", reader) + JsonParsingUtils.requireField(type, "type", reader) + JsonParsingUtils.requireField(updatedAt, "updated_at", reader) + JsonParsingUtils.requireField(user, "user", reader) + + val resolvedChannelInfo = channel ?: fallbackChannelInfo + + // Filter reactions by messageId (matching DomainMapping behavior) + val filteredLatestReactions = latestReactions.filter { it.messageId == id } + val filteredOwnReactions = ownReactions.filter { it.messageId == id } + + // Calculate last update time: max of updated_at and poll?.updatedAt + val lastUpdateTime = listOfNotNull( + updatedAt, + poll?.updatedAt, + ).maxByOrNull { it.time } ?: updatedAt + + return Message( + attachments = attachments, + channelInfo = resolvedChannelInfo, + cid = cid, + command = command, + createdAt = createdAt, + deletedAt = deletedAt, + html = html, + i18n = i18n ?: emptyMap(), + id = id, + latestReactions = filteredLatestReactions, + mentionedUsers = mentionedUsers, + ownReactions = filteredOwnReactions, + parentId = parentId, + pinExpires = pinExpires, + pinned = pinned ?: false, + pinnedAt = pinnedAt, + pinnedBy = pinnedBy, + reactionCounts = reactionCounts ?: mutableMapOf(), + reactionScores = reactionScores ?: mutableMapOf(), + reactionGroups = reactionGroups ?: emptyMap(), + replyCount = replyCount, + deletedReplyCount = deletedReplyCount, + replyMessageId = quotedMessageId, + replyTo = quotedMessage, + shadowed = shadowed ?: false, + showInChannel = showInChannel ?: false, + silent = silent, + text = text, + threadParticipants = threadParticipants ?: emptyList(), + type = type, + updatedAt = lastUpdateTime, + user = user, + moderationDetails = moderationDetails, + moderation = moderation, + messageTextUpdatedAt = messageTextUpdatedAt, + poll = poll, + restrictedVisibility = emptyList(), + reminder = reminder, + sharedLocation = sharedLocation, + channelRole = channelRole, + deletedForMe = deletedForMe ?: false, + extraData = extraData ?: emptyMap(), + ).let(messageTransformer::transform) + } + + private fun parseMemberChannelRole(reader: JsonReader): String? { + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue() + return null + } + + reader.beginObject() + var channelRole: String? = null + + while (reader.hasNext()) { + when (reader.nextName()) { + "channel_role" -> channelRole = JsonParsingUtils.readNullableString(reader) + else -> reader.skipValue() + } + } + reader.endObject() + + return channelRole + } + + override fun toJson(p0: JsonWriter, p1: Message?) { + error("Serialization not supported for direct-to-domain path") + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/MessageModerationDetailsAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/MessageModerationDetailsAdapter.kt new file mode 100644 index 00000000000..f1e894a5477 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/MessageModerationDetailsAdapter.kt @@ -0,0 +1,54 @@ +/* + * 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.direct + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import io.getstream.chat.android.models.MessageModerationAction +import io.getstream.chat.android.models.MessageModerationDetails + +internal class MessageModerationDetailsAdapter : JsonAdapter() { + override fun fromJson(reader: JsonReader): MessageModerationDetails? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + + reader.beginObject() + var originalText: String? = null + var action: String? = null + var errorMsg: String? = null + + while (reader.hasNext()) { + when (reader.nextName()) { + "original_text" -> originalText = JsonParsingUtils.readNullableString(reader) + "action" -> action = JsonParsingUtils.readNullableString(reader) + "error_msg" -> errorMsg = JsonParsingUtils.readNullableString(reader) + else -> reader.skipValue() + } + } + reader.endObject() + + return MessageModerationDetails( + originalText = originalText.orEmpty(), + action = MessageModerationAction.fromRawValue(action.orEmpty()), + errorMsg = errorMsg.orEmpty(), + ) + } + + override fun toJson(p0: JsonWriter, p1: MessageModerationDetails?) { + error("Serialization not supported for direct-to-domain path") + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/MessageReminderInfoAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/MessageReminderInfoAdapter.kt new file mode 100644 index 00000000000..ef0072dc1ec --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/MessageReminderInfoAdapter.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. + */ + +package io.getstream.chat.android.client.parser2.direct + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import io.getstream.chat.android.models.MessageReminderInfo +import java.util.Date + +internal class MessageReminderInfoAdapter( + private val dateAdapter: JsonAdapter, +) : JsonAdapter() { + override fun fromJson(reader: JsonReader): MessageReminderInfo? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + + reader.beginObject() + var remindAt: Date? = null + var createdAt: Date? = null + var updatedAt: Date? = null + + while (reader.hasNext()) { + when (reader.nextName()) { + "remind_at" -> remindAt = dateAdapter.fromJson(reader) + "created_at" -> createdAt = dateAdapter.fromJson(reader) + "updated_at" -> updatedAt = dateAdapter.fromJson(reader) + else -> reader.skipValue() + } + } + reader.endObject() + + JsonParsingUtils.requireField(createdAt, "created_at", reader) + JsonParsingUtils.requireField(updatedAt, "updated_at", reader) + + return MessageReminderInfo( + remindAt = remindAt, + createdAt = createdAt, + updatedAt = updatedAt, + ) + } + + override fun toJson(p0: JsonWriter, p1: MessageReminderInfo?) { + error("Serialization not supported for direct-to-domain path") + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/ModerationAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/ModerationAdapter.kt new file mode 100644 index 00000000000..da3186b3938 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/ModerationAdapter.kt @@ -0,0 +1,69 @@ +/* + * 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.direct + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import io.getstream.chat.android.models.Moderation +import io.getstream.chat.android.models.ModerationAction + +internal class ModerationAdapter : JsonAdapter() { + override fun fromJson(reader: JsonReader): Moderation? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + + reader.beginObject() + var action: String? = null + var originalText: String? = null + var textHarms: List? = null + var imageHarms: List? = null + var blocklistMatched: String? = null + var semanticFilterMatched: String? = null + var platformCircumvented: Boolean? = null + + while (reader.hasNext()) { + when (reader.nextName()) { + "action" -> action = reader.nextString() + "original_text" -> originalText = reader.nextString() + "text_harms" -> textHarms = JsonParsingUtils.parseStringList(reader) + "image_harms" -> imageHarms = JsonParsingUtils.parseStringList(reader) + "blocklist_matched" -> blocklistMatched = JsonParsingUtils.readNullableString(reader) + "semantic_filter_matched" -> semanticFilterMatched = JsonParsingUtils.readNullableString(reader) + "platform_circumvented" -> platformCircumvented = JsonParsingUtils.readNullableBoolean(reader) + else -> reader.skipValue() + } + } + reader.endObject() + + JsonParsingUtils.requireField(action, "action", reader) + JsonParsingUtils.requireField(originalText, "original_text", reader) + + return Moderation( + action = ModerationAction.fromValue(action), + originalText = originalText, + textHarms = textHarms.orEmpty(), + imageHarms = imageHarms.orEmpty(), + blocklistMatched = blocklistMatched, + semanticFilterMatched = semanticFilterMatched, + platformCircumvented = platformCircumvented ?: false, + ) + } + + override fun toJson(p0: JsonWriter, p1: Moderation?) { + error("Serialization not supported for direct-to-domain path") + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/NewMessageEventAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/NewMessageEventAdapter.kt new file mode 100644 index 00000000000..93858feba74 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/NewMessageEventAdapter.kt @@ -0,0 +1,172 @@ +/* + * 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.direct + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import io.getstream.chat.android.client.events.NewMessageEvent +import io.getstream.chat.android.client.parser2.adapters.internal.StreamDateFormatter +import io.getstream.chat.android.models.ChannelInfo +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.User +import java.util.Date + +internal class NewMessageEventAdapter( + private val messageAdapter: JsonAdapter, + private val userAdapter: JsonAdapter, +) : JsonAdapter() { + + private val streamDateFormatter = StreamDateFormatter("NewMessageEventAdapter") + + @Suppress("LongMethod") + override fun fromJson(reader: JsonReader): NewMessageEvent? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + + reader.beginObject() + + var type: String? = null + var createdAt: Date? = null + var rawCreatedAt: String? = null + var user: User? = null + var cid: String? = null + var channelMemberCount: Int? = null + var channelCustomName: String? = null + var channelCustomImage: String? = null + var channelType: String? = null + var channelId: String? = null + var message: Message? = null + var watcherCount: Int = 0 + var totalUnreadCount: Int = 0 + var unreadChannels: Int = 0 + var channelMessageCount: Int? = null + + while (reader.hasNext()) { + when (reader.nextName()) { + "type" -> type = reader.nextString() + "created_at" -> { + if (reader.peek() != JsonReader.Token.NULL) { + val rawValue = reader.nextString() + rawCreatedAt = rawValue + createdAt = streamDateFormatter.parse(rawValue) + } else { + reader.skipValue() + } + } + "user" -> user = userAdapter.fromJson(reader) + "cid" -> cid = reader.nextString() + "channel_member_count" -> channelMemberCount = JsonParsingUtils.readNullableInt(reader) + "channel_custom" -> { + val (name, image) = parseChannelCustom(reader) + channelCustomName = name + channelCustomImage = image + } + "channel_type" -> channelType = reader.nextString() + "channel_id" -> channelId = reader.nextString() + "message" -> message = messageAdapter.fromJson(reader) + "watcher_count" -> watcherCount = reader.nextInt() + "total_unread_count" -> totalUnreadCount = reader.nextInt() + "unread_channels" -> unreadChannels = reader.nextInt() + "channel_message_count" -> channelMessageCount = JsonParsingUtils.readNullableInt(reader) + else -> reader.skipValue() + } + } + reader.endObject() + + JsonParsingUtils.requireField(type, "type", reader) + JsonParsingUtils.requireField(rawCreatedAt, "created_at", reader) + JsonParsingUtils.requireField(user, "user", reader) + JsonParsingUtils.requireField(cid, "cid", reader) + JsonParsingUtils.requireField(channelType, "channel_type", reader) + JsonParsingUtils.requireField(channelId, "channel_id", reader) + JsonParsingUtils.requireField(message, "message", reader) + + // Enrich inline: set channelInfo + cid so parseAndProcessEvent can skip enrichIfNeeded(). + // Only copy if something actually needs to change. + val needsChannelInfo = message.channelInfo == null + val needsCid = message.cid != cid + val replyTo = message.replyTo + val needsReplyToCid = replyTo != null && replyTo.cid != cid + + val enrichedMessage = if (needsChannelInfo || needsCid || needsReplyToCid) { + val fallbackChannelInfo = ChannelInfo( + cid = cid, + id = channelId, + type = channelType, + memberCount = channelMemberCount ?: 0, + name = channelCustomName, + image = channelCustomImage, + ) + message.copy( + channelInfo = message.channelInfo ?: fallbackChannelInfo, + cid = if (needsCid) cid else message.cid, + replyTo = if (needsReplyToCid) replyTo.copy(cid = cid) else replyTo, + ) + } else { + message + } + + return NewMessageEvent( + type = type, + createdAt = createdAt ?: Date(0), + rawCreatedAt = rawCreatedAt, + user = user, + cid = cid, + channelType = channelType, + channelId = channelId, + message = enrichedMessage, + watcherCount = watcherCount, + totalUnreadCount = totalUnreadCount, + unreadChannels = unreadChannels, + channelMessageCount = channelMessageCount, + ) + } + + private fun parseChannelCustom(reader: JsonReader): Pair { + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue() + return null to null + } + + reader.beginObject() + var name: String? = null + var image: String? = null + + while (reader.hasNext()) { + when (reader.nextName()) { + "name" -> name = if (reader.peek() == JsonReader.Token.NULL) { + reader.nextNull() + } else { + reader.nextString() + } + "image" -> image = if (reader.peek() == JsonReader.Token.NULL) { + reader.nextNull() + } else { + reader.nextString() + } + else -> reader.skipValue() + } + } + reader.endObject() + + return name to image + } + + override fun toJson(p0: JsonWriter, p1: NewMessageEvent?) { + error("Serialization not supported for direct-to-domain path") + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/OptionAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/OptionAdapter.kt new file mode 100644 index 00000000000..1dd65686ce1 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/OptionAdapter.kt @@ -0,0 +1,57 @@ +/* + * 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.direct + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import io.getstream.chat.android.models.Option + +internal class OptionAdapter : JsonAdapter