Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions stream-chat-android-client/api/stream-chat-android-client.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <init> ()V
public fun <init> (Lio/getstream/chat/android/client/cache/VideoCacheConfig;)V
public synthetic fun <init> (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 <init> ()V
public fun <init> (J)V
public synthetic fun <init> (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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -1519,12 +1523,22 @@ internal constructor(
public fun clearCacheAndTemporaryFiles(context: Context): Call<Unit> =
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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -5255,6 +5282,7 @@ internal constructor(
api = api,
),
cdn = cdn,
videoCache = videoCache,
).apply {
attachmentsSender = AttachmentsSender(
context = appContext,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -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()

Check warning on line 51 in stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoCacheDataSourceFactory.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace with interface delegation using "by" in the class header.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-android&issues=AZ8U3XkWrJXjAhCQ-p1y&open=AZ8U3XkWrJXjAhCQ-p1y&pullRequest=6533

/**
* 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()
}
Loading
Loading