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
40 changes: 37 additions & 3 deletions stream-chat-android-client/api/stream-chat-android-client.api
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,10 @@ public final class io/getstream/chat/android/client/api/ChatClientConfig {
public fun <init> (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;)V
public fun <init> (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;Z)V
public fun <init> (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZ)V
public synthetic fun <init> (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;)V
public synthetic fun <init> (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Z
public final fun component10 ()Lio/getstream/chat/android/client/cache/StreamCacheConfig;
public final fun component2 ()Ljava/util/Set;
public final fun component3 ()Z
public final fun component4 ()Z
Expand All @@ -364,9 +366,10 @@ public final class io/getstream/chat/android/client/api/ChatClientConfig {
public final fun component7 ()Lio/getstream/chat/android/client/api/MessageLimitConfig;
public final fun component8 ()Z
public final fun component9 ()Z
public final fun copy (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZ)Lio/getstream/chat/android/client/api/ChatClientConfig;
public static synthetic fun copy$default (Lio/getstream/chat/android/client/api/ChatClientConfig;ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZILjava/lang/Object;)Lio/getstream/chat/android/client/api/ChatClientConfig;
public final fun copy (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;)Lio/getstream/chat/android/client/api/ChatClientConfig;
public static synthetic fun copy$default (Lio/getstream/chat/android/client/api/ChatClientConfig;ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;ILjava/lang/Object;)Lio/getstream/chat/android/client/api/ChatClientConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getCacheConfig ()Lio/getstream/chat/android/client/cache/StreamCacheConfig;
public final fun getFastEventParsing ()Z
public final fun getIgnoredOfflineChannelTypes ()Ljava/util/Set;
public final fun getMessageLimitConfig ()Lio/getstream/chat/android/client/api/MessageLimitConfig;
Expand Down Expand Up @@ -1001,6 +1004,37 @@ public final class io/getstream/chat/android/client/audio/WaveformExtractorKt {
public static final fun isEof (Landroid/media/MediaCodec$BufferInfo;)Z
}

public final class io/getstream/chat/android/client/cache/StreamCacheConfig {
public fun <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 @@ -75,6 +75,7 @@ import io.getstream.chat.android.client.attachment.prepareForUpload
import io.getstream.chat.android.client.audio.AudioPlayer
import io.getstream.chat.android.client.audio.NativeMediaPlayerImpl
import io.getstream.chat.android.client.audio.StreamAudioPlayer
import io.getstream.chat.android.client.cache.internal.VideoMediaCache
import io.getstream.chat.android.client.cdn.CDN
import io.getstream.chat.android.client.cdn.internal.StreamMediaDataSource
import io.getstream.chat.android.client.channel.ChannelClient
Expand Down Expand Up @@ -304,6 +305,8 @@ internal constructor(
internal val messageReceiptManager: MessageReceiptManager,
@InternalStreamChatApi
public val cdn: CDN? = null,
@InternalStreamChatApi
public val videoCache: VideoMediaCache? = null,
) {
private val logger by taggedLogger(TAG)
private val fileManager = StreamFileManager()
Expand Down Expand Up @@ -1511,12 +1514,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
Comment on lines +1517 to +1532

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map relevant symbols before reading slices
echo "== ChatClient outline =="
ast-grep outline stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt --view expanded | sed -n '1,220p'

echo
echo "== VideoMediaCache search =="
rg -n "class VideoMediaCache|fun clearAll|fun create|instances|clearVideoCache|clearAllCache|clearExternalStorage" stream-chat-android-client/src/main/java -S

echo
echo "== FileManager outline if present =="
fd -a "FileManager.kt" stream-chat-android-client/src/main/java || true

Repository: GetStream/stream-chat-android

Length of output: 31263


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ChatClient outline =="
ast-grep outline stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt --view expanded | sed -n '1,220p'

echo
echo "== VideoMediaCache search =="
rg -n "class VideoMediaCache|fun clearAll|fun create|instances|clearVideoCache|clearAllCache|clearExternalStorage" stream-chat-android-client/src/main/java -S

echo
echo "== FileManager files =="
fd -a "FileManager.kt" stream-chat-android-client/src/main/java || true

Repository: GetStream/stream-chat-android

Length of output: 31250


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant slices with line numbers once the file locations are known.
sed -n '1500,1545p' stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt

echo
echo "== VideoMediaCache.kt slices =="
sed -n '1,260p' stream-chat-android-client/src/main/java/io/getstream/chat/android/client/helpers/VideoMediaCache.kt

Repository: GetStream/stream-chat-android

Length of output: 3026


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the exact files and relevant symbols.
git ls-files 'stream-chat-android-client/src/main/java/**' | rg 'ChatClient\.kt|VideoMediaCache\.kt|FileManager\.kt'

echo
for f in \
  stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt \
  stream-chat-android-client/src/main/java/io/getstream/chat/android/client/helpers/VideoMediaCache.kt \
  stream-chat-android-client/src/main/java/io/getstream/chat/android/client/helpers/FileManager.kt
do
  if [ -f "$f" ]; then
    echo "== $f =="
    wc -l "$f"
  fi
done

Repository: GetStream/stream-chat-android

Length of output: 783


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for the helper implementation and call sites.
rg -n "class VideoMediaCache|object VideoMediaCache|clearAll\(|create\(|deleteRecursively|clearVideoCache\(" stream-chat-android-client/src/main/java/io/getstream/chat/android/client -S

Repository: GetStream/stream-chat-android

Length of output: 7459


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only the relevant sections of the two helper files.
python3 - <<'PY'
from pathlib import Path

paths = [
    Path("stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt"),
    Path("stream-chat-android-client/src/main/java/io/getstream/chat/android/client/helpers/VideoMediaCache.kt"),
    Path("stream-chat-android-client/src/main/java/io/getstream/chat/android/client/helpers/FileManager.kt"),
]
for p in paths:
    if p.exists():
        print(f"\n== {p} ==")
        lines = p.read_text().splitlines()
        for i, line in enumerate(lines, 1):
            if ("clearCacheAndTemporaryFiles" in line or
                "clearAll(" in line or
                "clearVideoCache" in line or
                "deleteRecursively" in line or
                "class VideoMediaCache" in line or
                "object VideoMediaCache" in line or
                "fun create(" in line):
                start = max(1, i - 20)
                end = min(len(lines), i + 40)
                for j in range(start, end + 1):
                    print(f"{j:5d}: {lines[j-1]}")
                print()
PY

Repository: GetStream/stream-chat-android

Length of output: 14827


🏁 Script executed:

#!/bin/bash
set -euo pipefail

f="stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cache/internal/VideoMediaCache.kt"
wc -l "$f"
echo
sed -n '1,240p' "$f"

Repository: GetStream/stream-chat-android

Length of output: 7626


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '360,390p' stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/file/StreamFileManager.kt

Repository: GetStream/stream-chat-android

Length of output: 1522


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path("stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/file/StreamFileManager.kt")
lines = p.read_text().splitlines()
for start in [360]:
    end = min(len(lines), start+35)
    for i in range(start, end+1):
        print(f"{i:4d}: {lines[i-1]}")
PY

Repository: GetStream/stream-chat-android

Length of output: 1954


Close the video-cache clear/delete race.

VideoMediaCache.clearAll() only tells you whether a live cache existed at that moment; if it returns false, the fallback fileManager.clearVideoCache(context) in ChatClient.kt:1521-1524 can run after another thread has created a new VideoMediaCache for the same directory. That deleteRecursively() path can corrupt the live SimpleCache index/lock. Move the fallback delete behind a single atomic VideoMediaCache API that holds the registry lock across the “clear live caches or delete on disk” decision.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt`
around lines 1517 - 1532, The video cache cleanup in ChatClient should not
decide between clearing live caches and deleting the directory in two separate
steps. Update the cache removal flow around VideoMediaCache.clearAll() so the
“clear live caches or delete on disk” decision is handled atomically under the
registry lock, and remove the fallback delete from ChatClient’s cache-clearing
path. Use the existing VideoMediaCache and fileManager.clearVideoCache(context)
symbols to locate the affected logic and route the fallback through a single
synchronized API that prevents a newly created cache from being deleted
mid-flight.

cacheResult is Result.Failure -> cacheResult
externalStorageResult is Result.Failure -> externalStorageResult
else -> Result.Success(Unit)
Expand Down Expand Up @@ -5274,7 +5287,10 @@ internal constructor(
val api = module.api()
val appSettingsManager = AppSettingManager(api)

val mediaDataSourceFactory = StreamMediaDataSource.factory(appContext, cdn)
val videoCache = chatClientConfig.cacheConfig.video?.let {
VideoMediaCache.create(appContext, StreamFileManager().getVideoCache(appContext), it)
}
val mediaDataSourceFactory = StreamMediaDataSource.factory(appContext, cdn, videoCache)
val audioPlayer: AudioPlayer = StreamAudioPlayer(
mediaPlayer = NativeMediaPlayerImpl(mediaDataSourceFactory) {
ExoPlayer.Builder(appContext)
Expand Down Expand Up @@ -5332,6 +5348,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
Expand Up @@ -17,6 +17,7 @@
package io.getstream.chat.android.client.api

import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.client.cache.StreamCacheConfig
import io.getstream.chat.android.client.setup.state.ClientState
import io.getstream.chat.android.models.TimeDuration

Expand Down Expand Up @@ -85,6 +86,9 @@ import io.getstream.chat.android.models.TimeDuration
* fall back to the default DTO-based parser, so behavior is preserved for events the fast path does not yet handle.
* Currently supported event types: `message.new`. Disabled by default. The set of supported event types may grow over
* time.
*
* @param cacheConfig Configuration for the SDK's user-configurable on-disk caches. By default all caches are off. See
* [StreamCacheConfig] for the available cache types and their options.
*/
public data class ChatClientConfig @JvmOverloads constructor(
public val offlineEnabled: Boolean = true,
Expand All @@ -96,6 +100,7 @@ public data class ChatClientConfig @JvmOverloads constructor(
public val messageLimitConfig: MessageLimitConfig = MessageLimitConfig(),
public val useLegacyChannelLogic: Boolean = false,
public val fastEventParsing: Boolean = false,
public val cacheConfig: StreamCacheConfig = StreamCacheConfig(),
)

/**
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.
*
* Set this on [io.getstream.chat.android.client.api.ChatClientConfig.cacheConfig] to configure the
* on-disk caches.
*
* @param video Configuration for the video playback cache used by SDK.
*/
public data class StreamCacheConfig(
public val video: VideoCacheConfig? = null,
)

/**
* Configuration for the on-disk cache used when streaming video attachments.
*
* Wrap an instance in [StreamCacheConfig] and set it on
* [io.getstream.chat.android.client.api.ChatClientConfig.cacheConfig] to opt in. When the cache is
* enabled, replaying or seeking within a previously watched video reuses cached byte ranges
* instead of re-downloading from the CDN.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small doc suggestion. The cache key strips the URL query, so entries are keyed by path alone, and this wraps all ExoPlayer media, not only Stream attachments. Worth one line here noting the cache assumes the path identifies the content? Sets expectations for anyone playing non-Stream URLs where the query is content-significant.

*
* @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
}
}
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=AZ8oOFYIsZkITzP56m5l&open=AZ8oOFYIsZkITzP56m5l&pullRequest=6542

/**
* 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