From e652f8d5cdea79cf3a288ce5d6f30fc969c162c4 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 29 Jun 2026 20:50:05 +0200 Subject: [PATCH 1/6] Add opt-in video disk cache for ExoPlayer playback Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client/test/MockedChatClientTest.kt | 4 + .../api/stream-chat-android-client.api | 32 +++ .../chat/android/client/ChatClient.kt | 18 ++ .../android/client/cache/StreamCacheConfig.kt | 50 ++++ .../internal/VideoCacheDataSourceFactory.kt | 45 ++++ .../client/cache/internal/VideoMediaCache.kt | 122 ++++++++++ .../cdn/internal/CDNDataSourceFactory.kt | 5 +- .../cdn/internal/StreamMediaDataSource.kt | 20 +- .../client/internal/file/StreamFileManager.kt | 32 +++ .../ChatClientCacheAndTemporaryFilesTest.kt | 7 + ...reamMediaDataSourceCacheIntegrationTest.kt | 219 ++++++++++++++++++ .../cache/internal/VideoMediaCacheTest.kt | 93 ++++++++ .../client/cdn/internal/CDNDataSourceTest.kt | 5 +- .../internal/file/StreamFileManagerTest.kt | 16 +- .../api/stream-chat-android-compose.api | 5 +- .../preview/MediaGalleryPreviewScreen.kt | 1 + .../preview/MediaPreviewActivity.kt | 35 ++- .../handler/MediaAttachmentPreviewHandler.kt | 2 + .../internal/StreamMediaPlayerContent.kt | 12 +- .../gallery/AttachmentMediaActivity.kt | 14 +- .../AttachmentGalleryVideoPageFragment.kt | 5 +- 21 files changed, 727 insertions(+), 15 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/StreamCacheConfig.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoMediaCache.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/VideoMediaCacheTest.kt diff --git a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/MockedChatClientTest.kt b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/MockedChatClientTest.kt index d33ea89aee7..32c3c4546ae 100644 --- a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/MockedChatClientTest.kt +++ b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/MockedChatClientTest.kt @@ -62,6 +62,10 @@ interface MockedChatClientTest { whenever(MockChatClient.clientState) doReturn MockClientState whenever(MockChatClient.inheritScope(any())) doReturn TestScope() + CoroutineExceptionHandler { _, _ -> } + // RETURNS_MOCKS would otherwise hand out mock instances for nullable getters that + // production code reads opportunistically (e.g. opt-in features). Stub them to null + // so default behaviour matches the unconfigured path. + whenever(MockChatClient.videoCache) doReturn null } @After 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 763ab234c89..9ed6a733acb 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -276,6 +276,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 cacheConfig (Lio/getstream/chat/android/client/cache/StreamCacheConfig;)Lio/getstream/chat/android/client/ChatClient$Builder; 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; @@ -737,6 +738,37 @@ public final class io/getstream/chat/android/client/audio/WaveformExtractorKt { public static final fun isEof (Landroid/media/MediaCodec$BufferInfo;)Z } +public final class io/getstream/chat/android/client/cache/StreamCacheConfig { + public fun ()V + public fun (Lio/getstream/chat/android/client/cache/VideoCacheConfig;)V + public synthetic fun (Lio/getstream/chat/android/client/cache/VideoCacheConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/getstream/chat/android/client/cache/VideoCacheConfig; + public final fun copy (Lio/getstream/chat/android/client/cache/VideoCacheConfig;)Lio/getstream/chat/android/client/cache/StreamCacheConfig; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/cache/StreamCacheConfig;Lio/getstream/chat/android/client/cache/VideoCacheConfig;ILjava/lang/Object;)Lio/getstream/chat/android/client/cache/StreamCacheConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getVideo ()Lio/getstream/chat/android/client/cache/VideoCacheConfig; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/client/cache/VideoCacheConfig { + public static final field Companion Lio/getstream/chat/android/client/cache/VideoCacheConfig$Companion; + public static final field DEFAULT_MAX_SIZE_BYTES J + public fun ()V + public fun (J)V + public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun copy (J)Lio/getstream/chat/android/client/cache/VideoCacheConfig; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/cache/VideoCacheConfig;JILjava/lang/Object;)Lio/getstream/chat/android/client/cache/VideoCacheConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getMaxSizeBytes ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/client/cache/VideoCacheConfig$Companion { +} + 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; 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 0407cd52633..9d305902513 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 @@ -72,6 +72,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.cache.StreamCacheConfig +import io.getstream.chat.android.client.cache.internal.VideoMediaCache 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 @@ -300,6 +302,8 @@ internal constructor( internal val messageReceiptManager: MessageReceiptManager, @InternalStreamChatApi public val cdn: CDN? = null, + @InternalStreamChatApi + public val videoCache: VideoMediaCache? = null, ) { private val logger by taggedLogger(TAG) private val fileManager = StreamFileManager() @@ -4810,6 +4814,7 @@ internal constructor( private var fileTransformer: FileTransformer = NoOpFileTransformer private var apiModelTransformers: ApiModelTransformers = ApiModelTransformers() private var cdn: CDN? = null + private var cacheConfig: StreamCacheConfig? = null private var appName: String? = null private var appVersion: String? = null @@ -5022,6 +5027,15 @@ internal constructor( this.cdn = cdn } + /** + * Configures the SDK's user-configurable on-disk caches. + * + * @param config The per-cache configurations. + */ + public fun cacheConfig(config: StreamCacheConfig): Builder = apply { + this.cacheConfig = config + } + /** * Sets the CDN URL to be used by the client. */ @@ -5200,6 +5214,9 @@ internal constructor( val api = module.api() val appSettingsManager = AppSettingManager(api) + val videoCache = cacheConfig?.video?.let { + VideoMediaCache.create(appContext, StreamFileManager().getVideoCache(appContext), it) + } val mediaDataSourceFactory = StreamMediaDataSource.factory(appContext, cdn) val audioPlayer: AudioPlayer = StreamAudioPlayer( mediaPlayer = NativeMediaPlayerImpl(mediaDataSourceFactory) { @@ -5255,6 +5272,7 @@ internal constructor( api = api, ), cdn = cdn, + videoCache = videoCache, ).apply { attachmentsSender = AttachmentsSender( context = appContext, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/StreamCacheConfig.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/StreamCacheConfig.kt new file mode 100644 index 00000000000..630e718fb01 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/StreamCacheConfig.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.cache + +/** + * Bundles the per-cache configurations exposed by the Stream Chat SDK. + * + * Pass an instance to [io.getstream.chat.android.client.ChatClient.Builder.cacheConfig] to configure + * the on-disk caches. + * + * @param video Configuration for the video playback cache used by SDK. + */ +public data class StreamCacheConfig( + public val video: VideoCacheConfig? = null, +) + +/** + * Configuration for the on-disk cache used when streaming video attachments. + * + * Wrap an instance in [StreamCacheConfig] and pass it to + * [io.getstream.chat.android.client.ChatClient.Builder.cacheConfig] to opt in. When the cache is + * enabled, replaying or seeking within a previously watched video reuses cached byte ranges + * instead of re-downloading from the CDN. + * + * @param maxSizeBytes Soft cap on cache size; LRU eviction kicks in once exceeded. Files larger + * than this cap are not effectively cached. Size [maxSizeBytes] to comfortably exceed the + * largest expected video. + */ +public data class VideoCacheConfig( + public val maxSizeBytes: Long = DEFAULT_MAX_SIZE_BYTES, +) { + public companion object { + /** Default cap of 150 MB. */ + public const val DEFAULT_MAX_SIZE_BYTES: Long = 150L * 1024 * 1024 + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt new file mode 100644 index 00000000000..6fed0f2e9e3 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt @@ -0,0 +1,45 @@ +/* + * 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.cache.internal + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.cache.CacheDataSource +import io.getstream.chat.android.client.cdn.internal.CDNDataSourceFactory + +/** + * A [DataSource.Factory] that serves video bytes from a [VideoMediaCache] on hit and delegates + * to [upstreamFactory] on miss, writing the fetched bytes back into the cache. + * + * @param videoCache The cache that holds the cached video spans. + * @param upstreamFactory Factory invoked on cache miss (typically the [CDNDataSourceFactory] when + * a custom CDN is configured, or the base data source otherwise). + */ +@OptIn(UnstableApi::class) +internal class VideoCacheDataSourceFactory( + videoCache: VideoMediaCache, + upstreamFactory: DataSource.Factory, +) : DataSource.Factory { + + private val delegate: DataSource.Factory = CacheDataSource.Factory() + .setCache(videoCache.cache) + .setUpstreamDataSourceFactory(upstreamFactory) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + + override fun createDataSource(): DataSource = delegate.createDataSource() +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoMediaCache.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoMediaCache.kt new file mode 100644 index 00000000000..f7ebce6dd45 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoMediaCache.kt @@ -0,0 +1,122 @@ +/* + * 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.cache.internal + +import android.content.Context +import androidx.annotation.OptIn +import androidx.annotation.VisibleForTesting +import androidx.media3.common.util.UnstableApi +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import io.getstream.chat.android.client.cache.VideoCacheConfig +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.log.taggedLogger +import java.io.File + +/** + * Owns a [SimpleCache] that caches video bytes streamed through ExoPlayer. + * + * [SimpleCache] requires one instance per directory per process. Use [create] to obtain an + * instance; the factory guarantees that subsequent calls for the same directory return the + * existing cache instead of attempting to construct a second [SimpleCache], which would throw. + * + * The cache layer is composed *outside* any CDN URL-rewriting layer (see + * [VideoCacheDataSourceFactory]), so entries are keyed by the raw `dataSpec.uri` (= the unsigned + * `attachment.assetUrl` from the `MediaItem`). On a cache miss the customer's + * [io.getstream.chat.android.client.cdn.CDN] still runs and signs the URL just in time; on a + * cache hit the bytes are served from disk and no CDN call is made. + */ +@OptIn(UnstableApi::class) +@InternalStreamChatApi +public class VideoMediaCache private constructor( + /** + * The underlying [SimpleCache]. Exposed so [VideoCacheDataSourceFactory] can plug it into the + * Media3 [androidx.media3.datasource.cache.CacheDataSource.Factory]; not intended for direct + * use by callers outside the `cache.internal` package. + */ + public val cache: SimpleCache, + private val databaseProvider: StandaloneDatabaseProvider, + private val dirPath: String, +) { + + private val logger by taggedLogger(TAG) + + /** + * Tears down the underlying [SimpleCache] and the [StandaloneDatabaseProvider] it owns, and + * removes this instance from the process-wide [instances] registry so that a fresh + * [VideoMediaCache] can be constructed for the same directory. + * + * Production code does not need to call this — the cache is designed to live for the process + * lifetime, and the OS reclaims its resources on process death. Tests call this between + * cases so the next [create] does not collide with Media3's per-directory `SimpleCache` lock. + */ + @VisibleForTesting + internal fun release() { + synchronized(instances) { + instances.remove(dirPath) + try { + cache.release() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + logger.e(e) { "[release] failed to release SimpleCache" } + } + try { + databaseProvider.close() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + logger.e(e) { "[release] failed to close StandaloneDatabaseProvider" } + } + } + } + + @InternalStreamChatApi + public companion object { + private const val TAG = "Chat:VideoMediaCache" + private val instances: MutableMap = mutableMapOf() + private val logger by taggedLogger(TAG) + + /** + * Returns a [VideoMediaCache] backed by the [SimpleCache] at [cacheDir]. If an instance + * for that absolute directory path already exists in this process, that instance is + * returned and [config] is ignored beyond the first call; this prevents a second + * [SimpleCache] from being constructed against the same directory (which would throw). + * + * @param appContext Application context used to construct the [StandaloneDatabaseProvider]. + * @param cacheDir Directory that backs the [SimpleCache]. Created if it does not exist. + * @param config Cache configuration. Honored only on the first call for [cacheDir]. + */ + @JvmStatic + public fun create(appContext: Context, cacheDir: File, config: VideoCacheConfig): VideoMediaCache = + synchronized(instances) { + cacheDir.mkdirs() + val key = cacheDir.absolutePath + instances[key]?.let { existing -> + logger.w { + "[create] Reusing existing VideoMediaCache for '$key'; " + + "additional VideoCacheConfig values are ignored." + } + return@synchronized existing + } + val dbProvider = StandaloneDatabaseProvider(appContext) + val simpleCache = SimpleCache( + cacheDir, + LeastRecentlyUsedCacheEvictor(config.maxSizeBytes), + dbProvider, + ) + VideoMediaCache(simpleCache, dbProvider, key).also { instances[key] = it } + } + } +} 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 index 69f609ef5f0..07274faba8b 100644 --- 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 @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.cdn.internal import android.net.Uri +import androidx.annotation.OptIn import androidx.core.net.toUri import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource @@ -36,7 +37,7 @@ import kotlinx.coroutines.runBlocking * @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 +@OptIn(UnstableApi::class) internal class CDNDataSourceFactory( private val cdn: CDN, private val upstreamFactory: DataSource.Factory = DefaultHttpDataSource.Factory(), @@ -54,7 +55,7 @@ internal class CDNDataSourceFactory( * [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 +@OptIn(UnstableApi::class) private class CDNDataSource( private val cdn: CDN, private val upstream: DataSource, 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 index 8143bf48470..357e9931aca 100644 --- 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 @@ -21,6 +21,8 @@ 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.cache.internal.VideoCacheDataSourceFactory +import io.getstream.chat.android.client.cache.internal.VideoMediaCache import io.getstream.chat.android.client.cdn.CDN import io.getstream.chat.android.core.internal.InternalStreamChatApi @@ -29,6 +31,8 @@ import io.getstream.chat.android.core.internal.InternalStreamChatApi * * 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). + * When a [VideoMediaCache] is provided, an on-disk cache layer is composed *outside* the CDN factory + * so cached bytes are served without re-signing the URL; misses still go through the CDN. */ @InternalStreamChatApi public object StreamMediaDataSource { @@ -39,12 +43,24 @@ public object StreamMediaDataSource { * 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. * + * When a [videoCache] is provided, it wraps the resulting factory so video byte ranges are + * served from disk on subsequent reads. The cache layer is the outermost wrapper, keyed by the + * raw `dataSpec.uri`, so cache lookups are not affected by CDN URL rewriting. + * * @param context The context used to create the base data source. * @param cdn Optional custom CDN for transforming network requests. + * @param videoCache Optional disk cache for video playback. Callers should pass `null` for + * non-video content (e.g. audio attachments, voice recordings) so audio bytes do not occupy + * disk space in the video cache. */ @OptIn(UnstableApi::class) - public fun factory(context: Context, cdn: CDN?): DataSource.Factory { + public fun factory( + context: Context, + cdn: CDN?, + videoCache: VideoMediaCache? = null, + ): DataSource.Factory { val base = DefaultDataSource.Factory(context) - return cdn?.let { CDNDataSourceFactory(it, base) } ?: base + val cdnWrapped = cdn?.let { CDNDataSourceFactory(it, base) } ?: base + return videoCache?.let { VideoCacheDataSourceFactory(it, cdnWrapped) } ?: cdnWrapped } } 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 c3fe2d2df73..093f21e6e40 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 @@ -42,6 +42,7 @@ import java.util.Locale * - External storage management for photos and videos captured using the SDK */ @InternalStreamChatApi +@Suppress("TooManyFunctions") public class StreamFileManager { /** @@ -56,6 +57,18 @@ public class StreamFileManager { return context.cacheDir.resolve(IMAGE_CACHE_DIR) } + /** + * Returns the directory used for caching video bytes streamed through ExoPlayer. + * + * Path: `{cacheDir}/stream_video_cache/` + * + * @param context Android context for accessing the cache directory. + * @return File pointing to the video cache directory. + */ + public fun getVideoCache(context: Context): File { + return context.cacheDir.resolve(VIDEO_CACHE_DIR) + } + /** * Creates a file reference in cache without writing content. * @@ -232,9 +245,11 @@ public class StreamFileManager { public fun clearAllCache(context: Context): Result { val streamCacheResult = clearCache(context) val imageCacheResult = clearImageCache(context) + val videoCacheResult = clearVideoCache(context) val timestampedCacheResult = clearTimestampedCacheFolders(context) return streamCacheResult .flatMap { imageCacheResult } + .flatMap { videoCacheResult } .flatMap { timestampedCacheResult } } @@ -423,6 +438,22 @@ public class StreamFileManager { } } + @Suppress("TooGenericExceptionCaught") + private fun clearVideoCache(context: Context): Result { + return try { + val directory = getVideoCache(context) + if (!directory.exists()) { + Result.Success(Unit) + } else if (directory.deleteRecursively()) { + Result.Success(Unit) + } else { + Result.Failure(Error.GenericError("Could not clear video cache directory.")) + } + } catch (e: Exception) { + Result.Failure(Error.ThrowableError("Could not clear video cache directory.", e)) + } + } + @Suppress("TooGenericExceptionCaught") private fun clearTimestampedCacheFolders(context: Context): Result { return try { @@ -448,6 +479,7 @@ public class StreamFileManager { private companion object { private const val CACHE_DIR = "stream_cache" private const val IMAGE_CACHE_DIR = "stream_image_cache" + private const val VIDEO_CACHE_DIR = "stream_video_cache" private const val TIMESTAMPED_DIR_TIMESTAMP_FORMAT = "HHmmssSSS" private const val TIMESTAMPED_DIR_PREFIX = "STREAM_" private const val EXTERNAL_DIR_TIMESTAMP_FORMAT = "yyyyMMdd_HHmmss" diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientCacheAndTemporaryFilesTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientCacheAndTemporaryFilesTest.kt index fd865475981..7467b29a2f5 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientCacheAndTemporaryFilesTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientCacheAndTemporaryFilesTest.kt @@ -70,6 +70,11 @@ internal class ChatClientCacheAndTemporaryFilesTest { imageCache.mkdirs() File(imageCache, "image.jpg").writeText("image") + // Create files in video cache + val videoCache = streamFileManager.getVideoCache(context) + videoCache.mkdirs() + File(videoCache, "video.mp4").writeText("video") + // Create files in timestamped cache streamFileManager.writeFileInTimestampedCache(context, "timestamped_${randomString()}.txt", "content".byteInputStream()) @@ -89,6 +94,7 @@ internal class ChatClientCacheAndTemporaryFilesTest { val streamCacheDir = File(context.cacheDir, "stream_cache") assertTrue(streamCacheDir.exists()) assertTrue(imageCache.exists()) + assertTrue(videoCache.exists()) assertTrue(photoFile.exists()) assertTrue(videoFile.exists()) @@ -99,6 +105,7 @@ internal class ChatClientCacheAndTemporaryFilesTest { assertTrue(result is Result.Success) assertFalse(streamCacheDir.exists()) assertFalse(imageCache.exists()) + assertFalse(videoCache.exists()) assertFalse(photoFile.exists()) assertFalse(videoFile.exists()) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt new file mode 100644 index 00000000000..51cda5b5c8e --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt @@ -0,0 +1,219 @@ +/* + * 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.cache.internal + +import android.content.Context +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import androidx.test.core.app.ApplicationProvider +import io.getstream.chat.android.client.cache.VideoCacheConfig +import io.getstream.chat.android.client.cdn.CDN +import io.getstream.chat.android.client.cdn.CDNRequest +import io.getstream.chat.android.client.cdn.internal.CDNDataSourceFactory +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * Verifies the cache + CDN composition end-to-end across the four cells of: + * `{custom CDN, no CDN} × {cache hit, cache miss}`. + * + * The composition under test mirrors what `StreamMediaDataSource.factory()` produces for video + * playback when a `VideoMediaCache` is supplied: `CacheDataSource` (outer) → `CDNDataSourceFactory` + * (inner, when CDN is configured) → upstream `DataSource`. The test drives `DataSpec`s through that + * composed factory directly — no ExoPlayer is involved — and asserts on a recording upstream and a + * fake CDN. + */ +@OptIn(UnstableApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +internal class StreamMediaDataSourceCacheIntegrationTest { + + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val cacheDir: File get() = File(context.cacheDir, SUB_DIR) + + private lateinit var cache: VideoMediaCache + + @Before + fun setUp() { + cacheDir.deleteRecursively() + cache = VideoMediaCache.create(context, cacheDir, VideoCacheConfig()) + } + + @After + fun tearDown() { + cache.release() + cacheDir.deleteRecursively() + } + + @Test + fun `no CDN - first open misses cache and reads upstream, second open serves from cache`() { + val upstream = RecordingDataSourceFactory() + val factory = VideoCacheDataSourceFactory(cache, upstream) + + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_URL))) + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_URL))) + + assertEquals(1, upstream.openCount) + assertEquals(listOf(VIDEO_URL), upstream.openedUris) + } + + @Test + fun `custom CDN - first open invokes CDN with raw URL and upstream sees the signed URL`() { + val cdn = FakeCDN() + val upstream = RecordingDataSourceFactory() + val factory = VideoCacheDataSourceFactory(cache, CDNDataSourceFactory(cdn) { upstream.createDataSource() }) + + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_URL))) + + assertEquals(listOf(VIDEO_URL), cdn.invocations) + assertEquals(listOf("$VIDEO_URL?sig=0"), upstream.openedUris) + assertEquals("0", upstream.lastOpenedHeaders["X-Sig"]) + } + + /** + * The load-bearing case: a cache hit must NOT consult the customer's CDN. If the cache were + * keyed by the signed URL, [FakeCDN] would have rotated the signature on the second open and + * the cache would have missed. + */ + @Test + fun `custom CDN - cache hit does not invoke CDN and does not hit upstream`() { + val cdn = FakeCDN() + val upstream = RecordingDataSourceFactory() + val factory = VideoCacheDataSourceFactory(cache, CDNDataSourceFactory(cdn) { upstream.createDataSource() }) + + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_URL))) + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_URL))) + + assertEquals( + "CDN should be consulted exactly once across the miss + hit", + 1, + cdn.invocations.size, + ) + assertEquals( + "Upstream should be opened exactly once across the miss + hit", + 1, + upstream.openCount, + ) + } + + private fun readFully(source: DataSource, spec: DataSpec) { + source.open(spec) + try { + val buffer = ByteArray(BUFFER_SIZE) + while (source.read(buffer, 0, buffer.size) != C.RESULT_END_OF_INPUT) { + /* drain */ + } + } finally { + source.close() + } + } + + /** + * A [DataSource.Factory] that produces [RecordingDataSource]s sharing a single open-log. + */ + @OptIn(UnstableApi::class) + private class RecordingDataSourceFactory( + private val totalLength: Long = TOTAL_LENGTH, + ) : DataSource.Factory { + + private val openedSpecs = mutableListOf() + + val openCount: Int get() = openedSpecs.size + val openedUris: List get() = openedSpecs.map { it.uri.toString() } + val lastOpenedHeaders: Map + get() = openedSpecs.lastOrNull()?.httpRequestHeaders.orEmpty() + + override fun createDataSource(): DataSource = + RecordingDataSource(totalLength) { openedSpecs += it } + } + + @OptIn(UnstableApi::class) + private class RecordingDataSource( + private val totalLength: Long, + private val onOpen: (DataSpec) -> Unit, + ) : DataSource { + + private var position = 0L + private var endPosition = 0L + private var lastUri: Uri? = null + + override fun open(dataSpec: DataSpec): Long { + onOpen(dataSpec) + lastUri = dataSpec.uri + position = dataSpec.position + endPosition = if (dataSpec.length == C.LENGTH_UNSET.toLong()) { + totalLength + } else { + position + dataSpec.length + } + return endPosition - position + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (position >= endPosition) return C.RESULT_END_OF_INPUT + val remaining = (endPosition - position).toInt() + val toRead = minOf(length, remaining) + repeat(toRead) { buffer[offset + it] = FILL_BYTE } + position += toRead + return toRead + } + + override fun close() { /* nothing to release */ } + + override fun getUri(): Uri? = lastUri + + override fun getResponseHeaders(): Map> = emptyMap() + + override fun addTransferListener(transferListener: TransferListener) { /* unused */ } + } + + /** + * Returns a fresh signature on every call so a cache miss after the first open would be visible + * via a rotated `?sig=N` query parameter in the upstream's recorded URL. + */ + private class FakeCDN : CDN { + private val _invocations = mutableListOf() + val invocations: List get() = _invocations.toList() + + private var counter = 0 + + override suspend fun fileRequest(url: String): CDNRequest { + _invocations += url + val sig = counter++ + return CDNRequest("$url?sig=$sig", mapOf("X-Sig" to sig.toString())) + } + } + + private companion object { + private const val SUB_DIR = "video_cache_integration_test" + private const val VIDEO_URL = "https://stream.io/v.mp4" + private const val TOTAL_LENGTH = 4096L + private const val BUFFER_SIZE = 512 + private const val FILL_BYTE: Byte = 0xAB.toByte() + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/VideoMediaCacheTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/VideoMediaCacheTest.kt new file mode 100644 index 00000000000..9c6d9251cc1 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/VideoMediaCacheTest.kt @@ -0,0 +1,93 @@ +/* + * 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.cache.internal + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.test.core.app.ApplicationProvider +import io.getstream.chat.android.client.cache.VideoCacheConfig +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +@OptIn(UnstableApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +internal class VideoMediaCacheTest { + + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val cacheDir: File get() = File(context.cacheDir, SUB_DIR) + + private lateinit var cache: VideoMediaCache + + @Before + fun setUp() { + cacheDir.deleteRecursively() + cache = VideoMediaCache.create(context, cacheDir, VideoCacheConfig()) + } + + @After + fun tearDown() { + cache.release() + cacheDir.deleteRecursively() + } + + @Test + fun `creates the cache directory under the provided path`() { + assertTrue("Expected cache directory to exist at ${cacheDir.absolutePath}", cacheDir.isDirectory) + } + + @Test + fun `release is idempotent across multiple calls`() { + cache.release() + cache.release() + } + + @Test + fun `create returns the same instance for the same directory`() { + val secondConfig = VideoCacheConfig(maxSizeBytes = VideoCacheConfig.DEFAULT_MAX_SIZE_BYTES / 2) + + val second = VideoMediaCache.create(context, cacheDir, secondConfig) + + assertSame(cache, second) + } + + @Test + fun `create returns a fresh instance after release for the same directory`() { + cache.release() + + val recreated = VideoMediaCache.create(context, cacheDir, VideoCacheConfig()) + + assertNotNull(recreated) + assertTrue("Expected a different instance after release", recreated !== cache) + // Reassign so @After releases the recreated instance and Media3's SimpleCache + // unlocks the directory; otherwise the next test's setUp would throw. + cache = recreated + } + + private companion object { + private const val SUB_DIR = "video_cache_test" + } +} 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 index f642c3cdd69..11cd8f56d56 100644 --- 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 @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.cdn.internal import android.net.Uri +import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec @@ -33,7 +34,7 @@ import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -@UnstableApi +@OptIn(UnstableApi::class) @RunWith(RobolectricTestRunner::class) @Config(sdk = [33]) internal class CDNDataSourceTest { @@ -205,7 +206,7 @@ internal class CDNDataSourceTest { /** * A simple fake [DataSource] that records the [DataSpec] passed to [open]. */ - @UnstableApi + @OptIn(UnstableApi::class) private class FakeDataSource : DataSource { var lastOpenedDataSpec: DataSpec? = null 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 784adbec4cb..445fedda2a6 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 @@ -60,6 +60,13 @@ internal class StreamFileManagerTest { assertEquals(File(context.cacheDir, "stream_image_cache").path, imageCache.path) } + @Test + fun `getVideoCache should return correct directory path`() { + val videoCache = streamFileManager.getVideoCache(context) + + assertEquals(File(context.cacheDir, "stream_video_cache").path, videoCache.path) + } + @Test fun `createFileInCache should return file reference without creating content`() { val fileName = "test_${randomString()}.txt" @@ -196,7 +203,7 @@ internal class StreamFileManagerTest { } @Test - fun `clearAllCache should clear stream cache, image cache, and timestamped folders`() = runTest { + fun `clearAllCache should clear stream cache, image cache, video cache, and timestamped folders`() = runTest { // Create file in stream cache val streamFileName = "stream_${randomString()}.txt" streamFileManager.writeFileInCache(context, streamFileName, "content".byteInputStream()) @@ -210,10 +217,16 @@ internal class StreamFileManagerTest { imageCache.mkdirs() File(imageCache, "image.jpg").writeText("image") + // Create video cache directory + val videoCache = streamFileManager.getVideoCache(context) + videoCache.mkdirs() + File(videoCache, "video.mp4").writeText("video") + // Verify files exist val streamCacheDir = File(context.cacheDir, "stream_cache") assertTrue(streamCacheDir.exists()) assertTrue(imageCache.exists()) + assertTrue(videoCache.exists()) // Clear all caches val result = streamFileManager.clearAllCache(context) @@ -221,6 +234,7 @@ internal class StreamFileManagerTest { assertTrue(result is Result.Success) assertFalse(streamCacheDir.exists()) assertFalse(imageCache.exists()) + assertFalse(videoCache.exists()) } @Test 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 7c4f15d949a..53bec26e94e 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -976,8 +976,11 @@ public final class io/getstream/chat/android/compose/ui/attachments/preview/Medi } public final class io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity$Companion { + public final fun getIntent (Landroid/content/Context;Ljava/lang/String;)Landroid/content/Intent; public final fun getIntent (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent; - public static synthetic fun getIntent$default (Lio/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity$Companion;Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Landroid/content/Intent; + public final fun getIntent (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent; + public final fun getIntent (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent; + public static synthetic fun getIntent$default (Lio/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity$Companion;Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Landroid/content/Intent; } public abstract interface class io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt index ad6ffbeb2ea..0d298285b73 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt @@ -584,6 +584,7 @@ internal fun MediaGalleryPager( if (!previewMode && player == null) { player = createPlayer( context = context, + useVideoCache = true, onBuffering = { isBuffering -> showBuffering = isBuffering }, onPlaybackError = onPlaybackError, ) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt index 827f533da0c..d53fce6d9d7 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt @@ -51,6 +51,7 @@ import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.attachments.preview.internal.StreamMediaPlayerContent import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.util.mirrorRtl +import io.getstream.chat.android.models.AttachmentType /** * An Activity that is capable of playing video/audio stream. @@ -62,12 +63,16 @@ public class MediaPreviewActivity : AppCompatActivity() { super.onCreate(savedInstanceState) val url = intent.getStringExtra(KEY_URL) val title = intent.getStringExtra(KEY_TITLE) ?: "" + val mimeType = intent.getStringExtra(KEY_MIME_TYPE) + val type = intent.getStringExtra(KEY_TYPE) if (url.isNullOrEmpty() || ChatClient.isInitialized.not()) { finish() return } + val enableVideoCache = isVideoContent(mimeType, type) + setContent { ChatTheme { MediaPreviewScreen( @@ -77,6 +82,7 @@ public class MediaPreviewActivity : AppCompatActivity() { .windowInsetsPadding(WindowInsets.systemBars), url = url, title = title, + useVideoCache = enableVideoCache, onPlaybackError = { Toast.makeText( this, @@ -91,11 +97,21 @@ public class MediaPreviewActivity : AppCompatActivity() { } } + /** + * Returns `true` when the playing attachment is confidently a video, so its bytes can be + * routed through the video cache. Any other content (audio, unknown mime/type) bypasses the + * cache. + */ + private fun isVideoContent(mimeType: String?, type: String?): Boolean = + type == AttachmentType.VIDEO || mimeType?.startsWith("video/") == true + /** * Represents a screen with a media player. * * @param url The URL of the stream for playback. * @param title The name of the file for playback. + * @param useVideoCache Whether to route playback through the configured video cache. Set to + * `true` only when the content is confidently a video — any other content bypasses the cache. * @param onPlaybackError Handler for playback errors. * @param onBackPressed Handler for back press action. */ @@ -104,6 +120,7 @@ public class MediaPreviewActivity : AppCompatActivity() { modifier: Modifier = Modifier, url: String, title: String, + useVideoCache: Boolean, onPlaybackError: (error: Throwable) -> Unit, onBackPressed: () -> Unit, ) { @@ -120,6 +137,7 @@ public class MediaPreviewActivity : AppCompatActivity() { .padding(padding), assetUrl = url, playWhenReady = true, + useVideoCache = useVideoCache, onPlaybackError = onPlaybackError, ) }, @@ -176,6 +194,8 @@ public class MediaPreviewActivity : AppCompatActivity() { public companion object { private const val KEY_URL: String = "url" private const val KEY_TITLE: String = "title" + private const val KEY_MIME_TYPE: String = "mime_type" + private const val KEY_TYPE: String = "type" /** * Used to build an [Intent] to start the [MediaPreviewActivity] with the required data. @@ -183,11 +203,24 @@ public class MediaPreviewActivity : AppCompatActivity() { * @param context The context to start the activity with. * @param url The URL of the media file. * @param title The name of the media file. + * @param mimeType The MIME type of the media file (e.g. `video/mp4`, `audio/mpeg`). Used + * together with [type] to decide whether to route playback through the video cache. + * @param type The attachment type (e.g. [AttachmentType.VIDEO], [AttachmentType.AUDIO]). + * Same caching behaviour as [mimeType]. */ - public fun getIntent(context: Context, url: String, title: String? = null): Intent { + @JvmOverloads + public fun getIntent( + context: Context, + url: String, + title: String? = null, + mimeType: String? = null, + type: String? = null, + ): Intent { return Intent(context, MediaPreviewActivity::class.java).apply { putExtra(KEY_URL, url) putExtra(KEY_TITLE, title) + putExtra(KEY_MIME_TYPE, mimeType) + putExtra(KEY_TYPE, type) } } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/MediaAttachmentPreviewHandler.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/MediaAttachmentPreviewHandler.kt index bb2c76dbde0..6ca616275fc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/MediaAttachmentPreviewHandler.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/MediaAttachmentPreviewHandler.kt @@ -49,6 +49,8 @@ public class MediaAttachmentPreviewHandler(private val context: Context) : Attac context = context, url = requireNotNull(attachment.assetUrl), title = attachment.title ?: attachment.name, + mimeType = attachment.mimeType, + type = attachment.type, ), ) } 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 95b9a5f77ea..9c2111ca5ad 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 @@ -72,6 +72,8 @@ import io.getstream.chat.android.core.internal.StreamHandsOff * @param onPlaybackError Callback invoked when video playback encounters an error. * @param modifier Modifier to be applied to the player container. * @param thumbnailUrl Optional URL of the thumbnail image to display before playback starts. + * @param useVideoCache Whether to route playback through the configured video cache. Pass `true` + * only when the content is confidently a video — any other content bypasses the cache. */ @OptIn(UnstableApi::class) @Composable @@ -81,6 +83,7 @@ internal fun StreamMediaPlayerContent( onPlaybackError: (error: Throwable) -> Unit, modifier: Modifier = Modifier, thumbnailUrl: String? = null, + useVideoCache: Boolean = true, ) { val context = LocalContext.current var showThumbnail by remember { mutableStateOf(!playWhenReady) } @@ -91,6 +94,7 @@ internal fun StreamMediaPlayerContent( LifecycleResumeEffect(Unit) { player = createPlayer( context = context, + useVideoCache = useVideoCache, onBuffering = onBuffering, onPlaybackError = onPlaybackError, ) @@ -188,18 +192,22 @@ internal fun MediaThumbnail( * Creates a player of type [ExoPlayer]. * * @param context The context to use for creating the player. + * @param useVideoCache Whether to wire the configured video cache into the data source pipeline. + * Pass `true` only when the content is confidently a video — any other content bypasses the cache. * @param onBuffering Callback to be invoked when the player enters or exits buffering state. * @param onPlaybackError Callback to be invoked when a playback error occurs. */ @OptIn(UnstableApi::class) internal fun createPlayer( context: Context, + useVideoCache: Boolean, onBuffering: (Boolean) -> Unit, onPlaybackError: (error: Throwable) -> Unit, ): Player { // Setup player - val cdn = ChatClient.instance().cdn - val dataSourceFactory = StreamMediaDataSource.factory(context, cdn) + val client = ChatClient.instance() + val videoCache = client.videoCache.takeIf { useVideoCache } + val dataSourceFactory = StreamMediaDataSource.factory(context, client.cdn, videoCache) val player = ExoPlayer.Builder(context) .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) .build() 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 d68c2b5474c..9c051a274f1 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 @@ -36,6 +36,7 @@ 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.models.AttachmentType import io.getstream.chat.android.ui.R import io.getstream.chat.android.ui.databinding.StreamUiActivityAttachmentMediaBinding import io.getstream.chat.android.ui.utils.extensions.applyEdgeToEdgePadding @@ -138,8 +139,9 @@ public class AttachmentMediaActivity : AppCompatActivity() { @OptIn(UnstableApi::class) private fun createPlayer(): Player { - val cdn = ChatClient.instance().cdn - val dataSourceFactory = StreamMediaDataSource.factory(this, cdn) + val client = ChatClient.instance() + val videoCache = client.videoCache.takeIf { isVideoContent() } + val dataSourceFactory = StreamMediaDataSource.factory(this, client.cdn, videoCache) val player = ExoPlayer.Builder(this) .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) .build() @@ -182,6 +184,14 @@ public class AttachmentMediaActivity : AppCompatActivity() { binding.controls.show() } + /** + * Returns `true` when the playing attachment is confidently a video, so its bytes can be + * routed through the video cache. Any other content (audio, unknown mime/type) bypasses the + * cache. + */ + private fun isVideoContent(): Boolean = + type == AttachmentType.VIDEO || mimeType?.startsWith("video/") == true + /** * Displays a Toast with an error if there was an issue playing the video. */ 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 e4a1912fdfb..0c94ef5334a 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 @@ -245,9 +245,10 @@ internal class AttachmentGalleryVideoPageFragment : Fragment() { @OptIn(UnstableApi::class) private fun createMediaSourceFactory(): MediaSource.Factory { - val cdn = ChatClient.instance().cdn + val client = ChatClient.instance() val headers = ChatUI.videoHeadersProvider.getVideoRequestHeaders(assetUrl ?: "") - val baseDataSourceFactory = StreamMediaDataSource.factory(requireContext(), cdn) + val baseDataSourceFactory = + StreamMediaDataSource.factory(requireContext(), client.cdn, client.videoCache) val dataSourceFactory = ResolvingDataSource.Factory(baseDataSourceFactory) { dataSpec -> dataSpec.withAdditionalHeaders(headers) } From 2f0f299dce8ed12d90f719a63e5aa91da962bb24 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 29 Jun 2026 21:18:16 +0200 Subject: [PATCH 2/6] Add LRU eviction test for video cache Co-Authored-By: Claude Opus 4.7 (1M context) --- ...reamMediaDataSourceCacheIntegrationTest.kt | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt index 51cda5b5c8e..28b9b694425 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt @@ -121,6 +121,53 @@ internal class StreamMediaDataSourceCacheIntegrationTest { ) } + /** + * Verifies the LRU evictor is wired with [VideoCacheConfig.maxSizeBytes]. Three videos are + * written into a cache sized to hold exactly two; the read between the second and third write + * bumps the first video's recency so the second video becomes the least-recently-used and is + * the one dropped. + */ + @Test + fun `LRU eviction drops the least-recently-used entry when cache fills`() { + val evictionDir = File(context.cacheDir, EVICTION_SUB_DIR).also { it.deleteRecursively() } + val evictionCache = VideoMediaCache.create( + context, + evictionDir, + VideoCacheConfig(maxSizeBytes = 2 * TOTAL_LENGTH), + ) + try { + val upstream = RecordingDataSourceFactory() + val factory = VideoCacheDataSourceFactory(evictionCache, upstream) + + // Fill the cache to capacity, then bump A's recency, then push C in to force eviction. + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_A_URL))) + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_B_URL))) + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_A_URL))) + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_C_URL))) + + assertEquals( + "A, B, and C should each have hit upstream exactly once so far", + 3, + upstream.openCount, + ) + + // A was the most-recently-touched, so it must still be cached. + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_A_URL))) + assertEquals("A must still be cached", 3, upstream.openCount) + + // C is the newest write, so it must still be cached. + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_C_URL))) + assertEquals("C must still be cached", 3, upstream.openCount) + + // B is the least-recently-used and must have been evicted. + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_B_URL))) + assertEquals("B must have been evicted and refetched", 4, upstream.openCount) + } finally { + evictionCache.release() + evictionDir.deleteRecursively() + } + } + private fun readFully(source: DataSource, spec: DataSpec) { source.open(spec) try { @@ -211,7 +258,11 @@ internal class StreamMediaDataSourceCacheIntegrationTest { private companion object { private const val SUB_DIR = "video_cache_integration_test" + private const val EVICTION_SUB_DIR = "video_cache_eviction_test" private const val VIDEO_URL = "https://stream.io/v.mp4" + private const val VIDEO_A_URL = "https://stream.io/a.mp4" + private const val VIDEO_B_URL = "https://stream.io/b.mp4" + private const val VIDEO_C_URL = "https://stream.io/c.mp4" private const val TOTAL_LENGTH = 4096L private const val BUFFER_SIZE = 512 private const val FILL_BYTE: Byte = 0xAB.toByte() From cad2a12743ab0098b9c6e6945f9f398113c106a6 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 30 Jun 2026 14:15:48 +0200 Subject: [PATCH 3/6] Make video cache key ignore URL query parameters Pre-signed URLs with rotating Expires/Signature query parameters would otherwise produce a fresh cache key on every open and defeat the cache. The CacheKeyFactory now strips the query, so the same path resolves to the same entry regardless of signature rotation. The full DataSpec still flows to the upstream on a miss, so a custom CDN sees the original URL with its query intact. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/VideoCacheDataSourceFactory.kt | 16 +++++++++++ ...reamMediaDataSourceCacheIntegrationTest.kt | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt index 6fed0f2e9e3..9e04df96b36 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.cache.internal import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec import androidx.media3.datasource.cache.CacheDataSource import io.getstream.chat.android.client.cdn.internal.CDNDataSourceFactory @@ -26,6 +27,11 @@ import io.getstream.chat.android.client.cdn.internal.CDNDataSourceFactory * A [DataSource.Factory] that serves video bytes from a [VideoMediaCache] on hit and delegates * to [upstreamFactory] on miss, writing the fetched bytes back into the cache. * + * Cache entries are keyed by the URI with its query stripped, so rotating signature/expiry + * parameters on the same path resolve to the same cache entry. The full [DataSpec] still flows + * to [upstreamFactory] on a miss, so a custom CDN sees the original URL and can re-sign or + * rewrite it. A caller-supplied [DataSpec.key] takes precedence over the URI-derived key. + * * @param videoCache The cache that holds the cached video spans. * @param upstreamFactory Factory invoked on cache miss (typically the [CDNDataSourceFactory] when * a custom CDN is configured, or the base data source otherwise). @@ -39,7 +45,17 @@ internal class VideoCacheDataSourceFactory( private val delegate: DataSource.Factory = CacheDataSource.Factory() .setCache(videoCache.cache) .setUpstreamDataSourceFactory(upstreamFactory) + .setCacheKeyFactory(::cacheKeyFor) .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) override fun createDataSource(): DataSource = delegate.createDataSource() } + +/** + * Returns the cache key for [dataSpec]. Strips the URI's query so rotating signature or expiry + * parameters on the same path land on the same cache entry; a caller-supplied [DataSpec.key] is + * honoured when present. + */ +@OptIn(UnstableApi::class) +private fun cacheKeyFor(dataSpec: DataSpec): String = + dataSpec.key ?: dataSpec.uri.buildUpon().clearQuery().build().toString() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt index 28b9b694425..40d6d2d4668 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt @@ -121,6 +121,34 @@ internal class StreamMediaDataSourceCacheIntegrationTest { ) } + /** + * The cache key strips the query, so a second open on the same path with a different query + * (e.g. a rotated `Expires`/`Signature` set on a pre-signed URL) lands on the existing cache + * entry. The CDN still receives the original URL with query intact on the miss. + */ + @Test + fun `same path with rotated query hits cache and forwards original URL to CDN on miss`() { + val cdn = FakeCDN() + val upstream = RecordingDataSourceFactory() + val factory = VideoCacheDataSourceFactory(cache, CDNDataSourceFactory(cdn) { upstream.createDataSource() }) + + val firstUrl = "$VIDEO_URL?expires=100&sig=alpha" + val secondUrl = "$VIDEO_URL?expires=200&sig=beta" + readFully(factory.createDataSource(), DataSpec(Uri.parse(firstUrl))) + readFully(factory.createDataSource(), DataSpec(Uri.parse(secondUrl))) + + assertEquals( + "CDN should be invoked exactly once with the original URL on miss, then bypassed on hit", + listOf(firstUrl), + cdn.invocations, + ) + assertEquals( + "Upstream should be opened exactly once across the miss + hit", + 1, + upstream.openCount, + ) + } + /** * Verifies the LRU evictor is wired with [VideoCacheConfig.maxSizeBytes]. Three videos are * written into a cache sized to hold exactly two; the read between the second and third write From 24a6965adaa867c5b781a9c035136d868140dde3 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 30 Jun 2026 14:17:59 +0200 Subject: [PATCH 4/6] Make cacheKeyFor private --- .../internal/VideoCacheDataSourceFactory.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt index 9e04df96b36..04c15dfe93b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt @@ -49,13 +49,13 @@ internal class VideoCacheDataSourceFactory( .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) override fun createDataSource(): DataSource = delegate.createDataSource() -} -/** - * Returns the cache key for [dataSpec]. Strips the URI's query so rotating signature or expiry - * parameters on the same path land on the same cache entry; a caller-supplied [DataSpec.key] is - * honoured when present. - */ -@OptIn(UnstableApi::class) -private fun cacheKeyFor(dataSpec: DataSpec): String = - dataSpec.key ?: dataSpec.uri.buildUpon().clearQuery().build().toString() + /** + * Returns the cache key for [dataSpec]. Strips the URI's query so rotating signature or expiry + * parameters on the same path land on the same cache entry; a caller-supplied [DataSpec.key] is + * honoured when present. + */ + @OptIn(UnstableApi::class) + private fun cacheKeyFor(dataSpec: DataSpec): String = + dataSpec.key ?: dataSpec.uri.buildUpon().clearQuery().build().toString() +} From 372ab0f9a5ca5b8a8ec2d1bf2faad267c103eead Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 1 Jul 2026 11:33:54 +0200 Subject: [PATCH 5/6] Address PR review remarks on video cache - Cache all ExoPlayer-driven playback (video, audio, voice recordings) through the same VideoCacheConfig; remove the now-dead isVideo gating and mimeType/type intent extras on MediaPreviewActivity. - Clear the video cache in place via VideoMediaCache.clear() so the live SimpleCache stays alive after ChatClient.clearCacheAndTemporaryFiles; StreamFileManager.clearAllCache no longer deletes the video directory out from under the live cache. - Reject non-positive VideoCacheConfig.maxSizeBytes at the API boundary. - Fix stale KDoc on StreamMediaDataSource to reflect the query-stripped cache key. - Stabilise the flaky LRU eviction test by spacing cache writes across distinct millisecond timestamps. --- .../chat/android/client/ChatClient.kt | 10 +++- .../android/client/cache/StreamCacheConfig.kt | 4 ++ .../client/cache/internal/VideoMediaCache.kt | 22 ++++++++ .../cdn/internal/StreamMediaDataSource.kt | 12 ++--- .../client/internal/file/StreamFileManager.kt | 47 +++++++++------- .../client/cache/VideoCacheConfigTest.kt | 48 +++++++++++++++++ ...reamMediaDataSourceCacheIntegrationTest.kt | 45 ++++++++++++++++ .../internal/file/StreamFileManagerTest.kt | 54 ++++++++++++++++--- .../api/stream-chat-android-compose.api | 4 +- .../preview/MediaGalleryPreviewScreen.kt | 1 - .../preview/MediaPreviewActivity.kt | 28 ---------- .../handler/MediaAttachmentPreviewHandler.kt | 2 - .../internal/StreamMediaPlayerContent.kt | 10 +--- 13 files changed, 211 insertions(+), 76 deletions(-) create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/VideoCacheConfigTest.kt 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 9d305902513..a4aa8f247d7 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 @@ -1523,12 +1523,20 @@ internal constructor( public fun clearCacheAndTemporaryFiles(context: Context): Call = CoroutineCall(clientScope) { logger.d { "[clearCacheAndTemporaryFiles] Clearing all cache and temporary files" } + // Clear video cache: in-place via the live cache when opted in (keeps the SimpleCache + // alive so playback continues to work), or by deleting the directory when no live + // cache owns it. + val videoCacheResult = videoCache?.let { + it.clear() + Result.Success(Unit) + } ?: fileManager.clearVideoCache(context) // Clear all cache directories val cacheResult = fileManager.clearAllCache(context) // Clear external (temporary) storage files - always run regardless of cache result val externalStorageResult = fileManager.clearExternalStorage(context) // Return the first failure if any, otherwise success when { + videoCacheResult is Result.Failure -> videoCacheResult cacheResult is Result.Failure -> cacheResult externalStorageResult is Result.Failure -> externalStorageResult else -> Result.Success(Unit) @@ -5217,7 +5225,7 @@ internal constructor( val videoCache = cacheConfig?.video?.let { VideoMediaCache.create(appContext, StreamFileManager().getVideoCache(appContext), it) } - val mediaDataSourceFactory = StreamMediaDataSource.factory(appContext, cdn) + val mediaDataSourceFactory = StreamMediaDataSource.factory(appContext, cdn, videoCache) val audioPlayer: AudioPlayer = StreamAudioPlayer( mediaPlayer = NativeMediaPlayerImpl(mediaDataSourceFactory) { ExoPlayer.Builder(appContext) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/StreamCacheConfig.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/StreamCacheConfig.kt index 630e718fb01..449f1c5b231 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/StreamCacheConfig.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/StreamCacheConfig.kt @@ -43,6 +43,10 @@ public data class StreamCacheConfig( public data class VideoCacheConfig( public val maxSizeBytes: Long = DEFAULT_MAX_SIZE_BYTES, ) { + init { + require(maxSizeBytes > 0) { "maxSizeBytes must be > 0, got $maxSizeBytes" } + } + public companion object { /** Default cap of 150 MB. */ public const val DEFAULT_MAX_SIZE_BYTES: Long = 150L * 1024 * 1024 diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoMediaCache.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoMediaCache.kt index f7ebce6dd45..89709acd912 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoMediaCache.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoMediaCache.kt @@ -56,6 +56,28 @@ public class VideoMediaCache private constructor( private val logger by taggedLogger(TAG) + /** + * Removes all cached content from the underlying [SimpleCache] while keeping the + * [SimpleCache] instance alive and its directory lock held. Subsequent playback continues to + * work against the same instance; the next open is a cache miss and re-fetches from the + * network. + * + * Use this to clear the cache in place. Deleting the cache directory from the outside while + * this [SimpleCache] is alive corrupts Media3's on-disk index and lock, so callers wanting to + * clear the video cache should call this instead of `deleteRecursively` on the directory. + */ + internal fun clear() { + synchronized(instances) { + cache.keys.toSet().forEach { key -> + try { + cache.removeResource(key) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + logger.e(e) { "[clear] failed to remove cached resource for key '$key'" } + } + } + } + } + /** * Tears down the underlying [SimpleCache] and the [StandaloneDatabaseProvider] it owns, and * removes this instance from the process-wide [instances] registry so that a fresh 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 index 357e9931aca..b64107e8a61 100644 --- 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 @@ -43,15 +43,15 @@ public object StreamMediaDataSource { * 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. * - * When a [videoCache] is provided, it wraps the resulting factory so video byte ranges are - * served from disk on subsequent reads. The cache layer is the outermost wrapper, keyed by the - * raw `dataSpec.uri`, so cache lookups are not affected by CDN URL rewriting. + * When a [videoCache] is provided, it wraps the resulting factory so bytes from any + * ExoPlayer-driven playback (video, audio, voice recordings) are served from disk on + * subsequent reads. The cache layer is the outermost wrapper, keyed by the `dataSpec.uri` + * with query parameters stripped, so cache lookups are unaffected by CDN URL rewriting and + * by rotating pre-signed query/signature values. * * @param context The context used to create the base data source. * @param cdn Optional custom CDN for transforming network requests. - * @param videoCache Optional disk cache for video playback. Callers should pass `null` for - * non-video content (e.g. audio attachments, voice recordings) so audio bytes do not occupy - * disk space in the video cache. + * @param videoCache Optional disk cache for ExoPlayer-driven playback (video and audio). */ @OptIn(UnstableApi::class) public fun factory( 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 093f21e6e40..26b47e903a9 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 @@ -239,17 +239,19 @@ public class StreamFileManager { * Clears all cached data including Stream cache, image cache, * and timestamped cache folders. * + * The video cache directory is owned by a live `SimpleCache` when the video cache is + * opted in, so it is cleared in-place by the owning `VideoMediaCache` at a higher layer + * (see `ChatClient.clearCacheAndTemporaryFiles`) rather than by deleting the directory here. + * * @param context Android context for cache directory access * @return [Result.Success] if all caches cleared successfully, or [Result.Failure] with an error */ public fun clearAllCache(context: Context): Result { val streamCacheResult = clearCache(context) val imageCacheResult = clearImageCache(context) - val videoCacheResult = clearVideoCache(context) val timestampedCacheResult = clearTimestampedCacheFolders(context) return streamCacheResult .flatMap { imageCacheResult } - .flatMap { videoCacheResult } .flatMap { timestampedCacheResult } } @@ -356,6 +358,31 @@ public class StreamFileManager { } } + /** + * Deletes the video cache directory. Intended for the case where no live `VideoMediaCache` + * owns the directory; when a live cache is opted in, callers should clear its contents + * through the cache instance instead of deleting the directory out from under it. + * + * @param context Android context for cache directory access. + * @return [Result.Success] if the directory was cleared (or did not exist), or + * [Result.Failure] with an error. + */ + @Suppress("TooGenericExceptionCaught") + internal fun clearVideoCache(context: Context): Result { + return try { + val directory = getVideoCache(context) + if (!directory.exists()) { + Result.Success(Unit) + } else if (directory.deleteRecursively()) { + Result.Success(Unit) + } else { + Result.Failure(Error.GenericError("Could not clear video cache directory.")) + } + } catch (e: Exception) { + Result.Failure(Error.ThrowableError("Could not clear video cache directory.", e)) + } + } + private fun createMediaFilename(prefix: String, extension: String): String { val dateFormat = SimpleDateFormat(EXTERNAL_DIR_TIMESTAMP_FORMAT, Locale.US) return "${prefix}_${dateFormat.format(Date().time)}.$extension" @@ -438,22 +465,6 @@ public class StreamFileManager { } } - @Suppress("TooGenericExceptionCaught") - private fun clearVideoCache(context: Context): Result { - return try { - val directory = getVideoCache(context) - if (!directory.exists()) { - Result.Success(Unit) - } else if (directory.deleteRecursively()) { - Result.Success(Unit) - } else { - Result.Failure(Error.GenericError("Could not clear video cache directory.")) - } - } catch (e: Exception) { - Result.Failure(Error.ThrowableError("Could not clear video cache directory.", e)) - } - } - @Suppress("TooGenericExceptionCaught") private fun clearTimestampedCacheFolders(context: Context): Result { return try { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/VideoCacheConfigTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/VideoCacheConfigTest.kt new file mode 100644 index 00000000000..01737101d01 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/VideoCacheConfigTest.kt @@ -0,0 +1,48 @@ +/* + * 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.cache + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +internal class VideoCacheConfigTest { + + @Test + fun `default constructor uses the documented default cap`() { + assertEquals(VideoCacheConfig.DEFAULT_MAX_SIZE_BYTES, VideoCacheConfig().maxSizeBytes) + } + + @Test + fun `accepts positive maxSizeBytes`() { + assertEquals(1L, VideoCacheConfig(maxSizeBytes = 1L).maxSizeBytes) + } + + @Test + fun `rejects zero maxSizeBytes`() { + assertThrows(IllegalArgumentException::class.java) { + VideoCacheConfig(maxSizeBytes = 0L) + } + } + + @Test + fun `rejects negative maxSizeBytes`() { + assertThrows(IllegalArgumentException::class.java) { + VideoCacheConfig(maxSizeBytes = -1L) + } + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt index 40d6d2d4668..b819345889a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt @@ -31,6 +31,8 @@ import io.getstream.chat.android.client.cdn.CDNRequest import io.getstream.chat.android.client.cdn.internal.CDNDataSourceFactory import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -149,6 +151,41 @@ internal class StreamMediaDataSourceCacheIntegrationTest { ) } + /** + * `clear()` empties the cache in place: the on-disk video content is removed from the cache + * directory, subsequent reads miss and re-fetch upstream, but the underlying `SimpleCache` + * stays alive and can serve new writes. + */ + @Test + fun `clear removes cached bytes from disk and keeps the cache functional`() { + val upstream = RecordingDataSourceFactory() + val factory = VideoCacheDataSourceFactory(cache, upstream) + + // Populate the cache and confirm the bytes landed on disk. + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_URL))) + assertEquals(1, upstream.openCount) + assertTrue("Cache should have tracked one key before clear", cache.cache.keys.isNotEmpty()) + assertTrue("Cache should report non-zero size before clear", cache.cache.cacheSpace > 0L) + assertTrue( + "Cache directory should contain content files before clear", + cacheDir.walkTopDown().any { it.isFile && it.length() >= TOTAL_LENGTH }, + ) + + cache.clear() + + assertTrue("Cache should track zero keys after clear", cache.cache.keys.isEmpty()) + assertEquals("Cache should report zero size after clear", 0L, cache.cache.cacheSpace) + assertFalse( + "Cache directory should no longer contain full-length content files after clear", + cacheDir.walkTopDown().any { it.isFile && it.length() >= TOTAL_LENGTH }, + ) + + // Subsequent read must miss (cache is empty) and re-populate; the SimpleCache stays alive. + readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_URL))) + assertEquals("A read after clear should hit upstream again", 2, upstream.openCount) + assertTrue("Cache should be re-populated after the second read", cache.cache.keys.isNotEmpty()) + } + /** * Verifies the LRU evictor is wired with [VideoCacheConfig.maxSizeBytes]. Three videos are * written into a cache sized to hold exactly two; the read between the second and third write @@ -168,9 +205,16 @@ internal class StreamMediaDataSourceCacheIntegrationTest { val factory = VideoCacheDataSourceFactory(evictionCache, upstream) // Fill the cache to capacity, then bump A's recency, then push C in to force eviction. + // Space the operations out so each span's `lastTouchTimestamp` lands in a distinct + // millisecond; Media3's LeastRecentlyUsedCacheEvictor uses `System.currentTimeMillis()` + // and falls back to alphabetical key order on ties, which would pick A over B under + // load and make the assertions non-deterministic. readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_A_URL))) + Thread.sleep(SPAN_SPACING_MS) readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_B_URL))) + Thread.sleep(SPAN_SPACING_MS) readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_A_URL))) + Thread.sleep(SPAN_SPACING_MS) readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_C_URL))) assertEquals( @@ -293,6 +337,7 @@ internal class StreamMediaDataSourceCacheIntegrationTest { private const val VIDEO_C_URL = "https://stream.io/c.mp4" private const val TOTAL_LENGTH = 4096L private const val BUFFER_SIZE = 512 + private const val SPAN_SPACING_MS = 5L private const val FILL_BYTE: Byte = 0xAB.toByte() } } 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 445fedda2a6..69e036495d3 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 @@ -203,7 +203,33 @@ internal class StreamFileManagerTest { } @Test - fun `clearAllCache should clear stream cache, image cache, video cache, and timestamped folders`() = runTest { + fun `clearVideoCache should delete files in video cache directory`() = runTest { + val videoCache = streamFileManager.getVideoCache(context) + videoCache.mkdirs() + val file1 = File(videoCache, "video1_${randomString()}.mp4").apply { writeText("video1") } + val file2 = File(videoCache, "video2_${randomString()}.mp4").apply { writeText("video2") } + assertTrue(file1.exists()) + assertTrue(file2.exists()) + + val result = streamFileManager.clearVideoCache(context) + + assertTrue(result is Result.Success) + assertFalse(videoCache.exists()) + } + + @Test + fun `clearVideoCache should handle already empty cache gracefully`() = runTest { + // Ensure cache is clear + streamFileManager.clearVideoCache(context) + + // Attempt to clear again + val result = streamFileManager.clearVideoCache(context) + + assertTrue(result is Result.Success) + } + + @Test + fun `clearAllCache should clear stream cache, image cache, and timestamped folders`() = runTest { // Create file in stream cache val streamFileName = "stream_${randomString()}.txt" streamFileManager.writeFileInCache(context, streamFileName, "content".byteInputStream()) @@ -217,16 +243,10 @@ internal class StreamFileManagerTest { imageCache.mkdirs() File(imageCache, "image.jpg").writeText("image") - // Create video cache directory - val videoCache = streamFileManager.getVideoCache(context) - videoCache.mkdirs() - File(videoCache, "video.mp4").writeText("video") - // Verify files exist val streamCacheDir = File(context.cacheDir, "stream_cache") assertTrue(streamCacheDir.exists()) assertTrue(imageCache.exists()) - assertTrue(videoCache.exists()) // Clear all caches val result = streamFileManager.clearAllCache(context) @@ -234,7 +254,25 @@ internal class StreamFileManagerTest { assertTrue(result is Result.Success) assertFalse(streamCacheDir.exists()) assertFalse(imageCache.exists()) - assertFalse(videoCache.exists()) + } + + @Test + fun `clearAllCache should leave the video cache directory untouched`() = runTest { + // The video cache directory is owned by a live VideoMediaCache when opted in; clearAllCache + // must not delete it out from under the live SimpleCache. Callers handle video cache + // clearing at a higher layer (ChatClient.clearCacheAndTemporaryFiles). + val videoCache = streamFileManager.getVideoCache(context) + videoCache.mkdirs() + File(videoCache, "video.mp4").writeText("video") + + val result = streamFileManager.clearAllCache(context) + + assertTrue(result is Result.Success) + assertTrue(videoCache.exists()) + assertTrue(File(videoCache, "video.mp4").exists()) + + // Cleanup + videoCache.deleteRecursively() } @Test 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 53bec26e94e..aee457a5d7c 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -978,9 +978,7 @@ public final class io/getstream/chat/android/compose/ui/attachments/preview/Medi public final class io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity$Companion { public final fun getIntent (Landroid/content/Context;Ljava/lang/String;)Landroid/content/Intent; public final fun getIntent (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent; - public final fun getIntent (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent; - public final fun getIntent (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent; - public static synthetic fun getIntent$default (Lio/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity$Companion;Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Landroid/content/Intent; + public static synthetic fun getIntent$default (Lio/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity$Companion;Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Landroid/content/Intent; } public abstract interface class io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt index 0d298285b73..ad6ffbeb2ea 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt @@ -584,7 +584,6 @@ internal fun MediaGalleryPager( if (!previewMode && player == null) { player = createPlayer( context = context, - useVideoCache = true, onBuffering = { isBuffering -> showBuffering = isBuffering }, onPlaybackError = onPlaybackError, ) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt index d53fce6d9d7..4476610f727 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt @@ -51,7 +51,6 @@ import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.attachments.preview.internal.StreamMediaPlayerContent import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.util.mirrorRtl -import io.getstream.chat.android.models.AttachmentType /** * An Activity that is capable of playing video/audio stream. @@ -63,16 +62,12 @@ public class MediaPreviewActivity : AppCompatActivity() { super.onCreate(savedInstanceState) val url = intent.getStringExtra(KEY_URL) val title = intent.getStringExtra(KEY_TITLE) ?: "" - val mimeType = intent.getStringExtra(KEY_MIME_TYPE) - val type = intent.getStringExtra(KEY_TYPE) if (url.isNullOrEmpty() || ChatClient.isInitialized.not()) { finish() return } - val enableVideoCache = isVideoContent(mimeType, type) - setContent { ChatTheme { MediaPreviewScreen( @@ -82,7 +77,6 @@ public class MediaPreviewActivity : AppCompatActivity() { .windowInsetsPadding(WindowInsets.systemBars), url = url, title = title, - useVideoCache = enableVideoCache, onPlaybackError = { Toast.makeText( this, @@ -97,21 +91,11 @@ public class MediaPreviewActivity : AppCompatActivity() { } } - /** - * Returns `true` when the playing attachment is confidently a video, so its bytes can be - * routed through the video cache. Any other content (audio, unknown mime/type) bypasses the - * cache. - */ - private fun isVideoContent(mimeType: String?, type: String?): Boolean = - type == AttachmentType.VIDEO || mimeType?.startsWith("video/") == true - /** * Represents a screen with a media player. * * @param url The URL of the stream for playback. * @param title The name of the file for playback. - * @param useVideoCache Whether to route playback through the configured video cache. Set to - * `true` only when the content is confidently a video — any other content bypasses the cache. * @param onPlaybackError Handler for playback errors. * @param onBackPressed Handler for back press action. */ @@ -120,7 +104,6 @@ public class MediaPreviewActivity : AppCompatActivity() { modifier: Modifier = Modifier, url: String, title: String, - useVideoCache: Boolean, onPlaybackError: (error: Throwable) -> Unit, onBackPressed: () -> Unit, ) { @@ -137,7 +120,6 @@ public class MediaPreviewActivity : AppCompatActivity() { .padding(padding), assetUrl = url, playWhenReady = true, - useVideoCache = useVideoCache, onPlaybackError = onPlaybackError, ) }, @@ -194,8 +176,6 @@ public class MediaPreviewActivity : AppCompatActivity() { public companion object { private const val KEY_URL: String = "url" private const val KEY_TITLE: String = "title" - private const val KEY_MIME_TYPE: String = "mime_type" - private const val KEY_TYPE: String = "type" /** * Used to build an [Intent] to start the [MediaPreviewActivity] with the required data. @@ -203,24 +183,16 @@ public class MediaPreviewActivity : AppCompatActivity() { * @param context The context to start the activity with. * @param url The URL of the media file. * @param title The name of the media file. - * @param mimeType The MIME type of the media file (e.g. `video/mp4`, `audio/mpeg`). Used - * together with [type] to decide whether to route playback through the video cache. - * @param type The attachment type (e.g. [AttachmentType.VIDEO], [AttachmentType.AUDIO]). - * Same caching behaviour as [mimeType]. */ @JvmOverloads public fun getIntent( context: Context, url: String, title: String? = null, - mimeType: String? = null, - type: String? = null, ): Intent { return Intent(context, MediaPreviewActivity::class.java).apply { putExtra(KEY_URL, url) putExtra(KEY_TITLE, title) - putExtra(KEY_MIME_TYPE, mimeType) - putExtra(KEY_TYPE, type) } } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/MediaAttachmentPreviewHandler.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/MediaAttachmentPreviewHandler.kt index 6ca616275fc..bb2c76dbde0 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/MediaAttachmentPreviewHandler.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/MediaAttachmentPreviewHandler.kt @@ -49,8 +49,6 @@ public class MediaAttachmentPreviewHandler(private val context: Context) : Attac context = context, url = requireNotNull(attachment.assetUrl), title = attachment.title ?: attachment.name, - mimeType = attachment.mimeType, - type = attachment.type, ), ) } 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 9c2111ca5ad..f289e6dfdf7 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 @@ -72,8 +72,6 @@ import io.getstream.chat.android.core.internal.StreamHandsOff * @param onPlaybackError Callback invoked when video playback encounters an error. * @param modifier Modifier to be applied to the player container. * @param thumbnailUrl Optional URL of the thumbnail image to display before playback starts. - * @param useVideoCache Whether to route playback through the configured video cache. Pass `true` - * only when the content is confidently a video — any other content bypasses the cache. */ @OptIn(UnstableApi::class) @Composable @@ -83,7 +81,6 @@ internal fun StreamMediaPlayerContent( onPlaybackError: (error: Throwable) -> Unit, modifier: Modifier = Modifier, thumbnailUrl: String? = null, - useVideoCache: Boolean = true, ) { val context = LocalContext.current var showThumbnail by remember { mutableStateOf(!playWhenReady) } @@ -94,7 +91,6 @@ internal fun StreamMediaPlayerContent( LifecycleResumeEffect(Unit) { player = createPlayer( context = context, - useVideoCache = useVideoCache, onBuffering = onBuffering, onPlaybackError = onPlaybackError, ) @@ -192,22 +188,18 @@ internal fun MediaThumbnail( * Creates a player of type [ExoPlayer]. * * @param context The context to use for creating the player. - * @param useVideoCache Whether to wire the configured video cache into the data source pipeline. - * Pass `true` only when the content is confidently a video — any other content bypasses the cache. * @param onBuffering Callback to be invoked when the player enters or exits buffering state. * @param onPlaybackError Callback to be invoked when a playback error occurs. */ @OptIn(UnstableApi::class) internal fun createPlayer( context: Context, - useVideoCache: Boolean, onBuffering: (Boolean) -> Unit, onPlaybackError: (error: Throwable) -> Unit, ): Player { // Setup player val client = ChatClient.instance() - val videoCache = client.videoCache.takeIf { useVideoCache } - val dataSourceFactory = StreamMediaDataSource.factory(context, client.cdn, videoCache) + val dataSourceFactory = StreamMediaDataSource.factory(context, client.cdn, client.videoCache) val player = ExoPlayer.Builder(context) .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) .build() From 154c7c147058e46d421d852f23716b081f7eea4c Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:10:20 +0200 Subject: [PATCH 6/6] compose: Avoid redundant player prepare that double-loads the same video --- .../preview/MediaGalleryPreviewScreen.kt | 5 +- .../internal/StreamMediaPlayerContent.kt | 29 ++++++- .../preview/internal/PrepareIfNeededTest.kt | 86 +++++++++++++++++++ 3 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/preview/internal/PrepareIfNeededTest.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt index ad6ffbeb2ea..7a020761b1e 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt @@ -70,7 +70,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.media3.common.MediaItem import androidx.media3.common.Player import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.attachment.isVideo @@ -80,6 +79,7 @@ import io.getstream.chat.android.compose.ui.attachments.preview.internal.MediaGa import io.getstream.chat.android.compose.ui.attachments.preview.internal.MediaGalleryPhotosMenu import io.getstream.chat.android.compose.ui.attachments.preview.internal.MediaGalleryVideoPage import io.getstream.chat.android.compose.ui.attachments.preview.internal.createPlayer +import io.getstream.chat.android.compose.ui.attachments.preview.internal.prepareIfNeeded import io.getstream.chat.android.compose.ui.components.NetworkLoadingIndicator import io.getstream.chat.android.compose.ui.components.SimpleDialog import io.getstream.chat.android.compose.ui.components.Timestamp @@ -569,8 +569,7 @@ internal fun MediaGalleryPager( ?.second ?: 0L savedPlaybackState = null - activePlayer.setMediaItem(MediaItem.fromUri(assetUrl), startPosition) - activePlayer.prepare() + activePlayer.prepareIfNeeded(assetUrl, startPosition) } } } 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 f289e6dfdf7..18569c6aefc 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 @@ -101,10 +101,10 @@ internal fun StreamMediaPlayerContent( } // Prepare media LaunchedEffect(player, assetUrl) { - if (player != null && assetUrl != null) { - player?.setMediaItem(MediaItem.fromUri(assetUrl)) - player?.prepare() - player?.playWhenReady = playWhenReady + val activePlayer = player + if (activePlayer != null && assetUrl != null) { + activePlayer.prepareIfNeeded(assetUrl) + activePlayer.playWhenReady = playWhenReady } } // Draw player @@ -183,6 +183,27 @@ internal fun MediaThumbnail( } } +/** + * Sets [assetUrl] on this player and prepares it, unless the player is already prepared with the + * same URL. + * + * The prepare-effects that call this can fire more than once for the same media (e.g. during + * initial composition, when the effect keyed on the player and page runs again as those settle). + * Re-preparing an already-loaded URL starts a second, concurrent load of the same asset; when the + * video disk cache is enabled, the two loads contend on the same cache entry and can leave the + * file's head uncached, which breaks offline playback. Skipping the redundant prepare avoids that + * while still (re)preparing whenever the URL actually changes or the player was recreated. + * + * @param assetUrl The URL to play. + * @param startPositionMs Position to start playback from, in milliseconds. + */ +internal fun Player.prepareIfNeeded(assetUrl: String, startPositionMs: Long = 0L) { + val alreadyPrepared = currentMediaItem?.localConfiguration?.uri?.toString() == assetUrl + if (alreadyPrepared) return + setMediaItem(MediaItem.fromUri(assetUrl), startPositionMs) + prepare() +} + /** * Creates a basic [Player] instance for playing audio/video. * Creates a player of type [ExoPlayer]. diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/preview/internal/PrepareIfNeededTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/preview/internal/PrepareIfNeededTest.kt new file mode 100644 index 00000000000..11373b4a364 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/preview/internal/PrepareIfNeededTest.kt @@ -0,0 +1,86 @@ +/* + * 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.compose.ui.attachments.preview.internal + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Guards [prepareIfNeeded] against regressions: it must skip only a redundant prepare of the URL + * the player already holds, and still (re)prepare whenever the URL changes or the player is fresh. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +internal class PrepareIfNeededTest { + + private val url = "https://cdn.example.com/video.mp4" + private val otherUrl = "https://cdn.example.com/other.mp4" + + @Test + fun `prepares when the player has no media item`() { + val player = mock() // currentMediaItem defaults to null + + player.prepareIfNeeded(url) + + verify(player).setMediaItem(any(), eq(0L)) + verify(player).prepare() + } + + @Test + fun `skips prepare when already prepared with the same url`() { + val player = mock { + on { currentMediaItem } doReturn MediaItem.fromUri(url) + } + + player.prepareIfNeeded(url) + + verify(player, never()).setMediaItem(any(), any()) + verify(player, never()).prepare() + } + + @Test + fun `prepares when the player holds a different url`() { + val player = mock { + on { currentMediaItem } doReturn MediaItem.fromUri(otherUrl) + } + + player.prepareIfNeeded(url) + + verify(player).setMediaItem(any(), eq(0L)) + verify(player).prepare() + } + + @Test + fun `prepares from the given start position`() { + val player = mock() + + player.prepareIfNeeded(url, startPositionMs = 5_000L) + + verify(player).setMediaItem(any(), eq(5_000L)) + verify(player).prepare() + } +}