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()