diff --git a/README.md b/README.md
index 5a6b8b4ae1b..5b3f340f3f4 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
+
diff --git a/docs/sdk-hero-android.png b/docs/sdk-hero-android.png
deleted file mode 100644
index 272b0dd3b63..00000000000
Binary files a/docs/sdk-hero-android.png and /dev/null differ
diff --git a/docs/stream-chat-android-github-cover.png b/docs/stream-chat-android-github-cover.png
new file mode 100644
index 00000000000..17bea0fd30a
Binary files /dev/null and b/docs/stream-chat-android-github-cover.png differ
diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api
index 7ffa2d91993..e79da900a58 100644
--- a/stream-chat-android-client/api/stream-chat-android-client.api
+++ b/stream-chat-android-client/api/stream-chat-android-client.api
@@ -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;
@@ -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 (Ljava/lang/String;Ljava/util/Map;)V
+ public synthetic fun (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;
diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt
index c26c7043dc2..34b818a604d 100644
--- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt
+++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt
@@ -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
@@ -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
@@ -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
@@ -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()
@@ -353,6 +361,7 @@ internal constructor(
*
* @see [Plugin]
*/
+ @Volatile
@InternalStreamChatApi
public var plugins: List = emptyList()
@@ -399,12 +408,16 @@ internal constructor(
@Suppress("ThrowsCount")
internal inline fun 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 ->
plugin is P
} ?: throw IllegalStateException(
"Plugin '${P::class.qualifiedName}' was not found. Did you init it within ChatClient?",
@@ -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()
@@ -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)
}
/**
@@ -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
@@ -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
@@ -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.
*/
@@ -4933,6 +4978,8 @@ internal constructor(
warmUpReflection()
}
+ val serverClockOffset = ServerClockOffset()
+
val module =
ChatModule(
appContext = appContext,
@@ -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()
@@ -4991,6 +5041,7 @@ internal constructor(
retryPolicy = retryPolicy,
appSettingsManager = appSettingsManager,
chatSocket = module.chatSocket,
+ serverClockOffset = serverClockOffset,
pluginFactories = allPluginFactories,
repositoryFactoryProvider = allPluginFactories
.filterIsInstance()
@@ -5011,6 +5062,7 @@ internal constructor(
messageReceiptRepository = repository,
api = api,
),
+ cdn = cdn,
).apply {
attachmentsSender = AttachmentsSender(
context = appContext,
diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/ChatClientStateExtensions.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/ChatClientStateExtensions.kt
index da75ee7648f..60af4f9859c 100644
--- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/ChatClientStateExtensions.kt
+++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/ChatClientStateExtensions.kt
@@ -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
@@ -302,6 +303,12 @@ public fun ChatClient.setMessageForReply(cid: String, message: Message?): Call
+ addRequestHeader(key, value)
+ }
+ }
.apply(interceptRequest),
)
Result.Success(Unit)
diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt
index 4fa17c6b418..fe0e28184b6 100644
--- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt
+++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt
@@ -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
@@ -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 {
@@ -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),
)
diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDN.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDN.kt
new file mode 100644
index 00000000000..fec6c9b9b6c
--- /dev/null
+++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDN.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.cdn
+
+/**
+ * Class defining a CDN (Content Delivery Network) interface.
+ * Override to transform requests loading images/files from the custom CDN.
+ */
+public interface CDN {
+
+ /**
+ * Transforms a request for loading an image from the CDN.
+ *
+ * Implementations that perform blocking or network I/O must use `withContext` to switch to the
+ * appropriate dispatcher (e.g. `Dispatchers.IO`).
+ *
+ * @param url Original CDN url for the image.
+ * @return A [CDNRequest] holding the modified request URL and/or custom headers to include with the request.
+ */
+ public suspend fun imageRequest(url: String): CDNRequest = CDNRequest(url)
+
+ /**
+ * Transforms a request for loading a non-image file from the CDN.
+ *
+ * Implementations that perform blocking or network I/O must use `withContext` to switch to the
+ * appropriate dispatcher (e.g. `Dispatchers.IO`).
+ *
+ * @param url Original CDN url for the file.
+ * @return A [CDNRequest] holding the modified request URL and/or custom headers to include with the request.
+ */
+ public suspend fun fileRequest(url: String): CDNRequest = CDNRequest(url)
+}
diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDNRequest.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDNRequest.kt
new file mode 100644
index 00000000000..598bdb10e5f
--- /dev/null
+++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDNRequest.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.cdn
+
+/**
+ * Model representing the request for loading a file from a CDN.
+ *
+ * @param url Url of the file to load.
+ * @param headers Map of headers added to the request.
+ */
+public data class CDNRequest(
+ val url: String,
+ val headers: Map? = null,
+)
diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceFactory.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceFactory.kt
new file mode 100644
index 00000000000..69f609ef5f0
--- /dev/null
+++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceFactory.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.client.cdn.internal
+
+import android.net.Uri
+import androidx.core.net.toUri
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.DataSpec
+import androidx.media3.datasource.DefaultHttpDataSource
+import androidx.media3.datasource.TransferListener
+import io.getstream.chat.android.client.cdn.CDN
+import io.getstream.chat.android.client.cdn.CDNRequest
+import io.getstream.log.taggedLogger
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+
+/**
+ * A [DataSource.Factory] that creates [CDNDataSource] instances which transform
+ * media requests through the [CDN.fileRequest] method before delegating to an upstream data source.
+ *
+ * @param cdn The CDN used to transform file request URLs and headers.
+ * @param upstreamFactory The factory for creating the upstream data source that performs the actual HTTP requests.
+ */
+@UnstableApi
+internal class CDNDataSourceFactory(
+ private val cdn: CDN,
+ private val upstreamFactory: DataSource.Factory = DefaultHttpDataSource.Factory(),
+) : DataSource.Factory {
+ override fun createDataSource(): DataSource {
+ return CDNDataSource(cdn, upstreamFactory.createDataSource())
+ }
+}
+
+/**
+ * A [DataSource] that transforms media requests through [CDN.fileRequest] before
+ * delegating to an upstream data source. This allows custom CDN implementations
+ * to rewrite URLs and inject headers for video/audio/voice recording playback via ExoPlayer.
+ *
+ * [CDN.fileRequest] is a suspend function and is called via [runBlocking] on [Dispatchers.IO].
+ * This is safe because ExoPlayer always calls [open] from its loader thread, never the main thread.
+ */
+@UnstableApi
+private class CDNDataSource(
+ private val cdn: CDN,
+ private val upstream: DataSource,
+) : DataSource {
+
+ private val logger by taggedLogger("Chat:CDNDataSource")
+
+ override fun open(dataSpec: DataSpec): Long {
+ val scheme = dataSpec.uri.scheme
+ if (scheme != "http" && scheme != "https") {
+ return upstream.open(dataSpec)
+ }
+ val url = dataSpec.uri.toString()
+ val cdnRequest = try {
+ runBlocking {
+ cdn.fileRequest(url)
+ }
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ logger.e(e) { "[open] CDN.fileRequest() failed for url: $url. Falling back to original request." }
+ CDNRequest(url)
+ }
+ val mergedHeaders = buildMap {
+ putAll(dataSpec.httpRequestHeaders)
+ cdnRequest.headers?.let { putAll(it) }
+ }
+ val transformedSpec = dataSpec.buildUpon()
+ .setUri(cdnRequest.url.toUri())
+ .setHttpRequestHeaders(mergedHeaders)
+ .build()
+ return upstream.open(transformedSpec)
+ }
+
+ override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
+ upstream.read(buffer, offset, length)
+
+ override fun close() {
+ upstream.close()
+ }
+
+ override fun getUri(): Uri? = upstream.uri
+
+ override fun getResponseHeaders(): Map> = upstream.responseHeaders
+
+ override fun addTransferListener(transferListener: TransferListener) {
+ upstream.addTransferListener(transferListener)
+ }
+}
diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptor.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptor.kt
new file mode 100644
index 00000000000..e8dfa6239af
--- /dev/null
+++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptor.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.cdn.internal
+
+import io.getstream.chat.android.client.cdn.CDN
+import io.getstream.chat.android.client.cdn.CDNRequest
+import io.getstream.log.taggedLogger
+import kotlinx.coroutines.runBlocking
+import okhttp3.Interceptor
+import okhttp3.Response
+
+/**
+ * OkHttp interceptor applying transformations to CDN requests.
+ */
+internal class CDNOkHttpInterceptor(private val cdn: CDN) : Interceptor {
+
+ private val logger by taggedLogger("Chat:CDNOkHttpInterceptor")
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalUrl = chain.request().url.toString()
+ val (url, headers) = try {
+ runBlocking {
+ cdn.fileRequest(originalUrl)
+ }
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ logger.e(e) {
+ "[intercept] CDN.fileRequest() failed for url: $originalUrl. " +
+ "Falling back to original request."
+ }
+ CDNRequest(originalUrl)
+ }
+ val request = chain.request().newBuilder()
+ .url(url)
+ .apply {
+ headers?.forEach {
+ header(it.key, it.value)
+ }
+ }
+ .build()
+ return chain.proceed(request)
+ }
+}
diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/StreamMediaDataSource.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/StreamMediaDataSource.kt
new file mode 100644
index 00000000000..8143bf48470
--- /dev/null
+++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/StreamMediaDataSource.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.cdn.internal
+
+import android.content.Context
+import androidx.annotation.OptIn
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.DefaultDataSource
+import io.getstream.chat.android.client.cdn.CDN
+import io.getstream.chat.android.core.internal.InternalStreamChatApi
+
+/**
+ * Centralized provider for Media3 [DataSource.Factory] instances.
+ *
+ * Wraps the base [DefaultDataSource.Factory] with [CDNDataSourceFactory] when a custom [CDN] is configured,
+ * enabling URL rewriting and header injection for media playback (video, audio, voice recordings).
+ */
+@InternalStreamChatApi
+public object StreamMediaDataSource {
+
+ /**
+ * Creates a [DataSource.Factory] that handles both local and network media URIs.
+ *
+ * When a [CDN] is provided, HTTP/HTTPS requests are transformed through [CDN.fileRequest]
+ * for URL rewriting and header injection. Local URIs (file://, content://) pass through unchanged.
+ *
+ * @param context The context used to create the base data source.
+ * @param cdn Optional custom CDN for transforming network requests.
+ */
+ @OptIn(UnstableApi::class)
+ public fun factory(context: Context, cdn: CDN?): DataSource.Factory {
+ val base = DefaultDataSource.Factory(context)
+ return cdn?.let { CDNDataSourceFactory(it, base) } ?: base
+ }
+}
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 6295d9a0853..25af3e171fc 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
@@ -56,6 +56,8 @@ import io.getstream.chat.android.client.api2.endpoint.UserApi
import io.getstream.chat.android.client.api2.mapping.DomainMapping
import io.getstream.chat.android.client.api2.mapping.DtoMapping
import io.getstream.chat.android.client.api2.mapping.EventMapping
+import io.getstream.chat.android.client.cdn.CDN
+import io.getstream.chat.android.client.cdn.internal.CDNOkHttpInterceptor
import io.getstream.chat.android.client.clientstate.UserStateService
import io.getstream.chat.android.client.debugger.ChatClientDebugger
import io.getstream.chat.android.client.interceptor.SendMessageInterceptor
@@ -82,6 +84,7 @@ import io.getstream.chat.android.client.uploader.FileUploader
import io.getstream.chat.android.client.uploader.StreamFileUploader
import io.getstream.chat.android.client.user.CurrentUserFetcher
import io.getstream.chat.android.client.utils.HeadersUtil
+import io.getstream.chat.android.client.utils.internal.ServerClockOffset
import io.getstream.chat.android.models.UserId
import io.getstream.log.StreamLog
import okhttp3.Interceptor
@@ -109,12 +112,14 @@ import java.util.concurrent.TimeUnit
* logic.
* @param shareFileDownloadRequestInterceptor Optional interceptor to customize file download requests done for the
* purpose of sharing the file.
+ * @param cdn Optional [CDN] implementation for transforming file download URLs and injecting headers.
* @param tokenManager Manager that provides and refreshes auth tokens for authenticated requests.
* @param customOkHttpClient Optional base [OkHttpClient] to reuse threads/connection pools and customize networking.
* @param clientDebugger Optional hooks for debugging client state, sockets, and network operations.
* @param lifecycle Host [Lifecycle] used to observe app foreground/background and manage socket behavior.
* @param appName Optional app name added to default headers for tracking.
* @param appVersion Optional app version added to default headers for tracking.
+ * @param serverClockOffset Shared clock-offset tracker used by the socket layer for time synchronisation.
*/
@Suppress("TooManyFunctions")
internal class ChatModule
@@ -130,12 +135,14 @@ constructor(
private val fileUploader: FileUploader?,
private val sendMessageInterceptor: SendMessageInterceptor?,
private val shareFileDownloadRequestInterceptor: Interceptor?,
+ private val cdn: CDN?,
private val tokenManager: TokenManager,
private val customOkHttpClient: OkHttpClient?,
private val clientDebugger: ChatClientDebugger?,
private val lifecycle: Lifecycle,
private val appName: String?,
private val appVersion: String?,
+ private val serverClockOffset: ServerClockOffset,
) {
private val headersUtil = HeadersUtil(appContext, appName, appVersion)
@@ -310,6 +317,7 @@ constructor(
lifecycleObserver,
networkStateProvider,
clientDebugger,
+ serverClockOffset,
)
private fun buildApi(chatConfig: ChatApiConfig): ChatApi = ProxyChatApi(
@@ -384,6 +392,7 @@ constructor(
private fun buildFileDownloadApi(): FileDownloadApi {
val okHttpClient = baseClientBuilder(BASE_TIMEOUT)
.apply {
+ cdn?.let { addInterceptor(CDNOkHttpInterceptor(it)) }
shareFileDownloadRequestInterceptor?.let { addInterceptor(it) }
}
.build()
diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/file/StreamFileManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/file/StreamFileManager.kt
index 9caf62ee77e..c3fe2d2df73 100644
--- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/file/StreamFileManager.kt
+++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/file/StreamFileManager.kt
@@ -164,6 +164,42 @@ public class StreamFileManager {
}
}
+ /**
+ * Evicts cached files matching [prefix] based on a time-to-live and a total size cap.
+ *
+ * 1. Deletes every file whose `lastModified` is older than [ttlMs] milliseconds.
+ * 2. If the remaining files exceed [maxSizeBytes] in total, deletes the oldest
+ * files first until the total drops below the cap.
+ *
+ * @param context Android context for cache directory access
+ * @param prefix Filename prefix that identifies files subject to eviction
+ * @param ttlMs Maximum age in milliseconds; older files are always deleted
+ * @param maxSizeBytes Soft size cap in bytes; exceeded only temporarily until the next eviction pass
+ */
+ public suspend fun evictCacheFiles(context: Context, prefix: String, ttlMs: Long, maxSizeBytes: Long): Unit =
+ withContext(DispatcherProvider.IO) {
+ val now = System.currentTimeMillis()
+ val files = listFilesInCache(context, prefix).toMutableList()
+ val expired = files.filter { now - it.lastModified() >= ttlMs }
+ expired.forEach { file ->
+ if (file.delete()) {
+ files.remove(file)
+ }
+ }
+
+ files.sortBy { it.lastModified() }
+ var totalSize = files.sumOf { it.length() }
+ val iterator = files.iterator()
+ while (totalSize > maxSizeBytes && iterator.hasNext()) {
+ val oldest = iterator.next()
+ val size = oldest.length()
+ if (oldest.delete()) {
+ totalSize -= size
+ iterator.remove()
+ }
+ }
+ }
+
/**
* Clears the Stream cache directory.
*
@@ -357,6 +393,20 @@ public class StreamFileManager {
}
}
+ /**
+ * Lists files in the Stream cache directory whose names start with the given [prefix].
+ *
+ * @param context Android context for cache directory access
+ * @param prefix Filename prefix to filter by
+ * @return List of matching files, or an empty list if the directory doesn't exist
+ */
+ private fun listFilesInCache(context: Context, prefix: String): List {
+ val cacheDir = getStreamCacheDir(context)
+ return cacheDir.listFiles { file ->
+ file.isFile && file.name.startsWith(prefix)
+ }?.toList() ?: emptyList()
+ }
+
@Suppress("TooGenericExceptionCaught")
private fun clearImageCache(context: Context): Result {
return try {
diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt
index 84aff4abaae..cca1f9ad227 100644
--- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt
+++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt
@@ -139,6 +139,7 @@ public class StreamStatePluginFactory(
userPresence = config.userPresence,
isAutomaticSyncOnReconnectEnabled = config.isAutomaticSyncOnReconnectEnabled,
syncMaxThreshold = config.syncMaxThreshold,
+ serverClockOffset = chatClient.serverClockOffset,
now = { System.currentTimeMillis() },
)
diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt
index 61d4493a73e..7b4730181af 100644
--- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt
+++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt
@@ -39,6 +39,7 @@ import io.getstream.chat.android.client.query.CreateChannelParams
import io.getstream.chat.android.client.setup.state.ClientState
import io.getstream.chat.android.client.sync.SyncState
import io.getstream.chat.android.client.sync.stringify
+import io.getstream.chat.android.client.utils.internal.ServerClockOffset
import io.getstream.chat.android.client.utils.message.isDeleted
import io.getstream.chat.android.client.utils.observable.Disposable
import io.getstream.chat.android.core.internal.coroutines.Tube
@@ -95,6 +96,7 @@ internal class SyncManager(
private val isAutomaticSyncOnReconnectEnabled: Boolean,
private val syncMaxThreshold: TimeDuration,
private val now: () -> Long,
+ private val serverClockOffset: ServerClockOffset,
scope: CoroutineScope,
private val events: Tube> = Tube(),
private val syncState: MutableStateFlow = MutableStateFlow(null),
@@ -598,7 +600,7 @@ internal class SyncManager(
repos.markMessageAsFailed(message)
} else {
logger.v { "[retryMessagesWithPendingAttachments] sending message($id)" }
- if (message.createdLocallyAt.exceedsSyncThreshold()) {
+ if (message.createdLocallyAt.exceedsSyncThresholdServerTime()) {
logger.w { "[retryMessagesWithPendingAttachments] outdated sending($id)" }
removeMessage(message).await()
} else {
@@ -672,7 +674,7 @@ internal class SyncManager(
channelClient: ChannelClient,
): Result {
logger.v { "[retrySendingOfMessageWithSyncedAttachments] sending message(${message.id})" }
- return if (message.createdLocallyAt.exceedsSyncThreshold()) {
+ return if (message.createdLocallyAt.exceedsSyncThresholdServerTime()) {
logger.w { "[retrySendingOfMessageWithSyncedAttachments] outdated sending($id)" }
removeMessage(message).await()
} else {
@@ -744,6 +746,15 @@ internal class SyncManager(
return this == null || diff(now()) > syncMaxThreshold
}
+ /**
+ * Important: Use only for local dates created with [ServerClockOffset.estimatedServerTime].
+ * Use for comparing:
+ * - [Message.createdLocallyAt]
+ */
+ private fun Date?.exceedsSyncThresholdServerTime(): Boolean {
+ return this == null || diff(serverClockOffset.estimatedServerTime()) > syncMaxThreshold
+ }
+
private enum class State {
Idle, Syncing
}
diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt
index 71d80074d63..2cf9c4b7603 100644
--- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt
+++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt
@@ -30,6 +30,7 @@ import io.getstream.chat.android.client.network.NetworkStateProvider
import io.getstream.chat.android.client.scope.UserScope
import io.getstream.chat.android.client.socket.ChatSocketStateService.State
import io.getstream.chat.android.client.token.TokenManager
+import io.getstream.chat.android.client.utils.internal.ServerClockOffset
import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider
import io.getstream.chat.android.models.User
import io.getstream.log.taggedLogger
@@ -52,6 +53,7 @@ internal open class ChatSocket(
private val lifecycleObserver: StreamLifecycleObserver,
private val networkStateProvider: NetworkStateProvider,
private val clientDebugger: ChatClientDebugger? = null,
+ private val serverClockOffset: ServerClockOffset,
) {
private var streamWebSocket: StreamWebSocket? = null
private val logger by taggedLogger(TAG)
@@ -61,7 +63,13 @@ internal open class ChatSocket(
private var socketStateObserverJob: Job? = null
private val healthMonitor = HealthMonitor(
userScope = userScope,
- checkCallback = { (chatSocketStateService.currentState as? State.Connected)?.event?.let(::sendEvent) },
+ checkCallback = {
+ (chatSocketStateService.currentState as? State.Connected)?.event?.let {
+ if (sendEvent(it)) {
+ serverClockOffset.onHealthCheckSent()
+ }
+ }
+ },
reconnectCallback = { chatSocketStateService.onWebSocketEventLost() },
)
private val lifecycleHandler = object : LifecycleHandler {
@@ -84,6 +92,7 @@ internal open class ChatSocket(
socketListenerJob?.cancel()
when (networkStateProvider.isConnected()) {
true -> {
+ serverClockOffset.onConnectionStarted()
streamWebSocket = socketFactory.createSocket(connectionConf).apply {
socketListenerJob = listen().onEach {
when (it) {
@@ -194,8 +203,14 @@ internal open class ChatSocket(
private suspend fun handleEvent(chatEvent: ChatEvent) {
when (chatEvent) {
- is ConnectedEvent -> chatSocketStateService.onConnectionEstablished(chatEvent)
- is HealthEvent -> healthMonitor.ack()
+ is ConnectedEvent -> {
+ serverClockOffset.onConnected(chatEvent.createdAt)
+ chatSocketStateService.onConnectionEstablished(chatEvent)
+ }
+ is HealthEvent -> {
+ serverClockOffset.onHealthCheck(chatEvent.createdAt)
+ healthMonitor.ack()
+ }
else -> callListeners { listener -> listener.onEvent(chatEvent) }
}
}
diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt
new file mode 100644
index 00000000000..2eee99da6de
--- /dev/null
+++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt
@@ -0,0 +1,177 @@
+/*
+ * 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.utils.internal
+
+import io.getstream.chat.android.client.events.ConnectedEvent
+import io.getstream.chat.android.client.events.HealthEvent
+import io.getstream.chat.android.core.internal.InternalStreamChatApi
+import java.util.Date
+
+/**
+ * Tracks the offset between the local device clock and the server clock using
+ * NTP-style estimation from WebSocket health check round-trips.
+ *
+ * The algorithm keeps only the sample with the lowest observed RTT, since a
+ * smaller round-trip means less room for network asymmetry to distort the
+ * measurement. Under the assumption that clock skew is constant for the
+ * duration of a session, the estimate monotonically improves over time.
+ *
+ * Thread-safe: single-field writes use [Volatile] for visibility; compound
+ * read-modify-write sequences are guarded by [lock] for atomicity.
+ *
+ * @param localTimeMs Clock source for the local device time (injectable for tests).
+ * @param maxRttMs Upper bound on plausible RTT. Samples exceeding this are
+ * discarded as stale or mismatched. Defaults to the health check cycle
+ * interval (MONITOR_INTERVAL + HEALTH_CHECK_INTERVAL = 11 000 ms).
+ * @param maxOffsetMs Upper bound on the absolute value of the computed clock offset.
+ * If the derived offset exceeds this threshold the sample is considered unreliable
+ * (e.g. a stale / static server timestamp in a test environment) and the offset is
+ * reset to zero so that [estimatedServerTime] falls back to the raw local time.
+ * Defaults to 1 hour, which is already far beyond any real-world NTP drift.
+ */
+@InternalStreamChatApi
+public class ServerClockOffset(
+ private val localTimeMs: () -> Long = { System.currentTimeMillis() },
+ private val maxRttMs: Long = DEFAULT_MAX_RTT_MS,
+ private val maxOffsetMs: Long = DEFAULT_MAX_OFFSET_MS,
+) {
+
+ private val lock = Any()
+
+ @Volatile
+ private var offsetMs: Long = 0L
+
+ @Volatile
+ private var bestRttMs: Long = Long.MAX_VALUE
+
+ @Volatile
+ private var healthCheckSentAtMs: Long = 0L
+
+ @Volatile
+ private var connectionStartedAtMs: Long = 0L
+
+ /**
+ * Record the local time immediately before starting a WebSocket connection.
+ * When the next [ConnectedEvent] arrives, [onConnected] will pair with this
+ * timestamp to compute the offset using the NTP midpoint formula.
+ */
+ internal fun onConnectionStarted() {
+ connectionStartedAtMs = localTimeMs()
+ }
+
+ /**
+ * Record the local time immediately before sending a health check echo.
+ * The next [onHealthCheck] call will pair with this timestamp to compute RTT.
+ */
+ internal fun onHealthCheckSent() {
+ healthCheckSentAtMs = localTimeMs()
+ }
+
+ /**
+ * Calibration from a [ConnectedEvent].
+ *
+ * If [onConnectionStarted] was called before this connection (e.g. right before
+ * opening the WebSocket), uses the NTP midpoint of (connectionStartedAt, receivedAt)
+ * and serverTime for a more accurate offset. Otherwise falls back to a naive
+ * `localTime - serverTime` estimate.
+ *
+ * Resets health check state, since a new connection means any in-flight health
+ * check from the previous connection is stale.
+ */
+ internal fun onConnected(serverTime: Date) {
+ synchronized(lock) {
+ bestRttMs = Long.MAX_VALUE
+ healthCheckSentAtMs = 0L
+ offsetMs = 0L
+
+ val receivedAtMs = localTimeMs()
+ val startedAtMs = connectionStartedAtMs
+ connectionStartedAtMs = 0L
+
+ if (startedAtMs > 0L) {
+ val rtt = receivedAtMs - startedAtMs
+ if (rtt in 1..maxRttMs) {
+ acceptOffset((startedAtMs + receivedAtMs) / 2 - serverTime.time)
+ bestRttMs = rtt
+ return
+ }
+ }
+ acceptOffset(receivedAtMs - serverTime.time)
+ }
+ }
+
+ /**
+ * Refine the offset using a [HealthEvent] paired with [onHealthCheckSent].
+ *
+ * Computes RTT from the stored send time and the current receive time,
+ * then applies the NTP midpoint formula:
+ * ```
+ * offset = (sentAt + receivedAt) / 2 - serverTime
+ * ```
+ *
+ * The sample is accepted only if:
+ * - There is a pending [onHealthCheckSent] timestamp.
+ * - RTT is positive (guards against clock anomalies).
+ * - RTT is below [maxRttMs] (rejects stale / mismatched pairs).
+ * - RTT is lower than any previous sample (min-RTT selection).
+ */
+ internal fun onHealthCheck(serverTime: Date) {
+ synchronized(lock) {
+ val sentAtMs = healthCheckSentAtMs
+ if (sentAtMs <= 0L) return
+ healthCheckSentAtMs = 0L
+
+ val receivedAtMs = localTimeMs()
+ val rtt = receivedAtMs - sentAtMs
+ if (rtt !in 1..maxRttMs) return
+
+ if (rtt < bestRttMs) {
+ bestRttMs = rtt
+ acceptOffset((sentAtMs + receivedAtMs) / 2 - serverTime.time)
+ }
+ }
+ }
+
+ /**
+ * Returns the current time adjusted to the server timescale.
+ *
+ * Before the first [onConnected] call, this returns the raw local time
+ * (offset = 0).
+ */
+ @InternalStreamChatApi
+ public fun estimatedServerTime(): Date =
+ Date(localTimeMs() - offsetMs)
+
+ /**
+ * Accepts [candidate] as the new [offsetMs] only when its absolute value is within
+ * [maxOffsetMs]. Offsets that are implausibly large (e.g. produced by a stale or
+ * static server timestamp) are silently discarded and [offsetMs] is left unchanged.
+ *
+ * Note: callers that want a rejected offset to reset to zero (e.g. [onConnected])
+ * should set [offsetMs] = 0 before calling this function.
+ */
+ private fun acceptOffset(candidate: Long) {
+ if (kotlin.math.abs(candidate) <= maxOffsetMs) {
+ offsetMs = candidate
+ }
+ }
+
+ internal companion object {
+ internal const val DEFAULT_MAX_RTT_MS = 11_000L
+ internal const val DEFAULT_MAX_OFFSET_MS = 3_600_000L // 1 hour
+ }
+}
diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt
index 0987343f63d..75bbd415609 100644
--- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt
+++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt
@@ -34,6 +34,7 @@ import io.getstream.chat.android.client.token.FakeTokenManager
import io.getstream.chat.android.client.user.CredentialConfig
import io.getstream.chat.android.client.user.storage.UserCredentialStorage
import io.getstream.chat.android.client.utils.TokenUtils
+import io.getstream.chat.android.client.utils.internal.ServerClockOffset
import io.getstream.chat.android.models.ConnectionData
import io.getstream.chat.android.models.EventType
import io.getstream.chat.android.models.GuestUser
@@ -126,6 +127,7 @@ internal class ChatClientConnectionTests {
retryPolicy = mock(),
appSettingsManager = mock(),
chatSocket = fakeChatSocket,
+ serverClockOffset = ServerClockOffset(),
pluginFactories = emptyList(),
repositoryFactoryProvider = NoOpRepositoryFactory.Provider,
mutableClientState = mutableClientState,
diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt
index 9e76db822ef..c4aa7b58797 100644
--- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt
+++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt
@@ -37,6 +37,7 @@ import io.getstream.chat.android.client.scope.UserTestScope
import io.getstream.chat.android.client.socket.FakeChatSocket
import io.getstream.chat.android.client.token.FakeTokenManager
import io.getstream.chat.android.client.utils.TokenUtils
+import io.getstream.chat.android.client.utils.internal.ServerClockOffset
import io.getstream.chat.android.client.utils.retry.NoRetryPolicy
import io.getstream.chat.android.models.ConnectionState
import io.getstream.chat.android.models.EventType
@@ -138,6 +139,7 @@ internal class ChatClientTest {
retryPolicy = NoRetryPolicy(),
appSettingsManager = mock(),
chatSocket = fakeChatSocket,
+ serverClockOffset = ServerClockOffset(),
pluginFactories = emptyList(),
mutableClientState = Mother.mockedClientState(),
repositoryFactoryProvider = NoOpRepositoryFactory.Provider,
diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt
index c449bf03179..847ccf5deaa 100644
--- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt
+++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt
@@ -23,13 +23,17 @@ import io.getstream.chat.android.client.plugin.factory.PluginFactory
import io.getstream.chat.android.client.scope.ClientTestScope
import io.getstream.chat.android.client.scope.UserTestScope
import io.getstream.chat.android.client.setup.state.internal.MutableClientState
+import io.getstream.chat.android.client.utils.internal.ServerClockOffset
import io.getstream.chat.android.core.internal.InternalStreamChatApi
import io.getstream.chat.android.models.InitializationState
import io.getstream.chat.android.models.NoOpMessageTransformer
import io.getstream.chat.android.models.NoOpUserTransformer
import io.getstream.chat.android.models.User
import io.getstream.chat.android.test.TestCoroutineExtension
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.TestResult
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.invoking
@@ -43,6 +47,7 @@ import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
+import java.util.concurrent.atomic.AtomicBoolean
import kotlin.reflect.KClass
public class DependencyResolverTest {
@@ -128,6 +133,22 @@ public class DependencyResolverTest {
fResult `should be` expectedDependency
}
+ @Test
+ public fun `Should resolve dependency when plugins are cleared during resolution`(): TestResult = runTest {
+ val expectedDependency = SomeDependency()
+ val fixture = Fixture()
+ .with(PluginDependency(mapOf(SomeDependency::class to expectedDependency)))
+
+ val client = fixture.get()
+
+ val racingFlow = DisconnectSimulatingStateFlow(client)
+ whenever(fixture.mutableClientState.initializationState).thenReturn(racingFlow)
+
+ val result = client.resolveDependency()
+
+ result `should be` expectedDependency
+ }
+
public companion object {
@JvmField
@@ -174,6 +195,7 @@ public class DependencyResolverTest {
retryPolicy = mock(),
appSettingsManager = mock(),
chatSocket = mock(),
+ serverClockOffset = ServerClockOffset(),
pluginFactories = pluginFactories,
repositoryFactoryProvider = mock(),
mutableClientState = mutableClientState,
@@ -217,4 +239,28 @@ public class DependencyResolverTest {
}
private class SomeDependency
+
+ private class DisconnectSimulatingStateFlow(
+ private val client: ChatClient,
+ ) : StateFlow {
+
+ private val disconnected = AtomicBoolean(false)
+
+ override val value: InitializationState
+ get() {
+ if (disconnected.compareAndSet(false, true)) {
+ client.plugins = emptyList()
+ return InitializationState.COMPLETE
+ }
+ return InitializationState.NOT_INITIALIZED
+ }
+
+ override val replayCache: List
+ get() = listOf(InitializationState.COMPLETE)
+
+ override suspend fun collect(collector: FlowCollector): Nothing {
+ collector.emit(InitializationState.COMPLETE)
+ awaitCancellation()
+ }
+ }
}
diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt
index 54c5840ac6c..328b57c25e7 100644
--- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt
+++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt
@@ -33,6 +33,7 @@ import io.getstream.chat.android.client.setup.state.internal.MutableClientState
import io.getstream.chat.android.client.token.FakeTokenManager
import io.getstream.chat.android.client.uploader.FileUploader
import io.getstream.chat.android.client.utils.TokenUtils
+import io.getstream.chat.android.client.utils.internal.ServerClockOffset
import io.getstream.chat.android.client.utils.retry.NoRetryPolicy
import io.getstream.chat.android.models.EventType
import io.getstream.chat.android.models.NoOpMessageTransformer
@@ -121,6 +122,7 @@ internal class MockClientBuilder(
retryPolicy = NoRetryPolicy(),
appSettingsManager = mock(),
chatSocket = mock(),
+ serverClockOffset = ServerClockOffset(),
pluginFactories = emptyList(),
repositoryFactoryProvider = NoOpRepositoryFactory.Provider,
mutableClientState = mutableClientState,
diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceTest.kt
new file mode 100644
index 00000000000..f642c3cdd69
--- /dev/null
+++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceTest.kt
@@ -0,0 +1,223 @@
+/*
+ * 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.cdn.internal
+
+import android.net.Uri
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.DataSpec
+import androidx.media3.datasource.TransferListener
+import io.getstream.chat.android.client.cdn.CDN
+import io.getstream.chat.android.client.cdn.CDNRequest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@UnstableApi
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33])
+internal class CDNDataSourceTest {
+
+ @Test
+ fun `open rewrites URI and headers when CDN returns new URL and headers`() {
+ val cdn = object : CDN {
+ override suspend fun fileRequest(url: String) =
+ CDNRequest("https://cdn.example.com/video.mp4", mapOf("Auth" to "token"))
+ }
+ val upstream = FakeDataSource()
+ val factory = CDNDataSourceFactory(cdn) { upstream }
+ val dataSource = factory.createDataSource()
+ val dataSpec = DataSpec(Uri.parse("https://original.com/video.mp4"))
+
+ dataSource.open(dataSpec)
+
+ val opened = upstream.lastOpenedDataSpec!!
+ assertEquals("https://cdn.example.com/video.mp4", opened.uri.toString())
+ assertEquals("token", opened.httpRequestHeaders["Auth"])
+ }
+
+ @Test
+ fun `open merges CDN headers with existing DataSpec headers`() {
+ val cdn = object : CDN {
+ override suspend fun fileRequest(url: String) =
+ CDNRequest(url, mapOf("X-CDN" to "cdn-value"))
+ }
+ val upstream = FakeDataSource()
+ val factory = CDNDataSourceFactory(cdn) { upstream }
+ val dataSource = factory.createDataSource()
+ val dataSpec = DataSpec.Builder()
+ .setUri("https://original.com/video.mp4")
+ .setHttpRequestHeaders(mapOf("X-Existing" to "existing-value"))
+ .build()
+
+ dataSource.open(dataSpec)
+
+ val opened = upstream.lastOpenedDataSpec!!
+ assertEquals("existing-value", opened.httpRequestHeaders["X-Existing"])
+ assertEquals("cdn-value", opened.httpRequestHeaders["X-CDN"])
+ }
+
+ @Test
+ fun `open CDN headers override existing headers for same key`() {
+ val cdn = object : CDN {
+ override suspend fun fileRequest(url: String) =
+ CDNRequest(url, mapOf("Auth" to "new-token"))
+ }
+ val upstream = FakeDataSource()
+ val factory = CDNDataSourceFactory(cdn) { upstream }
+ val dataSource = factory.createDataSource()
+ val dataSpec = DataSpec.Builder()
+ .setUri("https://original.com/video.mp4")
+ .setHttpRequestHeaders(mapOf("Auth" to "old-token"))
+ .build()
+
+ dataSource.open(dataSpec)
+
+ val opened = upstream.lastOpenedDataSpec!!
+ assertEquals("new-token", opened.httpRequestHeaders["Auth"])
+ }
+
+ @Test
+ @Suppress("TooGenericExceptionThrown")
+ fun `open falls back to original DataSpec when CDN throws`() {
+ val cdn = object : CDN {
+ override suspend fun fileRequest(url: String): CDNRequest {
+ throw RuntimeException("CDN error")
+ }
+ }
+ val upstream = FakeDataSource()
+ val factory = CDNDataSourceFactory(cdn) { upstream }
+ val dataSource = factory.createDataSource()
+ val originalUri = Uri.parse("https://original.com/video.mp4")
+ val dataSpec = DataSpec(originalUri)
+
+ dataSource.open(dataSpec)
+
+ val opened = upstream.lastOpenedDataSpec!!
+ assertEquals("https://original.com/video.mp4", opened.uri.toString())
+ }
+
+ @Test
+ fun `open skips CDN for non-HTTP schemes`() {
+ var cdnCalled = false
+ val cdn = object : CDN {
+ override suspend fun fileRequest(url: String): CDNRequest {
+ cdnCalled = true
+ return CDNRequest("https://should-not-be-used.com")
+ }
+ }
+ val upstream = FakeDataSource()
+ val factory = CDNDataSourceFactory(cdn) { upstream }
+ val dataSource = factory.createDataSource()
+ val dataSpec = DataSpec(Uri.parse("file:///local/video.mp4"))
+
+ dataSource.open(dataSpec)
+
+ val opened = upstream.lastOpenedDataSpec!!
+ assertEquals("file:///local/video.mp4", opened.uri.toString())
+ assertTrue("CDN should not be called for file:// URIs", !cdnCalled)
+ }
+
+ @Test
+ fun `delegates read to upstream`() {
+ val cdn = object : CDN {}
+ val upstream: DataSource = mock()
+ val factory = CDNDataSourceFactory(cdn) { upstream }
+ val dataSource = factory.createDataSource()
+ val buffer = ByteArray(1024)
+ whenever(upstream.read(buffer, 0, 1024)).thenReturn(512)
+
+ val result = dataSource.read(buffer, 0, 1024)
+
+ assertEquals(512, result)
+ verify(upstream).read(buffer, 0, 1024)
+ }
+
+ @Test
+ fun `delegates close to upstream`() {
+ val cdn = object : CDN {}
+ val upstream: DataSource = mock()
+ val factory = CDNDataSourceFactory(cdn) { upstream }
+ val dataSource = factory.createDataSource()
+
+ dataSource.close()
+
+ verify(upstream).close()
+ }
+
+ @Test
+ fun `delegates getUri to upstream`() {
+ val cdn = object : CDN {}
+ val expectedUri = Uri.parse("https://example.com")
+ val upstream: DataSource = mock()
+ whenever(upstream.uri).thenReturn(expectedUri)
+ val factory = CDNDataSourceFactory(cdn) { upstream }
+ val dataSource = factory.createDataSource()
+
+ assertEquals(expectedUri, dataSource.uri)
+ }
+
+ @Test
+ fun `delegates getResponseHeaders to upstream`() {
+ val cdn = object : CDN {}
+ val expectedHeaders = mapOf("Content-Type" to listOf("video/mp4"))
+ val upstream: DataSource = mock()
+ whenever(upstream.responseHeaders).thenReturn(expectedHeaders)
+ val factory = CDNDataSourceFactory(cdn) { upstream }
+ val dataSource = factory.createDataSource()
+
+ assertEquals(expectedHeaders, dataSource.responseHeaders)
+ }
+
+ @Test
+ fun `delegates addTransferListener to upstream`() {
+ val cdn = object : CDN {}
+ val upstream: DataSource = mock()
+ val listener: TransferListener = mock()
+ val factory = CDNDataSourceFactory(cdn) { upstream }
+ val dataSource = factory.createDataSource()
+
+ dataSource.addTransferListener(listener)
+
+ verify(upstream).addTransferListener(listener)
+ }
+
+ /**
+ * A simple fake [DataSource] that records the [DataSpec] passed to [open].
+ */
+ @UnstableApi
+ private class FakeDataSource : DataSource {
+ var lastOpenedDataSpec: DataSpec? = null
+
+ override fun open(dataSpec: DataSpec): Long {
+ lastOpenedDataSpec = dataSpec
+ return 0
+ }
+
+ override fun read(buffer: ByteArray, offset: Int, length: Int): Int = 0
+ override fun close() { /* empty on purpose */ }
+ override fun getUri(): Uri? = null
+ override fun getResponseHeaders(): Map> = emptyMap()
+ override fun addTransferListener(transferListener: TransferListener) { /* empty on purpose */ }
+ }
+}
diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptorTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptorTest.kt
new file mode 100644
index 00000000000..db1896f81d4
--- /dev/null
+++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptorTest.kt
@@ -0,0 +1,119 @@
+/*
+ * 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.cdn.internal
+
+import io.getstream.chat.android.client.api.FakeChain
+import io.getstream.chat.android.client.api.FakeResponse
+import io.getstream.chat.android.client.cdn.CDN
+import io.getstream.chat.android.client.cdn.CDNRequest
+import okhttp3.Request
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+internal class CDNOkHttpInterceptorTest {
+
+ @Test
+ fun `intercept rewrites URL when CDN returns different URL`() {
+ val cdn = object : CDN {
+ override suspend fun fileRequest(url: String) =
+ CDNRequest("https://cdn.example.com/rewritten")
+ }
+ val interceptor = CDNOkHttpInterceptor(cdn)
+ val chain = FakeChain(
+ FakeResponse(200),
+ request = Request.Builder().url("https://original.com/file.mp4").build(),
+ )
+
+ val response = interceptor.intercept(chain)
+
+ assertEquals("https://cdn.example.com/rewritten", response.request.url.toString())
+ }
+
+ @Test
+ fun `intercept adds CDN headers to the request`() {
+ val cdn = object : CDN {
+ override suspend fun fileRequest(url: String) =
+ CDNRequest(url, headers = mapOf("Authorization" to "Bearer token123", "X-Custom" to "value"))
+ }
+ val interceptor = CDNOkHttpInterceptor(cdn)
+ val chain = FakeChain(
+ FakeResponse(200),
+ request = Request.Builder().url("https://original.com/file.mp4").build(),
+ )
+
+ val response = interceptor.intercept(chain)
+
+ assertEquals("Bearer token123", response.request.header("Authorization"))
+ assertEquals("value", response.request.header("X-Custom"))
+ }
+
+ @Test
+ fun `intercept adds CDN headers without removing existing ones`() {
+ val cdn = object : CDN {
+ override suspend fun fileRequest(url: String) =
+ CDNRequest(url, headers = mapOf("X-CDN" to "cdn-value"))
+ }
+ val interceptor = CDNOkHttpInterceptor(cdn)
+ val originalRequest = Request.Builder()
+ .url("https://original.com/file.mp4")
+ .addHeader("X-Existing", "existing-value")
+ .build()
+ val chain = FakeChain(FakeResponse(200), request = originalRequest)
+
+ val response = interceptor.intercept(chain)
+
+ assertEquals("existing-value", response.request.header("X-Existing"))
+ assertEquals("cdn-value", response.request.header("X-CDN"))
+ }
+
+ @Test
+ @Suppress("TooGenericExceptionThrown")
+ fun `intercept falls back to original request when CDN throws`() {
+ val cdn = object : CDN {
+ override suspend fun fileRequest(url: String): CDNRequest {
+ throw RuntimeException("CDN unavailable")
+ }
+ }
+ val interceptor = CDNOkHttpInterceptor(cdn)
+ val chain = FakeChain(
+ FakeResponse(200),
+ request = Request.Builder().url("https://original.com/file.mp4").build(),
+ )
+
+ val response = interceptor.intercept(chain)
+
+ assertEquals("https://original.com/file.mp4", response.request.url.toString())
+ assertNull(response.request.header("Authorization"))
+ }
+
+ @Test
+ fun `intercept passes through unchanged when CDN returns original URL and null headers`() {
+ val cdn = object : CDN {
+ override suspend fun fileRequest(url: String) = CDNRequest(url, headers = null)
+ }
+ val interceptor = CDNOkHttpInterceptor(cdn)
+ val chain = FakeChain(
+ FakeResponse(200),
+ request = Request.Builder().url("https://original.com/file.mp4").build(),
+ )
+
+ val response = interceptor.intercept(chain)
+
+ assertEquals("https://original.com/file.mp4", response.request.url.toString())
+ }
+}
diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt
index 4647cd25ad1..fa1b22aad86 100644
--- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt
+++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt
@@ -37,6 +37,7 @@ import io.getstream.chat.android.client.socket.FakeChatSocket
import io.getstream.chat.android.client.token.TokenManager
import io.getstream.chat.android.client.user.CurrentUserFetcher
import io.getstream.chat.android.client.utils.TokenUtils
+import io.getstream.chat.android.client.utils.internal.ServerClockOffset
import io.getstream.chat.android.client.utils.retry.NoRetryPolicy
import io.getstream.chat.android.models.NoOpMessageTransformer
import io.getstream.chat.android.models.NoOpUserTransformer
@@ -124,6 +125,7 @@ internal open class BaseChatClientTest {
retryPolicy = NoRetryPolicy(),
appSettingsManager = mock(),
chatSocket = getChatSocket(),
+ serverClockOffset = ServerClockOffset(),
pluginFactories = pluginFactories,
repositoryFactoryProvider = NoOpRepositoryFactory.Provider,
mutableClientState = mutableClientState,
diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt
index 612166711ab..2b57355edc9 100644
--- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt
+++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt
@@ -36,6 +36,7 @@ import io.getstream.chat.android.client.scope.UserTestScope
import io.getstream.chat.android.client.socket.FakeChatSocket
import io.getstream.chat.android.client.token.FakeTokenManager
import io.getstream.chat.android.client.utils.TokenUtils
+import io.getstream.chat.android.client.utils.internal.ServerClockOffset
import io.getstream.chat.android.client.utils.retry.NoRetryPolicy
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.models.NoOpMessageTransformer
@@ -142,6 +143,7 @@ internal class ChatClientDebuggerTest {
retryPolicy = NoRetryPolicy(),
appSettingsManager = mock(),
chatSocket = fakeChatSocket,
+ serverClockOffset = ServerClockOffset(),
pluginFactories = pluginFactories,
mutableClientState = Mother.mockedClientState(),
repositoryFactoryProvider = NoOpRepositoryFactory.Provider,
diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/file/StreamFileManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/file/StreamFileManagerTest.kt
index 24c726196ba..784adbec4cb 100644
--- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/file/StreamFileManagerTest.kt
+++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/file/StreamFileManagerTest.kt
@@ -366,6 +366,75 @@ internal class StreamFileManagerTest {
assertTrue(file.name.matches(Regex("STREAM_VID_\\d{8}_\\d{6}\\.mp4")))
}
+ @Test
+ fun `evictCacheFiles should delete files older than TTL`() = runTest {
+ val freshFile = "TMP_fresh.txt"
+ val expiredFile = "TMP_expired.txt"
+
+ streamFileManager.writeFileInCache(context, freshFile, "fresh".byteInputStream())
+ streamFileManager.writeFileInCache(context, expiredFile, "expired".byteInputStream())
+
+ val expiredResult = streamFileManager.getFileFromCache(context, expiredFile)
+ assertTrue(expiredResult is Result.Success)
+ (expiredResult as Result.Success).value.setLastModified(System.currentTimeMillis() - 10 * 60 * 1000L)
+
+ streamFileManager.evictCacheFiles(context, "TMP", 5 * 60 * 1000L, 100L * 1024 * 1024)
+
+ assertTrue(streamFileManager.getFileFromCache(context, freshFile) is Result.Success)
+ assertTrue(streamFileManager.getFileFromCache(context, expiredFile) is Result.Failure)
+ }
+
+ @Test
+ fun `evictCacheFiles should delete oldest files when size cap exceeded`() = runTest {
+ val oldFile = "TMP_old.txt"
+ val newFile = "TMP_new.txt"
+ val largeContent = "x".repeat(1024)
+
+ streamFileManager.writeFileInCache(context, oldFile, largeContent.byteInputStream())
+ val oldResult = streamFileManager.getFileFromCache(context, oldFile)
+ assertTrue(oldResult is Result.Success)
+ (oldResult as Result.Success).value.setLastModified(System.currentTimeMillis() - 60_000L)
+
+ streamFileManager.writeFileInCache(context, newFile, largeContent.byteInputStream())
+
+ streamFileManager.evictCacheFiles(context, "TMP", 5 * 60 * 1000L, 1500L)
+
+ assertTrue(streamFileManager.getFileFromCache(context, oldFile) is Result.Failure)
+ assertTrue(streamFileManager.getFileFromCache(context, newFile) is Result.Success)
+ }
+
+ @Test
+ fun `evictCacheFiles should keep files within TTL and under size cap`() = runTest {
+ val file1 = "TMP_keep1.txt"
+ val file2 = "TMP_keep2.txt"
+
+ streamFileManager.writeFileInCache(context, file1, "content1".byteInputStream())
+ streamFileManager.writeFileInCache(context, file2, "content2".byteInputStream())
+
+ streamFileManager.evictCacheFiles(context, "TMP", 5 * 60 * 1000L, 100L * 1024 * 1024)
+
+ assertTrue(streamFileManager.getFileFromCache(context, file1) is Result.Success)
+ assertTrue(streamFileManager.getFileFromCache(context, file2) is Result.Success)
+ }
+
+ @Test
+ fun `evictCacheFiles should not affect files with different prefix`() = runTest {
+ val matchFile = "TMP_match.txt"
+ val otherFile = "OTHER_keep.txt"
+
+ streamFileManager.writeFileInCache(context, matchFile, "match".byteInputStream())
+ streamFileManager.writeFileInCache(context, otherFile, "other".byteInputStream())
+
+ val matchResult = streamFileManager.getFileFromCache(context, matchFile)
+ assertTrue(matchResult is Result.Success)
+ (matchResult as Result.Success).value.setLastModified(System.currentTimeMillis() - 10 * 60 * 1000L)
+
+ streamFileManager.evictCacheFiles(context, "TMP", 5 * 60 * 1000L, 100L * 1024 * 1024)
+
+ assertTrue(streamFileManager.getFileFromCache(context, matchFile) is Result.Failure)
+ assertTrue(streamFileManager.getFileFromCache(context, otherFile) is Result.Success)
+ }
+
@Test
fun `clearExternalStorage should delete both photos and videos`() {
// Create photo and video files
diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt
index 3cb33b7959c..d9fc4b01d2a 100644
--- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt
+++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt
@@ -32,6 +32,7 @@ import io.getstream.chat.android.client.persistance.repository.RepositoryFacade
import io.getstream.chat.android.client.setup.state.ClientState
import io.getstream.chat.android.client.sync.SyncState
import io.getstream.chat.android.client.test.randomConnectedEvent
+import io.getstream.chat.android.client.utils.internal.ServerClockOffset
import io.getstream.chat.android.client.utils.observable.Disposable
import io.getstream.chat.android.core.internal.coroutines.Tube
import io.getstream.chat.android.models.ConnectionState
@@ -546,6 +547,7 @@ internal class SyncManagerTest {
isAutomaticSyncOnReconnectEnabled = isAutomaticSyncOnReconnectEnabled,
syncMaxThreshold = syncMaxThreshold,
now = { currentTime },
+ serverClockOffset = ServerClockOffset(localTimeMs = { currentTime }),
)
}
}
diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/FakeChatSocket.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/FakeChatSocket.kt
index e6178e185c3..83ebcb1f7e2 100644
--- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/FakeChatSocket.kt
+++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/FakeChatSocket.kt
@@ -25,6 +25,7 @@ import io.getstream.chat.android.client.parser2.adapters.internal.StreamDateForm
import io.getstream.chat.android.client.scope.UserScope
import io.getstream.chat.android.client.token.FakeTokenManager
import io.getstream.chat.android.client.token.TokenManager
+import io.getstream.chat.android.client.utils.internal.ServerClockOffset
import io.getstream.chat.android.models.EventType
import io.getstream.chat.android.models.User
import io.getstream.chat.android.randomString
@@ -46,6 +47,7 @@ internal class FakeChatSocket private constructor(
userScope: UserScope,
lifecycleObserver: StreamLifecycleObserver,
networkStateProvider: NetworkStateProvider,
+ serverClockOffset: ServerClockOffset,
getWebSocketListener: () -> WebSocketListener,
) : ChatSocket(
apiKey,
@@ -55,6 +57,7 @@ internal class FakeChatSocket private constructor(
userScope,
lifecycleObserver,
networkStateProvider,
+ serverClockOffset = serverClockOffset,
) {
private val streamDateFormatter = StreamDateFormatter()
private val webSocketListener: WebSocketListener by lazy { getWebSocketListener() }
@@ -89,6 +92,7 @@ internal class FakeChatSocket private constructor(
wssUrl: String = randomString(),
tokenManager: TokenManager = FakeTokenManager(randomString()),
networkStateProvider: NetworkStateProvider = mock(),
+ serverClockOffset: ServerClockOffset = ServerClockOffset(),
): FakeChatSocket {
var webSocketListener: WebSocketListener? = null
val parser: ChatParser = mock()
@@ -107,6 +111,7 @@ internal class FakeChatSocket private constructor(
userScope,
lifecycleObserver,
networkStateProvider,
+ serverClockOffset,
) { webSocketListener!! }
}
}
diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/ServerClockOffsetTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/ServerClockOffsetTest.kt
new file mode 100644
index 00000000000..a8c98f60c0a
--- /dev/null
+++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/ServerClockOffsetTest.kt
@@ -0,0 +1,399 @@
+/*
+ * 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.utils.internal
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import java.util.Date
+
+internal class ServerClockOffsetTest {
+
+ // ── estimatedServerTime before any calibration ──────────────────────
+
+ @Test
+ fun `estimatedServerTime equals local time before any calibration`() {
+ val sut = ServerClockOffset(localTimeMs = { 1_000_000L })
+
+ assertEquals(Date(1_000_000L), sut.estimatedServerTime())
+ }
+
+ // ── onConnected (naive one-way estimate) ────────────────────────────
+
+ @Test
+ fun `onConnected calibrates when local clock is ahead`() {
+ val sut = ServerClockOffset(localTimeMs = { 10_000L })
+
+ sut.onConnected(serverTime = Date(7_000L))
+
+ assertEquals(Date(7_000L), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onConnected calibrates when local clock is behind`() {
+ val sut = ServerClockOffset(localTimeMs = { 5_000L })
+
+ sut.onConnected(serverTime = Date(8_000L))
+
+ assertEquals(Date(8_000L), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onConnected resets health check state from previous connection`() {
+ var localTime = 10_000L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+
+ sut.onHealthCheckSent()
+
+ localTime = 10_200L
+ sut.onConnected(serverTime = Date(10_100L))
+
+ localTime = 10_400L
+ sut.onHealthCheck(serverTime = Date(10_300L))
+ assertEquals(Date(10_100L + (10_400L - 10_200L)), sut.estimatedServerTime())
+ }
+
+ // ── onConnectionStarted + onConnected (NTP for initial connection) ───
+
+ @Test
+ fun `onConnected uses NTP midpoint when onConnectionStarted was called`() {
+ val skew = 3_000L
+ var localTime = 10_000L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+
+ sut.onConnectionStarted()
+
+ localTime = 10_200L
+ val serverTimeAtMidpoint = (10_000L + 10_200L) / 2 - skew
+ sut.onConnected(serverTime = Date(serverTimeAtMidpoint))
+
+ // offset = (10_000 + 10_200) / 2 - serverTimeAtMidpoint = 3_000
+ localTime = 15_000L
+ assertEquals(Date(15_000L - skew), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onConnected falls back to naive when onConnectionStarted was not called`() {
+ val localTime = 10_000L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+
+ sut.onConnected(serverTime = Date(7_000L))
+
+ assertEquals(Date(7_000L), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onConnected rejects connection pair when RTT exceeds maxRttMs and uses naive`() {
+ var localTime = 0L
+ val sut = ServerClockOffset(localTimeMs = { localTime }, maxRttMs = 100L)
+
+ sut.onConnectionStarted()
+
+ localTime = 500L
+ sut.onConnected(serverTime = Date(250L))
+
+ // RTT = 500 > maxRttMs = 100 → rejected, naive used: offset = 500 - 250 = 250
+ assertEquals(Date(500L - 250L), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onConnectionStarted is consumed so second onConnected uses naive`() {
+ var localTime = 0L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+
+ sut.onConnectionStarted()
+ localTime = 100L
+ sut.onConnected(serverTime = Date(50L))
+
+ localTime = 1_000L
+ sut.onConnected(serverTime = Date(999L))
+
+ // No connectionStartedAtMs (consumed), so naive: offset = 1000 - 999 = 1
+ assertEquals(Date(1_000L - 1L), sut.estimatedServerTime())
+ }
+
+ // ── onHealthCheck (NTP midpoint with min-RTT selection) ─────────────
+
+ @Test
+ fun `onHealthCheck computes NTP midpoint offset`() {
+ val skew = 3_000L
+ var localTime = 10_000L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+ sut.onConnected(serverTime = Date(localTime - skew))
+
+ localTime = 20_000L
+ sut.onHealthCheckSent()
+
+ localTime = 20_200L
+ sut.onHealthCheck(serverTime = Date(17_100L))
+
+ // offset = (20_000 + 20_200) / 2 - 17_100 = 3_000
+ // estimatedServerTime = 20_200 - 3_000 = 17_200
+ assertEquals(Date(17_200L), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onHealthCheck keeps lowest RTT sample`() {
+ var localTime = 0L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+ sut.onConnected(serverTime = Date(0L))
+
+ // First health check: RTT = 500
+ localTime = 1_000L
+ sut.onHealthCheckSent()
+ localTime = 1_500L
+ sut.onHealthCheck(serverTime = Date(1_250L))
+ val offsetAfterFirst = (1_000L + 1_500L) / 2 - 1_250L
+
+ // Second health check: RTT = 100 (better)
+ localTime = 2_000L
+ sut.onHealthCheckSent()
+ localTime = 2_100L
+ sut.onHealthCheck(serverTime = Date(2_050L))
+ val offsetAfterSecond = (2_000L + 2_100L) / 2 - 2_050L
+
+ localTime = 5_000L
+ assertEquals(Date(5_000L - offsetAfterSecond), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onHealthCheck ignores higher RTT sample`() {
+ var localTime = 0L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+ sut.onConnected(serverTime = Date(0L))
+
+ // First health check: RTT = 100 (best)
+ localTime = 1_000L
+ sut.onHealthCheckSent()
+ localTime = 1_100L
+ sut.onHealthCheck(serverTime = Date(1_050L))
+ val bestOffset = (1_000L + 1_100L) / 2 - 1_050L
+
+ // Second health check: RTT = 500 (worse -- ignored)
+ localTime = 2_000L
+ sut.onHealthCheckSent()
+ localTime = 2_500L
+ sut.onHealthCheck(serverTime = Date(2_250L))
+
+ localTime = 5_000L
+ assertEquals(Date(5_000L - bestOffset), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onHealthCheck overrides naive onConnected estimate`() {
+ val skew = 3_000L
+ var localTime = 10_000L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+ sut.onConnected(serverTime = Date(localTime - skew))
+
+ // Naive estimate at localTime = 10_000: offset = 3_000
+ assertEquals(Date(7_000L), sut.estimatedServerTime())
+
+ localTime = 20_000L
+ sut.onHealthCheckSent()
+ localTime = 20_200L
+ sut.onHealthCheck(serverTime = Date(17_100L))
+
+ // NTP offset = (20_000 + 20_200) / 2 - 17_100 = 3_000
+ // At localTime = 20_200: estimated = 20_200 - 3_000 = 17_200
+ assertEquals(Date(17_200L), sut.estimatedServerTime())
+ }
+
+ // ── Guards: mismatched / stale / implausible pairs ──────────────────
+
+ @Test
+ fun `onHealthCheck is no-op without prior onHealthCheckSent`() {
+ var localTime = 10_000L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+ sut.onConnected(serverTime = Date(7_000L))
+
+ localTime = 20_000L
+ sut.onHealthCheck(serverTime = Date(17_000L))
+
+ // Offset unchanged from onConnected: 10_000 - 7_000 = 3_000
+ assertEquals(Date(20_000L - 3_000L), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onHealthCheck consumes sentAt so second call is no-op`() {
+ var localTime = 0L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+ sut.onConnected(serverTime = Date(0L))
+
+ localTime = 1_000L
+ sut.onHealthCheckSent()
+
+ localTime = 1_100L
+ sut.onHealthCheck(serverTime = Date(1_050L))
+ val offsetAfterFirst = (1_000L + 1_100L) / 2 - 1_050L
+
+ localTime = 50_000L
+ sut.onHealthCheck(serverTime = Date(99_999L))
+
+ // Offset unchanged -- second call was a no-op (sentAtMs consumed)
+ assertEquals(Date(50_000L - offsetAfterFirst), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onHealthCheck rejects RTT exceeding maxRttMs`() {
+ var localTime = 0L
+ val sut = ServerClockOffset(localTimeMs = { localTime }, maxRttMs = 500L)
+ sut.onConnected(serverTime = Date(0L))
+
+ localTime = 1_000L
+ sut.onHealthCheckSent()
+ localTime = 2_000L
+ sut.onHealthCheck(serverTime = Date(1_500L))
+
+ // RTT = 1_000 > maxRttMs = 500 → rejected, offset unchanged (= 0)
+ assertEquals(Date(2_000L), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onHealthCheck rejects non-positive RTT`() {
+ val localTime = 1_000L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+ sut.onConnected(serverTime = Date(1_000L))
+
+ sut.onHealthCheckSent()
+ // localTime hasn't advanced → RTT = 0 → rejected
+ sut.onHealthCheck(serverTime = Date(1_000L))
+
+ assertEquals(Date(1_000L), sut.estimatedServerTime())
+ }
+
+ // ── Reconnect resets ────────────────────────────────────────────────
+
+ @Test
+ fun `onConnected resets bestRtt so health checks re-converge`() {
+ var localTime = 0L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+ sut.onConnected(serverTime = Date(0L))
+
+ // Excellent RTT on first connection
+ localTime = 1_000L
+ sut.onHealthCheckSent()
+ localTime = 1_050L
+ sut.onHealthCheck(serverTime = Date(1_025L))
+
+ // Reconnect resets bestRtt
+ localTime = 50_000L
+ sut.onConnected(serverTime = Date(50_000L))
+
+ // Worse RTT on new connection should still be accepted
+ localTime = 51_000L
+ sut.onHealthCheckSent()
+ localTime = 51_200L
+ sut.onHealthCheck(serverTime = Date(51_100L))
+
+ val expectedOffset = (51_000L + 51_200L) / 2 - 51_100L
+ localTime = 60_000L
+ assertEquals(Date(60_000L - expectedOffset), sut.estimatedServerTime())
+ }
+
+ // ── Clock directions with health check ──────────────────────────────
+
+ @Test
+ fun `clock 1 hour ahead is corrected by health check`() {
+ val skew = 3_600_000L
+ var localTime = 36_000_000L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+ sut.onConnected(serverTime = Date(localTime - skew))
+
+ localTime = 36_010_000L
+ sut.onHealthCheckSent()
+ localTime = 36_010_200L
+ val serverTimeAtMidpoint = (36_010_000L + 36_010_200L) / 2 - skew
+ sut.onHealthCheck(serverTime = Date(serverTimeAtMidpoint))
+
+ localTime = 36_020_000L
+ val expected = 36_020_000L - skew
+ assertEquals(Date(expected), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `clock 1 hour behind is corrected by health check`() {
+ val skew = -3_600_000L
+ var localTime = 28_800_000L
+ val sut = ServerClockOffset(localTimeMs = { localTime })
+ sut.onConnected(serverTime = Date(localTime - skew))
+
+ localTime = 28_810_000L
+ sut.onHealthCheckSent()
+ localTime = 28_810_200L
+ val serverTimeAtMidpoint = (28_810_000L + 28_810_200L) / 2 - skew
+ sut.onHealthCheck(serverTime = Date(serverTimeAtMidpoint))
+
+ localTime = 28_820_000L
+ val expected = 28_820_000L - skew
+ assertEquals(Date(expected), sut.estimatedServerTime())
+ }
+
+ // ── maxOffsetMs: implausibly large offsets are rejected ──────────────
+
+ @Test
+ fun `onConnected naive falls back to local time when offset exceeds maxOffsetMs`() {
+ // Simulates a mock server with a stale static timestamp (e.g. 155 days in the past)
+ val localTime = 10_000L
+ val sut = ServerClockOffset(localTimeMs = { localTime }, maxOffsetMs = 1_000L)
+
+ sut.onConnected(serverTime = Date(localTime - 5_000L)) // offset = 5_000 > 1_000
+
+ // offset rejected → estimatedServerTime == local time
+ assertEquals(Date(localTime), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onConnected NTP falls back to local time when offset exceeds maxOffsetMs`() {
+ var localTime = 0L
+ val sut = ServerClockOffset(localTimeMs = { localTime }, maxOffsetMs = 1_000L)
+
+ sut.onConnectionStarted()
+ localTime = 200L
+ sut.onConnected(serverTime = Date(localTime - 5_000L)) // offset = 5_000 > 1_000
+
+ localTime = 1_000L
+ assertEquals(Date(1_000L), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onConnected accepts offset exactly at maxOffsetMs boundary`() {
+ val localTime = 10_000L
+ val sut = ServerClockOffset(localTimeMs = { localTime }, maxOffsetMs = 1_000L)
+
+ sut.onConnected(serverTime = Date(localTime - 1_000L)) // offset = 1_000 == maxOffsetMs
+
+ assertEquals(Date(9_000L), sut.estimatedServerTime())
+ }
+
+ @Test
+ fun `onHealthCheck falls back to prior offset when new offset exceeds maxOffsetMs`() {
+ var localTime = 10_000L
+ val skew = 500L
+ val sut = ServerClockOffset(localTimeMs = { localTime }, maxOffsetMs = 1_000L)
+ sut.onConnected(serverTime = Date(localTime - skew)) // valid offset = 500
+
+ // Health check producing an implausibly large offset
+ localTime = 20_000L
+ sut.onHealthCheckSent()
+ localTime = 20_200L
+ sut.onHealthCheck(serverTime = Date(localTime - 5_000L)) // would-be offset = 5_000 > 1_000
+
+ // Offset unchanged from onConnected (= 500)
+ localTime = 30_000L
+ assertEquals(Date(30_000L - skew), sut.estimatedServerTime())
+ }
+}
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/attachments/ChannelMediaAttachmentsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/attachments/ChannelMediaAttachmentsActivity.kt
index 8b69c6de05d..53efdb3ed38 100644
--- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/attachments/ChannelMediaAttachmentsActivity.kt
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/attachments/ChannelMediaAttachmentsActivity.kt
@@ -34,7 +34,6 @@ import io.getstream.chat.android.compose.viewmodel.channel.ChannelAttachmentsVie
import io.getstream.chat.android.compose.viewmodel.channel.ChannelAttachmentsViewModelFactory
import io.getstream.chat.android.models.AttachmentType
import io.getstream.chat.android.ui.common.feature.channel.attachments.ChannelAttachmentsViewEvent
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
import kotlinx.coroutines.flow.collectLatest
class ChannelMediaAttachmentsActivity : ComponentActivity() {
@@ -50,7 +49,7 @@ class ChannelMediaAttachmentsActivity : ComponentActivity() {
ChannelAttachmentsViewModelFactory(
cid = requireNotNull(intent.getStringExtra(KEY_CID)),
attachmentTypes = listOf(AttachmentType.IMAGE, AttachmentType.VIDEO),
- localFilter = { !it.imagePreviewUrl.isNullOrEmpty() && it.titleLink.isNullOrEmpty() },
+ localFilter = { !(it.imageUrl ?: it.thumbUrl).isNullOrEmpty() && it.titleLink.isNullOrEmpty() },
)
}
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt
index 2e4c971e4ce..46800240d8c 100644
--- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt
@@ -101,7 +101,6 @@ import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewE
import io.getstream.chat.android.ui.common.state.channel.info.ChannelInfoViewState
import io.getstream.chat.android.ui.common.state.messages.list.ChannelHeaderViewState
import io.getstream.chat.android.ui.common.state.messages.list.DeletedMessageVisibility
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@@ -569,7 +568,7 @@ class ChatsActivity : ComponentActivity() {
val viewModelFactory = ChannelAttachmentsViewModelFactory(
cid = cid,
attachmentTypes = listOf(AttachmentType.IMAGE, AttachmentType.VIDEO),
- localFilter = { !it.imagePreviewUrl.isNullOrEmpty() && it.titleLink.isNullOrEmpty() },
+ localFilter = { !(it.imageUrl ?: it.thumbUrl).isNullOrEmpty() && it.titleLink.isNullOrEmpty() },
)
val viewModel = viewModel(
factory = viewModelFactory,
diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api
index bc9deeeae86..b19db7a075b 100644
--- a/stream-chat-android-compose/api/stream-chat-android-compose.api
+++ b/stream-chat-android-compose/api/stream-chat-android-compose.api
@@ -717,12 +717,14 @@ public abstract interface class io/getstream/chat/android/compose/ui/attachments
}
public final class io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler$Companion {
- public final fun defaultAttachmentHandlers (Landroid/content/Context;)Ljava/util/List;
+ public final fun defaultAttachmentHandlers (Landroid/content/Context;Z)Ljava/util/List;
+ public static synthetic fun defaultAttachmentHandlers$default (Lio/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler$Companion;Landroid/content/Context;ZILjava/lang/Object;)Ljava/util/List;
}
public final class io/getstream/chat/android/compose/ui/attachments/preview/handler/DocumentAttachmentPreviewHandler : io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler {
public static final field $stable I
- public fun (Landroid/content/Context;)V
+ public fun (Landroid/content/Context;Z)V
+ public synthetic fun (Landroid/content/Context;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun canHandle (Lio/getstream/chat/android/models/Attachment;)Z
public fun handleAttachmentPreview (Lio/getstream/chat/android/models/Attachment;)V
}
@@ -3918,7 +3920,7 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatTheme {
}
public final class io/getstream/chat/android/compose/ui/theme/ChatThemeKt {
- public static final fun ChatTheme (ZLio/getstream/chat/android/compose/ui/theme/ChatUiConfig;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Typography;Lio/getstream/chat/android/compose/ui/theme/StreamRippleConfiguration;Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Ljava/util/List;Lio/getstream/chat/android/compose/ui/util/ReactionResolver;Lio/getstream/chat/android/compose/ui/theme/ReactionOptionsTheme;Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory;ZLio/getstream/chat/android/ui/common/helper/DateFormatter;Lio/getstream/chat/android/ui/common/helper/TimeProvider;Lio/getstream/chat/android/ui/common/helper/DurationFormatter;Lio/getstream/chat/android/ui/common/utils/ChannelNameFormatter;Lio/getstream/chat/android/compose/ui/util/MessagePreviewFormatter;Lio/getstream/chat/android/compose/ui/util/SearchResultNameFormatter;Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory;Lio/getstream/chat/android/ui/common/helper/ImageHeadersProvider;Lio/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider;Lio/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator;Lio/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor;Lio/getstream/chat/android/ui/common/helper/ImageAssetTransformer;Lio/getstream/chat/android/compose/ui/util/MessageAlignmentProvider;Lio/getstream/chat/android/compose/ui/theme/MessageOptionsTheme;Lio/getstream/chat/android/compose/ui/theme/ChannelOptionsTheme;Lio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;Lio/getstream/chat/android/compose/ui/theme/MessageComposerTheme;Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;IIIII)V
+ public static final fun ChatTheme (ZLio/getstream/chat/android/compose/ui/theme/ChatUiConfig;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Typography;Lio/getstream/chat/android/compose/ui/theme/StreamRippleConfiguration;Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;ZLjava/util/List;Lio/getstream/chat/android/compose/ui/util/ReactionResolver;Lio/getstream/chat/android/compose/ui/theme/ReactionOptionsTheme;Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory;ZLio/getstream/chat/android/ui/common/helper/DateFormatter;Lio/getstream/chat/android/ui/common/helper/TimeProvider;Lio/getstream/chat/android/ui/common/helper/DurationFormatter;Lio/getstream/chat/android/ui/common/utils/ChannelNameFormatter;Lio/getstream/chat/android/compose/ui/util/MessagePreviewFormatter;Lio/getstream/chat/android/compose/ui/util/SearchResultNameFormatter;Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory;Lio/getstream/chat/android/ui/common/helper/ImageHeadersProvider;Lio/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider;Lio/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator;Lio/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor;Lio/getstream/chat/android/ui/common/helper/ImageAssetTransformer;Lio/getstream/chat/android/compose/ui/util/MessageAlignmentProvider;Lio/getstream/chat/android/compose/ui/theme/MessageOptionsTheme;Lio/getstream/chat/android/compose/ui/theme/ChannelOptionsTheme;Lio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;Lio/getstream/chat/android/compose/ui/theme/MessageComposerTheme;Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;IIIIII)V
public static final fun getLocalChatUiConfig ()Landroidx/compose/runtime/ProvidableCompositionLocal;
public static final fun getLocalComponentFactory ()Landroidx/compose/runtime/ProvidableCompositionLocal;
}
@@ -7052,7 +7054,8 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/MessageC
public final fun clearActiveCommand ()V
public final fun clearAttachments ()V
public final fun clearData ()V
- public final fun completeRecording ()V
+ public final fun completeRecording (Lkotlin/jvm/functions/Function1;)V
+ public static synthetic fun completeRecording$default (Lio/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
public final fun createPoll (Lio/getstream/chat/android/models/CreatePollParams;)V
public final fun dismissMessageActions ()V
public final fun getInputFocusEvents ()Lkotlinx/coroutines/flow/SharedFlow;
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/LinkAttachmentContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/LinkAttachmentContent.kt
index 91d0a0a881e..9bbb424cd60 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/LinkAttachmentContent.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/LinkAttachmentContent.kt
@@ -69,7 +69,7 @@ import io.getstream.chat.android.models.Attachment
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.ui.common.utils.extensions.addSchemeToUrlIfNeeded
import io.getstream.chat.android.ui.common.utils.extensions.hasLink
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
+import io.getstream.chat.android.ui.common.utils.extensions.linkPreviewImageUrl
/**
* Builds a link attachment message, which shows the link image preview, the title of the link
@@ -151,7 +151,7 @@ private fun FullSizeLinkAttachmentContent(
textColor: Color,
) {
Column(modifier = modifier) {
- attachment.imagePreviewUrl?.let {
+ attachment.linkPreviewImageUrl?.let {
LinkAttachmentImagePreview(it, Modifier.height(144.dp))
}
@@ -207,7 +207,7 @@ private fun CompactLinkAttachmentContent(
),
horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs),
) {
- attachment.imagePreviewUrl?.let {
+ attachment.linkPreviewImageUrl?.let {
LinkAttachmentImagePreview(
imageUrl = it,
modifier = Modifier
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt
index 8c0ffb8e724..4e06c56c916 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt
@@ -100,7 +100,6 @@ import io.getstream.chat.android.ui.common.helper.DownloadAttachmentUriGenerator
import io.getstream.chat.android.ui.common.helper.DownloadRequestInterceptor
import io.getstream.chat.android.ui.common.images.resizing.StreamCdnImageResizing
import io.getstream.chat.android.ui.common.utils.extensions.hasLink
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
/**
* Displays a preview of single or multiple video or attachments.
@@ -481,7 +480,7 @@ internal fun MediaAttachmentContentItem(
MediaAttachmentClickData(
mediaGalleryPreviewLauncher = mixedMediaPreviewLauncher,
message = message,
- selectedAttachmentUrl = attachment.imagePreviewUrl,
+ selectedAttachmentUrl = attachment.thumbUrl ?: attachment.imageUrl,
videoThumbnailsEnabled = videoThumbnailsEnabled,
downloadAttachmentUriGenerator = downloadAttachmentUriGenerator,
downloadRequestInterceptor = downloadRequestInterceptor,
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt
index 9e22ffc52a3..5482520e094 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt
@@ -91,7 +91,6 @@ import io.getstream.chat.android.models.Constants
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.models.User
import io.getstream.chat.android.ui.common.utils.extensions.hasLink
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
import kotlinx.coroutines.launch
import java.util.Date
@@ -365,7 +364,10 @@ public fun MediaGalleryPreviewScreen(
0
} else {
filteredAttachments
- .indexOfFirst { it.imagePreviewUrl == selectedAttachmentUrl }
+ .indexOfFirst {
+ val imagePreviewUrl = it.thumbUrl ?: it.imageUrl
+ imagePreviewUrl == selectedAttachmentUrl
+ }
.coerceAtLeast(0)
}
@@ -628,7 +630,6 @@ internal fun MediaGalleryPager(
* @param onLeadingContentClick Callback to be invoked when the leading content is clicked.
* @param onTrailingContentClick Callback to be invoked when the trailing content is clicked.
* @param modifier The [Modifier] to be applied to the footer.
- * @param elevation The elevation of the footer.
* @param backgroundColor The background color of the footer.
* @param contentColor The content color of the footer.
* @param config The configuration for the media gallery.
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler.kt
index eaa02dd8064..05195c7d52a 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler.kt
@@ -44,12 +44,18 @@ public interface AttachmentPreviewHandler {
* Builds the default list of file preview providers.
*
* @param context The context to start the preview Activity with.
+ * @param useDocumentGView Whether to use Google Docs Viewer for document attachments. When `true`
+ * (default), documents are rendered via Google Docs Viewer. When `false`, text-based files are
+ * rendered in-app and other file types are downloaded and opened with an external application.
* @return The list handlers that can be used to show a preview for an attachment.
*/
- public fun defaultAttachmentHandlers(context: Context): List {
+ public fun defaultAttachmentHandlers(
+ context: Context,
+ useDocumentGView: Boolean = true,
+ ): List {
return listOf(
MediaAttachmentPreviewHandler(context),
- DocumentAttachmentPreviewHandler(context),
+ DocumentAttachmentPreviewHandler(context, useDocumentGView),
UrlAttachmentPreviewHandler(context),
)
}
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/DocumentAttachmentPreviewHandler.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/DocumentAttachmentPreviewHandler.kt
index f0f82f2b186..4a5a9e0cfb9 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/DocumentAttachmentPreviewHandler.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/DocumentAttachmentPreviewHandler.kt
@@ -19,12 +19,22 @@ package io.getstream.chat.android.compose.ui.attachments.preview.handler
import android.content.Context
import io.getstream.chat.android.models.Attachment
import io.getstream.chat.android.ui.common.feature.documents.AttachmentDocumentActivity
+import io.getstream.chat.android.ui.common.feature.documents.DocumentAttachmentHandler
import io.getstream.chat.android.ui.common.model.MimeType
/**
- * Shows a preview for the document in the attachment using Google Docs.
+ * Shows a preview for document attachments.
+ *
+ * Behavior depends on [useDocumentGView]:
+ * - `true` (default): documents are rendered via Google Docs Viewer.
+ * - `false`: text-based files (TXT, HTML) are rendered in-app, others open with an external app.
+ *
+ * Set via `ChatTheme(useDocumentGView = false)`.
*/
-public class DocumentAttachmentPreviewHandler(private val context: Context) : AttachmentPreviewHandler {
+public class DocumentAttachmentPreviewHandler(
+ private val context: Context,
+ private val useDocumentGView: Boolean = true,
+) : AttachmentPreviewHandler {
override fun canHandle(attachment: Attachment): Boolean {
val assetUrl = attachment.assetUrl
@@ -45,6 +55,11 @@ public class DocumentAttachmentPreviewHandler(private val context: Context) : At
}
override fun handleAttachmentPreview(attachment: Attachment) {
- context.startActivity(AttachmentDocumentActivity.getIntent(context, attachment.assetUrl))
+ @Suppress("DEPRECATION")
+ if (useDocumentGView) {
+ context.startActivity(AttachmentDocumentActivity.getIntent(context, attachment.assetUrl))
+ } else {
+ DocumentAttachmentHandler.openAttachment(context, attachment)
+ }
}
}
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt
index 50b4d37a890..1bca3b4fa62 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt
@@ -66,7 +66,6 @@ import io.getstream.chat.android.compose.ui.theme.ChatTheme
import io.getstream.chat.android.compose.ui.util.StreamAsyncImage
import io.getstream.chat.android.compose.ui.util.clickable
import io.getstream.chat.android.models.Attachment
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
import kotlinx.coroutines.coroutineScope
import kotlin.math.abs
@@ -105,7 +104,7 @@ internal fun MediaGalleryImagePage(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
- val data = attachment.imagePreviewUrl
+ val data = attachment.imageUrl
val context = LocalContext.current
// Ensure we have a new imageRequest in case the data changes
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt
index f5a96a060c6..4c5cc84bb39 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt
@@ -39,7 +39,10 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.PlayerView
+import io.getstream.chat.android.client.ChatClient
+import io.getstream.chat.android.client.cdn.internal.StreamMediaDataSource
import io.getstream.chat.android.compose.R
import io.getstream.chat.android.compose.ui.components.LoadingIndicator
import io.getstream.chat.android.compose.ui.components.common.PlayButton
@@ -187,7 +190,11 @@ internal fun createPlayer(
onPlaybackError: (error: Throwable) -> Unit,
): Player {
// Setup player
- val player = ExoPlayer.Builder(context).build()
+ val cdn = ChatClient.instance().cdn
+ val dataSourceFactory = StreamMediaDataSource.factory(context, cdn)
+ val player = ExoPlayer.Builder(context)
+ .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
+ .build()
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_BUFFERING) {
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt
index ac11c5908c1..b820ab14364 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt
@@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import io.getstream.chat.android.client.extensions.duration
+import io.getstream.chat.android.client.utils.attachment.isImage
import io.getstream.chat.android.client.utils.attachment.isVideo
import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize
import io.getstream.chat.android.compose.ui.components.common.VideoBadge
@@ -49,7 +50,6 @@ import io.getstream.chat.android.compose.viewmodel.channel.ChannelAttachmentsVie
import io.getstream.chat.android.previewdata.PreviewMessageData
import io.getstream.chat.android.ui.common.feature.channel.attachments.ChannelAttachmentsViewAction
import io.getstream.chat.android.ui.common.state.channel.attachments.ChannelAttachmentsViewState
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
import io.getstream.result.Error
/**
@@ -142,7 +142,8 @@ internal fun ChannelMediaAttachmentsItem(
item: ChannelAttachmentsViewState.Content.Item,
onClick: () -> Unit,
) {
- val data = item.attachment.upload ?: item.attachment.imagePreviewUrl
+ val data = item.attachment.upload
+ ?: if (item.attachment.isImage()) item.attachment.imageUrl else item.attachment.thumbUrl
Box(
modifier = Modifier
.clickable(onClick = onClick),
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/ComposerLinkPreview.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/ComposerLinkPreview.kt
index a9d4c3875e6..63dc59ca594 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/ComposerLinkPreview.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/ComposerLinkPreview.kt
@@ -63,7 +63,7 @@ import io.getstream.chat.android.compose.ui.util.StreamAsyncImage
import io.getstream.chat.android.models.Attachment
import io.getstream.chat.android.models.LinkPreview
import io.getstream.chat.android.ui.common.utils.extensions.addSchemeToUrlIfNeeded
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
+import io.getstream.chat.android.ui.common.utils.extensions.linkPreviewImageUrl
import io.getstream.log.StreamLog
private const val TAG = "ComposerLinkPreview"
@@ -149,10 +149,10 @@ public fun ComposerLinkPreview(
@Composable
private fun ComposerLinkImagePreview(attachment: Attachment, colors: StreamDesign.Colors) {
- val imagePreviewUrl = attachment.imagePreviewUrl ?: return
+ val linkPreviewUrl = attachment.linkPreviewImageUrl ?: return
val shape = RoundedCornerShape(StreamTokens.radiusMd)
StreamAsyncImage(
- data = imagePreviewUrl,
+ data = linkPreviewUrl,
modifier = Modifier
.size(width = 40.dp, height = 40.dp)
.clip(shape)
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilder.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilder.kt
index 6535ed2436f..e0d29bbf849 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilder.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilder.kt
@@ -34,8 +34,9 @@ import io.getstream.chat.android.models.User
import io.getstream.chat.android.ui.common.helper.DurationFormatter
import io.getstream.chat.android.ui.common.images.resizing.StreamCdnImageResizing
import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled
+import io.getstream.chat.android.ui.common.utils.extensions.giphyFallbackPreviewUrl
import io.getstream.chat.android.ui.common.utils.extensions.hasLink
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
+import io.getstream.chat.android.ui.common.utils.extensions.linkPreviewImageUrl
internal class QuotedMessageBodyBuilder(
private val resources: Resources,
@@ -100,7 +101,7 @@ internal class QuotedMessageBodyBuilder(
QuotedMessageBody(
text = messageText.ifBlank { summary.linkAttachment.run { titleLink ?: ogUrl } }.orEmpty(),
iconId = R.drawable.stream_compose_ic_link,
- imagePreviewData = summary.linkAttachment.imagePreviewUrl,
+ imagePreviewData = summary.linkAttachment.linkPreviewImageUrl,
)
}
@@ -111,7 +112,7 @@ internal class QuotedMessageBodyBuilder(
resources.getString(R.string.stream_compose_quoted_message_giphy_tag)
},
iconId = R.drawable.stream_compose_ic_file,
- imagePreviewData = summary.giphyAttachment.imagePreviewUrl,
+ imagePreviewData = summary.giphyAttachment.giphyFallbackPreviewUrl,
)
}
@@ -200,14 +201,14 @@ internal class QuotedMessageBodyBuilder(
type == AttachmentType.IMAGE -> {
imageCount++
fileCount++
- mediaPreviewData = attachment.upload ?: attachment.imagePreviewUrl
+ mediaPreviewData = attachment.upload ?: attachment.imageUrl
?.applyStreamCdnImageResizingIfEnabled(streamCdnImageResizing)
}
type == AttachmentType.VIDEO -> {
videoCount++
fileCount++
- mediaPreviewData = attachment.upload ?: attachment.imagePreviewUrl
+ mediaPreviewData = attachment.upload ?: attachment.thumbUrl
}
type == AttachmentType.AUDIO_RECORDING -> {
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt
index 043de11552b..7296c5274ca 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt
@@ -77,6 +77,17 @@ public fun AttachmentPicker(
BackHandler(onBack = actions.onDismiss)
val context = LocalContext.current
+ val hasUnresolvedAttachments = attachmentsPickerViewModel.hasUnresolvedAttachments
+ LaunchedEffect(hasUnresolvedAttachments) {
+ if (hasUnresolvedAttachments) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.stream_ui_attachment_picker_error_unresolvable_attachments),
+ Toast.LENGTH_LONG,
+ ).show()
+ attachmentsPickerViewModel.clearUnresolvedAttachments()
+ }
+ }
LaunchedEffect(Unit) {
attachmentsPickerViewModel.submittedAttachments.collect { submitted ->
if (submitted.hasUnsupportedFiles) {
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt
index 98e5b06580e..562a433dea3 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt
@@ -60,6 +60,7 @@ import io.getstream.chat.android.ui.common.helper.DurationFormatter
import io.getstream.chat.android.ui.common.helper.ImageAssetTransformer
import io.getstream.chat.android.ui.common.helper.ImageHeadersProvider
import io.getstream.chat.android.ui.common.helper.TimeProvider
+import io.getstream.chat.android.ui.common.images.internal.CDNImageInterceptor
import io.getstream.chat.android.ui.common.images.resizing.StreamCdnImageResizing
import io.getstream.chat.android.ui.common.utils.ChannelNameFormatter
import io.getstream.sdk.chat.audio.recording.DefaultStreamMediaRecorder
@@ -174,6 +175,11 @@ private val LocalStreamMediaRecorder = compositionLocalOf {
* @param typography The set of typography styles we provide, wrapped in [StreamDesign.Typography].
* @param rippleConfiguration Defines the appearance for ripples.
* @param componentFactory Provide to customize the stateless components that are used throughout the UI
+ * @param attachmentFactories Attachment factories that we provide.
+ * @param useDocumentGView Whether to use Google Docs Viewer (gview) for document attachments. When `true` (default),
+ * documents are rendered via the legacy [AttachmentDocumentActivity] which loads them through Google Docs Viewer.
+ * When `false`, text-based files (TXT, HTML) are rendered in-app and other file types are downloaded and opened with an
+ * external application.
* @param attachmentPreviewHandlers Attachment preview handlers we provide.
* @param reactionResolver Provides available reactions and resolves reaction types to emoji codes.
* @param reactionOptionsTheme [ReactionOptionsTheme] Theme for the reaction option list in the selected message menu.
@@ -225,8 +231,9 @@ public fun ChatTheme(
lightTheme = !isInDarkMode,
),
componentFactory: ChatComponentFactory = DefaultChatComponentFactory(),
+ useDocumentGView: Boolean = true,
attachmentPreviewHandlers: List =
- AttachmentPreviewHandler.defaultAttachmentHandlers(LocalContext.current),
+ AttachmentPreviewHandler.defaultAttachmentHandlers(LocalContext.current, useDocumentGView),
reactionResolver: ReactionResolver = ReactionResolver.defaultResolver(),
reactionOptionsTheme: ReactionOptionsTheme = ReactionOptionsTheme.defaultTheme(),
messagePreviewIconFactory: MessagePreviewIconFactory = MessagePreviewIconFactory.defaultFactory(),
@@ -270,14 +277,16 @@ public fun ChatTheme(
}
val context = LocalContext.current
- val imageLoader = remember(imageLoaderFactory, asyncImageHeadersProvider) {
- if (asyncImageHeadersProvider == null) {
+ val cdn = remember { ChatClient.instance().cdn }
+ val imageLoader = remember(imageLoaderFactory, asyncImageHeadersProvider, cdn) {
+ val interceptors = buildList {
+ asyncImageHeadersProvider?.let { add(ImageHeadersInterceptor(it)) }
+ cdn?.let { add(CDNImageInterceptor(it)) }
+ }
+ if (interceptors.isEmpty()) {
imageLoaderFactory.imageLoader(context.applicationContext)
} else {
- imageLoaderFactory.imageLoader(
- context.applicationContext,
- listOf(ImageHeadersInterceptor(asyncImageHeadersProvider)),
- )
+ imageLoaderFactory.imageLoader(context.applicationContext, interceptors)
}
}
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageHeadersInterceptor.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageHeadersInterceptor.kt
index 9acaf6db0cc..13d9a07dbb6 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageHeadersInterceptor.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageHeadersInterceptor.kt
@@ -37,8 +37,18 @@ internal class ImageHeadersInterceptor(private val headersProvider: AsyncImageHe
val headers = withContext(Dispatchers.IO) {
headersProvider.getImageRequestHeaders(url)
}
+ // Merge: existing headers (from CDN interceptor / sync ImageHeadersProvider) as base,
+ // async provider headers override for same keys.
+ val existingHeaders = chain.request.httpHeaders
+ val mergedHeaders = buildMap {
+ existingHeaders.asMap().forEach { (name, values) ->
+ values.lastOrNull()?.let { put(name, it) }
+ }
+ putAll(headers)
+ }.toNetworkHeaders()
+
val newRequest = chain.request.newBuilder()
- .httpHeaders(headers.toNetworkHeaders())
+ .httpHeaders(mergedHeaders)
.build()
return chain.withRequest(newRequest).proceed()
}
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt
index e43db9f6225..9bd7ae444f5 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt
@@ -23,7 +23,6 @@ import io.getstream.chat.android.compose.ui.theme.ChatTheme
import io.getstream.chat.android.models.Attachment
import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI
import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
/**
* The content URI stored when the attachment was created from a device picker,
@@ -44,24 +43,35 @@ internal val Attachment.stableKey: String
/**
* Best available data source for rendering an unsent attachment preview.
*
- * Prefers [Attachment.upload] (local file), then [imagePreviewUrl] (CDN URL),
- * then [sourceUri] (content URI from the picker).
+ * Prefers [Attachment.upload] (local file), then [Attachment.imageUrl] (CDN URL for images), then [Attachment.thumbUrl]
+ * (CDN URL for video thumbnails), then [sourceUri] (content URI from the picker).
*/
internal val Attachment.localPreviewData: Any?
- get() = upload ?: imagePreviewUrl ?: sourceUri
+ get() = upload ?: imageUrl ?: thumbUrl ?: sourceUri
/**
* Image preview data for a sent or received attachment.
*
* Returns the CDN image URL (with Stream resizing applied) or the local [Attachment.upload] file
* for images and videos (when video thumbnails are enabled). Returns `null` for other types.
+ * This property checks if the attachment is an image or a video with enabled thumbnails.
+ * If so, it returns the appropriate URL (applied with Stream CDN image resizing if enabled)
+ * or the upload [java.io.File] object.
+ * Otherwise, it returns null.
+ *
+ * For image attachments, [Attachment.imageUrl] is used.
+ * For video attachments when thumbnails are enabled, [Attachment.thumbUrl] is used.
*/
@get:Composable
internal val Attachment.imagePreviewData: Any?
- get() = if (isImage() || (isVideo() && ChatTheme.config.messageList.videoThumbnailsEnabled)) {
- imagePreviewUrl
- ?.applyStreamCdnImageResizingIfEnabled(ChatTheme.streamCdnImageResizing)
- ?: upload
- } else {
- null
+ get() = when {
+ isImage() ->
+ imageUrl
+ ?.applyStreamCdnImageResizingIfEnabled(ChatTheme.streamCdnImageResizing)
+ ?: upload
+ isVideo() && ChatTheme.config.messageList.videoThumbnailsEnabled ->
+ thumbUrl
+ ?.applyStreamCdnImageResizingIfEnabled(ChatTheme.streamCdnImageResizing)
+ ?: upload
+ else -> null
}
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModel.kt
index 0e99ca82159..dddf51db36c 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModel.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModel.kt
@@ -25,7 +25,6 @@ import io.getstream.chat.android.models.Attachment
import io.getstream.chat.android.ui.common.feature.channel.attachments.ChannelAttachmentsViewController
import io.getstream.chat.android.ui.common.utils.AttachmentConstants
import io.getstream.chat.android.ui.common.utils.extensions.getDisplayableName
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
import io.getstream.log.taggedLogger
import io.getstream.result.Error
import io.getstream.result.onErrorSuspend
@@ -81,7 +80,10 @@ internal class ChannelMediaAttachmentsPreviewViewModel(
}
private fun startSharing(attachment: Attachment) {
- logger.d { "[startSharing] mimeType: ${attachment.mimeType}, attachment: ${attachment.imagePreviewUrl}" }
+ logger.d {
+ "[startSharing] mimeType: ${attachment.mimeType}, imageUrl: ${attachment.imageUrl}, " +
+ "thumbUrl: ${attachment.thumbUrl}"
+ }
if (attachment.fileSize >= AttachmentConstants.MAX_SIZE_BEFORE_DOWNLOAD_WARNING_IN_BYTES) {
logger.d {
"[startSharing] Attachment larger than " +
@@ -111,7 +113,10 @@ internal class ChannelMediaAttachmentsPreviewViewModel(
}
private fun share(attachment: Attachment) {
- logger.d { "[share] mimeType: ${attachment.mimeType}, attachment: ${attachment.imagePreviewUrl}" }
+ logger.d {
+ "[share] mimeType: ${attachment.mimeType}, imageUrl: ${attachment.imageUrl}, " +
+ "thumbUrl: ${attachment.thumbUrl}"
+ }
_state.update { currentState ->
currentState.copy(
isPreparingToShare = true,
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt
index 06e2cd24ca3..b7980766f75 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt
@@ -18,6 +18,8 @@ package io.getstream.chat.android.compose.viewmodel.messages
import android.net.Uri
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -164,6 +166,22 @@ public class AttachmentsPickerViewModel @JvmOverloads constructor(
if (!visible) resetPickerState()
}
+ /**
+ * Set to `true` when one or more selected attachments could not be resolved (e.g. the
+ * content URI points to a cloud file that is not locally available). The UI layer should
+ * observe this flag and show an appropriate message, then call [clearUnresolvedAttachments]
+ * to reset it.
+ */
+ internal var hasUnresolvedAttachments: Boolean by mutableStateOf(false)
+ private set
+
+ /**
+ * Resets the [hasUnresolvedAttachments] flag after the UI has consumed the event.
+ */
+ internal fun clearUnresolvedAttachments() {
+ hasUnresolvedAttachments = false
+ }
+
/**
* Toggles the attachment picker visibility.
*/
@@ -256,6 +274,9 @@ public class AttachmentsPickerViewModel @JvmOverloads constructor(
viewModelScope.launch {
val metadata = withContext(DispatcherProvider.IO) { storageHelper.resolveMetadata(uris) }
val attachments = storageHelper.toAttachments(metadata)
+ if (attachments.size < metadata.size) {
+ hasUnresolvedAttachments = true
+ }
_submittedAttachments.trySend(
SubmittedAttachments(
attachments = attachments,
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt
index 30ea3cefe25..1bd0ef71231 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt
@@ -32,6 +32,7 @@ import io.getstream.chat.android.ui.common.state.messages.MessageMode
import io.getstream.chat.android.ui.common.state.messages.Reply
import io.getstream.chat.android.ui.common.state.messages.composer.MessageComposerState
import io.getstream.chat.android.ui.common.utils.typing.TypingUpdatesBuffer
+import io.getstream.result.Result
import io.getstream.result.call.Call
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
@@ -258,7 +259,8 @@ public class MessageComposerViewModel(
public fun toggleRecordingPlayback(): Unit = messageComposerController.toggleRecordingPlayback()
- public fun completeRecording(): Unit = messageComposerController.completeRecording()
+ public fun completeRecording(onComplete: ((Result) -> Unit)? = null): Unit =
+ messageComposerController.completeRecording(onComplete)
public fun pauseRecording(): Unit = messageComposerController.pauseRecording()
diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilderTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilderTest.kt
index 56675309193..fc87a15010c 100644
--- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilderTest.kt
+++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilderTest.kt
@@ -194,7 +194,7 @@ internal class QuotedMessageBodyBuilderTest {
Attachment(
type = AttachmentType.GIPHY,
name = "Happy Dance",
- imageUrl = "https://giphy.com/image.gif",
+ thumbUrl = "https://giphy.com/image.gif",
),
),
),
@@ -214,7 +214,7 @@ internal class QuotedMessageBodyBuilderTest {
Attachment(
type = AttachmentType.GIPHY,
name = "Happy Dance",
- imageUrl = "https://giphy.com/image.gif",
+ thumbUrl = "https://giphy.com/image.gif",
),
),
),
@@ -304,7 +304,7 @@ internal class QuotedMessageBodyBuilderTest {
Attachment(
type = AttachmentType.VIDEO,
assetUrl = "https://example.com/video.mp4",
- imageUrl = "https://example.com/thumb.jpg",
+ thumbUrl = "https://example.com/thumb.jpg",
),
),
),
@@ -324,7 +324,7 @@ internal class QuotedMessageBodyBuilderTest {
Attachment(
type = AttachmentType.VIDEO,
assetUrl = "https://example.com/video.mp4",
- imageUrl = "https://example.com/thumb.jpg",
+ thumbUrl = "https://example.com/thumb.jpg",
),
),
),
diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt
index 7979a8f0a09..18b11edad27 100644
--- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt
+++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt
@@ -518,6 +518,73 @@ internal class AttachmentsPickerViewModelTest {
assertTrue(results.isEmpty())
}
+ @Test
+ fun `Given unresolvable metadata When resolving URIs Should set hasUnresolvedAttachments`() = runTest {
+ val uris = listOf(imageUri1, imageUri2)
+ val metadata = listOf(imageAttachment1, imageAttachment2)
+ val partialAttachments = listOf(Attachment(type = "image", upload = mock()))
+ whenever(storageHelper.resolveMetadata(uris)) doReturn metadata
+ whenever(storageHelper.toAttachments(metadata)) doReturn partialAttachments
+ val viewModel = createViewModel()
+
+ assertFalse(viewModel.hasUnresolvedAttachments)
+
+ val results = mutableListOf()
+ val job = launch(UnconfinedTestDispatcher(testScheduler)) {
+ viewModel.submittedAttachments.collect(results::add)
+ }
+ viewModel.resolveAndSubmitUris(uris)
+ advanceUntilIdle()
+ job.cancel()
+
+ assertEquals(1, results.first().attachments.size)
+ assertTrue(viewModel.hasUnresolvedAttachments)
+ }
+
+ @Test
+ fun `Given hasUnresolvedAttachments is true When clearing Should reset to false`() = runTest {
+ val uris = listOf(imageUri1, imageUri2)
+ val metadata = listOf(imageAttachment1, imageAttachment2)
+ whenever(storageHelper.resolveMetadata(uris)) doReturn metadata
+ whenever(storageHelper.toAttachments(metadata)) doReturn listOf(Attachment(type = "image", upload = mock()))
+ val viewModel = createViewModel()
+
+ val job = launch(UnconfinedTestDispatcher(testScheduler)) {
+ viewModel.submittedAttachments.collect {}
+ }
+ viewModel.resolveAndSubmitUris(uris)
+ advanceUntilIdle()
+ assertTrue(viewModel.hasUnresolvedAttachments)
+
+ viewModel.clearUnresolvedAttachments()
+ assertFalse(viewModel.hasUnresolvedAttachments)
+ job.cancel()
+ }
+
+ @Test
+ fun `Given all attachments resolved When resolving URIs Should not set hasUnresolvedAttachments`() = runTest {
+ val uris = listOf(imageUri1, imageUri2)
+ val metadata = listOf(imageAttachment1, imageAttachment2)
+ val expectedAttachments = listOf(
+ Attachment(type = "image", upload = mock()),
+ Attachment(type = "image", upload = mock()),
+ )
+ whenever(storageHelper.resolveMetadata(uris)) doReturn metadata
+ whenever(storageHelper.toAttachments(metadata)) doReturn expectedAttachments
+ val viewModel = createViewModel()
+
+ val results = mutableListOf()
+ val job = launch(UnconfinedTestDispatcher(testScheduler)) {
+ viewModel.submittedAttachments.collect(results::add)
+ }
+ viewModel.resolveAndSubmitUris(uris)
+ advanceUntilIdle()
+ job.cancel()
+
+ assertEquals(2, results.first().attachments.size)
+ assertFalse(viewModel.hasUnresolvedAttachments)
+ }
+
private fun createViewModel(): AttachmentsPickerViewModel =
AttachmentsPickerViewModel(storageHelper, channelState)
diff --git a/stream-chat-android-ui-common/src/main/AndroidManifest.xml b/stream-chat-android-ui-common/src/main/AndroidManifest.xml
index ed4a5f15850..46b41f79fec 100644
--- a/stream-chat-android-ui-common/src/main/AndroidManifest.xml
+++ b/stream-chat-android-ui-common/src/main/AndroidManifest.xml
@@ -33,7 +33,6 @@
android:exported="false"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
/>
-
(android.R.id.content)
+
+ lifecycleOwner.lifecycleScope.launch {
+ var snackbar: Snackbar? = null
+ val snackbarJob = rootView?.let {
+ launch {
+ delay(SNACKBAR_DELAY_MS)
+ snackbar = Snackbar.make(
+ it,
+ context.getString(
+ R.string.stream_ui_message_list_attachment_downloading,
+ MediaStringUtil.convertFileSizeByteCount(0L),
+ MediaStringUtil.convertFileSizeByteCount(attachment.fileSize.toLong()),
+ ),
+ Snackbar.LENGTH_INDEFINITE,
+ ).also { sb -> sb.show() }
+ }
+ }
+
+ shareFileManager.writeAttachmentToShareableFile(
+ context = context,
+ attachment = attachment,
+ onProgress = { bytesDownloaded, totalBytes ->
+ snackbar?.let { sb ->
+ val downloaded = MediaStringUtil.convertFileSizeByteCount(bytesDownloaded)
+ val total = MediaStringUtil.convertFileSizeByteCount(totalBytes)
+ val text = context.getString(
+ R.string.stream_ui_message_list_attachment_downloading,
+ downloaded,
+ total,
+ )
+ sb.view.post { sb.setText(text) }
+ }
+ },
+ )
+ .onSuccess { uri ->
+ snackbarJob?.cancel()
+ snackbar?.dismiss()
+ openFileUri(context, uri, attachment)
+ }
+ .onError { error ->
+ snackbarJob?.cancel()
+ snackbar?.dismiss()
+ logger.e { "[openWithExternalApp] Failed to download file: ${error.message}" }
+ val msg = context.getString(
+ R.string.stream_ui_message_list_attachment_download_failed,
+ attachment.name ?: "",
+ )
+ Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun openFileUri(context: Context, uri: android.net.Uri, attachment: Attachment) {
+ try {
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, attachment.mimeType)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, attachment.name))
+ } catch (e: ActivityNotFoundException) {
+ logger.e(e) { "[openFileUri] No app available to open file." }
+ Toast.makeText(context, R.string.stream_ui_message_list_attachment_no_app, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun Context.findLifecycleOwner(): LifecycleOwner? {
+ var ctx: Context? = this
+ while (ctx != null) {
+ if (ctx is LifecycleOwner) return ctx
+ ctx = (ctx as? ContextWrapper)?.baseContext
+ }
+ return null
+ }
+
+ private fun Context.findActivity(): Activity? {
+ var ctx: Context? = this
+ while (ctx != null) {
+ if (ctx is Activity) return ctx
+ ctx = (ctx as? ContextWrapper)?.baseContext
+ }
+ return null
+ }
+}
diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt
index b7ba1a216b5..966745d9cdb 100644
--- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt
+++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt
@@ -1018,12 +1018,23 @@ public class MessageComposerController(
}
/**
- * Completes audio recording and moves [MessageComposerState.recording] state to [RecordingState.Complete].
- * Also, it wil update [MessageComposerState.attachments] list.
+ * Completes audio recording and updates the [MessageComposerState.attachments] list.
+ *
+ * @param onComplete Optional callback invoked with the result of the recording once the recording has been
+ * finalized. On success, the recorded [Attachment] is added to the attachment list before the callback
+ * is invoked, so callers can safely build and send a message using the received attachment.
*/
- public fun completeRecording() {
+ public fun completeRecording(onComplete: ((Result) -> Unit)? = null) {
scope.launch {
- audioRecordingController.completeRecording()
+ if (onComplete != null) {
+ val result = audioRecordingController.completeRecordingSync()
+ if (result is Result.Success) {
+ addAttachments(listOf(result.value))
+ }
+ onComplete(result)
+ } else {
+ audioRecordingController.completeRecording()
+ }
}
}
diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider.kt
index 8b1c07e3fa5..35f452ace31 100644
--- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider.kt
+++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider.kt
@@ -24,10 +24,13 @@ package io.getstream.chat.android.ui.common.helper
* Implementations are always invoked on [kotlinx.coroutines.Dispatchers.IO], so blocking
* calls are safe.
*
- * Prefer this over [ImageHeadersProvider] when integrating with [ChatTheme].
+ * @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.
*
* @see ImageHeadersProvider
*/
+@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().")
public interface AsyncImageHeadersProvider {
/**
diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator.kt
index c744532364d..6a81cbc4de6 100644
--- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator.kt
+++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator.kt
@@ -21,7 +21,12 @@ import io.getstream.chat.android.models.Attachment
/**
* Generates a download URI for the given attachment.
+ *
+ * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via
+ * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to transform URLs for all image, file,
+ * and download requests.
*/
+@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().")
public fun interface DownloadAttachmentUriGenerator {
/**
@@ -37,7 +42,12 @@ public fun interface DownloadAttachmentUriGenerator {
/**
* Default implementation of [DownloadAttachmentUriGenerator] that generates a download URI based on the asset URL
* or image URL of the attachment.
+ *
+ * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via
+ * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to transform URLs for all image, file,
+ * and download requests.
*/
+@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().")
public object DefaultDownloadAttachmentUriGenerator : DownloadAttachmentUriGenerator {
override fun generateDownloadUri(attachment: Attachment): Uri =
Uri.parse(attachment.assetUrl ?: attachment.imageUrl)
diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor.kt
index 0bc7734e2e2..d3ba7d4ac37 100644
--- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor.kt
+++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor.kt
@@ -20,7 +20,12 @@ import android.app.DownloadManager
/**
* Intercepts and modifies the download request before it is enqueued.
+ *
+ * @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 interface DownloadRequestInterceptor {
/**
diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageAssetTransformer.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageAssetTransformer.kt
index d1803e8568e..6568170c4d4 100644
--- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageAssetTransformer.kt
+++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageAssetTransformer.kt
@@ -25,8 +25,13 @@ import java.io.File
import java.nio.ByteBuffer
/**
- * Provides HTTP headers for image loading requests.
+ * Transforms image assets before loading.
+ *
+ * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via
+ * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to transform URLs for all image, file,
+ * and download requests.
*/
+@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().")
public interface ImageAssetTransformer {
/**
@@ -51,7 +56,12 @@ public interface ImageAssetTransformer {
/**
* Default implementation of [ImageAssetTransformer] that doesn't provide any headers.
+ *
+ * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via
+ * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to transform URLs for all image, file,
+ * and download requests.
*/
+@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().")
public object DefaultImageAssetTransformer : ImageAssetTransformer {
override fun transform(asset: Any): Any = asset
}
diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageHeadersProvider.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageHeadersProvider.kt
index c58bdd300f3..5147d70a86f 100644
--- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageHeadersProvider.kt
+++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageHeadersProvider.kt
@@ -18,7 +18,12 @@ package io.getstream.chat.android.ui.common.helper
/**
* Provides HTTP headers for image loading requests.
+ *
+ * @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 interface ImageHeadersProvider {
/**
@@ -32,7 +37,12 @@ public interface ImageHeadersProvider {
/**
* Default implementation of [ImageHeadersProvider] that doesn't provide any headers.
+ *
+ * @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 object DefaultImageHeadersProvider : ImageHeadersProvider {
override fun getImageRequestHeaders(url: String): Map = emptyMap()
}
diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/VideoHeadersProvider.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/VideoHeadersProvider.kt
index 2504b4e13fa..6aa48591679 100644
--- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/VideoHeadersProvider.kt
+++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/VideoHeadersProvider.kt
@@ -20,7 +20,12 @@ import io.getstream.chat.android.core.internal.InternalStreamChatApi
/**
* Provides HTTP headers for video loading requests.
+ *
+ * @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 interface VideoHeadersProvider {
/**
diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/StorageHelper.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/StorageHelper.kt
index 9e26d93cb6d..98e10222771 100644
--- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/StorageHelper.kt
+++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/StorageHelper.kt
@@ -25,6 +25,7 @@ import android.webkit.MimeTypeMap
import io.getstream.chat.android.client.internal.file.StreamFileManager
import io.getstream.chat.android.models.AttachmentType
import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData
+import io.getstream.log.taggedLogger
import java.io.File
/**
@@ -43,6 +44,7 @@ import java.io.File
@Suppress("TooManyFunctions")
public class StorageHelper {
private val fileManager = StreamFileManager()
+ private val logger by taggedLogger("Chat:StorageHelper")
/**
* Retrieves or creates a cached copy of a file from the given attachment metadata.
@@ -55,12 +57,16 @@ public class StorageHelper {
* to prevent naming conflicts. The file name is derived from the attachment's title with
* proper extension handling.
*
+ * Content URIs backed by cloud storage providers (e.g. Google Drive) may fail to provide
+ * an input stream if the file is not locally available. In such cases this method returns
+ * `null` instead of throwing.
+ *
* @param context The Android context used to access the content resolver and cache directory.
* @param attachmentMetaData The attachment metadata containing either a file or URI reference.
- * @return A [File] object pointing to the cached file, or `null` if both file and URI are null.
- *
- * @throws java.io.IOException If there's an error reading from the URI or writing to cache.
+ * @return A [File] object pointing to the cached file, or `null` if both file and URI are null
+ * or if the content could not be read from the URI.
*/
+ @Suppress("TooGenericExceptionCaught")
public fun getCachedFileFromUri(
context: Context,
attachmentMetaData: AttachmentMetaData,
@@ -68,13 +74,16 @@ public class StorageHelper {
if (attachmentMetaData.file == null && attachmentMetaData.uri == null) {
return null
}
- if (attachmentMetaData.file != null) {
- return attachmentMetaData.file!!
- }
+ attachmentMetaData.file?.let { return it }
+ val uri = attachmentMetaData.uri ?: return null
val fileName = attachmentMetaData.getTitleWithExtension()
- val inputStream = context.contentResolver.openInputStream(attachmentMetaData.uri!!)
- ?: return null
+ val inputStream = try {
+ context.contentResolver.openInputStream(uri)
+ } catch (e: Exception) {
+ logger.e(e) { "[getCachedFileFromUri] Failed to open input stream for URI: $uri" }
+ null
+ } ?: return null
return fileManager.writeFileInTimestampedCache(
context = context,
fileName = fileName,
diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt
index 77012a3472c..47eac193416 100644
--- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt
+++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt
@@ -29,7 +29,6 @@ import coil3.request.allowHardware
import coil3.request.crossfade
import coil3.video.VideoFrameDecoder
import io.getstream.chat.android.client.internal.file.StreamFileManager
-import kotlinx.coroutines.Dispatchers
import okio.Path.Companion.toOkioPath
private const val DEFAULT_MEMORY_PERCENTAGE = 0.25
@@ -81,7 +80,6 @@ public class StreamImageLoaderFactory(
.maxSizePercent(DEFAULT_DISK_CACHE_PERCENTAGE)
.build()
}
- .interceptorCoroutineContext(Dispatchers.IO)
.components {
interceptors.forEach { add(it) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptor.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptor.kt
new file mode 100644
index 00000000000..184a2e6266b
--- /dev/null
+++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptor.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.ui.common.images.internal
+
+import coil3.intercept.Interceptor
+import coil3.network.httpHeaders
+import coil3.request.ImageResult
+import io.getstream.chat.android.client.cdn.CDN
+import io.getstream.chat.android.core.internal.InternalStreamChatApi
+import io.getstream.log.taggedLogger
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * A Coil [Interceptor] that intercepts image requests and applies CDN transformations.
+ *
+ * The interceptor calls [CDN.imageRequest] to obtain a potentially modified URL and additional
+ * headers. CDN headers take precedence over any headers already present on the request
+ * (e.g. from [io.getstream.chat.android.ui.common.helper.ImageHeadersProvider]), overriding
+ * them for the same key.
+ *
+ * Only HTTP/HTTPS URLs are intercepted; local resources, content URIs, etc. pass through unchanged.
+ */
+@InternalStreamChatApi
+public class CDNImageInterceptor(private val cdn: CDN) : Interceptor {
+
+ private val logger by taggedLogger("Chat:CDNImageInterceptor")
+
+ override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
+ val request = chain.request
+ val url = request.data.toString()
+
+ // Only intercept http/https URLs
+ if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) {
+ return chain.proceed()
+ }
+
+ val cdnRequest = try {
+ withContext(Dispatchers.IO) {
+ cdn.imageRequest(url)
+ }
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ logger.e(e) { "[intercept] CDN.imageRequest() failed for url: $url. Falling back to original request." }
+ return chain.proceed()
+ }
+
+ // Merge headers: existing request headers as base, CDN headers override for same keys
+ val existingHeaders = request.httpHeaders
+ val mergedHeaders = buildMap {
+ existingHeaders.asMap().forEach { (name, values) ->
+ values.lastOrNull()?.let { put(name, it) }
+ }
+ cdnRequest.headers?.let { putAll(it) }
+ }.toNetworkHeaders()
+
+ val newRequest = request.newBuilder()
+ .data(cdnRequest.url)
+ .httpHeaders(mergedHeaders)
+ .build()
+
+ return chain.withRequest(newRequest).proceed()
+ }
+}
diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/StreamCoil.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/StreamCoil.kt
index 235d2d3de85..0cdb1659bbe 100644
--- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/StreamCoil.kt
+++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/StreamCoil.kt
@@ -19,6 +19,7 @@ package io.getstream.chat.android.ui.common.images.internal
import android.content.Context
import coil3.ImageLoader
import coil3.SingletonImageLoader
+import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.core.internal.InternalStreamChatApi
import io.getstream.chat.android.ui.common.images.StreamImageLoaderFactory
@@ -47,7 +48,11 @@ public object StreamCoil {
}
private fun newImageLoaderFactory(): SingletonImageLoader.Factory {
- return StreamImageLoaderFactory().apply {
+ val cdn = ChatClient.instance().cdn
+ val interceptors = buildList {
+ cdn?.let { add(CDNImageInterceptor(it)) }
+ }
+ return StreamImageLoaderFactory(interceptors).apply {
imageLoaderFactory = this
}
}
diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManager.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManager.kt
index 20599a22754..10b2b2ad634 100644
--- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManager.kt
+++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManager.kt
@@ -31,6 +31,8 @@ import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
+import java.io.FilterInputStream
+import java.io.InputStream
/**
* Class handling operations related to sharing files with external apps.
@@ -39,11 +41,14 @@ import java.io.File
public class StreamShareFileManager(
private val fileManager: StreamFileManager = StreamFileManager(),
private val uriProvider: ShareableUriProvider = ShareableUriProvider(),
+ private val config: ShareCacheConfig = ShareCacheConfig(),
) {
/**
* Writes a bitmap to a shareable file in the cache directory and returns a shareable URI.
*
+ * Uses a fixed filename so that only the most recent shared bitmap is kept in cache.
+ *
* @param context The Android context.
* @param bitmap The bitmap to write.
* @return A [Result] containing the [Uri] of the shareable file, or an error if the operation fails.
@@ -53,14 +58,13 @@ public class StreamShareFileManager(
context: Context,
bitmap: Bitmap,
): Result = withContext(DispatcherProvider.IO) {
- val fileName = "shared_image_${System.currentTimeMillis()}.png"
try {
val byteArrayOutputStream = ByteArrayOutputStream()
- bitmap.compress(Bitmap.CompressFormat.PNG, BITMAP_QUALITY, byteArrayOutputStream)
+ bitmap.compress(Bitmap.CompressFormat.PNG, config.bitmapQuality, byteArrayOutputStream)
val byteArray = byteArrayOutputStream.toByteArray()
val inputStream = ByteArrayInputStream(byteArray)
fileManager
- .writeFileInCache(context, fileName, inputStream)
+ .writeFileInCache(context, config.bitmapShareFilename, inputStream)
.map { file -> getUriForFile(context, file) }
} catch (e: Exception) {
Result.Failure(Error.ThrowableError("Could not write bitmap.", e))
@@ -74,20 +78,21 @@ public class StreamShareFileManager(
*
* @param context The Android context.
* @param attachment The attachment to write.
+ * @param onProgress Optional callback informing the caller about the download progress.
* @param chatClient Lambda providing the [ChatClient] instance for downloading. Defaults to [ChatClient.instance].
* @return A [Result] containing the [Uri] of the shareable file, or an error if the operation fails.
*/
public suspend fun writeAttachmentToShareableFile(
context: Context,
attachment: Attachment,
+ onProgress: ((bytesDownloaded: Long, totalBytes: Long) -> Unit)? = null,
chatClient: () -> ChatClient = { ChatClient.instance() },
): Result = withContext(DispatcherProvider.IO) {
- // Check if already cached
val cachedFile = getCachedFileForAttachment(context, attachment)
if (cachedFile is Result.Success) {
return@withContext Result.Success(getUriForFile(context, cachedFile.value))
}
- // Not cached -> download and cache
+ fileManager.evictCacheFiles(context, config.cacheFilePrefix, config.cacheTtlMs, config.maxCacheSizeBytes)
val url = attachment.assetUrl ?: attachment.imageUrl
?: return@withContext Result.Failure(Error.GenericError(message = "File URL cannot be null."))
@@ -96,7 +101,11 @@ public class StreamShareFileManager(
.await()
.flatMap { response ->
val fileName = getCacheFileName(attachment)
- val source = response.byteStream()
+ val source = if (onProgress != null) {
+ ProgressInputStream(response.byteStream(), attachment.fileSize.toLong(), onProgress)
+ } else {
+ response.byteStream()
+ }
fileManager.writeFileInCache(context, fileName, source)
}
.map { file -> getUriForFile(context, file) }
@@ -122,26 +131,64 @@ public class StreamShareFileManager(
private suspend fun getCachedFileForAttachment(context: Context, attachment: Attachment): Result {
val fileName = getCacheFileName(attachment)
return fileManager.getFileFromCache(context, fileName).flatMap { file ->
- // Ensure attachment was really cached
- if (file.exists() && file.length() == attachment.fileSize.toLong()) {
+ if (isCachedFileValid(file, attachment.fileSize.toLong())) {
Result.Success(file)
} else {
- Result.Failure(Error.GenericError("Cached file is invalid or incomplete."))
+ Result.Failure(Error.GenericError("Cached file is invalid, incomplete, or expired."))
}
}
}
+ private fun isCachedFileValid(file: File, expectedSize: Long): Boolean =
+ file.exists() &&
+ file.length() == expectedSize &&
+ System.currentTimeMillis() - file.lastModified() < config.cacheTtlMs
+
private fun getCacheFileName(attachment: Attachment): String {
val url = attachment.assetUrl ?: attachment.imageUrl
val hashCode = url?.hashCode() ?: 0
- return "${CACHE_FILE_PREFIX}${hashCode}${attachment.name}"
+ return "${config.cacheFilePrefix}${hashCode}${attachment.name}"
}
private fun getUriForFile(context: Context, file: File): Uri =
uriProvider.getUriForFile(context, file)
+}
+
+/**
+ * Configuration for the share file cache.
+ *
+ * @param cacheFilePrefix Filename prefix for cached attachment files.
+ * @param bitmapShareFilename Fixed filename used for shared bitmaps.
+ * @param bitmapQuality Compression quality (0-100) for shared bitmap PNGs (default: 90).
+ * @param cacheTtlMs Maximum age in milliseconds before a cached file is considered expired (default: 5 min.).
+ * @param maxCacheSizeBytes Soft size cap in bytes for all cached attachment files (default: 25MB).
+ */
+@Suppress("MagicNumber")
+@InternalStreamChatApi
+public data class ShareCacheConfig(
+ val cacheFilePrefix: String = "TMP",
+ val bitmapShareFilename: String = "shared_image.png",
+ val bitmapQuality: Int = 90,
+ val cacheTtlMs: Long = 5 * 60 * 1000L,
+ val maxCacheSizeBytes: Long = 25L * 1024 * 1024,
+)
+
+/**
+ * An [InputStream] wrapper that reports read progress via [onProgress].
+ */
+private class ProgressInputStream(
+ delegate: InputStream,
+ private val totalBytes: Long,
+ private val onProgress: (bytesRead: Long, totalBytes: Long) -> Unit,
+) : FilterInputStream(delegate) {
+ private var bytesRead = 0L
- private companion object {
- private const val CACHE_FILE_PREFIX = "TMP"
- private const val BITMAP_QUALITY = 90
+ override fun read(b: ByteArray, off: Int, len: Int): Int {
+ val count = super.read(b, off, len)
+ if (count > 0) {
+ bytesRead += count
+ onProgress(bytesRead, totalBytes)
+ }
+ return count
}
}
diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Attachment.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Attachment.kt
index 4e65215bcee..d933b65ce7c 100644
--- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Attachment.kt
+++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Attachment.kt
@@ -21,6 +21,7 @@ import io.getstream.chat.android.client.utils.attachment.isAudio
import io.getstream.chat.android.client.utils.attachment.isAudioRecording
import io.getstream.chat.android.client.utils.attachment.isFile
import io.getstream.chat.android.client.utils.attachment.isVideo
+import io.getstream.chat.android.core.internal.InternalStreamChatApi
import io.getstream.chat.android.models.Attachment
import io.getstream.chat.android.ui.common.helper.internal.StorageHelper
import io.getstream.chat.android.ui.common.utils.StringUtils
@@ -42,9 +43,43 @@ public fun Attachment.getDisplayableName(): String? {
*
* It first checks for the thumbnail URL, and if not present, falls back to the main image URL.
*/
+@Deprecated(
+ message = "Use the appropriate field for your attachment type: " +
+ "imageUrl for image attachments, " +
+ "thumbUrl for video thumbnails and link/giphy previews.",
+ level = DeprecationLevel.WARNING,
+)
public val Attachment.imagePreviewUrl: String?
get() = thumbUrl ?: imageUrl
+/**
+ * The image URL to display for link attachment previews.
+ *
+ * Prefers [Attachment.thumbUrl] over [Attachment.imageUrl].
+ */
+@InternalStreamChatApi
+public val Attachment.linkPreviewImageUrl: String?
+ get() = thumbUrl ?: imageUrl
+
+/**
+ * The navigation URL for link attachments.
+ *
+ * Prefers [Attachment.titleLink] over [Attachment.ogUrl].
+ */
+@InternalStreamChatApi
+public val Attachment.linkUrl: String?
+ get() = titleLink ?: ogUrl
+
+/**
+ * The fallback preview URL for Giphy attachments when [io.getstream.chat.android.ui.common.utils.giphyInfo]
+ * is not available.
+ *
+ * Falls back through [Attachment.thumbUrl], [Attachment.titleLink], and [Attachment.ogUrl].
+ */
+@InternalStreamChatApi
+public val Attachment.giphyFallbackPreviewUrl: String?
+ get() = thumbUrl ?: titleLink ?: ogUrl
+
/**
* Checks if the attachment is of any file type (file, video, audio or audio recording).
*/
diff --git a/stream-chat-android-ui-common/src/main/res/values/strings.xml b/stream-chat-android-ui-common/src/main/res/values/strings.xml
index c1f6c02f9d9..a84be3b84d8 100644
--- a/stream-chat-android-ui-common/src/main/res/values/strings.xml
+++ b/stream-chat-android-ui-common/src/main/res/values/strings.xml
@@ -43,6 +43,7 @@
Allow access to more visual media
+ Some files could not be loaded and were skipped.
Files
Media
Capture
@@ -53,6 +54,10 @@
The load failed due to the invalid url.
Something went wrong. Unable to open attachment: %s
Error. File can\'t be displayed
+ Opening…
+ Downloading… %1$s / %2$s
+ No app available to open this file
+ Failed to download file: %s
Error. Video can\'t be displayed
There is no app to view this url:\n%s
just now
diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptorTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptorTest.kt
new file mode 100644
index 00000000000..c34644fc9f4
--- /dev/null
+++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptorTest.kt
@@ -0,0 +1,185 @@
+/*
+ * 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.ui.common.images.internal
+
+import android.content.Context
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import coil3.intercept.Interceptor
+import coil3.network.NetworkHeaders
+import coil3.network.httpHeaders
+import coil3.request.ImageRequest
+import coil3.request.ImageResult
+import coil3.request.SuccessResult
+import coil3.size.Size
+import io.getstream.chat.android.client.cdn.CDN
+import io.getstream.chat.android.client.cdn.CDNRequest
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+@Config(sdk = [33])
+internal class CDNImageInterceptorTest {
+
+ private val context: Context get() = RuntimeEnvironment.getApplication()
+
+ @Test
+ fun `intercept rewrites URL when CDN returns different URL`() = runTest {
+ val cdn = object : CDN {
+ override suspend fun imageRequest(url: String) =
+ CDNRequest("https://cdn.example.com/image.jpg")
+ }
+ val interceptor = CDNImageInterceptor(cdn)
+ val request = ImageRequest.Builder(context)
+ .data("https://original.com/image.jpg")
+ .build()
+ val chain = FakeCoilChain(request)
+
+ interceptor.intercept(chain)
+
+ val proceededRequest = chain.proceededRequest!!
+ assertEquals("https://cdn.example.com/image.jpg", proceededRequest.data.toString())
+ }
+
+ @Test
+ fun `intercept adds CDN headers to request`() = runTest {
+ val cdn = object : CDN {
+ override suspend fun imageRequest(url: String) =
+ CDNRequest(url, mapOf("Authorization" to "Bearer token", "X-Custom" to "value"))
+ }
+ val interceptor = CDNImageInterceptor(cdn)
+ val request = ImageRequest.Builder(context)
+ .data("https://original.com/image.jpg")
+ .build()
+ val chain = FakeCoilChain(request)
+
+ interceptor.intercept(chain)
+
+ val headers = chain.proceededRequest!!.httpHeaders
+ assertEquals("Bearer token", headers["Authorization"])
+ assertEquals("value", headers["X-Custom"])
+ }
+
+ @Test
+ fun `intercept CDN headers override existing headers for same key`() = runTest {
+ val cdn = object : CDN {
+ override suspend fun imageRequest(url: String) =
+ CDNRequest(url, mapOf("Authorization" to "CDN-token"))
+ }
+ val interceptor = CDNImageInterceptor(cdn)
+ val existingHeaders = NetworkHeaders.Builder()
+ .add("Authorization", "Original-token")
+ .add("X-Existing", "keep-me")
+ .build()
+ val request = ImageRequest.Builder(context)
+ .data("https://original.com/image.jpg")
+ .httpHeaders(existingHeaders)
+ .build()
+ val chain = FakeCoilChain(request)
+
+ interceptor.intercept(chain)
+
+ val headers = chain.proceededRequest!!.httpHeaders
+ assertEquals("CDN-token", headers["Authorization"])
+ assertEquals("keep-me", headers["X-Existing"])
+ }
+
+ @Test
+ fun `intercept skips non-HTTP URLs`() = runTest {
+ var cdnCalled = false
+ val cdn = object : CDN {
+ override suspend fun imageRequest(url: String): CDNRequest {
+ cdnCalled = true
+ return CDNRequest("https://should-not-be-used.com")
+ }
+ }
+ val interceptor = CDNImageInterceptor(cdn)
+ val request = ImageRequest.Builder(context)
+ .data("content://media/image.jpg")
+ .build()
+ val chain = FakeCoilChain(request)
+
+ interceptor.intercept(chain)
+
+ assertTrue("CDN should not be called for content:// URLs", !cdnCalled)
+ assertTrue("Request should pass through unchanged", chain.proceededRequest == null || chain.directProceed)
+ }
+
+ @Test
+ @Suppress("TooGenericExceptionThrown")
+ fun `intercept falls back to original request when CDN throws`() = runTest {
+ val cdn = object : CDN {
+ override suspend fun imageRequest(url: String): CDNRequest {
+ throw RuntimeException("CDN unavailable")
+ }
+ }
+ val interceptor = CDNImageInterceptor(cdn)
+ val request = ImageRequest.Builder(context)
+ .data("https://original.com/image.jpg")
+ .build()
+ val chain = FakeCoilChain(request)
+
+ interceptor.intercept(chain)
+
+ assertTrue("Should fall back to direct proceed on CDN error", chain.directProceed)
+ }
+
+ @Suppress("EmptyFunctionBlock")
+ private class FakeCoilChain(
+ override val request: ImageRequest,
+ ) : Interceptor.Chain {
+ var proceededRequest: ImageRequest? = null
+ var directProceed: Boolean = false
+
+ override val size: Size get() = Size.ORIGINAL
+
+ override suspend fun proceed(): ImageResult {
+ directProceed = true
+ return mock()
+ }
+
+ override fun withRequest(request: ImageRequest): Interceptor.Chain {
+ return FakeCoilChainWithRequest(request, this)
+ }
+
+ override fun withSize(size: Size): Interceptor.Chain = this
+ }
+
+ @Suppress("EmptyFunctionBlock")
+ private class FakeCoilChainWithRequest(
+ override val request: ImageRequest,
+ private val parent: FakeCoilChain,
+ ) : Interceptor.Chain {
+ override val size: Size get() = Size.ORIGINAL
+
+ override suspend fun proceed(): ImageResult {
+ parent.proceededRequest = request
+ return mock()
+ }
+
+ override fun withRequest(request: ImageRequest): Interceptor.Chain {
+ return FakeCoilChainWithRequest(request, parent)
+ }
+
+ override fun withSize(size: Size): Interceptor.Chain = this
+ }
+}
diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManagerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManagerTest.kt
index 40613216b88..0acf465265d 100644
--- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManagerTest.kt
+++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManagerTest.kt
@@ -120,6 +120,7 @@ internal class StreamShareFileManagerTest {
.thenReturn(Result.Success(cachedFile))
whenever(cachedFile.exists()).thenReturn(true)
whenever(cachedFile.length()).thenReturn(1024L)
+ whenever(cachedFile.lastModified()).thenReturn(System.currentTimeMillis())
// when
val result = shareFileManager.writeAttachmentToShareableFile(context, attachment)
@@ -287,6 +288,7 @@ internal class StreamShareFileManagerTest {
.thenReturn(Result.Success(cachedFile))
whenever(cachedFile.exists()).thenReturn(true)
whenever(cachedFile.length()).thenReturn(1024L)
+ whenever(cachedFile.lastModified()).thenReturn(System.currentTimeMillis())
// when
val result = shareFileManager.getShareableUriForAttachment(context, attachment)
@@ -356,6 +358,119 @@ internal class StreamShareFileManagerTest {
Assert.assertTrue((result as Result.Failure).value is Error.GenericError)
}
+ @Test
+ fun `writeAttachmentToShareableFile treats expired cached file as cache miss`() = runTest {
+ // given
+ val attachment = randomAttachment(
+ assetUrl = "https://example.com/file.pdf",
+ fileSize = 1024,
+ name = "document.pdf",
+ )
+ val cachedFile = mock()
+ whenever(fileManager.getFileFromCache(any(), any()))
+ .thenReturn(Result.Success(cachedFile))
+ whenever(cachedFile.exists()).thenReturn(true)
+ whenever(cachedFile.length()).thenReturn(1024L)
+ whenever(cachedFile.lastModified()).thenReturn(System.currentTimeMillis() - 6 * 60 * 1000L)
+
+ val chatClient = mock()
+ val downloadedFile = File("path/to/downloaded/file.pdf")
+ val responseBody = TestResponseBody("test content")
+ whenever(chatClient.downloadFile(any())) doReturn TestCall(Result.Success(responseBody))
+ whenever(fileManager.writeFileInCache(any(), any(), any()))
+ .thenReturn(Result.Success(downloadedFile))
+
+ // when
+ val result = shareFileManager.writeAttachmentToShareableFile(
+ context = context,
+ attachment = attachment,
+ chatClient = { chatClient },
+ )
+
+ // then
+ Assert.assertTrue(result.isSuccess)
+ }
+
+ @Test
+ fun `getShareableUriForAttachment returns Error when cached file is expired`() = runTest {
+ // given
+ val attachment = randomAttachment(
+ assetUrl = "https://example.com/file.pdf",
+ fileSize = 1024,
+ name = "document.pdf",
+ )
+ val cachedFile = mock()
+ whenever(fileManager.getFileFromCache(any(), any()))
+ .thenReturn(Result.Success(cachedFile))
+ whenever(cachedFile.exists()).thenReturn(true)
+ whenever(cachedFile.length()).thenReturn(1024L)
+ whenever(cachedFile.lastModified()).thenReturn(System.currentTimeMillis() - 6 * 60 * 1000L)
+
+ // when
+ val result = shareFileManager.getShareableUriForAttachment(context, attachment)
+
+ // then
+ Assert.assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `writeAttachmentToShareableFile calls evictCacheFiles on cache miss`() = runTest {
+ // given
+ val attachment = randomAttachment(
+ assetUrl = "https://example.com/new-file.pdf",
+ fileSize = 512,
+ name = "new.pdf",
+ )
+ whenever(fileManager.getFileFromCache(any(), any()))
+ .thenReturn(Result.Failure(Error.GenericError("Not cached")))
+
+ val chatClient = mock()
+ val downloadedFile = File("path/to/new.pdf")
+ val responseBody = TestResponseBody("new content")
+ whenever(chatClient.downloadFile(any())) doReturn TestCall(Result.Success(responseBody))
+ whenever(fileManager.writeFileInCache(any(), any(), any()))
+ .thenReturn(Result.Success(downloadedFile))
+
+ // when
+ shareFileManager.writeAttachmentToShareableFile(
+ context = context,
+ attachment = attachment,
+ chatClient = { chatClient },
+ )
+
+ // then
+ val defaults = ShareCacheConfig()
+ org.mockito.kotlin.verify(fileManager).evictCacheFiles(
+ context,
+ defaults.cacheFilePrefix,
+ defaults.cacheTtlMs,
+ defaults.maxCacheSizeBytes,
+ )
+ }
+
+ @Test
+ fun `writeAttachmentToShareableFile does not call evictCacheFiles on cache hit`() = runTest {
+ // given
+ val attachment = randomAttachment(
+ assetUrl = "https://example.com/file.pdf",
+ fileSize = 1024,
+ name = "document.pdf",
+ )
+ val cachedFile = mock()
+ whenever(fileManager.getFileFromCache(any(), any()))
+ .thenReturn(Result.Success(cachedFile))
+ whenever(cachedFile.exists()).thenReturn(true)
+ whenever(cachedFile.length()).thenReturn(1024L)
+ whenever(cachedFile.lastModified()).thenReturn(System.currentTimeMillis())
+
+ // when
+ shareFileManager.writeAttachmentToShareableFile(context, attachment)
+
+ // then
+ org.mockito.kotlin.verify(fileManager, org.mockito.kotlin.never())
+ .evictCacheFiles(any(), any(), any(), any())
+ }
+
private fun createTestBitmap(width: Int = 100, height: Int = 100): Bitmap {
// 1. Define the pixel colors. Here, a simple pattern of red and black.
val pixels = IntArray(width * height)
@@ -373,6 +488,93 @@ internal class StreamShareFileManagerTest {
return bitmap
}
+ @Test
+ fun `writeAttachmentToShareableFile onProgress receives incremental bytes`() = runTest {
+ // given
+ val content = "A".repeat(2048)
+ val attachment = randomAttachment(
+ assetUrl = "https://example.com/file.bin",
+ fileSize = content.length,
+ name = "file.bin",
+ )
+ val chatClient = mock()
+ val responseBody = TestResponseBody(content)
+ whenever(fileManager.getFileFromCache(any(), any()))
+ .thenReturn(Result.Failure(Error.GenericError("Not cached")))
+ whenever(chatClient.downloadFile(any())) doReturn TestCall(Result.Success(responseBody))
+ whenever(fileManager.writeFileInCache(any(), any(), any()))
+ .thenAnswer { invocation ->
+ // Consume the stream to trigger onProgress callbacks
+ val inputStream = invocation.getArgument(2)
+ val buf = ByteArray(512)
+ while (inputStream.read(buf) != -1) { /* drain */ }
+ Result.Success(File("path/to/file.bin"))
+ }
+
+ val progressValues = mutableListOf>()
+
+ // when
+ shareFileManager.writeAttachmentToShareableFile(
+ context = context,
+ attachment = attachment,
+ onProgress = { bytesRead, totalBytes -> progressValues.add(bytesRead to totalBytes) },
+ chatClient = { chatClient },
+ )
+
+ // then
+ Assert.assertTrue("onProgress should have been called", progressValues.isNotEmpty())
+ // Bytes should be monotonically increasing
+ for (i in 1 until progressValues.size) {
+ Assert.assertTrue(
+ "bytesRead should increase",
+ progressValues[i].first >= progressValues[i - 1].first,
+ )
+ }
+ // Last bytesRead should equal total content length
+ Assert.assertEquals(content.length.toLong(), progressValues.last().first)
+ }
+
+ @Test
+ fun `writeAttachmentToShareableFile onProgress receives correct totalBytes from attachment fileSize`() = runTest {
+ // given
+ val fileSize = 4096
+ val content = "B".repeat(fileSize)
+ val attachment = randomAttachment(
+ assetUrl = "https://example.com/file.bin",
+ fileSize = fileSize,
+ name = "file.bin",
+ )
+ val chatClient = mock()
+ val responseBody = TestResponseBody(content)
+ whenever(fileManager.getFileFromCache(any(), any()))
+ .thenReturn(Result.Failure(Error.GenericError("Not cached")))
+ whenever(chatClient.downloadFile(any())) doReturn TestCall(Result.Success(responseBody))
+ whenever(fileManager.writeFileInCache(any(), any(), any()))
+ .thenAnswer { invocation ->
+ val inputStream = invocation.getArgument(2)
+ val buf = ByteArray(1024)
+ while (inputStream.read(buf) != -1) { /* drain */ }
+ Result.Success(File("path/to/file.bin"))
+ }
+
+ val progressValues = mutableListOf>()
+
+ // when
+ shareFileManager.writeAttachmentToShareableFile(
+ context = context,
+ attachment = attachment,
+ onProgress = { bytesRead, totalBytes -> progressValues.add(bytesRead to totalBytes) },
+ chatClient = { chatClient },
+ )
+
+ // then
+ Assert.assertTrue("onProgress should have been called", progressValues.isNotEmpty())
+ // All totalBytes values should match the attachment's fileSize
+ progressValues.forEach { (_, totalBytes) ->
+ Assert.assertEquals(fileSize.toLong(), totalBytes)
+ }
+ }
+
private class TestResponseBody(content: String) : ResponseBody() {
private val buffer = Buffer().writeString(content, Charset.defaultCharset())
override fun contentLength(): Long = buffer.size
diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/AttachmentExtensionsTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/AttachmentExtensionsTest.kt
new file mode 100644
index 00000000000..b7e6d9dc2d1
--- /dev/null
+++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/AttachmentExtensionsTest.kt
@@ -0,0 +1,132 @@
+/*
+ * 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.ui.common.utils.extensions
+
+import io.getstream.chat.android.models.Attachment
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+@Suppress("DEPRECATION")
+internal class AttachmentExtensionsTest {
+
+ @Test
+ fun `imagePreviewUrl returns thumbUrl when both thumbUrl and imageUrl are set`() {
+ val attachment = Attachment(
+ thumbUrl = "https://cdn.example.com/thumb.jpg",
+ imageUrl = "https://cdn.example.com/image.jpg",
+ )
+
+ assertEquals("https://cdn.example.com/thumb.jpg", attachment.imagePreviewUrl)
+ }
+
+ @Test
+ fun `imagePreviewUrl returns thumbUrl when imageUrl is null`() {
+ val attachment = Attachment(
+ thumbUrl = "https://cdn.example.com/thumb.jpg",
+ imageUrl = null,
+ )
+
+ assertEquals("https://cdn.example.com/thumb.jpg", attachment.imagePreviewUrl)
+ }
+
+ @Test
+ fun `imagePreviewUrl returns imageUrl when thumbUrl is null`() {
+ val attachment = Attachment(
+ thumbUrl = null,
+ imageUrl = "https://cdn.example.com/image.jpg",
+ )
+
+ assertEquals("https://cdn.example.com/image.jpg", attachment.imagePreviewUrl)
+ }
+
+ @Test
+ fun `imagePreviewUrl returns null when both are null`() {
+ val attachment = Attachment(
+ thumbUrl = null,
+ imageUrl = null,
+ )
+
+ assertNull(attachment.imagePreviewUrl)
+ }
+
+ // linkPreviewImageUrl tests
+
+ @Test
+ fun `linkPreviewImageUrl returns thumbUrl when both thumbUrl and imageUrl are set`() {
+ val attachment = Attachment(thumbUrl = "thumb", imageUrl = "image")
+ assertEquals("thumb", attachment.linkPreviewImageUrl)
+ }
+
+ @Test
+ fun `linkPreviewImageUrl returns imageUrl when thumbUrl is null`() {
+ val attachment = Attachment(thumbUrl = null, imageUrl = "image")
+ assertEquals("image", attachment.linkPreviewImageUrl)
+ }
+
+ @Test
+ fun `linkPreviewImageUrl returns null when both are null`() {
+ val attachment = Attachment(thumbUrl = null, imageUrl = null)
+ assertNull(attachment.linkPreviewImageUrl)
+ }
+
+ // linkUrl tests
+
+ @Test
+ fun `linkUrl returns titleLink when both titleLink and ogUrl are set`() {
+ val attachment = Attachment(titleLink = "titleLink", ogUrl = "ogUrl")
+ assertEquals("titleLink", attachment.linkUrl)
+ }
+
+ @Test
+ fun `linkUrl returns ogUrl when titleLink is null`() {
+ val attachment = Attachment(titleLink = null, ogUrl = "ogUrl")
+ assertEquals("ogUrl", attachment.linkUrl)
+ }
+
+ @Test
+ fun `linkUrl returns null when both are null`() {
+ val attachment = Attachment(titleLink = null, ogUrl = null)
+ assertNull(attachment.linkUrl)
+ }
+
+ // giphyFallbackPreviewUrl tests
+
+ @Test
+ fun `giphyFallbackPreviewUrl returns thumbUrl when all are set`() {
+ val attachment = Attachment(thumbUrl = "thumb", titleLink = "titleLink", ogUrl = "ogUrl")
+ assertEquals("thumb", attachment.giphyFallbackPreviewUrl)
+ }
+
+ @Test
+ fun `giphyFallbackPreviewUrl returns titleLink when thumbUrl is null`() {
+ val attachment = Attachment(thumbUrl = null, titleLink = "titleLink", ogUrl = "ogUrl")
+ assertEquals("titleLink", attachment.giphyFallbackPreviewUrl)
+ }
+
+ @Test
+ fun `giphyFallbackPreviewUrl returns ogUrl when thumbUrl and titleLink are null`() {
+ val attachment = Attachment(thumbUrl = null, titleLink = null, ogUrl = "ogUrl")
+ assertEquals("ogUrl", attachment.giphyFallbackPreviewUrl)
+ }
+
+ @Test
+ fun `giphyFallbackPreviewUrl returns null when all are null`() {
+ val attachment = Attachment(thumbUrl = null, titleLink = null, ogUrl = null)
+ assertNull(attachment.giphyFallbackPreviewUrl)
+ }
+}
diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/shared/media/ChatInfoSharedMediaFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/shared/media/ChatInfoSharedMediaFragment.kt
index 49607a99b2a..4538d4b59d7 100644
--- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/shared/media/ChatInfoSharedMediaFragment.kt
+++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/shared/media/ChatInfoSharedMediaFragment.kt
@@ -33,7 +33,6 @@ import io.getstream.chat.android.models.AttachmentType
import io.getstream.chat.android.ui.ChatUI
import io.getstream.chat.android.ui.common.feature.channel.attachments.ChannelAttachmentsViewAction
import io.getstream.chat.android.ui.common.state.channel.attachments.ChannelAttachmentsViewState
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
import io.getstream.chat.android.ui.feature.gallery.AttachmentGalleryDestination
import io.getstream.chat.android.ui.feature.gallery.AttachmentGalleryItem
import io.getstream.chat.android.ui.viewmodel.channel.ChannelAttachmentsViewModel
@@ -49,7 +48,7 @@ class ChatInfoSharedMediaFragment : Fragment() {
ChannelAttachmentsViewModelFactory(
cid = args.cid!!,
attachmentTypes = listOf(AttachmentType.IMAGE, AttachmentType.VIDEO),
- localFilter = { !it.imagePreviewUrl.isNullOrEmpty() && it.titleLink.isNullOrEmpty() },
+ localFilter = { !(it.imageUrl ?: it.thumbUrl).isNullOrEmpty() && it.titleLink.isNullOrEmpty() },
)
}
diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api
index d747ec1cd84..99dfdedf44c 100644
--- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api
+++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api
@@ -25,6 +25,7 @@ public final class io/getstream/chat/android/ui/ChatUI {
public static final fun getStreamCdnImageResizing ()Lio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;
public static final fun getStyle ()Lio/getstream/chat/android/ui/font/ChatStyle;
public static final fun getSupportedReactions ()Lio/getstream/chat/android/ui/helper/SupportedReactions;
+ public static final fun getUseDocumentGView ()Z
public static final fun getUserAvatarRenderer ()Lio/getstream/chat/android/ui/widgets/avatar/UserAvatarRenderer;
public static final fun getVideoHeadersProvider ()Lio/getstream/chat/android/ui/common/helper/VideoHeadersProvider;
public static final fun getVideoThumbnailsEnabled ()Z
@@ -53,6 +54,7 @@ public final class io/getstream/chat/android/ui/ChatUI {
public static final fun setStreamCdnImageResizing (Lio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;)V
public static final fun setStyle (Lio/getstream/chat/android/ui/font/ChatStyle;)V
public static final fun setSupportedReactions (Lio/getstream/chat/android/ui/helper/SupportedReactions;)V
+ public static final fun setUseDocumentGView (Z)V
public static final fun setUserAvatarRenderer (Lio/getstream/chat/android/ui/widgets/avatar/UserAvatarRenderer;)V
public static final fun setVideoHeadersProvider (Lio/getstream/chat/android/ui/common/helper/VideoHeadersProvider;)V
public static final fun setVideoThumbnailsEnabled (Z)V
@@ -4406,7 +4408,8 @@ public final class io/getstream/chat/android/ui/viewmodel/messages/MessageCompos
public final fun cancelRecording ()V
public final fun clearAttachments ()V
public final fun clearData ()V
- public final fun completeRecording ()V
+ public final fun completeRecording (Lkotlin/jvm/functions/Function1;)V
+ public static synthetic fun completeRecording$default (Lio/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
public final fun createPoll (Lio/getstream/chat/android/models/CreatePollParams;)V
public final fun dismissMessageActions ()V
public final fun dismissSuggestionsPopup ()V
diff --git a/stream-chat-android-ui-components/detekt-baseline.xml b/stream-chat-android-ui-components/detekt-baseline.xml
index 370e3254295..9a455d5660c 100644
--- a/stream-chat-android-ui-components/detekt-baseline.xml
+++ b/stream-chat-android-ui-components/detekt-baseline.xml
@@ -6,7 +6,6 @@
ComplexCondition:FileAttachmentsView.kt$GeneralFileAttachmentViewHolder$item.uploadState is Attachment.UploadState.Idle || item.uploadState is Attachment.UploadState.InProgress || (item.uploadState is Attachment.UploadState.Success && item.fileSize == 0)
ComplexCondition:FileAttachmentsView.kt$RecordingFileAttachmentViewHolder$item.uploadState is Attachment.UploadState.Idle || item.uploadState is Attachment.UploadState.InProgress || (item.uploadState is Attachment.UploadState.Success && item.fileSize == 0)
ComplexCondition:FootnoteDecorator.kt$FootnoteDecorator$!isGiphy && !isDeleted && userLanguage != i18nLanguage && translatedText != data.message.text
- ComplexCondition:MediaAttachmentView.kt$MediaAttachmentView$attachment.isImage() || (attachment.isVideo() && ChatUI.videoThumbnailsEnabled && attachment.thumbUrl != null)
ForbiddenComment:MediaAttachmentGridView.kt$MediaAttachmentGridView.SharedMediaSpaceItemDecorator$// TODO: leaves empty space after pagination
IteratorNotThrowingNoSuchElementException:MessageComposerView.kt$<no name provided>$<no name provided> : Iterator
LargeClass:MessageComposerViewStyle.kt$MessageComposerViewStyle$Companion
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/ChatUI.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/ChatUI.kt
index 4c69503ac27..f5815d7f59b 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/ChatUI.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/ChatUI.kt
@@ -73,31 +73,56 @@ public object ChatUI {
/**
* Provides a custom implementation for loading images.
+ *
+ * @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().")
@JvmStatic
public var imageAssetTransformer: ImageAssetTransformer by StreamImageLoader.instance()::imageAssetTransformer
/**
* Generates a download URI for the given attachment.
+ *
+ * @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().")
@JvmStatic
public var downloadAttachmentUriGenerator: DownloadAttachmentUriGenerator = DefaultDownloadAttachmentUriGenerator
/**
* Intercepts and modifies the download request before it is enqueued.
+ *
+ * @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().")
@JvmStatic
public var downloadRequestInterceptor: DownloadRequestInterceptor = DownloadRequestInterceptor { }
/**
* Provides HTTP headers for image loading requests.
+ *
+ * @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().")
@JvmStatic
public var imageHeadersProvider: ImageHeadersProvider by StreamImageLoader.instance()::imageHeadersProvider
/**
* Provides HTTP headers for video loading requests.
+ *
+ * @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().")
@JvmStatic
public var videoHeadersProvider: VideoHeadersProvider = DefaultVideoHeadersProvider
@@ -217,6 +242,16 @@ public object ChatUI {
@JvmStatic
public var draftMessagesEnabled: Boolean = false
+ /**
+ * Whether to use Google Docs Viewer (gview) for document attachments.
+ *
+ * When `true` (default), documents are rendered via the legacy [AttachmentDocumentActivity]
+ * which loads them through Google Docs Viewer. When `false`, text-based files (TXT, HTML)
+ * are rendered in-app and other file types are downloaded and opened with an external application.
+ */
+ @JvmStatic
+ public var useDocumentGView: Boolean = true
+
/**
* Sets the strategy for resizing images hosted on Stream's CDN. Disabled by default,
* set [StreamCdnImageResizing.imageResizingEnabled] to true if you wish to enable resizing images. Note that
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/AttachmentMediaActivity.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/AttachmentMediaActivity.kt
index e1facce5ad9..d68c2b5474c 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/AttachmentMediaActivity.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/AttachmentMediaActivity.kt
@@ -32,8 +32,10 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.PlayerView
import io.getstream.chat.android.client.ChatClient
+import io.getstream.chat.android.client.cdn.internal.StreamMediaDataSource
import io.getstream.chat.android.ui.R
import io.getstream.chat.android.ui.databinding.StreamUiActivityAttachmentMediaBinding
import io.getstream.chat.android.ui.utils.extensions.applyEdgeToEdgePadding
@@ -134,8 +136,13 @@ public class AttachmentMediaActivity : AppCompatActivity() {
binding.root.applyEdgeToEdgePadding(typeMask = WindowInsetsCompat.Type.systemBars())
}
+ @OptIn(UnstableApi::class)
private fun createPlayer(): Player {
- val player = ExoPlayer.Builder(this).build()
+ val cdn = ChatClient.instance().cdn
+ val dataSourceFactory = StreamMediaDataSource.factory(this, cdn)
+ val player = ExoPlayer.Builder(this)
+ .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
+ .build()
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
val isBuffering = playbackState == Player.STATE_BUFFERING
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryImagePageFragment.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryImagePageFragment.kt
index 5765da1a195..1b61a24cae8 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryImagePageFragment.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryImagePageFragment.kt
@@ -21,8 +21,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
-import io.getstream.chat.android.models.Attachment
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
import io.getstream.chat.android.ui.databinding.StreamUiItemAttachmentGalleryImageBinding
import io.getstream.chat.android.ui.utils.load
@@ -72,10 +70,10 @@ internal class AttachmentGalleryImagePageFragment : Fragment() {
companion object {
private const val ARG_IMAGE_URL = "image_url"
- fun create(attachment: Attachment, imageClickListener: () -> Unit = {}): Fragment {
+ fun create(imageUrl: String?, imageClickListener: () -> Unit = {}): Fragment {
return AttachmentGalleryImagePageFragment().apply {
arguments = Bundle().apply {
- putString(ARG_IMAGE_URL, attachment.imagePreviewUrl)
+ putString(ARG_IMAGE_URL, imageUrl)
}
this.imageClickListener = imageClickListener
}
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryPagerAdapter.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryPagerAdapter.kt
index 1c1b6797a4d..8de33e7d76e 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryPagerAdapter.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryPagerAdapter.kt
@@ -33,8 +33,15 @@ internal class AttachmentGalleryPagerAdapter(
val attachment = getItem(position)
return when (attachment.type) {
- AttachmentType.IMAGE -> AttachmentGalleryImagePageFragment.create(attachment, mediaClickListener)
- AttachmentType.VIDEO -> AttachmentGalleryVideoPageFragment.create(attachment, mediaClickListener)
+ AttachmentType.IMAGE -> AttachmentGalleryImagePageFragment.create(
+ imageUrl = attachment.imageUrl,
+ imageClickListener = mediaClickListener,
+ )
+ AttachmentType.VIDEO -> AttachmentGalleryVideoPageFragment.create(
+ thumbUrl = attachment.thumbUrl,
+ assetUrl = attachment.assetUrl,
+ imageClickListener = mediaClickListener,
+ )
else -> throw Throwable("Unsupported attachment type")
}
}
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryVideoPageFragment.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryVideoPageFragment.kt
index df93ebd267a..e4a1912fdfb 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryVideoPageFragment.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryVideoPageFragment.kt
@@ -31,13 +31,13 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
-import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.ui.PlayerView
-import io.getstream.chat.android.models.Attachment
+import io.getstream.chat.android.client.ChatClient
+import io.getstream.chat.android.client.cdn.internal.StreamMediaDataSource
import io.getstream.chat.android.ui.ChatUI
import io.getstream.chat.android.ui.R
import io.getstream.chat.android.ui.databinding.StreamUiItemAttachmentGalleryVideoBinding
@@ -245,8 +245,9 @@ internal class AttachmentGalleryVideoPageFragment : Fragment() {
@OptIn(UnstableApi::class)
private fun createMediaSourceFactory(): MediaSource.Factory {
+ val cdn = ChatClient.instance().cdn
val headers = ChatUI.videoHeadersProvider.getVideoRequestHeaders(assetUrl ?: "")
- val baseDataSourceFactory = DefaultDataSource.Factory(requireContext())
+ val baseDataSourceFactory = StreamMediaDataSource.factory(requireContext(), cdn)
val dataSourceFactory = ResolvingDataSource.Factory(baseDataSourceFactory) { dataSpec ->
dataSpec.withAdditionalHeaders(headers)
}
@@ -260,11 +261,11 @@ internal class AttachmentGalleryVideoPageFragment : Fragment() {
private const val CONTROLLER_SHOW_TIMEOUT = 2000
- fun create(attachment: Attachment, imageClickListener: () -> Unit = {}): Fragment {
+ fun create(thumbUrl: String?, assetUrl: String?, imageClickListener: () -> Unit = {}): Fragment {
return AttachmentGalleryVideoPageFragment().apply {
arguments = Bundle().apply {
- putString(ARG_THUMB_URL, attachment.thumbUrl)
- putString(ARG_ASSET_URL, attachment.assetUrl)
+ putString(ARG_THUMB_URL, thumbUrl)
+ putString(ARG_ASSET_URL, assetUrl)
}
this.imageClickListener = imageClickListener
}
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt
index 86f50774a96..a6224952d52 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt
@@ -27,7 +27,6 @@ import io.getstream.chat.android.client.utils.attachment.isVideo
import io.getstream.chat.android.models.AttachmentType
import io.getstream.chat.android.ui.ChatUI
import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
import io.getstream.chat.android.ui.databinding.StreamUiItemMediaAttachmentBinding
import io.getstream.chat.android.ui.feature.gallery.AttachmentGalleryItem
import io.getstream.chat.android.ui.feature.gallery.MediaAttachmentGridViewStyle
@@ -94,14 +93,17 @@ internal class MediaAttachmentAdapter(
val shouldLoadImage = attachmentGalleryItem.attachment.isImage() ||
(attachmentGalleryItem.attachment.isVideo() && ChatUI.videoThumbnailsEnabled)
+ val imageData = if (shouldLoadImage) {
+ val attachment = attachmentGalleryItem.attachment
+ val url = if (attachment.isImage()) attachment.imageUrl else attachment.thumbUrl
+ url?.applyStreamCdnImageResizingIfEnabled(
+ streamCdnImageResizing = ChatUI.streamCdnImageResizing,
+ )
+ } else {
+ null
+ }
binding.mediaImageView.load(
- data = if (shouldLoadImage) {
- attachmentGalleryItem.attachment.imagePreviewUrl?.applyStreamCdnImageResizingIfEnabled(
- streamCdnImageResizing = ChatUI.streamCdnImageResizing,
- )
- } else {
- null
- },
+ data = imageData,
placeholderDrawable = if (!isVideoAttachment) {
style.imagePlaceholder
} else {
@@ -186,7 +188,8 @@ internal class MediaAttachmentAdapter(
private object AttachmentGalleryItemDiffCallback : DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: AttachmentGalleryItem, newItem: AttachmentGalleryItem): Boolean {
- return oldItem.attachment.imagePreviewUrl == newItem.attachment.imagePreviewUrl &&
+ return (oldItem.attachment.imageUrl ?: oldItem.attachment.thumbUrl) ==
+ (newItem.attachment.imageUrl ?: newItem.attachment.thumbUrl) &&
oldItem.createdAt == newItem.createdAt
}
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/attachment/picker/AttachmentsPickerDialogFragment.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/attachment/picker/AttachmentsPickerDialogFragment.kt
index e0c0f7547fb..029e1bf3b05 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/attachment/picker/AttachmentsPickerDialogFragment.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/attachment/picker/AttachmentsPickerDialogFragment.kt
@@ -94,7 +94,7 @@ public class AttachmentsPickerDialogFragment : BottomSheetDialogFragment() {
override fun onDismiss(dialog: DialogInterface) {
if (::style.isInitialized && style.saveAttachmentsOnDismiss) {
attachmentsSelectionListener?.onAttachmentsSelected(
- selectedAttachments.map { it.toAttachment(requireContext()) },
+ selectedAttachments.mapNotNull { it.toAttachment(requireContext()) },
)
}
super.onDismiss(dialog)
@@ -118,7 +118,7 @@ public class AttachmentsPickerDialogFragment : BottomSheetDialogFragment() {
binding.attachButton.isEnabled = false
binding.attachButton.setOnClickListener {
attachmentsSelectionListener?.onAttachmentsSelected(
- selectedAttachments.map { it.toAttachment(requireContext()) },
+ selectedAttachments.mapNotNull { it.toAttachment(requireContext()) },
)
dismiss()
}
@@ -163,7 +163,7 @@ public class AttachmentsPickerDialogFragment : BottomSheetDialogFragment() {
override fun onSelectedAttachmentsSubmitted() {
attachmentsSelectionListener?.onAttachmentsSelected(
- selectedAttachments.map { it.toAttachment(requireContext()) },
+ selectedAttachments.mapNotNull { it.toAttachment(requireContext()) },
)
dismiss()
}
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt
index a9e980ed65a..33da026450f 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt
@@ -22,8 +22,18 @@ import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelp
import io.getstream.chat.android.ui.common.helper.internal.StorageHelper
import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData
-internal fun AttachmentMetaData.toAttachment(context: Context): Attachment {
+/**
+ * Converts this metadata into an [Attachment] ready for upload.
+ *
+ * @param context Used to access the content resolver for caching the file.
+ * @return The attachment, or `null` when the content URI cannot be resolved
+ * (e.g. a cloud-backed file that is not locally available).
+ */
+internal fun AttachmentMetaData.toAttachment(context: Context): Attachment? {
val fileFromUri = StorageHelper().getCachedFileFromUri(context, this)
+ if (fileFromUri == null && uri != null) {
+ return null
+ }
val extra = uri?.let { mapOf(EXTRA_SOURCE_URI to it.toString()) } ?: emptyMap()
return Attachment(
upload = fileFromUri,
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListView.kt
index e5cea2051fb..75b2d61632e 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListView.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListView.kt
@@ -77,7 +77,6 @@ import io.getstream.chat.android.ui.common.state.messages.list.DeletedMessageVis
import io.getstream.chat.android.ui.common.state.messages.list.GiphyAction
import io.getstream.chat.android.ui.common.state.messages.list.ModeratedMessageOption
import io.getstream.chat.android.ui.common.utils.extensions.hasLink
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
import io.getstream.chat.android.ui.databinding.StreamUiMessageListViewBinding
import io.getstream.chat.android.ui.feature.gallery.AttachmentGalleryActivity
import io.getstream.chat.android.ui.feature.gallery.AttachmentGalleryDestination
@@ -496,7 +495,7 @@ public class MessageListView : ConstraintLayout {
}
if (attachment.isGiphy()) {
- val url = attachment.imagePreviewUrl ?: attachment.titleLink ?: attachment.ogUrl
+ val url = attachment.thumbUrl ?: attachment.titleLink ?: attachment.ogUrl
if (url != null) {
ChatUI.navigator.navigate(WebLinkDestination(context, url))
@@ -507,7 +506,7 @@ public class MessageListView : ConstraintLayout {
val filteredAttachments = message.attachments
.filter {
(
- it.isImage() && !it.imagePreviewUrl.isNullOrEmpty() ||
+ it.isImage() && !it.imageUrl.isNullOrEmpty() ||
it.isVideo() && !it.assetUrl.isNullOrEmpty()
) &&
!it.hasLink()
@@ -2244,7 +2243,7 @@ public class MessageListView : ConstraintLayout {
internal companion object {
fun parseValue(value: Int): NewMessagesBehaviour {
- return values().find { behaviour -> behaviour.value == value }
+ return entries.find { behaviour -> behaviour.value == value }
?: throw IllegalArgumentException("Unknown behaviour type. It must be either SCROLL_TO_BOTTOM (int 0) or COUNT_UPDATE (int 1)")
}
}
@@ -2255,7 +2254,7 @@ public class MessageListView : ConstraintLayout {
internal companion object {
fun parseValue(value: Int): MessagesStart {
- return values().find { behaviour -> behaviour.value == value }
+ return entries.find { behaviour -> behaviour.value == value }
?: throw IllegalArgumentException("Unknown messages start type. It must be either BOTTOM (int 0) or TOP (int 1)")
}
}
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/DefaultQuotedAttachmentView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/DefaultQuotedAttachmentView.kt
index 9234812d657..416c5429074 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/DefaultQuotedAttachmentView.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/DefaultQuotedAttachmentView.kt
@@ -24,7 +24,6 @@ import io.getstream.chat.android.models.AttachmentType
import io.getstream.chat.android.ui.ChatUI
import io.getstream.chat.android.ui.common.images.internal.StreamImageLoader
import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
import io.getstream.chat.android.ui.feature.messages.list.DefaultQuotedAttachmentViewStyle
import io.getstream.chat.android.ui.utils.extensions.createStreamThemeWrapper
import io.getstream.chat.android.ui.utils.load
@@ -82,7 +81,7 @@ internal class DefaultQuotedAttachmentView : AppCompatImageView {
when (attachment.type) {
AttachmentType.FILE, AttachmentType.VIDEO, AttachmentType.AUDIO_RECORDING -> loadAttachmentThumb(attachment)
AttachmentType.IMAGE -> showAttachmentThumb(
- attachment.imagePreviewUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing),
+ attachment.imageUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing),
)
AttachmentType.GIPHY -> showAttachmentThumb(attachment.thumbUrl)
else -> showAttachmentThumb(attachment.image)
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/FileAttachmentsView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/FileAttachmentsView.kt
index 7dd86dc3ab9..20c728753d7 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/FileAttachmentsView.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/FileAttachmentsView.kt
@@ -171,6 +171,7 @@ private class FileAttachmentsAdapter(
style,
)
}
+
else ->
StreamUiItemFileAttachmentBinding
.inflate(parent.streamThemeInflater, parent, false)
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/GiphyMediaAttachmentView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/GiphyMediaAttachmentView.kt
index a98fe697b2f..a722abaed02 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/GiphyMediaAttachmentView.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/GiphyMediaAttachmentView.kt
@@ -28,7 +28,7 @@ import io.getstream.chat.android.models.Attachment
import io.getstream.chat.android.ui.common.utils.GiphyInfo
import io.getstream.chat.android.ui.common.utils.GiphyInfoType
import io.getstream.chat.android.ui.common.utils.GiphySizingMode
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
+import io.getstream.chat.android.ui.common.utils.extensions.giphyFallbackPreviewUrl
import io.getstream.chat.android.ui.common.utils.giphyInfo
import io.getstream.chat.android.ui.databinding.StreamUiGiphyMediaAttachmentViewBinding
import io.getstream.chat.android.ui.feature.messages.list.adapter.view.GiphyMediaAttachmentViewStyle
@@ -95,9 +95,7 @@ public class GiphyMediaAttachmentView : ConstraintLayout {
) {
val giphyInfo = attachment.giphyInfo(giphyType)
- val url = giphyInfo?.url ?: attachment.let {
- it.imagePreviewUrl ?: it.titleLink ?: it.ogUrl
- } ?: return
+ val url = giphyInfo?.url ?: attachment.giphyFallbackPreviewUrl ?: return
if (style.sizingMode == GiphySizingMode.ADAPTIVE) {
applyAdaptiveSizing(
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/LinkAttachmentView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/LinkAttachmentView.kt
index 4c2f240b0e6..c784a0d5bdc 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/LinkAttachmentView.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/LinkAttachmentView.kt
@@ -24,7 +24,8 @@ import androidx.core.view.isVisible
import io.getstream.chat.android.models.Attachment
import io.getstream.chat.android.ui.R
import io.getstream.chat.android.ui.common.images.internal.StreamImageLoader.ImageTransformation.RoundedCorners
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
+import io.getstream.chat.android.ui.common.utils.extensions.linkPreviewImageUrl
+import io.getstream.chat.android.ui.common.utils.extensions.linkUrl
import io.getstream.chat.android.ui.databinding.StreamUiLinkAttachmentsViewBinding
import io.getstream.chat.android.ui.feature.messages.list.MessageListItemStyle
import io.getstream.chat.android.ui.font.TextStyle
@@ -53,7 +54,7 @@ internal class LinkAttachmentView : FrameLayout {
* @param style The style used for applying various things such as text styles.
*/
fun showLinkAttachment(attachment: Attachment, style: MessageListItemStyle) {
- previewUrl = attachment.titleLink ?: attachment.ogUrl
+ previewUrl = attachment.linkUrl
showTitle(attachment, style)
showDescription(attachment, style)
showLabel(attachment, style)
@@ -121,11 +122,12 @@ internal class LinkAttachmentView : FrameLayout {
* Shows the attachment preview image if it is not null.
*/
private fun showAttachmentImage(attachment: Attachment) {
- if (attachment.imagePreviewUrl != null) {
+ val linkPreviewUrl = attachment.linkPreviewImageUrl
+ if (linkPreviewUrl != null) {
binding.linkPreviewContainer.isVisible = true
binding.linkPreviewImageView.load(
- data = attachment.imagePreviewUrl,
+ data = linkPreviewUrl,
placeholderResId = R.drawable.stream_ui_picture_placeholder,
onStart = { binding.progressBar.isVisible = true },
onComplete = { binding.progressBar.isVisible = false },
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt
index 38db07f0203..f0e5f723846 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt
@@ -29,7 +29,6 @@ import io.getstream.chat.android.models.Attachment
import io.getstream.chat.android.ui.ChatUI
import io.getstream.chat.android.ui.R
import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
import io.getstream.chat.android.ui.databinding.StreamUiMediaAttachmentViewBinding
import io.getstream.chat.android.ui.feature.messages.list.adapter.view.MediaAttachmentViewStyle
import io.getstream.chat.android.ui.font.setTextStyle
@@ -132,11 +131,12 @@ internal class MediaAttachmentView : ConstraintLayout {
*/
fun showAttachment(attachment: Attachment, andMoreCount: Int = NO_MORE_COUNT) {
val url =
- if (attachment.isImage() ||
- (attachment.isVideo() && ChatUI.videoThumbnailsEnabled && attachment.thumbUrl != null)
- ) {
- attachment.imagePreviewUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing)
- ?: attachment.titleLink ?: attachment.ogUrl ?: attachment.upload ?: return
+ if (attachment.isImage()) {
+ attachment.imageUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing)
+ ?: attachment.upload ?: return
+ } else if (attachment.isVideo() && ChatUI.videoThumbnailsEnabled && attachment.thumbUrl != null) {
+ attachment.thumbUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing)
+ ?: return
} else {
null
}
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/impl/GiphyViewHolder.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/impl/GiphyViewHolder.kt
index 7c8aae65893..da1f50a1a0f 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/impl/GiphyViewHolder.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/impl/GiphyViewHolder.kt
@@ -23,7 +23,7 @@ import io.getstream.chat.android.ui.common.state.messages.list.CancelGiphy
import io.getstream.chat.android.ui.common.state.messages.list.SendGiphy
import io.getstream.chat.android.ui.common.state.messages.list.ShuffleGiphy
import io.getstream.chat.android.ui.common.utils.GiphyInfoType
-import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
+import io.getstream.chat.android.ui.common.utils.extensions.giphyFallbackPreviewUrl
import io.getstream.chat.android.ui.common.utils.giphyInfo
import io.getstream.chat.android.ui.databinding.StreamUiItemMessageGiphyBinding
import io.getstream.chat.android.ui.feature.messages.list.GiphyViewHolderStyle
@@ -73,9 +73,8 @@ public class GiphyViewHolder internal constructor(
.attachments
.firstOrNull()
?.let {
- val url = it.giphyInfo(GiphyInfoType.FIXED_HEIGHT)?.url ?: it.let {
- it.imagePreviewUrl ?: it.titleLink ?: it.ogUrl
- } ?: return
+ val url = it.giphyInfo(GiphyInfoType.FIXED_HEIGHT)?.url
+ ?: it.giphyFallbackPreviewUrl ?: return
binding.giphyPreview.load(
data = url,
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/navigation/destinations/AttachmentDestination.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/navigation/destinations/AttachmentDestination.kt
index a65aa3070dc..aa9c3957540 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/navigation/destinations/AttachmentDestination.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/navigation/destinations/AttachmentDestination.kt
@@ -17,7 +17,6 @@
package io.getstream.chat.android.ui.navigation.destinations
import android.content.Context
-import android.content.Intent
import android.widget.ImageView
import android.widget.Toast
import io.getstream.chat.android.client.utils.attachment.isAudio
@@ -27,8 +26,10 @@ import io.getstream.chat.android.client.utils.attachment.isVideo
import io.getstream.chat.android.models.Attachment
import io.getstream.chat.android.models.AttachmentType
import io.getstream.chat.android.models.Message
+import io.getstream.chat.android.ui.ChatUI
import io.getstream.chat.android.ui.common.R
import io.getstream.chat.android.ui.common.feature.documents.AttachmentDocumentActivity
+import io.getstream.chat.android.ui.common.feature.documents.DocumentAttachmentHandler
import io.getstream.chat.android.ui.common.model.MimeType
import io.getstream.chat.android.ui.feature.gallery.AttachmentActivity
import io.getstream.chat.android.ui.feature.gallery.AttachmentMediaActivity
@@ -130,10 +131,12 @@ public open class AttachmentDestination(
}
docMimeType(mimeType) -> {
- val intent = Intent(context, AttachmentDocumentActivity::class.java).apply {
- putExtra("url", url)
+ @Suppress("DEPRECATION")
+ if (ChatUI.useDocumentGView) {
+ start(AttachmentDocumentActivity.getIntent(context, url))
+ } else {
+ DocumentAttachmentHandler.openAttachment(context, attachment)
}
- start(intent)
}
else -> {
diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt
index 02d07e6f56c..c7cdbfbb1ff 100644
--- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt
+++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt
@@ -30,6 +30,7 @@ import io.getstream.chat.android.ui.common.state.messages.MessageInput
import io.getstream.chat.android.ui.common.state.messages.MessageMode
import io.getstream.chat.android.ui.common.state.messages.Reply
import io.getstream.chat.android.ui.common.state.messages.composer.MessageComposerState
+import io.getstream.result.Result
import io.getstream.result.call.Call
import kotlinx.coroutines.flow.StateFlow
@@ -204,7 +205,8 @@ public class MessageComposerViewModel(
public fun toggleRecordingPlayback(): Unit = messageComposerController.toggleRecordingPlayback()
- public fun completeRecording(): Unit = messageComposerController.completeRecording()
+ public fun completeRecording(onComplete: ((Result) -> Unit)? = null): Unit =
+ messageComposerController.completeRecording(onComplete)
public fun pauseRecording(): Unit = messageComposerController.pauseRecording()