Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5153,6 +5153,7 @@ internal constructor(
private var forceInsecureConnection = false
private var forceHttpUrl: String? = null
private var forceWsUrl: String? = null
private var useV2WebSocket: Boolean = false
private var baseUrl: String = "chat.stream-io-api.com"
private var cdnUrl: String? = null
private var logLevel = ChatLogLevel.NOTHING
Expand Down Expand Up @@ -5360,6 +5361,18 @@ internal constructor(
forceWsUrl = value
}

/**
* Connects the websocket through the v2 endpoint instead of v1.
*
* This for internal use only, until the v2 websocket becomes the default.
*
* @param enabled Whether to use the v2 websocket.
*/
@InternalStreamChatApi
public fun useV2WebSocket(enabled: Boolean): Builder = apply {
useV2WebSocket = enabled
}

/**
* Sets a custom [CDN] implementation to be used by the client.
*
Expand Down Expand Up @@ -5486,6 +5499,7 @@ internal constructor(
debugRequests = debugRequests,
notificationConfig = notificationConfig,
fastEventParsing = chatClientConfig.fastEventParsing,
useV2WebSocket = useV2WebSocket,
)
setupStreamLog()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import io.getstream.chat.android.client.notifications.handler.NotificationConfig
* When `true`, supported event types are parsed directly into domain models, bypassing the DTO
* intermediate layer; unsupported event types fall back to the default DTO-based parser.
* Currently supported event types: `message.new`. Disabled by default.
* @param useV2WebSocket Connects the websocket through the v2 endpoint instead of v1. Disabled by default.
*/
@Suppress("LongParameterList")
internal class ChatApiConfig @JvmOverloads constructor(
Expand All @@ -51,6 +52,7 @@ internal class ChatApiConfig @JvmOverloads constructor(
val debugRequests: Boolean,
val notificationConfig: NotificationConfig,
val fastEventParsing: Boolean = false,
val useV2WebSocket: Boolean = false,
) {
var isAnonymous: Boolean = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import io.getstream.chat.android.client.notifications.handler.NotificationHandle
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.parser2.V2EventParser
import io.getstream.chat.android.client.parser2.withErrorLogging
import io.getstream.chat.android.client.plugins.requests.ApiRequestsAnalyser
import io.getstream.chat.android.client.scope.ClientScope
Expand Down Expand Up @@ -200,7 +201,14 @@ constructor(
directEventParser = directEventParser,
)
}
private val socketFactory: SocketFactory by lazy { SocketFactory(moshiParser, tokenManager, headersUtil) }
private val socketFactory: SocketFactory by lazy {
SocketFactory(
parser = moshiParser,
tokenManager = tokenManager,
headersUtil = headersUtil,
v2EventParser = if (config.useV2WebSocket) V2EventParser(v2DomainMapping) else null,
)
}

private val defaultNotifications by lazy { buildChatNotifications(notificationsHandler, config.notificationConfig) }
private val defaultApi by lazy { buildApi(config) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ internal open class ChatSocket(
userScope = userScope,
checkCallback = {
(chatSocketStateService.currentState as? State.Connected)?.event?.let {
if (sendEvent(it)) {
if (streamWebSocket?.sendKeepAlive(it) == true) {
serverClockOffset.onHealthCheckSent()
}
}
Expand Down Expand Up @@ -275,8 +275,6 @@ internal open class ChatSocket(
*
* @see [okhttp3.WebSocket.send]
*/
internal fun sendEvent(event: ChatEvent): Boolean = streamWebSocket?.send(event) ?: false

internal fun isConnected(): Boolean = chatSocketStateService.currentState is State.Connected

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.socket
import io.getstream.chat.android.PrivacySettings
import io.getstream.chat.android.client.api2.mapping.USER_DEVICES_CUSTOM_KEY
import io.getstream.chat.android.client.parser.ChatParser
import io.getstream.chat.android.client.parser2.V2EventParser
import io.getstream.chat.android.client.token.TokenManager
import io.getstream.chat.android.client.utils.HeadersUtil
import io.getstream.chat.android.models.User
Expand All @@ -29,21 +30,86 @@ import java.io.UnsupportedEncodingException
import java.net.URLEncoder
import java.nio.charset.StandardCharsets

/**
* @param v2EventParser Connects through the v2 endpoint and parses its events with it, or null for v1.
*/
internal class SocketFactory(
private val parser: ChatParser,
private val tokenManager: TokenManager,
private val headersUtil: HeadersUtil,
private val httpClient: OkHttpClient = OkHttpClient(),
private val v2EventParser: V2EventParser? = null,
) {
private val logger by taggedLogger("Chat:SocketFactory")

@Throws(UnsupportedEncodingException::class)
fun createSocket(connectionConf: ConnectionConf): StreamWebSocket {
if (v2EventParser != null) return createV2Socket(connectionConf, v2EventParser)
val request = buildRequest(connectionConf)
logger.i { "new web socket: ${request.url}" }
return StreamWebSocket(parser) { httpClient.newWebSocket(request, it) }
}

/**
* The v2 socket authenticates with a frame sent once open, which must arrive within the server's handshake
* deadline, so the token is resolved before the upgrade. The request carries no Authorization header: the server
* prefers it over the frame's token.
*/
private fun createV2Socket(connectionConf: ConnectionConf, eventParser: V2EventParser): StreamWebSocket {
val authType = when (connectionConf) {
is ConnectionConf.AnonymousConnectionConf -> "anonymous"
is ConnectionConf.UserConnectionConf -> "jwt"
}
val request = Request.Builder()
.url(
"${connectionConf.endpoint}api/v2/connect?api_key=${connectionConf.apiKey}" +
"&stream-auth-type=$authType" +
"&$X_STREAM_CLIENT=${encodedSdkTrackingHeaders()}",
)
.addHeader(X_STREAM_CLIENT, headersUtil.buildSdkTrackingHeaders())
.build()
logger.i { "new v2 web socket: ${request.url}" }
val protocol = V2SocketProtocol(buildAuthFrame(connectionConf), eventParser)
return StreamWebSocket(parser, protocol) { httpClient.newWebSocket(request, it) }
}

private fun encodedSdkTrackingHeaders(): String =
URLEncoder.encode(headersUtil.buildSdkTrackingHeaders(), StandardCharsets.UTF_8.name())

private fun buildAuthFrame(connectionConf: ConnectionConf): String {
val token = when (connectionConf) {
// iOS sends this placeholder until anonymous connections with an empty token are deployed everywhere.
is ConnectionConf.AnonymousConnectionConf -> ANONYMOUS_TOKEN
is ConnectionConf.UserConnectionConf -> {
tokenManager.ensureTokenLoaded()
tokenManager.getToken()
}
}
val frame = mapOf(
"token" to token,
"user_details" to connectionConf.reduceV2UserDetails(),
"products" to listOf("chat"),
)
return V2SocketProtocol.frameAdapter.toJson(frame)
}

/**
* The user details the v2 connect reads, with custom data nested under `custom`; it ignores any other field.
* Reconnections send the id only, as on v1.
*/
private fun ConnectionConf.reduceV2UserDetails(): Map<String, Any> = mutableMapOf<String, Any>("id" to id)
.apply {
if (!isReconnection) {
if (user.name.isNotBlank()) put("name", user.name)
if (user.image.isNotBlank()) put("image", user.image)
if (user.language.isNotBlank()) put("language", user.language)
user.invisible?.also { put("invisible", it) }
user.privacySettings?.also { put("privacy_settings", it.reducePrivacySettings()) }
val custom = user.extraData - USER_DEVICES_CUSTOM_KEY
if (custom.isNotEmpty()) put("custom", custom)
}
}

@Throws(UnsupportedEncodingException::class)
private fun buildRequest(connectionConf: ConnectionConf): Request =
Request.Builder()
Expand All @@ -57,8 +123,7 @@ internal class SocketFactory(
var json = buildUserDetailJson(connectionConf)
return try {
json = URLEncoder.encode(json, StandardCharsets.UTF_8.name())
val sdkTrackingHeaders =
URLEncoder.encode(headersUtil.buildSdkTrackingHeaders(), StandardCharsets.UTF_8.name())
val sdkTrackingHeaders = encodedSdkTrackingHeaders()
val baseWsUrl = "${connectionConf.endpoint}connect?" +
"json=$json" +
"&api_key=${connectionConf.apiKey}" +
Expand Down Expand Up @@ -149,5 +214,6 @@ internal class SocketFactory(

companion object {
private const val X_STREAM_CLIENT = "X-Stream-Client"
private const val ANONYMOUS_TOKEN = "anon"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ private const val EVENTS_BUFFER_SIZE = 100
private const val CLOSE_SOCKET_CODE = 1000
private const val CLOSE_SOCKET_REASON = "Connection close by client"

/**
* @param v2 What the v2 socket does differently, or null for the v1 socket.
*/
internal class StreamWebSocket(
private val parser: ChatParser,
private val v2: V2SocketProtocol? = null,
socketCreator: (WebSocketListener) -> WebSocket,
) {
private val logger by taggedLogger("Chat:StreamWebSocket")
Expand All @@ -48,9 +52,17 @@ internal class StreamWebSocket(
private var connected = false

private val webSocket = socketCreator(object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
v2?.let { webSocket.send(it.authFrame) }
}

override fun onMessage(webSocket: WebSocket, text: String) {
StreamLog.v("Chat:Events") { "[handleEvent] event: `$text`" }
parseMessage(text)?.let(eventFlow::tryEmit)
val event = when (v2) {
null -> parseMessage(text)
else -> parseV2Message(text, v2)
}
event?.let(eventFlow::tryEmit)
}

override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Expand Down Expand Up @@ -79,6 +91,10 @@ internal class StreamWebSocket(
})

fun send(chatEvent: ChatEvent): Boolean = webSocket.send(parser.toJson(chatEvent))

/** Keeps the connection [connectedEvent] established alive: v1 echoes the event, v2 sends a health check. */
fun sendKeepAlive(connectedEvent: ConnectedEvent): Boolean =
v2?.let { webSocket.send(it.keepAliveFrame(connectedEvent)) } ?: send(connectedEvent)
fun close(): Boolean = webSocket.close(CLOSE_SOCKET_CODE, CLOSE_SOCKET_REASON)
fun listen(): Flow<StreamWebSocketEvent> = eventFlow.asSharedFlow()

Expand All @@ -96,6 +112,18 @@ internal class StreamWebSocket(
is Result.Failure -> parseFailure(text, result.value)
}

/** Null for a frame the v2 parser ignores, or one dropped as [parseFailure] describes. */
@Suppress("TooGenericExceptionCaught")
private fun parseV2Message(text: String, v2: V2SocketProtocol): StreamWebSocketEvent? = try {
v2.eventParser.parse(text)?.let { event ->
if (event is ConnectedEvent) connected = true
StreamWebSocketEvent.Message(event)
}
} catch (expected: Throwable) {
// Throwable, as v1's parser catches: an error escaping here would reach OkHttp's reader thread.
parseFailure(text, Error.ThrowableError("Failed to parse the v2 event", expected))
}

private fun parseFailure(text: String, parseChatError: Error): StreamWebSocketEvent? {
val errorResponse = when (val chatErrorResult = parser.fromJsonOrError(text, SocketErrorMessage::class.java)) {
is Result.Success -> chatErrorResult.value.error
Expand Down
Original file line number Diff line number Diff line change
@@ -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.socket

import io.getstream.chat.android.client.events.ConnectedEvent
import io.getstream.chat.android.client.parser2.V2EventParser
import io.getstream.chat.android.models.EventType
import io.getstream.chat.android.network.infrastructure.Serializer

/**
* What the v2 socket does differently from v1: it authenticates with [authFrame] once open, parses events with
* [eventParser], and keeps the connection alive with a minimal health check, which the server answers whatever the
* frame carries.
*/
internal class V2SocketProtocol(
val authFrame: String,
val eventParser: V2EventParser,
) {
/** The keepalive frame for the connection [connectedEvent] established. */
fun keepAliveFrame(connectedEvent: ConnectedEvent): String = frameAdapter.toJson(
listOf(mapOf("type" to EventType.HEALTH_CHECK, "client_id" to connectedEvent.connectionId)),
)

internal companion object {
internal val frameAdapter = Serializer.moshi.adapter(Any::class.java)
}
}
Loading
Loading