Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
de7779c
Improve `Message.createdLocallyAt` creation logic using estimated ser…
VelikovPetar Mar 23, 2026
3945757
[skip ci] Update SDK sizes
github-actions[bot] Mar 23, 2026
13e5f79
Update README cover image (#6282)
andremion Mar 23, 2026
6c61cbd
Fix XML image flicker caused by `interceptorCoroutineContext(Dispatch…
VelikovPetar Mar 23, 2026
aa4d913
[skip ci] Update SDK sizes
github-actions[bot] Mar 23, 2026
cdccfd9
AUTOMATION: Version Bump
github-actions[bot] Mar 23, 2026
f9084ff
Fix race condition in plugin resolution during disconnect (#6269)
andremion Mar 24, 2026
4924fd1
Handle unresolvable attachments in picker (#6285)
andremion Mar 24, 2026
6c63890
[skip ci] Update SDK sizes
github-actions[bot] Mar 24, 2026
a7b8580
Fix wrong message selected on quoted message long click (#6292)
gpunto Mar 25, 2026
45c9f28
Use type-specific attachment URL fields and deprecate `imagePreviewUr…
VelikovPetar Mar 26, 2026
0f268bd
Expose optional completion callback for audio recording (#6290)
VelikovPetar Mar 26, 2026
2bd5fe8
AUTOMATION: Version Bump
github-actions[bot] Mar 26, 2026
d33c0ef
AUTOMATION: Clean Detekt Baseline Files (#6299)
stream-pr-merger[bot] Mar 27, 2026
331237f
Add support for intercepting CDN file requests (#6295)
VelikovPetar Mar 30, 2026
cb66033
[skip ci] Update SDK sizes
github-actions[bot] Mar 30, 2026
3d8989f
Merge branch 'develop' into develop_to_v7_20260330
VelikovPetar Mar 30, 2026
117e375
Post-merge clean-up.
VelikovPetar Mar 30, 2026
9db7522
Post-merge clean-up.
VelikovPetar Mar 30, 2026
01cf1ed
Merge branch 'v7' into develop_to_v7_20260330
VelikovPetar Mar 30, 2026
389faba
Merge branch 'v7' into develop_to_v7_20260330
VelikovPetar Mar 30, 2026
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<p align="center">
<a href="https://getstream.io/tutorials/android-chat/">
<img src="/docs/sdk-hero-android.png"/>
<img src="/docs/stream-chat-android-github-cover.png"/>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add alt text to the hero image for accessibility.

The image lacks an alt attribute, which impacts accessibility for screen reader users. Static analysis (markdownlint MD045) flagged this.

Proposed fix
-    <img src="/docs/stream-chat-android-github-cover.png"/>
+    <img src="/docs/stream-chat-android-github-cover.png" alt="Stream Chat Android SDK"/>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<img src="/docs/stream-chat-android-github-cover.png"/>
<img src="/docs/stream-chat-android-github-cover.png" alt="Stream Chat Android SDK"/>
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 5-5: Images should have alternate text (alt text)

(MD045, no-alt-text)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 5, The hero image tag <img
src="/docs/stream-chat-android-github-cover.png"/> is missing an alt attribute;
update that element in README.md to include a concise descriptive alt text (for
example alt="Stream Chat Android GitHub cover") so screen readers can interpret
the image and satisfy markdownlint MD045.

</a>
</p>

Expand Down
Binary file removed docs/sdk-hero-android.png
Binary file not shown.
Binary file added docs/stream-chat-android-github-cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions stream-chat-android-client/api/stream-chat-android-client.api
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ public final class io/getstream/chat/android/client/ChatClient$Builder : io/gets
public final fun appVersion (Ljava/lang/String;)Lio/getstream/chat/android/client/ChatClient$Builder;
public final fun baseUrl (Ljava/lang/String;)Lio/getstream/chat/android/client/ChatClient$Builder;
public fun build ()Lio/getstream/chat/android/client/ChatClient;
public final fun cdn (Lio/getstream/chat/android/client/cdn/CDN;)Lio/getstream/chat/android/client/ChatClient$Builder;
public final fun cdnUrl (Ljava/lang/String;)Lio/getstream/chat/android/client/ChatClient$Builder;
public final fun clientDebugger (Lio/getstream/chat/android/client/debugger/ChatClientDebugger;)Lio/getstream/chat/android/client/ChatClient$Builder;
public final fun config (Lio/getstream/chat/android/client/api/ChatClientConfig;)Lio/getstream/chat/android/client/ChatClient$Builder;
Expand Down Expand Up @@ -909,6 +910,27 @@ public final class io/getstream/chat/android/client/audio/WaveformExtractorKt {
public static final fun isEof (Landroid/media/MediaCodec$BufferInfo;)Z
}

public abstract interface class io/getstream/chat/android/client/cdn/CDN {
public fun fileRequest (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun fileRequest$suspendImpl (Lio/getstream/chat/android/client/cdn/CDN;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun imageRequest (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun imageRequest$suspendImpl (Lio/getstream/chat/android/client/cdn/CDN;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}

public final class io/getstream/chat/android/client/cdn/CDNRequest {
public fun <init> (Ljava/lang/String;Ljava/util/Map;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/util/Map;
public final fun copy (Ljava/lang/String;Ljava/util/Map;)Lio/getstream/chat/android/client/cdn/CDNRequest;
public static synthetic fun copy$default (Lio/getstream/chat/android/client/cdn/CDNRequest;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/cdn/CDNRequest;
public fun equals (Ljava/lang/Object;)Z
public final fun getHeaders ()Ljava/util/Map;
public final fun getUrl ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class io/getstream/chat/android/client/channel/ChannelClient {
public final fun acceptInvite (Ljava/lang/String;)Lio/getstream/result/call/Call;
public final fun addMembers (Lio/getstream/chat/android/client/query/AddMembersParams;)Lio/getstream/result/call/Call;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ import io.getstream.chat.android.client.attachment.prepareForUpload
import io.getstream.chat.android.client.audio.AudioPlayer
import io.getstream.chat.android.client.audio.NativeMediaPlayerImpl
import io.getstream.chat.android.client.audio.StreamAudioPlayer
import io.getstream.chat.android.client.cdn.CDN
import io.getstream.chat.android.client.cdn.internal.StreamMediaDataSource
import io.getstream.chat.android.client.channel.ChannelClient
import io.getstream.chat.android.client.channel.state.ChannelStateLogicProvider
import io.getstream.chat.android.client.clientstate.DisconnectCause
Expand Down Expand Up @@ -104,6 +106,7 @@ import io.getstream.chat.android.client.extensions.ATTACHMENT_TYPE_FILE
import io.getstream.chat.android.client.extensions.ATTACHMENT_TYPE_IMAGE
import io.getstream.chat.android.client.extensions.cidToTypeAndId
import io.getstream.chat.android.client.extensions.extractBaseUrl
import io.getstream.chat.android.client.extensions.getCreatedAtOrNull
import io.getstream.chat.android.client.extensions.internal.hasPendingAttachments
import io.getstream.chat.android.client.extensions.internal.isLaterThanDays
import io.getstream.chat.android.client.header.VersionPrefixHeader
Expand Down Expand Up @@ -162,6 +165,7 @@ import io.getstream.chat.android.client.user.storage.SharedPreferencesCredential
import io.getstream.chat.android.client.user.storage.UserCredentialStorage
import io.getstream.chat.android.client.utils.ProgressCallback
import io.getstream.chat.android.client.utils.TokenUtils
import io.getstream.chat.android.client.utils.internal.ServerClockOffset
import io.getstream.chat.android.client.utils.mergePartially
import io.getstream.chat.android.client.utils.message.ensureId
import io.getstream.chat.android.client.utils.observable.ChatEventsObservable
Expand Down Expand Up @@ -289,9 +293,13 @@ internal constructor(
@InternalStreamChatApi
public val audioPlayer: AudioPlayer,
private val now: () -> Date = ::Date,
@InternalStreamChatApi
public val serverClockOffset: ServerClockOffset,
private val repository: ChatClientRepository,
private val messageReceiptReporter: MessageReceiptReporter,
internal val messageReceiptManager: MessageReceiptManager,
@InternalStreamChatApi
public val cdn: CDN? = null,
) {
private val logger by taggedLogger(TAG)
private val fileManager = StreamFileManager()
Expand Down Expand Up @@ -353,6 +361,7 @@ internal constructor(
*
* @see [Plugin]
*/
@Volatile
@InternalStreamChatApi
public var plugins: List<Plugin> = emptyList()

Expand Down Expand Up @@ -399,12 +408,16 @@ internal constructor(
@Suppress("ThrowsCount")
internal inline fun <reified P : DependencyResolver, reified T : Any> resolvePluginDependency(): T {
StreamLog.v(TAG) { "[resolvePluginDependency] P: ${P::class.simpleName}, T: ${T::class.simpleName}" }
// Snapshot plugins BEFORE checking initializationState to avoid a race with disconnect().
// disconnect() sets initializationState to NOT_INITIALIZED before clearing plugins,
// so if we snapshot plugins first and then see COMPLETE, the snapshot is guaranteed valid.
val currentPlugins = plugins
val initState = awaitInitializationState(RESOLVE_DEPENDENCY_TIMEOUT)
if (initState != InitializationState.COMPLETE) {
StreamLog.e(TAG) { "[resolvePluginDependency] failed (initializationState is not COMPLETE): $initState " }
throw IllegalStateException("ChatClient::connectUser() must be called before resolving any dependency")
}
val resolver = plugins.find { plugin ->
val resolver = currentPlugins.find { plugin ->
Comment on lines +411 to +420
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The early plugins snapshot breaks resolvePluginDependency() during initialization.

If Line 414 runs while the client is still INITIALIZING, it can capture the pre-init or empty plugins list. awaitInitializationState() may then return COMPLETE, but Lines 420-424 still search that stale snapshot and throw "Plugin ... was not found" even though initializeClientWithUser() has already installed the new plugins. Please re-read plugins after the wait, or guard the state/plugin pair with the same synchronization so both connect and disconnect races are covered.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt`
around lines 411 - 420, The early snapshot of plugins in resolvePluginDependency
breaks when initialization completes after the snapshot; instead of using the
pre-wait currentPlugins, re-read the plugins list after
awaitInitializationState(RESOLVE_DEPENDENCY_TIMEOUT) (or hold the same
synchronization used by connect/disconnect) so that the resolver lookup uses the
up-to-date plugins; specifically update the code around
currentPlugins/awaitInitializationState/InitializationState.COMPLETE and the
subsequent resolver lookup to fetch plugins again (or perform the lookup under
the same lock) to avoid stale-plugin "was not found" errors.

plugin is P
} ?: throw IllegalStateException(
"Plugin '${P::class.qualifiedName}' was not found. Did you init it within ChatClient?",
Expand Down Expand Up @@ -1569,9 +1582,9 @@ internal constructor(

notifications.onLogout()
// Set initializationState to NOT_INITIALIZED BEFORE clearing plugins to prevent race condition.
// This ensures the StatePlugin extension methods don't access the plugin during disconnect.
// resolvePluginDependency() snapshots plugins before checking state, so if it sees COMPLETE
// here, the snapshot is guaranteed to still contain the plugins.
mutableClientState.setInitializationState(InitializationState.NOT_INITIALIZED)

plugins.forEach { it.onUserDisconnected() }
plugins = emptyList()
userStateService.onLogout()
Expand Down Expand Up @@ -2534,16 +2547,34 @@ internal constructor(

/**
* Ensure the message has a [Message.createdLocallyAt] timestamp.
* If not, set it to the max of the channel's [Channel.lastMessageAt] + 1 millisecond and [now].
* This ensures that the message appears in the correct order in the channel.
* If not, set it to the max of the channel's [Channel.lastMessageAt] + 1 millisecond and the
* estimated server time. Using estimated server time (instead of raw local clock) prevents
* cross-user ordering issues when the device clock is skewed.
*/
private suspend fun Message.ensureCreatedLocallyAt(cid: String): Message {
val lastMessageAt = repositoryFacade.selectChannel(cid = cid)?.lastMessageAt
val lastMessageAtPlusOneMillisecond = lastMessageAt?.let {
Date(it.time + 1)
val parentId = this.parentId
if (parentId != null) {
// Thread reply
val lastMessage = repositoryFacade.selectMessagesForThread(parentId, limit = 1).lastOrNull()
val lastMessageAt = lastMessage?.getCreatedAtOrNull()
val lastMessageAtPlusOneMillisecond = lastMessageAt?.let {
Date(it.time + 1)
}
val createdLocallyAt = max(lastMessageAtPlusOneMillisecond, serverClockOffset.estimatedServerTime())
return copy(createdLocallyAt = this.createdLocallyAt ?: createdLocallyAt)
} else {
// Regular message
val (type, id) = cid.cidToTypeAndId()
// Fetch channel lastMessageAt from state, fallback to offline storage
val channelState = logicRegistry?.channelStateLogic(type, id)?.channelState()
val lastMessageAt = channelState?.channelData?.value?.lastMessageAt
?: repositoryFacade.selectChannel(cid = cid)?.lastMessageAt
val lastMessageAtPlusOneMillisecond = lastMessageAt?.let {
Date(it.time + 1)
}
val createdLocallyAt = max(lastMessageAtPlusOneMillisecond, serverClockOffset.estimatedServerTime())
return copy(createdLocallyAt = this.createdLocallyAt ?: createdLocallyAt)
}
val createdLocallyAt = max(lastMessageAtPlusOneMillisecond, now())
return copy(createdLocallyAt = this.createdLocallyAt ?: createdLocallyAt)
}

/**
Expand Down Expand Up @@ -4608,6 +4639,7 @@ internal constructor(
private var uploadAttachmentsNetworkType = UploadAttachmentsNetworkType.CONNECTED
private var fileTransformer: FileTransformer = NoOpFileTransformer
private var apiModelTransformers: ApiModelTransformers = ApiModelTransformers()
private var cdn: CDN? = null
private var appName: String? = null
private var appVersion: String? = null

Expand Down Expand Up @@ -4736,7 +4768,11 @@ internal constructor(
*
* @param shareFileDownloadRequestInterceptor Your [Interceptor] implementation for the share file download
* call.
* @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via
* [io.getstream.chat.android.client.ChatClient.Builder.cdn] to provide headers and transform URLs
* for all image, file, and download requests.
*/
@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().")
public fun shareFileDownloadRequestInterceptor(shareFileDownloadRequestInterceptor: Interceptor): Builder {
this.shareFileDownloadRequestInterceptor = shareFileDownloadRequestInterceptor
return this
Expand Down Expand Up @@ -4807,6 +4843,15 @@ internal constructor(
forceWsUrl = value
}

/**
* Sets a custom [CDN] implementation to be used by the client.
*
* @param cdn The custom CDN implementation.
*/
public fun cdn(cdn: CDN): Builder = apply {
this.cdn = cdn
}

/**
* Sets the CDN URL to be used by the client.
*/
Expand Down Expand Up @@ -4933,6 +4978,8 @@ internal constructor(
warmUpReflection()
}

val serverClockOffset = ServerClockOffset()

val module =
ChatModule(
appContext = appContext,
Expand All @@ -4945,19 +4992,22 @@ internal constructor(
fileUploader = fileUploader,
sendMessageInterceptor = sendMessageInterceptor,
shareFileDownloadRequestInterceptor = shareFileDownloadRequestInterceptor,
cdn = cdn,
tokenManager = tokenManager,
customOkHttpClient = customOkHttpClient,
clientDebugger = clientDebugger,
lifecycle = lifecycle,
appName = this.appName,
appVersion = this.appVersion,
serverClockOffset = serverClockOffset,
)

val api = module.api()
val appSettingsManager = AppSettingManager(api)

val mediaDataSourceFactory = StreamMediaDataSource.factory(appContext, cdn)
val audioPlayer: AudioPlayer = StreamAudioPlayer(
mediaPlayer = NativeMediaPlayerImpl(appContext) {
mediaPlayer = NativeMediaPlayerImpl(mediaDataSourceFactory) {
ExoPlayer.Builder(appContext)
.setAudioAttributes(
AudioAttributes.Builder()
Expand Down Expand Up @@ -4991,6 +5041,7 @@ internal constructor(
retryPolicy = retryPolicy,
appSettingsManager = appSettingsManager,
chatSocket = module.chatSocket,
serverClockOffset = serverClockOffset,
pluginFactories = allPluginFactories,
repositoryFactoryProvider = allPluginFactories
.filterIsInstance<RepositoryFactory.Provider>()
Expand All @@ -5011,6 +5062,7 @@ internal constructor(
messageReceiptRepository = repository,
api = api,
),
cdn = cdn,
).apply {
attachmentsSender = AttachmentsSender(
context = appContext,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import io.getstream.chat.android.client.internal.state.extensions.internal.parse
import io.getstream.chat.android.client.internal.state.extensions.internal.requestsAsState
import io.getstream.chat.android.client.internal.state.plugin.factory.StreamStatePluginFactory
import io.getstream.chat.android.client.internal.state.plugin.internal.StatePlugin
import io.getstream.chat.android.client.utils.attachment.isImage
import io.getstream.chat.android.client.utils.internal.validateCidWithResult
import io.getstream.chat.android.client.utils.message.isEphemeral
import io.getstream.chat.android.core.internal.InternalStreamChatApi
Expand Down Expand Up @@ -302,6 +303,12 @@ public fun ChatClient.setMessageForReply(cid: String, message: Message?): Call<U
/**
* Downloads the selected attachment to the "Download" folder in the public external storage directory.
*
* If a [CDN][io.getstream.chat.android.client.cdn.CDN] is configured on this [ChatClient], the download URL
* and headers are transformed via [CDN.imageRequest][io.getstream.chat.android.client.cdn.CDN.imageRequest] (for
* images) or [CDN.fileRequest][io.getstream.chat.android.client.cdn.CDN.fileRequest] (for other files) before
* the download is enqueued. CDN transformations are applied after [generateDownloadUri] and before
* [interceptRequest], so custom interceptors can override CDN headers.
*
* @param context The context used to access the [DownloadManager].
* @param attachment The attachment to download.
* @param generateDownloadUri The function that generates the download URI for the attachment.
Expand All @@ -326,13 +333,29 @@ public fun ChatClient.downloadAttachment(
val subPath = attachment.name ?: attachment.title ?: attachment.parseAttachmentNameFromUrl()
?: createAttachmentFallbackName()

logger.d { "Downloading attachment. Name: $subPath, Uri: $uri" }
// Apply CDN transformation if available
val cdnRequest = try {
val cdn = this@downloadAttachment.cdn
val url = uri.toString()
if (attachment.isImage()) cdn?.imageRequest(url) else cdn?.fileRequest(url)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
logger.e(e) { "CDN request failed for attachment. Falling back to original URL." }
null
}
val finalUri = cdnRequest?.url?.let(Uri::parse) ?: uri

logger.d { "Downloading attachment. Name: $subPath, Uri: $finalUri" }

downloadManager.enqueue(
DownloadManager.Request(uri)
DownloadManager.Request(finalUri)
.setTitle(subPath)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, subPath)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.apply {
cdnRequest?.headers?.forEach { (key, value) ->
addRequestHeader(key, value)
}
}
.apply(interceptRequest),
)
Result.Success(Unit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,13 @@

package io.getstream.chat.android.client.audio

import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
Expand Down Expand Up @@ -198,12 +197,12 @@ public enum class NativeMediaPlayerState {
/**
* Default implementation of [NativeMediaPlayer] based on ExoPlayer.
*
* @param context The context.
* @param dataSourceFactory The data source factory used for creating media sources.
* @param builder A builder function to create an [ExoPlayer] instance.
*/
@OptIn(UnstableApi::class)
internal class NativeMediaPlayerImpl(
context: Context,
dataSourceFactory: DataSource.Factory,
private val builder: () -> ExoPlayer,
) : NativeMediaPlayer {

Expand Down Expand Up @@ -232,7 +231,7 @@ internal class NativeMediaPlayerImpl(
* For more info see [ExoPlayer Progressive](https://developer.android.com/media/media3/exoplayer/progressive).
*/
private val mediaSourceFactory: MediaSource.Factory = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
dataSourceFactory,
DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true),
)

Expand Down
Loading
Loading