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 8c58106eb11..4b8e6d4e130 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -274,6 +274,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 credentialStorage (Lio/getstream/chat/android/client/user/storage/UserCredentialStorage;)Lio/getstream/chat/android/client/ChatClient$Builder; @@ -711,6 +712,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 2c2bed029e1..f0ae9d84b38 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 @@ -71,6 +71,8 @@ import io.getstream.chat.android.client.attachment.AttachmentsSender 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 @@ -293,6 +295,8 @@ internal constructor( 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() @@ -4731,6 +4735,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 @@ -4859,7 +4864,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 @@ -4930,6 +4939,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. */ @@ -5078,6 +5096,7 @@ internal constructor( fileUploader = fileUploader, sendMessageInterceptor = sendMessageInterceptor, shareFileDownloadRequestInterceptor = shareFileDownloadRequestInterceptor, + cdn = cdn, tokenManager = tokenManager, customOkHttpClient = customOkHttpClient, clientDebugger = clientDebugger, @@ -5090,8 +5109,9 @@ internal constructor( 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() @@ -5143,6 +5163,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/audio/NativeMediaPlayer.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt index dfec5688c01..8cc96ac2a99 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 @@ -358,12 +357,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 { @@ -392,7 +391,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 7575471ff5a..24d30ae97eb 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 @@ -57,6 +57,8 @@ import io.getstream.chat.android.client.api2.endpoint.VideoCallApi 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 @@ -111,6 +113,7 @@ 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. @@ -133,6 +136,7 @@ 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?, @@ -390,6 +394,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/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/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-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 481942080cf..7739de4f09b 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -987,12 +987,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 } @@ -3245,7 +3247,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 (ZZZZZLio/getstream/chat/android/ui/common/permissions/SystemAttachmentsPickerConfig;Lio/getstream/chat/android/compose/ui/theme/StreamColors;Lio/getstream/chat/android/compose/ui/theme/StreamDimens;Lio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamShapes;Lio/getstream/chat/android/compose/ui/theme/StreamRippleConfiguration;Lio/getstream/chat/android/ui/common/model/UserPresence;Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Ljava/util/List;Lio/getstream/chat/android/compose/ui/components/messages/factory/MessageContentFactory;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/compose/ui/util/ReactionIconFactory;Lio/getstream/chat/android/ui/common/helper/ReactionPushEmojiFactory;Lio/getstream/chat/android/compose/ui/theme/ReactionOptionsTheme;Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory;Lio/getstream/chat/android/compose/ui/util/PollSwitchItemFactory;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/state/messages/list/MessageOptionsUserReactionAlignment;Ljava/util/List;ZLio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;ZLio/getstream/chat/android/compose/ui/theme/MessageTheme;Lio/getstream/chat/android/compose/ui/theme/MessageTheme;Lio/getstream/chat/android/compose/ui/theme/MessageDateSeparatorTheme;Lio/getstream/chat/android/compose/ui/theme/MessageUnreadSeparatorTheme;Lio/getstream/chat/android/compose/ui/theme/MessageComposerTheme;Lio/getstream/chat/android/compose/ui/theme/AttachmentPickerTheme;Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;Lio/getstream/chat/android/compose/ui/util/QuotedMessageTextFormatter;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lio/getstream/chat/android/compose/ui/theme/StreamKeyboardBehaviour;Lio/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryConfig;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;IIIIIIII)V + public static final fun ChatTheme (ZZZZZLio/getstream/chat/android/ui/common/permissions/SystemAttachmentsPickerConfig;Lio/getstream/chat/android/compose/ui/theme/StreamColors;Lio/getstream/chat/android/compose/ui/theme/StreamDimens;Lio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamShapes;Lio/getstream/chat/android/compose/ui/theme/StreamRippleConfiguration;Lio/getstream/chat/android/ui/common/model/UserPresence;Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Ljava/util/List;Lio/getstream/chat/android/compose/ui/components/messages/factory/MessageContentFactory;ZLjava/util/List;Ljava/util/List;Lio/getstream/chat/android/compose/ui/util/ReactionIconFactory;Lio/getstream/chat/android/ui/common/helper/ReactionPushEmojiFactory;Lio/getstream/chat/android/compose/ui/theme/ReactionOptionsTheme;Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory;Lio/getstream/chat/android/compose/ui/util/PollSwitchItemFactory;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/state/messages/list/MessageOptionsUserReactionAlignment;Ljava/util/List;ZLio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;ZLio/getstream/chat/android/compose/ui/theme/MessageTheme;Lio/getstream/chat/android/compose/ui/theme/MessageTheme;Lio/getstream/chat/android/compose/ui/theme/MessageDateSeparatorTheme;Lio/getstream/chat/android/compose/ui/theme/MessageUnreadSeparatorTheme;Lio/getstream/chat/android/compose/ui/theme/MessageComposerTheme;Lio/getstream/chat/android/compose/ui/theme/AttachmentPickerTheme;Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;Lio/getstream/chat/android/compose/ui/util/QuotedMessageTextFormatter;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lio/getstream/chat/android/compose/ui/theme/StreamKeyboardBehaviour;Lio/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryConfig;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;IIIIIIII)V public static final fun getLocalComponentFactory ()Landroidx/compose/runtime/ProvidableCompositionLocal; } 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 3d06bf18df2..2c6c84871c5 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.uiutils.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/StreamMediaPlayerContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt index 6fe00f972fd..95b9a5f77ea 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 @@ -43,7 +43,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.attachments.content.PlayButton import io.getstream.chat.android.compose.ui.components.LoadingIndicator @@ -195,7 +198,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/theme/ChatTheme.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt index b73eef72968..d0e79da7602 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 @@ -48,6 +48,9 @@ import io.getstream.chat.android.compose.ui.attachments.preview.handler.Attachme import io.getstream.chat.android.compose.ui.components.messages.factory.MessageContentFactory import io.getstream.chat.android.compose.ui.messages.attachments.factory.AttachmentsPickerTabFactories import io.getstream.chat.android.compose.ui.messages.attachments.factory.AttachmentsPickerTabFactory +import io.getstream.chat.android.compose.ui.theme.ChatTheme.autoTranslationEnabled +import io.getstream.chat.android.compose.ui.theme.ChatTheme.isComposerLinkPreviewEnabled +import io.getstream.chat.android.compose.ui.theme.ChatTheme.showOriginalTranslationEnabled import io.getstream.chat.android.compose.ui.theme.messages.attachments.FileAttachmentTheme import io.getstream.chat.android.compose.ui.util.DefaultPollSwitchItemFactory import io.getstream.chat.android.compose.ui.util.ImageHeadersInterceptor @@ -73,6 +76,7 @@ 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.ReactionPushEmojiFactory 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.model.UserPresence import io.getstream.chat.android.ui.common.permissions.SystemAttachmentsPickerConfig @@ -287,6 +291,10 @@ private val LocalMediaGalleryConfig = compositionLocalOf { * @param userPresence The user presence display configuration. * @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 quotedAttachmentFactories Quoted attachment factories that we provide. * @param reactionIconFactory Used to create an icon [Painter] for the given reaction type. @@ -355,8 +363,9 @@ public fun ChatTheme( componentFactory: ChatComponentFactory = DefaultChatComponentFactory(), attachmentFactories: List = StreamAttachmentFactories.defaults(), messageContentFactory: MessageContentFactory = MessageContentFactory.Deprecated, + useDocumentGView: Boolean = true, attachmentPreviewHandlers: List = - AttachmentPreviewHandler.defaultAttachmentHandlers(LocalContext.current), + AttachmentPreviewHandler.defaultAttachmentHandlers(LocalContext.current, useDocumentGView), quotedAttachmentFactories: List = StreamAttachmentFactories.defaultQuotedFactories(), reactionIconFactory: ReactionIconFactory = ReactionIconFactory.defaultFactory(), reactionPushEmojiFactory: ReactionPushEmojiFactory = ReactionPushEmojiFactory.defaultFactory(), @@ -449,14 +458,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-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt index c10ed1caf3e..30bcc85bdd1 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt @@ -28,6 +28,7 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.cidToTypeAndId +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 @@ -279,6 +280,12 @@ public fun ChatClient.setMessageForReply(cid: String, message: Message?): Call + addRequestHeader(key, value) + } + } .apply(interceptRequest), ) Result.Success(Unit) diff --git a/stream-chat-android-ui-common/src/main/AndroidManifest.xml b/stream-chat-android-ui-common/src/main/AndroidManifest.xml index 68b5cb14d64..e2403e729fa 100644 --- a/stream-chat-android-ui-common/src/main/AndroidManifest.xml +++ b/stream-chat-android-ui-common/src/main/AndroidManifest.xml @@ -46,7 +46,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/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/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/res/values/strings.xml b/stream-chat-android-ui-common/src/main/res/values/strings.xml index 569c8d4d755..fa285dbd99b 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 @@ -53,6 +53,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-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index bea62fad715..28cfa3bff82 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 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/AttachmentGalleryVideoPageFragment.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryVideoPageFragment.kt index 4e531a67691..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,12 +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.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 @@ -244,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) } 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 abc2e6fa427..063f6e3c9e6 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.feature.gallery.AttachmentActivity import io.getstream.chat.android.ui.feature.gallery.AttachmentMediaActivity import io.getstream.chat.android.ui.utils.load @@ -131,10 +132,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 -> {