Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,20 @@ 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 the live cache when opted in (keeps the SimpleCache
// alive so playback continues to work), or by deleting the directory when no live
// cache owns it.
val videoCacheResult = videoCache?.let {
it.clear()
Result.Success(Unit)
} ?: fileManager.clearVideoCache(context)
// Clear all cache directories
val cacheResult = fileManager.clearAllCache(context)
// Clear external (temporary) storage files - always run regardless of cache result
val externalStorageResult = fileManager.clearExternalStorage(context)
// Return the first failure if any, otherwise success
when {
videoCacheResult is Result.Failure -> videoCacheResult
cacheResult is Result.Failure -> cacheResult
externalStorageResult is Result.Failure -> externalStorageResult
else -> Result.Success(Unit)
Expand Down Expand Up @@ -4810,6 +4822,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 +5035,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 +5222,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 +5280,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