From 87610b32c1fbada4ad38b4a5bd44cf0203779192 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 3 Jul 2026 15:30:53 +0200 Subject: [PATCH 1/5] Port V6 fix: Add opt-in video disk cache for ExoPlayer playback Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/test/MockedChatClientTest.kt | 4 + .../api/stream-chat-android-client.api | 40 +- .../chat/android/client/ChatClient.kt | 19 +- .../android/client/api/ChatClientConfig.kt | 5 + .../android/client/cache/StreamCacheConfig.kt | 54 +++ .../internal/VideoCacheDataSourceFactory.kt | 61 +++ .../client/cache/internal/VideoMediaCache.kt | 158 +++++++ .../cdn/internal/CDNDataSourceFactory.kt | 5 +- .../cdn/internal/StreamMediaDataSource.kt | 20 +- .../client/internal/file/StreamFileManager.kt | 43 ++ .../ChatClientCacheAndTemporaryFilesTest.kt | 7 + .../client/cache/VideoCacheConfigTest.kt | 48 +++ ...reamMediaDataSourceCacheIntegrationTest.kt | 395 ++++++++++++++++++ .../cache/internal/VideoMediaCacheTest.kt | 93 +++++ .../client/cdn/internal/CDNDataSourceTest.kt | 5 +- .../internal/file/StreamFileManagerTest.kt | 52 +++ .../internal/MediaGalleryPlayerState.kt | 4 +- .../internal/StreamMediaPlayerContent.kt | 33 +- .../preview/internal/PrepareIfNeededTest.kt | 86 ++++ .../gallery/AttachmentMediaActivity.kt | 4 +- .../AttachmentGalleryVideoPageFragment.kt | 4 +- 21 files changed, 1117 insertions(+), 23 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/VideoCacheConfigTest.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 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-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 bbe115f1023..dd5481d23bd 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -354,8 +354,10 @@ public final class io/getstream/chat/android/client/api/ChatClientConfig { public fun (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;)V public fun (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;Z)V public fun (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZ)V - public synthetic fun (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;)V + public synthetic fun (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Z + public final fun component10 ()Lio/getstream/chat/android/client/cache/StreamCacheConfig; public final fun component2 ()Ljava/util/Set; public final fun component3 ()Z public final fun component4 ()Z @@ -364,9 +366,10 @@ public final class io/getstream/chat/android/client/api/ChatClientConfig { public final fun component7 ()Lio/getstream/chat/android/client/api/MessageLimitConfig; public final fun component8 ()Z public final fun component9 ()Z - public final fun copy (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZ)Lio/getstream/chat/android/client/api/ChatClientConfig; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/api/ChatClientConfig;ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZILjava/lang/Object;)Lio/getstream/chat/android/client/api/ChatClientConfig; + public final fun copy (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;)Lio/getstream/chat/android/client/api/ChatClientConfig; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/api/ChatClientConfig;ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;ILjava/lang/Object;)Lio/getstream/chat/android/client/api/ChatClientConfig; public fun equals (Ljava/lang/Object;)Z + public final fun getCacheConfig ()Lio/getstream/chat/android/client/cache/StreamCacheConfig; public final fun getFastEventParsing ()Z public final fun getIgnoredOfflineChannelTypes ()Ljava/util/Set; public final fun getMessageLimitConfig ()Lio/getstream/chat/android/client/api/MessageLimitConfig; @@ -1001,6 +1004,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 dd1078f4711..94a94a82809 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 @@ -75,6 +75,7 @@ import io.getstream.chat.android.client.attachment.prepareForUpload import io.getstream.chat.android.client.audio.AudioPlayer import io.getstream.chat.android.client.audio.NativeMediaPlayerImpl import io.getstream.chat.android.client.audio.StreamAudioPlayer +import io.getstream.chat.android.client.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 @@ -304,6 +305,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() @@ -1511,12 +1514,22 @@ 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 any live cache in the process (keeps the SimpleCache + // alive so playback continues to work), or by deleting the directory when no live cache + // owns it. The registry is process-wide, so this covers caches from a prior ChatClient + // build even if the current client was built without a cacheConfig. + val videoCacheResult = if (VideoMediaCache.clearAll()) { + Result.Success(Unit) + } else { + 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) @@ -5274,7 +5287,10 @@ internal constructor( val api = module.api() val appSettingsManager = AppSettingManager(api) - val mediaDataSourceFactory = StreamMediaDataSource.factory(appContext, cdn) + val videoCache = chatClientConfig.cacheConfig.video?.let { + VideoMediaCache.create(appContext, StreamFileManager().getVideoCache(appContext), it) + } + val mediaDataSourceFactory = StreamMediaDataSource.factory(appContext, cdn, videoCache) val audioPlayer: AudioPlayer = StreamAudioPlayer( mediaPlayer = NativeMediaPlayerImpl(mediaDataSourceFactory) { ExoPlayer.Builder(appContext) @@ -5332,6 +5348,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/api/ChatClientConfig.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatClientConfig.kt index 8f51ff1a07c..1855a6a8ade 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatClientConfig.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatClientConfig.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.api import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.cache.StreamCacheConfig import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.models.TimeDuration @@ -85,6 +86,9 @@ import io.getstream.chat.android.models.TimeDuration * fall back to the default DTO-based parser, so behavior is preserved for events the fast path does not yet handle. * Currently supported event types: `message.new`. Disabled by default. The set of supported event types may grow over * time. + * + * @param cacheConfig Configuration for the SDK's user-configurable on-disk caches. By default all caches are off. See + * [StreamCacheConfig] for the available cache types and their options. */ public data class ChatClientConfig @JvmOverloads constructor( public val offlineEnabled: Boolean = true, @@ -96,6 +100,7 @@ public data class ChatClientConfig @JvmOverloads constructor( public val messageLimitConfig: MessageLimitConfig = MessageLimitConfig(), public val useLegacyChannelLogic: Boolean = false, public val fastEventParsing: Boolean = false, + public val cacheConfig: StreamCacheConfig = StreamCacheConfig(), ) /** 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..2bc4d6c44a6 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/StreamCacheConfig.kt @@ -0,0 +1,54 @@ +/* + * 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. + * + * Set this on [io.getstream.chat.android.client.api.ChatClientConfig.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 set it on + * [io.getstream.chat.android.client.api.ChatClientConfig.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, +) { + 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/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..04c15dfe93b --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt @@ -0,0 +1,61 @@ +/* + * 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.DataSpec +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. + * + * 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). + */ +@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) + .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/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..90574907655 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoMediaCache.kt @@ -0,0 +1,158 @@ +/* + * 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) + + /** + * 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 + * [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) + + /** + * Clears every live [VideoMediaCache] in this process in place (see [clear]), keeping each + * [SimpleCache] and its directory lock alive. Returns `true` if at least one live cache was + * cleared, `false` if the registry was empty (no cache directory is owned in this process). + * + * Callers use the return value to decide whether it is safe to delete the cache directory + * from disk: deleting a directory owned by a live [SimpleCache] corrupts Media3's on-disk + * index and lock. + */ + internal fun clearAll(): Boolean = synchronized(instances) { + instances.values.forEach { it.clear() } + instances.isNotEmpty() + } + + /** + * 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..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 @@ -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 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 ExoPlayer-driven playback (video and audio). */ @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..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 @@ -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. * @@ -226,6 +239,10 @@ 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 */ @@ -341,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" @@ -448,6 +490,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/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 new file mode 100644 index 00000000000..1055f9a8a5a --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt @@ -0,0 +1,395 @@ +/* + * 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.Assert.assertFalse +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 + +/** + * 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, + ) + } + + /** + * 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, + ) + } + + /** + * `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()) + } + + /** + * `clearAll()` fans out over every live cache in the process registry and empties each in + * place. Here the single registered cache (created in [setUp]) is populated with real bytes; + * after `clearAll()` its content is gone, the call reports it cleared something (`true`), and + * the still-alive `SimpleCache` can serve a fresh write. + */ + @Test + fun `clearAll empties the live cache in place and reports it cleared`() { + // Arrange: write real bytes into the cache registered in setUp() and confirm they landed. + val upstream = RecordingDataSourceFactory() + val factory = VideoCacheDataSourceFactory(cache, upstream) + populateCache(factory, VIDEO_URL) + assertEquals("Precondition: one upstream fetch populated the cache", 1, upstream.openCount) + assertTrue("Precondition: cache tracks the written key", cache.cache.keys.isNotEmpty()) + assertTrue("Precondition: cache reports non-zero size", cache.cache.cacheSpace > 0L) + + // Act: clear every live cache in the process. + val cleared = VideoMediaCache.clearAll() + + // Assert: it reported clearing a live cache, and this cache is now empty. + assertTrue("clearAll should report that a live cache was cleared", cleared) + assertTrue("Cache should track zero keys after clearAll", cache.cache.keys.isEmpty()) + assertEquals("Cache should report zero size after clearAll", 0L, cache.cache.cacheSpace) + + // Assert: the SimpleCache stays alive, so a subsequent read re-fetches and re-populates. + populateCache(factory, VIDEO_URL) + assertEquals("A read after clearAll should hit upstream again", 2, upstream.openCount) + assertTrue("Cache should be re-populated after the second read", cache.cache.keys.isNotEmpty()) + } + + /** + * When no live cache is registered in the process (e.g. the app never opted in, or a prior + * cache was released), `clearAll()` has nothing to empty and reports `false` — the signal + * `ChatClient` uses to fall back to deleting the cache directory from disk. + */ + @Test + fun `clearAll reports nothing to clear when no live cache is registered`() { + // Arrange: release the only registered cache so the process registry is empty. + cache.release() + + // Act + Assert. + assertFalse("clearAll should report nothing was cleared on an empty registry", VideoMediaCache.clearAll()) + + // Recreate so tearDown() releases a live instance and unlocks the directory for the next test. + cache = VideoMediaCache.create(context, cacheDir, VideoCacheConfig()) + } + + /** + * 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. + // 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( + "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() + } + } + + /** Reads [url] through [factory] once, writing its bytes into the wrapped cache. */ + private fun populateCache(factory: VideoCacheDataSourceFactory, url: String) { + readFully(factory.createDataSource(), DataSpec(Uri.parse(url))) + } + + 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 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 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/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..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 @@ -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" @@ -195,6 +202,32 @@ internal class StreamFileManagerTest { assertTrue(result is Result.Success) } + @Test + 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 @@ -223,6 +256,25 @@ internal class StreamFileManagerTest { assertFalse(imageCache.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 fun `clearAllCache should not affect other cache directories`() { // Create a non-Stream cache directory diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPlayerState.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPlayerState.kt index eb051bd2a63..4d8b1e03273 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPlayerState.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPlayerState.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.media3.common.MediaItem import androidx.media3.common.Player import io.getstream.chat.android.client.utils.attachment.isVideo import io.getstream.chat.android.models.Attachment @@ -124,8 +123,7 @@ internal fun GalleryMediaEffect( } else { 0L } - player.setMediaItem(MediaItem.fromUri(assetUrl), startPosition) - player.prepare() + player.prepareIfNeeded(assetUrl, startPosition) } } lastPreparedPage = currentPage 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 4c5cc84bb39..8444ab8b22d 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 @@ -99,10 +99,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 @@ -175,6 +175,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]. @@ -190,8 +211,8 @@ internal fun createPlayer( onPlaybackError: (error: Throwable) -> Unit, ): Player { // Setup player - val cdn = ChatClient.instance().cdn - val dataSourceFactory = StreamMediaDataSource.factory(context, cdn) + val client = ChatClient.instance() + val dataSourceFactory = StreamMediaDataSource.factory(context, client.cdn, client.videoCache) val player = ExoPlayer.Builder(context) .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) .build() 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() + } +} 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 4fa96a81e35..d6ba97ed663 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 @@ -139,8 +139,8 @@ 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 dataSourceFactory = StreamMediaDataSource.factory(this, client.cdn, client.videoCache) val player = ExoPlayer.Builder(this) .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) .build() 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 42f13201dd8..4db47de28ca 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 @@ -243,8 +243,8 @@ internal class AttachmentGalleryVideoPageFragment : Fragment() { @OptIn(UnstableApi::class) private fun createMediaSourceFactory(): MediaSource.Factory { - val cdn = ChatClient.instance().cdn - val dataSourceFactory = StreamMediaDataSource.factory(requireContext(), cdn) + val client = ChatClient.instance() + val dataSourceFactory = StreamMediaDataSource.factory(requireContext(), client.cdn, client.videoCache) return DefaultMediaSourceFactory(requireContext()) .setDataSourceFactory(dataSourceFactory) } From 5575eb0acc60710b62e76fa3c9d6c78cb307fabb Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 9 Jul 2026 11:59:04 +0200 Subject: [PATCH 2/5] client: Guard SimpleCache construction and document cache key behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VideoMediaCache.create() now returns VideoMediaCache? and wraps SimpleCache construction in a try-catch; a stale directory lock from a prior crash no longer aborts ChatClient initialisation — video caching is silently disabled instead. - VideoCacheConfig KDoc notes that cache entries are keyed by URL path only (query parameters are stripped). - LRU eviction test comment acknowledges the wall-clock dependency of the Thread.sleep spacing and what to do if the test becomes flaky. Co-Authored-By: Claude Sonnet 4.6 --- .../android/client/cache/StreamCacheConfig.kt | 3 ++ .../client/cache/internal/VideoMediaCache.kt | 30 +++++++++++-------- ...reamMediaDataSourceCacheIntegrationTest.kt | 7 +++-- .../cache/internal/VideoMediaCacheTest.kt | 4 +-- 4 files changed, 27 insertions(+), 17 deletions(-) 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 2bc4d6c44a6..cbf1e106fa6 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 @@ -36,6 +36,9 @@ public data class StreamCacheConfig( * enabled, replaying or seeking within a previously watched video reuses cached byte ranges * instead of re-downloading from the CDN. * + * Cache entries are keyed by URL path only; query parameters are stripped. URLs that differ + * only in their query string share the same cache entry. + * * @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. 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 90574907655..eced80c3de8 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 @@ -125,17 +125,18 @@ public class VideoMediaCache private constructor( } /** - * 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). + * Returns a [VideoMediaCache] backed by the [SimpleCache] at [cacheDir], or `null` if + * [SimpleCache] construction fails (e.g. a stale directory lock left by a prior crash). + * 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 = + public fun create(appContext: Context, cacheDir: File, config: VideoCacheConfig): VideoMediaCache? = synchronized(instances) { cacheDir.mkdirs() val key = cacheDir.absolutePath @@ -146,13 +147,18 @@ public class VideoMediaCache private constructor( } return@synchronized existing } - val dbProvider = StandaloneDatabaseProvider(appContext) - val simpleCache = SimpleCache( - cacheDir, - LeastRecentlyUsedCacheEvictor(config.maxSizeBytes), - dbProvider, - ) - VideoMediaCache(simpleCache, dbProvider, key).also { instances[key] = it } + try { + val dbProvider = StandaloneDatabaseProvider(appContext) + val simpleCache = SimpleCache( + cacheDir, + LeastRecentlyUsedCacheEvictor(config.maxSizeBytes), + dbProvider, + ) + VideoMediaCache(simpleCache, dbProvider, key).also { instances[key] = it } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + logger.e(e) { "[create] Failed to construct SimpleCache at '$key'; video caching disabled." } + null + } } } } 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 1055f9a8a5a..1b2f0729b13 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 @@ -63,7 +63,7 @@ internal class StreamMediaDataSourceCacheIntegrationTest { @Before fun setUp() { cacheDir.deleteRecursively() - cache = VideoMediaCache.create(context, cacheDir, VideoCacheConfig()) + cache = VideoMediaCache.create(context, cacheDir, VideoCacheConfig())!! } @After @@ -230,7 +230,7 @@ internal class StreamMediaDataSourceCacheIntegrationTest { assertFalse("clearAll should report nothing was cleared on an empty registry", VideoMediaCache.clearAll()) // Recreate so tearDown() releases a live instance and unlocks the directory for the next test. - cache = VideoMediaCache.create(context, cacheDir, VideoCacheConfig()) + cache = VideoMediaCache.create(context, cacheDir, VideoCacheConfig())!! } /** @@ -255,7 +255,8 @@ internal class StreamMediaDataSourceCacheIntegrationTest { // 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. + // load and make the assertions non-deterministic. The sleeps are wall-clock dependent; + // if this test becomes flaky on a slow runner, increase SPAN_SPACING_MS. readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_A_URL))) Thread.sleep(SPAN_SPACING_MS) readFully(factory.createDataSource(), DataSpec(Uri.parse(VIDEO_B_URL))) 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 index 9c6d9251cc1..a122add0e03 100644 --- 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 @@ -45,7 +45,7 @@ internal class VideoMediaCacheTest { @Before fun setUp() { cacheDir.deleteRecursively() - cache = VideoMediaCache.create(context, cacheDir, VideoCacheConfig()) + cache = VideoMediaCache.create(context, cacheDir, VideoCacheConfig())!! } @After @@ -84,7 +84,7 @@ internal class VideoMediaCacheTest { 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 + cache = recreated!! } private companion object { From 7b0b0a9fec44786050c4e663923413d19256cd6d Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 9 Jul 2026 12:26:33 +0200 Subject: [PATCH 3/5] client: Fix missing !! on evictionCache in LRU eviction test Co-Authored-By: Claude Sonnet 4.6 --- .../cache/internal/StreamMediaDataSourceCacheIntegrationTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1b2f0729b13..438f6f2b89e 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 @@ -246,7 +246,7 @@ internal class StreamMediaDataSourceCacheIntegrationTest { context, evictionDir, VideoCacheConfig(maxSizeBytes = 2 * TOTAL_LENGTH), - ) + )!! try { val upstream = RecordingDataSourceFactory() val factory = VideoCacheDataSourceFactory(evictionCache, upstream) From 2cce1d170573196d1f2f2986a2c47cea08b5264e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 14 Jul 2026 13:29:39 +0200 Subject: [PATCH 4/5] client: Close video-cache clear/delete race under registry lock `clearCacheAndTemporaryFiles` decided whether to delete the video cache directory based on `VideoMediaCache.clearAll()`, a process-wide boolean that does not line up with the single directory being deleted. When it reported no live cache, the fallback `deleteRecursively()` could run after another thread created a new `SimpleCache` for the same directory, corrupting Media3's on-disk index and lock. Replace `clearAll()` with `clearOrDelete(cacheDir, deleteOnDisk)`, which decides per directory under the registry lock: clear a live cache in place, or delete on disk when none owns it. Holding the lock across the check and the delete keeps a concurrent `create()` from registering a cache mid-delete. This also decouples the cleanup test from global registry state. Co-Authored-By: Claude Opus 4.8 --- .../chat/android/client/ChatClient.kt | 18 +++--- .../client/cache/internal/VideoMediaCache.kt | 30 ++++++---- ...reamMediaDataSourceCacheIntegrationTest.kt | 56 ++++++++++++------- 3 files changed, 66 insertions(+), 38 deletions(-) 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 94a94a82809..a72778fc782 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 @@ -1514,15 +1514,15 @@ 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 any live cache in the process (keeps the SimpleCache - // alive so playback continues to work), or by deleting the directory when no live cache - // owns it. The registry is process-wide, so this covers caches from a prior ChatClient - // build even if the current client was built without a cacheConfig. - val videoCacheResult = if (VideoMediaCache.clearAll()) { - Result.Success(Unit) - } else { - fileManager.clearVideoCache(context) - } + // Clear the video cache for its directory atomically: if a live cache owns the + // directory, clear it in place (keeps the SimpleCache alive so playback continues to + // work); otherwise delete the directory from disk. Deciding and deleting under the + // registry lock prevents a concurrent client build from registering a new cache for the + // directory mid-delete, which would corrupt Media3's on-disk index and lock. + val videoCacheResult = VideoMediaCache.clearOrDelete( + cacheDir = fileManager.getVideoCache(context), + deleteOnDisk = { fileManager.clearVideoCache(context) }, + ) // Clear all cache directories val cacheResult = fileManager.clearAllCache(context) // Clear external (temporary) storage files - always run regardless of cache result 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 eced80c3de8..58ebf155fe3 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 @@ -26,6 +26,7 @@ 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 io.getstream.result.Result import java.io.File /** @@ -111,18 +112,27 @@ public class VideoMediaCache private constructor( private val logger by taggedLogger(TAG) /** - * Clears every live [VideoMediaCache] in this process in place (see [clear]), keeping each - * [SimpleCache] and its directory lock alive. Returns `true` if at least one live cache was - * cleared, `false` if the registry was empty (no cache directory is owned in this process). + * Clears the video cache backing [cacheDir] atomically under the registry lock. * - * Callers use the return value to decide whether it is safe to delete the cache directory - * from disk: deleting a directory owned by a live [SimpleCache] corrupts Media3's on-disk - * index and lock. + * If a live [VideoMediaCache] owns [cacheDir], clears it in place (see [clear]) so the + * [SimpleCache] and its directory lock stay alive and playback keeps working. Otherwise runs + * [deleteOnDisk] to remove the directory from disk. The "is a cache live?" check and the + * delete happen under the same lock, so a concurrent [create] cannot register a new cache + * for [cacheDir] between them — which would otherwise let the delete corrupt Media3's + * on-disk index and lock. + * + * @param cacheDir Directory whose video cache should be cleared, used to look up any live + * cache that owns it. Must resolve to the same directory that [deleteOnDisk] removes. + * @param deleteOnDisk Deletes the cache directory from disk. Invoked only when no live cache + * owns [cacheDir]. */ - internal fun clearAll(): Boolean = synchronized(instances) { - instances.values.forEach { it.clear() } - instances.isNotEmpty() - } + internal fun clearOrDelete(cacheDir: File, deleteOnDisk: () -> Result): Result = + synchronized(instances) { + instances[cacheDir.absolutePath]?.let { live -> + live.clear() + Result.Success(Unit) + } ?: deleteOnDisk() + } /** * Returns a [VideoMediaCache] backed by the [SimpleCache] at [cacheDir], or `null` if 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 438f6f2b89e..b17926081f4 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 @@ -29,6 +29,7 @@ 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 io.getstream.result.Result import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -187,13 +188,13 @@ internal class StreamMediaDataSourceCacheIntegrationTest { } /** - * `clearAll()` fans out over every live cache in the process registry and empties each in - * place. Here the single registered cache (created in [setUp]) is populated with real bytes; - * after `clearAll()` its content is gone, the call reports it cleared something (`true`), and - * the still-alive `SimpleCache` can serve a fresh write. + * When a live [VideoMediaCache] owns the directory, `clearOrDelete` empties it in place (see + * [VideoMediaCache.clear]) and never runs the on-disk delete. Here the single registered cache + * (created in [setUp]) is populated with real bytes; after `clearOrDelete` its content is gone, + * the delete lambda was not invoked, and the still-alive `SimpleCache` can serve a fresh write. */ @Test - fun `clearAll empties the live cache in place and reports it cleared`() { + fun `clearOrDelete empties the live cache in place and skips the on-disk delete`() { // Arrange: write real bytes into the cache registered in setUp() and confirm they landed. val upstream = RecordingDataSourceFactory() val factory = VideoCacheDataSourceFactory(cache, upstream) @@ -202,32 +203,49 @@ internal class StreamMediaDataSourceCacheIntegrationTest { assertTrue("Precondition: cache tracks the written key", cache.cache.keys.isNotEmpty()) assertTrue("Precondition: cache reports non-zero size", cache.cache.cacheSpace > 0L) - // Act: clear every live cache in the process. - val cleared = VideoMediaCache.clearAll() + // Act: clear the directory owned by the live cache; the on-disk delete must not run. + var deleteInvoked = false + val result = VideoMediaCache.clearOrDelete(cacheDir) { + deleteInvoked = true + Result.Success(Unit) + } - // Assert: it reported clearing a live cache, and this cache is now empty. - assertTrue("clearAll should report that a live cache was cleared", cleared) - assertTrue("Cache should track zero keys after clearAll", cache.cache.keys.isEmpty()) - assertEquals("Cache should report zero size after clearAll", 0L, cache.cache.cacheSpace) + // Assert: cleared in place, delete skipped, and this cache is now empty. + assertTrue("clearOrDelete should succeed", result is Result.Success) + assertFalse("On-disk delete must be skipped when a live cache owns the directory", deleteInvoked) + assertTrue("Cache should track zero keys after clearOrDelete", cache.cache.keys.isEmpty()) + assertEquals("Cache should report zero size after clearOrDelete", 0L, cache.cache.cacheSpace) // Assert: the SimpleCache stays alive, so a subsequent read re-fetches and re-populates. populateCache(factory, VIDEO_URL) - assertEquals("A read after clearAll should hit upstream again", 2, upstream.openCount) + assertEquals("A read after clearOrDelete should hit upstream again", 2, upstream.openCount) assertTrue("Cache should be re-populated after the second read", cache.cache.keys.isNotEmpty()) } /** - * When no live cache is registered in the process (e.g. the app never opted in, or a prior - * cache was released), `clearAll()` has nothing to empty and reports `false` — the signal - * `ChatClient` uses to fall back to deleting the cache directory from disk. + * When no live cache owns the directory (e.g. the app never opted in, or a prior cache was + * released), `clearOrDelete` runs the on-disk delete against that directory instead of clearing + * a live cache in place. */ @Test - fun `clearAll reports nothing to clear when no live cache is registered`() { - // Arrange: release the only registered cache so the process registry is empty. + fun `clearOrDelete deletes on disk when no live cache owns the directory`() { + // Arrange: release the only registered cache so no live cache owns cacheDir, and confirm + // the directory still exists on disk. cache.release() + assertTrue("Precondition: cache directory exists on disk", cacheDir.exists()) + + // Act: clear the now-unowned directory; the delete lambda should run. + var deleteInvoked = false + val result = VideoMediaCache.clearOrDelete(cacheDir) { + deleteInvoked = true + cacheDir.deleteRecursively() + Result.Success(Unit) + } - // Act + Assert. - assertFalse("clearAll should report nothing was cleared on an empty registry", VideoMediaCache.clearAll()) + // Assert: the delete ran and removed the directory from disk. + assertTrue("clearOrDelete should succeed", result is Result.Success) + assertTrue("Delete lambda should run when no live cache owns the directory", deleteInvoked) + assertFalse("Cache directory should be gone after clearOrDelete", cacheDir.exists()) // Recreate so tearDown() releases a live instance and unlocks the directory for the next test. cache = VideoMediaCache.create(context, cacheDir, VideoCacheConfig())!! From 11cece64b18c0d73c830b81f3d3d4c97fa8388e0 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 14 Jul 2026 14:46:50 +0200 Subject: [PATCH 5/5] client: Close StandaloneDatabaseProvider when SimpleCache init fails In VideoMediaCache.create, the StandaloneDatabaseProvider is opened before the SimpleCache is constructed. When construction throws (e.g. a stale directory lock), the catch returned null without closing the provider, leaking its database connection on that path. Close it in the catch, guarded like release() does, since close() can itself throw. Co-Authored-By: Claude Opus 4.8 --- .../android/client/cache/internal/VideoMediaCache.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 58ebf155fe3..11f3e3e2a76 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 @@ -157,8 +157,9 @@ public class VideoMediaCache private constructor( } return@synchronized existing } + var dbProvider: StandaloneDatabaseProvider? = null try { - val dbProvider = StandaloneDatabaseProvider(appContext) + dbProvider = StandaloneDatabaseProvider(appContext) val simpleCache = SimpleCache( cacheDir, LeastRecentlyUsedCacheEvictor(config.maxSizeBytes), @@ -167,6 +168,13 @@ public class VideoMediaCache private constructor( VideoMediaCache(simpleCache, dbProvider, key).also { instances[key] = it } } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { logger.e(e) { "[create] Failed to construct SimpleCache at '$key'; video caching disabled." } + // SimpleCache construction failed, so no VideoMediaCache owns the provider that + // was opened just above. Close it here to avoid leaking the database connection. + try { + dbProvider?.close() + } catch (@Suppress("TooGenericExceptionCaught") closeError: Exception) { + logger.e(closeError) { "[create] Failed to close StandaloneDatabaseProvider after failed init" } + } null } }