diff --git a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/MockedChatClientTest.kt b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/MockedChatClientTest.kt index d33ea89aee7..32c3c4546ae 100644 --- a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/MockedChatClientTest.kt +++ b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/MockedChatClientTest.kt @@ -62,6 +62,10 @@ interface MockedChatClientTest { whenever(MockChatClient.clientState) doReturn MockClientState whenever(MockChatClient.inheritScope(any())) doReturn TestScope() + CoroutineExceptionHandler { _, _ -> } + // RETURNS_MOCKS would otherwise hand out mock instances for nullable getters that + // production code reads opportunistically (e.g. opt-in features). Stub them to null + // so default behaviour matches the unconfigured path. + whenever(MockChatClient.videoCache) doReturn null } @After diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index 763ab234c89..9ed6a733acb 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -276,6 +276,7 @@ public final class io/getstream/chat/android/client/ChatClient$Builder : io/gets public final fun appVersion (Ljava/lang/String;)Lio/getstream/chat/android/client/ChatClient$Builder; public final fun baseUrl (Ljava/lang/String;)Lio/getstream/chat/android/client/ChatClient$Builder; public fun build ()Lio/getstream/chat/android/client/ChatClient; + public final fun cacheConfig (Lio/getstream/chat/android/client/cache/StreamCacheConfig;)Lio/getstream/chat/android/client/ChatClient$Builder; public final fun cdn (Lio/getstream/chat/android/client/cdn/CDN;)Lio/getstream/chat/android/client/ChatClient$Builder; public final fun cdnUrl (Ljava/lang/String;)Lio/getstream/chat/android/client/ChatClient$Builder; public final fun clientDebugger (Lio/getstream/chat/android/client/debugger/ChatClientDebugger;)Lio/getstream/chat/android/client/ChatClient$Builder; @@ -737,6 +738,37 @@ public final class io/getstream/chat/android/client/audio/WaveformExtractorKt { public static final fun isEof (Landroid/media/MediaCodec$BufferInfo;)Z } +public final class io/getstream/chat/android/client/cache/StreamCacheConfig { + public fun ()V + public fun (Lio/getstream/chat/android/client/cache/VideoCacheConfig;)V + public synthetic fun (Lio/getstream/chat/android/client/cache/VideoCacheConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/getstream/chat/android/client/cache/VideoCacheConfig; + public final fun copy (Lio/getstream/chat/android/client/cache/VideoCacheConfig;)Lio/getstream/chat/android/client/cache/StreamCacheConfig; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/cache/StreamCacheConfig;Lio/getstream/chat/android/client/cache/VideoCacheConfig;ILjava/lang/Object;)Lio/getstream/chat/android/client/cache/StreamCacheConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getVideo ()Lio/getstream/chat/android/client/cache/VideoCacheConfig; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/client/cache/VideoCacheConfig { + public static final field Companion Lio/getstream/chat/android/client/cache/VideoCacheConfig$Companion; + public static final field DEFAULT_MAX_SIZE_BYTES J + public fun ()V + public fun (J)V + public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun copy (J)Lio/getstream/chat/android/client/cache/VideoCacheConfig; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/cache/VideoCacheConfig;JILjava/lang/Object;)Lio/getstream/chat/android/client/cache/VideoCacheConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getMaxSizeBytes ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/client/cache/VideoCacheConfig$Companion { +} + public abstract interface class io/getstream/chat/android/client/cdn/CDN { public fun fileRequest (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun fileRequest$suspendImpl (Lio/getstream/chat/android/client/cdn/CDN;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt index 0407cd52633..918decff67d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -72,6 +72,8 @@ import io.getstream.chat.android.client.attachment.AttachmentsSender import io.getstream.chat.android.client.audio.AudioPlayer import io.getstream.chat.android.client.audio.NativeMediaPlayerImpl import io.getstream.chat.android.client.audio.StreamAudioPlayer +import io.getstream.chat.android.client.cache.StreamCacheConfig +import io.getstream.chat.android.client.cache.internal.VideoMediaCache import io.getstream.chat.android.client.cdn.CDN import io.getstream.chat.android.client.cdn.internal.StreamMediaDataSource import io.getstream.chat.android.client.channel.ChannelClient @@ -300,6 +302,8 @@ internal constructor( internal val messageReceiptManager: MessageReceiptManager, @InternalStreamChatApi public val cdn: CDN? = null, + @InternalStreamChatApi + public val videoCache: VideoMediaCache? = null, ) { private val logger by taggedLogger(TAG) private val fileManager = StreamFileManager() @@ -1519,12 +1523,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) @@ -4810,6 +4824,7 @@ internal constructor( private var fileTransformer: FileTransformer = NoOpFileTransformer private var apiModelTransformers: ApiModelTransformers = ApiModelTransformers() private var cdn: CDN? = null + private var cacheConfig: StreamCacheConfig? = null private var appName: String? = null private var appVersion: String? = null @@ -5022,6 +5037,15 @@ internal constructor( this.cdn = cdn } + /** + * Configures the SDK's user-configurable on-disk caches. + * + * @param config The per-cache configurations. + */ + public fun cacheConfig(config: StreamCacheConfig): Builder = apply { + this.cacheConfig = config + } + /** * Sets the CDN URL to be used by the client. */ @@ -5200,7 +5224,10 @@ internal constructor( val api = module.api() val appSettingsManager = AppSettingManager(api) - val mediaDataSourceFactory = StreamMediaDataSource.factory(appContext, cdn) + val videoCache = 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) @@ -5255,6 +5282,7 @@ internal constructor( api = api, ), cdn = cdn, + videoCache = videoCache, ).apply { attachmentsSender = AttachmentsSender( context = appContext, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/StreamCacheConfig.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/StreamCacheConfig.kt new file mode 100644 index 00000000000..449f1c5b231 --- /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. + * + * Pass an instance to [io.getstream.chat.android.client.ChatClient.Builder.cacheConfig] to configure + * the on-disk caches. + * + * @param video Configuration for the video playback cache used by SDK. + */ +public data class StreamCacheConfig( + public val video: VideoCacheConfig? = null, +) + +/** + * Configuration for the on-disk cache used when streaming video attachments. + * + * Wrap an instance in [StreamCacheConfig] and pass it to + * [io.getstream.chat.android.client.ChatClient.Builder.cacheConfig] to opt in. When the cache is + * enabled, replaying or seeking within a previously watched video reuses cached byte ranges + * instead of re-downloading from the CDN. + * + * @param maxSizeBytes Soft cap on cache size; LRU eviction kicks in once exceeded. Files larger + * than this cap are not effectively cached. Size [maxSizeBytes] to comfortably exceed the + * largest expected video. + */ +public data class VideoCacheConfig( + public val maxSizeBytes: Long = DEFAULT_MAX_SIZE_BYTES, +) { + 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/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 7c4f15d949a..aee457a5d7c 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -976,6 +976,7 @@ public final class io/getstream/chat/android/compose/ui/attachments/preview/Medi } public final class io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity$Companion { + public final fun getIntent (Landroid/content/Context;Ljava/lang/String;)Landroid/content/Intent; public final fun getIntent (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent; public static synthetic fun getIntent$default (Lio/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity$Companion;Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Landroid/content/Intent; } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt index ad6ffbeb2ea..7a020761b1e 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt @@ -70,7 +70,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.media3.common.MediaItem import androidx.media3.common.Player import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.attachment.isVideo @@ -80,6 +79,7 @@ import io.getstream.chat.android.compose.ui.attachments.preview.internal.MediaGa import io.getstream.chat.android.compose.ui.attachments.preview.internal.MediaGalleryPhotosMenu import io.getstream.chat.android.compose.ui.attachments.preview.internal.MediaGalleryVideoPage import io.getstream.chat.android.compose.ui.attachments.preview.internal.createPlayer +import io.getstream.chat.android.compose.ui.attachments.preview.internal.prepareIfNeeded import io.getstream.chat.android.compose.ui.components.NetworkLoadingIndicator import io.getstream.chat.android.compose.ui.components.SimpleDialog import io.getstream.chat.android.compose.ui.components.Timestamp @@ -569,8 +569,7 @@ internal fun MediaGalleryPager( ?.second ?: 0L savedPlaybackState = null - activePlayer.setMediaItem(MediaItem.fromUri(assetUrl), startPosition) - activePlayer.prepare() + activePlayer.prepareIfNeeded(assetUrl, startPosition) } } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt index 827f533da0c..4476610f727 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt @@ -184,7 +184,12 @@ public class MediaPreviewActivity : AppCompatActivity() { * @param url The URL of the media file. * @param title The name of the media file. */ - public fun getIntent(context: Context, url: String, title: String? = null): Intent { + @JvmOverloads + public fun getIntent( + context: Context, + url: String, + title: String? = null, + ): Intent { return Intent(context, MediaPreviewActivity::class.java).apply { putExtra(KEY_URL, url) putExtra(KEY_TITLE, title) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt index 95b9a5f77ea..18569c6aefc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt @@ -101,10 +101,10 @@ internal fun StreamMediaPlayerContent( } // Prepare media LaunchedEffect(player, assetUrl) { - if (player != null && assetUrl != null) { - player?.setMediaItem(MediaItem.fromUri(assetUrl)) - player?.prepare() - player?.playWhenReady = playWhenReady + val activePlayer = player + if (activePlayer != null && assetUrl != null) { + activePlayer.prepareIfNeeded(assetUrl) + activePlayer.playWhenReady = playWhenReady } } // Draw player @@ -183,6 +183,27 @@ internal fun MediaThumbnail( } } +/** + * Sets [assetUrl] on this player and prepares it, unless the player is already prepared with the + * same URL. + * + * The prepare-effects that call this can fire more than once for the same media (e.g. during + * initial composition, when the effect keyed on the player and page runs again as those settle). + * Re-preparing an already-loaded URL starts a second, concurrent load of the same asset; when the + * video disk cache is enabled, the two loads contend on the same cache entry and can leave the + * file's head uncached, which breaks offline playback. Skipping the redundant prepare avoids that + * while still (re)preparing whenever the URL actually changes or the player was recreated. + * + * @param assetUrl The URL to play. + * @param startPositionMs Position to start playback from, in milliseconds. + */ +internal fun Player.prepareIfNeeded(assetUrl: String, startPositionMs: Long = 0L) { + val alreadyPrepared = currentMediaItem?.localConfiguration?.uri?.toString() == assetUrl + if (alreadyPrepared) return + setMediaItem(MediaItem.fromUri(assetUrl), startPositionMs) + prepare() +} + /** * Creates a basic [Player] instance for playing audio/video. * Creates a player of type [ExoPlayer]. @@ -198,8 +219,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 d68c2b5474c..34cca7f6b0f 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 @@ -138,8 +138,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 e4a1912fdfb..0c94ef5334a 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryVideoPageFragment.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryVideoPageFragment.kt @@ -245,9 +245,10 @@ internal class AttachmentGalleryVideoPageFragment : Fragment() { @OptIn(UnstableApi::class) private fun createMediaSourceFactory(): MediaSource.Factory { - val cdn = ChatClient.instance().cdn + val client = ChatClient.instance() val headers = ChatUI.videoHeadersProvider.getVideoRequestHeaders(assetUrl ?: "") - val baseDataSourceFactory = StreamMediaDataSource.factory(requireContext(), cdn) + val baseDataSourceFactory = + StreamMediaDataSource.factory(requireContext(), client.cdn, client.videoCache) val dataSourceFactory = ResolvingDataSource.Factory(baseDataSourceFactory) { dataSpec -> dataSpec.withAdditionalHeaders(headers) }