Skip to content

Commit eb5e473

Browse files
VelikovPetarclaude
andcommitted
Port V6 fix: Add opt-in fast event parsing (message.new)
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4f2d20b commit eb5e473

68 files changed

Lines changed: 9503 additions & 33 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

stream-chat-android-client/api/stream-chat-android-client.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ public final class io/getstream/chat/android/client/ChatClient$Builder : io/gets
274274
public final fun debugRequests (Z)Lio/getstream/chat/android/client/ChatClient$Builder;
275275
public final fun disableDistinctApiCalls ()Lio/getstream/chat/android/client/ChatClient$Builder;
276276
public final fun disableWarmUp ()Lio/getstream/chat/android/client/ChatClient$Builder;
277+
public final fun fastEventParsing (Z)Lio/getstream/chat/android/client/ChatClient$Builder;
277278
public final fun fileTransformer (Lio/getstream/chat/android/client/uploader/FileTransformer;)Lio/getstream/chat/android/client/ChatClient$Builder;
278279
public final fun fileUploader (Lio/getstream/chat/android/client/uploader/FileUploader;)Lio/getstream/chat/android/client/ChatClient$Builder;
279280
public final fun forceInsecureConnection ()Lio/getstream/chat/android/client/ChatClient$Builder;

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4633,6 +4633,7 @@ internal constructor(
46334633
private var retryPolicy: RetryPolicy = NoRetryPolicy()
46344634
private var distinctApiCalls: Boolean = true
46354635
private var debugRequests: Boolean = false
4636+
private var fastEventParsing: Boolean = false
46364637
private var chatClientConfig: ChatClientConfig = ChatClientConfig()
46374638
private var uploadAttachmentsNetworkType = UploadAttachmentsNetworkType.CONNECTED
46384639
private var fileTransformer: FileTransformer = NoOpFileTransformer
@@ -4874,6 +4875,22 @@ internal constructor(
48744875
this.debugRequests = shouldDebug
48754876
}
48764877

4878+
/**
4879+
* Enables the fast event-parsing path for incoming WebSocket events.
4880+
*
4881+
* When enabled, supported event types are parsed directly into domain models, bypassing
4882+
* the DTO intermediate layer. Unsupported event types fall back to the default DTO-based
4883+
* parser, so behavior is preserved for events the fast path does not yet handle.
4884+
*
4885+
* Currently supported event types:
4886+
* - `message.new`
4887+
*
4888+
* Disabled by default. The set of supported event types may grow over time.
4889+
*/
4890+
public fun fastEventParsing(enabled: Boolean): Builder = apply {
4891+
this.fastEventParsing = enabled
4892+
}
4893+
48774894
/**
48784895
* Sets a custom [RetryPolicy] used to determine whether a particular call should be retried.
48794896
* By default, no calls are retried.
@@ -4947,8 +4964,9 @@ internal constructor(
49474964
warmUp = warmUp,
49484965
loggerConfig = ChatLoggerConfigImpl(logLevel, loggerHandler),
49494966
distinctApiCalls = distinctApiCalls,
4950-
debugRequests,
4951-
notificationConfig,
4967+
debugRequests = debugRequests,
4968+
notificationConfig = notificationConfig,
4969+
fastEventParsing = fastEventParsing,
49524970
)
49534971
setupStreamLog()
49544972

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApiConfig.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ import io.getstream.chat.android.client.notifications.handler.NotificationConfig
3434
* @param distinctApiCalls Controls whether [DistinctChatApi] is enabled or not.
3535
* @param debugRequests Controls whether requests can be recorded or not.
3636
* @param notificationConfig A notification config to be used by the client.
37+
* @param fastEventParsing Enables the fast event-parsing path for incoming WebSocket events.
38+
* When `true`, supported event types are parsed directly into domain models, bypassing the DTO
39+
* intermediate layer; unsupported event types fall back to the default DTO-based parser.
40+
* Currently supported event types: `message.new`. Disabled by default.
3741
*/
3842
@Suppress("LongParameterList")
3943
internal class ChatApiConfig @JvmOverloads constructor(
@@ -46,6 +50,7 @@ internal class ChatApiConfig @JvmOverloads constructor(
4650
var distinctApiCalls: Boolean = true,
4751
val debugRequests: Boolean,
4852
val notificationConfig: NotificationConfig,
53+
val fastEventParsing: Boolean = false,
4954
) {
5055
var isAnonymous: Boolean = false
5156
}

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import io.getstream.chat.android.client.notifications.handler.NotificationConfig
7070
import io.getstream.chat.android.client.notifications.handler.NotificationHandler
7171
import io.getstream.chat.android.client.notifications.handler.NotificationHandlerFactory
7272
import io.getstream.chat.android.client.parser.ChatParser
73+
import io.getstream.chat.android.client.parser2.DirectEventParser
7374
import io.getstream.chat.android.client.parser2.MoshiChatParser
7475
import io.getstream.chat.android.client.plugins.requests.ApiRequestsAnalyser
7576
import io.getstream.chat.android.client.scope.ClientScope
@@ -160,10 +161,23 @@ constructor(
160161
}
161162
private val eventMapping by lazy { EventMapping(domainMapping) }
162163

164+
private val directEventParser: DirectEventParser? by lazy {
165+
if (config.fastEventParsing) {
166+
DirectEventParser(
167+
currentUserIdProvider = currentUserIdProvider,
168+
messageTransformer = apiModelTransformers.incomingMessageTransformer,
169+
userTransformer = apiModelTransformers.incomingUserTransformer,
170+
)
171+
} else {
172+
null
173+
}
174+
}
175+
163176
private val moshiParser: ChatParser by lazy {
164177
MoshiChatParser(
165178
eventMapping = eventMapping,
166179
dtoMapping = dtoMapping,
180+
directEventParser = directEventParser,
167181
)
168182
}
169183
private val socketFactory: SocketFactory by lazy { SocketFactory(moshiParser, tokenManager, headersUtil) }

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChatEvent.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import io.getstream.chat.android.client.events.MessageDeletedEvent
2424
import io.getstream.chat.android.client.events.MessageUpdatedEvent
2525
import io.getstream.chat.android.client.events.NewMessageEvent
2626
import io.getstream.chat.android.client.events.NotificationMessageNewEvent
27+
import io.getstream.chat.android.client.events.NotificationThreadMessageNewEvent
2728
import io.getstream.chat.android.client.events.ReactionDeletedEvent
2829
import io.getstream.chat.android.client.events.ReactionNewEvent
2930
import io.getstream.chat.android.client.events.ReactionUpdateEvent
@@ -43,5 +44,6 @@ public fun ChatEvent.enrichIfNeeded(): ChatEvent = when (this) {
4344
is ChannelTruncatedEvent -> copy(message = message?.enrichWithCid(cid))
4445
is ChannelUpdatedByUserEvent -> copy(message = message?.enrichWithCid(cid))
4546
is NotificationMessageNewEvent -> copy(message = message.enrichWithCid(cid))
47+
is NotificationThreadMessageNewEvent -> copy(message = message.enrichWithCid(cid))
4648
else -> this
4749
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.client.parser2
18+
19+
import com.squareup.moshi.JsonAdapter
20+
import com.squareup.moshi.JsonReader
21+
import com.squareup.moshi.Moshi
22+
import io.getstream.chat.android.client.events.ChatEvent
23+
import io.getstream.chat.android.client.parser2.adapters.DateAdapter
24+
import io.getstream.chat.android.client.parser2.direct.AttachmentAdapter
25+
import io.getstream.chat.android.client.parser2.direct.ChannelInfoAdapter
26+
import io.getstream.chat.android.client.parser2.direct.DeviceAdapter
27+
import io.getstream.chat.android.client.parser2.direct.LocationAdapter
28+
import io.getstream.chat.android.client.parser2.direct.MessageAdapter
29+
import io.getstream.chat.android.client.parser2.direct.MessageModerationDetailsAdapter
30+
import io.getstream.chat.android.client.parser2.direct.MessageReminderInfoAdapter
31+
import io.getstream.chat.android.client.parser2.direct.ModerationAdapter
32+
import io.getstream.chat.android.client.parser2.direct.NewMessageEventAdapter
33+
import io.getstream.chat.android.client.parser2.direct.OptionAdapter
34+
import io.getstream.chat.android.client.parser2.direct.PollAdapter
35+
import io.getstream.chat.android.client.parser2.direct.PrivacySettingsAdapter
36+
import io.getstream.chat.android.client.parser2.direct.ReactionAdapter
37+
import io.getstream.chat.android.client.parser2.direct.ReactionGroupAdapter
38+
import io.getstream.chat.android.client.parser2.direct.UserAdapter
39+
import io.getstream.chat.android.models.EventType
40+
import io.getstream.chat.android.models.MessageTransformer
41+
import io.getstream.chat.android.models.UserId
42+
import io.getstream.chat.android.models.UserTransformer
43+
import io.getstream.log.taggedLogger
44+
import okio.Buffer
45+
import java.util.Date
46+
47+
/**
48+
* Routes incoming JSON events to hand-written [JsonAdapter]s that parse directly into domain models,
49+
* bypassing the DTO intermediate layer. Returns `null` for unsupported event types so the caller
50+
* can fall back to the existing DTO path.
51+
*/
52+
internal class DirectEventParser(
53+
private val currentUserIdProvider: () -> UserId?,
54+
private val messageTransformer: MessageTransformer,
55+
private val userTransformer: UserTransformer,
56+
) {
57+
58+
// region Leaf adapters
59+
60+
private val moshi by lazy { Moshi.Builder().add(Date::class.java, DateAdapter()).build() }
61+
private val dateAdapter by lazy { moshi.adapter(Date::class.java) }
62+
private val deviceAdapter by lazy { DeviceAdapter() }
63+
private val privacySettingsAdapter by lazy { PrivacySettingsAdapter() }
64+
private val attachmentAdapter by lazy { AttachmentAdapter() }
65+
private val channelInfoAdapter by lazy { ChannelInfoAdapter() }
66+
private val moderationDetailsAdapter by lazy { MessageModerationDetailsAdapter() }
67+
private val moderationAdapter by lazy { ModerationAdapter() }
68+
private val optionAdapter by lazy { OptionAdapter() }
69+
private val locationAdapter by lazy { LocationAdapter(dateAdapter) }
70+
private val reactionGroupAdapter by lazy { ReactionGroupAdapter(dateAdapter) }
71+
72+
// endregion
73+
74+
// region Composed adapters
75+
76+
private val userAdapter by lazy {
77+
UserAdapter(deviceAdapter, privacySettingsAdapter, dateAdapter, userTransformer)
78+
}
79+
private val reactionAdapter by lazy { ReactionAdapter(userAdapter, dateAdapter) }
80+
private val pollAdapter by lazy {
81+
PollAdapter(userAdapter, optionAdapter, dateAdapter, currentUserIdProvider)
82+
}
83+
private val reminderAdapter by lazy { MessageReminderInfoAdapter(dateAdapter) }
84+
private val messageAdapter by lazy {
85+
MessageAdapter(
86+
attachmentAdapter, channelInfoAdapter, reactionAdapter,
87+
reactionGroupAdapter, userAdapter, moderationDetailsAdapter, moderationAdapter,
88+
pollAdapter, reminderAdapter, locationAdapter, dateAdapter, messageTransformer,
89+
)
90+
}
91+
92+
// endregion
93+
94+
// region Event adapters
95+
96+
private val newMessageEventAdapter by lazy {
97+
NewMessageEventAdapter(messageAdapter, userAdapter)
98+
}
99+
100+
// endregion
101+
102+
/** Registry mapping event type strings to their direct adapters. */
103+
private val adapterMap: Map<String, JsonAdapter<out ChatEvent>> by lazy {
104+
mapOf(EventType.MESSAGE_NEW to newMessageEventAdapter)
105+
}
106+
107+
/**
108+
* Attempts to parse [raw] JSON into a [ChatEvent] using a direct adapter.
109+
* Returns `null` if the event type is not supported by any direct adapter.
110+
*/
111+
fun parse(raw: String): ChatEvent? {
112+
val type = extractType(raw) ?: return null
113+
val adapter = adapterMap[type] ?: return null
114+
return adapter.fromJson(raw)
115+
}
116+
117+
companion object {
118+
119+
private val logger by taggedLogger("DirectEventParser")
120+
121+
/**
122+
* Extracts the `"type"` field value from the top level of a JSON object
123+
* using a streaming [JsonReader]. Stops as soon as the field is found.
124+
*/
125+
@Suppress("NestedBlockDepth")
126+
internal fun extractType(raw: String): String? {
127+
if (raw.isBlank()) return null
128+
val reader = JsonReader.of(Buffer().writeUtf8(raw))
129+
return try {
130+
reader.use {
131+
if (it.peek() != JsonReader.Token.BEGIN_OBJECT) return null
132+
it.beginObject()
133+
while (it.hasNext()) {
134+
if (it.nextName() == "type") {
135+
return if (it.peek() == JsonReader.Token.NULL) {
136+
it.nextNull<String>()
137+
} else {
138+
it.nextString()
139+
}
140+
} else {
141+
it.skipValue()
142+
}
143+
}
144+
null
145+
}
146+
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
147+
logger.v { "extractType failed; falling back to DTO path: ${e.message}" }
148+
null
149+
}
150+
}
151+
}
152+
}

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import retrofit2.converter.moshi.MoshiConverterFactory
5959
internal class MoshiChatParser(
6060
private val eventMapping: EventMapping,
6161
private val dtoMapping: DtoMapping,
62+
private val directEventParser: DirectEventParser?,
6263
) : ChatParser {
6364

6465
private val moshi: Moshi by lazy {
@@ -141,8 +142,12 @@ internal class MoshiChatParser(
141142

142143
private val chatEventDtoAdapter = moshi.adapter(ChatEventDto::class.java)
143144

144-
private fun parseAndProcessEvent(raw: String): ChatEvent = with(eventMapping) {
145-
val event = chatEventDtoAdapter.fromJson(raw)!!.toDomain()
146-
return event.enrichIfNeeded()
145+
private fun parseAndProcessEvent(raw: String): ChatEvent {
146+
val directEvent = directEventParser?.parse(raw)
147+
if (directEvent != null) {
148+
// Direct adapters handle enrichment inline — no enrichIfNeeded() needed.
149+
return directEvent
150+
}
151+
return with(eventMapping) { chatEventDtoAdapter.fromJson(raw)!!.toDomain() }.enrichIfNeeded()
147152
}
148153
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.client.parser2.direct
18+
19+
import com.squareup.moshi.JsonAdapter
20+
import com.squareup.moshi.JsonReader
21+
import com.squareup.moshi.JsonWriter
22+
import io.getstream.chat.android.models.Attachment
23+
24+
internal class AttachmentAdapter : JsonAdapter<Attachment>() {
25+
26+
@Suppress("LongMethod")
27+
override fun fromJson(reader: JsonReader): Attachment? {
28+
if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull()
29+
30+
reader.beginObject()
31+
var assetUrl: String? = null
32+
var authorName: String? = null
33+
var authorLink: String? = null
34+
var fallback: String? = null
35+
var fileSize: Int = 0
36+
var image: String? = null
37+
var imageUrl: String? = null
38+
var mimeType: String? = null
39+
var name: String? = null
40+
var ogScrapeUrl: String? = null
41+
var text: String? = null
42+
var thumbUrl: String? = null
43+
var title: String? = null
44+
var titleLink: String? = null
45+
var type: String? = null
46+
var originalHeight: Int? = null
47+
var originalWidth: Int? = null
48+
var extraData: MutableMap<String, Any>? = null
49+
50+
while (reader.hasNext()) {
51+
val key = reader.nextName()
52+
when (key) {
53+
"asset_url" -> assetUrl = JsonParsingUtils.readNullableString(reader)
54+
"author_name" -> authorName = JsonParsingUtils.readNullableString(reader)
55+
"author_link" -> authorLink = JsonParsingUtils.readNullableString(reader)
56+
"fallback" -> fallback = JsonParsingUtils.readNullableString(reader)
57+
"file_size" -> fileSize = JsonParsingUtils.readNullableInt(reader) ?: 0
58+
"image" -> image = JsonParsingUtils.readNullableString(reader)
59+
"image_url" -> imageUrl = JsonParsingUtils.readNullableString(reader)
60+
"mime_type" -> mimeType = JsonParsingUtils.readNullableString(reader)
61+
"name" -> name = JsonParsingUtils.readNullableString(reader)
62+
"og_scrape_url" -> ogScrapeUrl = JsonParsingUtils.readNullableString(reader)
63+
"text" -> text = JsonParsingUtils.readNullableString(reader)
64+
"thumb_url" -> thumbUrl = JsonParsingUtils.readNullableString(reader)
65+
"title" -> title = JsonParsingUtils.readNullableString(reader)
66+
"title_link" -> titleLink = JsonParsingUtils.readNullableString(reader)
67+
"type" -> type = JsonParsingUtils.readNullableString(reader)
68+
"original_height" -> originalHeight = JsonParsingUtils.readNullableInt(reader)
69+
"original_width" -> originalWidth = JsonParsingUtils.readNullableInt(reader)
70+
else -> extraData = JsonParsingUtils.accumulateExtraData(key, reader, extraData)
71+
}
72+
}
73+
reader.endObject()
74+
75+
return Attachment(
76+
assetUrl = assetUrl,
77+
authorName = authorName,
78+
authorLink = authorLink,
79+
fallback = fallback,
80+
fileSize = fileSize,
81+
image = image,
82+
imageUrl = imageUrl,
83+
mimeType = mimeType,
84+
name = name,
85+
ogUrl = ogScrapeUrl,
86+
text = text,
87+
thumbUrl = thumbUrl,
88+
title = title,
89+
titleLink = titleLink,
90+
type = type,
91+
originalHeight = originalHeight,
92+
originalWidth = originalWidth,
93+
extraData = extraData?.toMutableMap() ?: mutableMapOf(),
94+
)
95+
}
96+
97+
override fun toJson(p0: JsonWriter, p1: Attachment?) {
98+
error("Serialization not supported for direct-to-domain path")
99+
}
100+
}

0 commit comments

Comments
 (0)