From 99c1f2eeb8b779370558a51cb2adc089ea890751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 26 Jun 2026 10:29:52 +0100 Subject: [PATCH 1/6] Fall back to a video frame when the server thumbnail is not available A sent video showed a blank preview in the message list and the shared-media grids until the chat was reopened. The upload-intent response returns a thumbnail URL before server-side thumbnail generation has finished, so the client requests it and gets a 404, leaving the preview blank. The existing `?: upload` fallback never helped because the thumbnail URL is present; only the load fails. Add a `VideoThumbnailFallbackInterceptor` in ui-common that loads the server thumbnail first and, when it is missing or fails to load, extracts a frame from the video asset itself via Coil's `VideoFrameDecoder`. Both UI kits now pass `VideoThumbnailImageData(thumbUrl, assetUrl)` for videos. The interceptor rewrites the request to a plain URL before proceeding, so CDN signing, network fetch, disk cache, and decoding are reused for both the thumbnail and the fallback frame. It is registered as the outermost interceptor so the CDN interceptor still signs the fallback video URL. This matches the iOS behaviour in StreamMediaLoader. --- .../ChannelMediaAttachmentsScreen.kt | 10 +- .../internal/AttachmentExtensions.kt | 17 ++- .../common/images/StreamImageLoaderFactory.kt | 2 + .../VideoThumbnailFallbackInterceptor.kt | 91 ++++++++++++ .../VideoThumbnailFallbackInterceptorTest.kt | 136 ++++++++++++++++++ .../internal/MediaAttachmentAdapter.kt | 19 ++- .../view/internal/MediaAttachmentView.kt | 12 +- 7 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt create mode 100644 stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt index 4cbb9206e62..b8604e70b48 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt @@ -50,6 +50,7 @@ import io.getstream.chat.android.compose.viewmodel.channel.ChannelAttachmentsVie import io.getstream.chat.android.compose.viewmodel.channel.ChannelAttachmentsViewModelFactory import io.getstream.chat.android.previewdata.PreviewMessageData import io.getstream.chat.android.ui.common.feature.channel.attachments.ChannelAttachmentsViewAction +import io.getstream.chat.android.ui.common.images.internal.VideoThumbnailImageData import io.getstream.chat.android.ui.common.state.channel.attachments.ChannelAttachmentsViewState import io.getstream.result.Error @@ -141,8 +142,13 @@ internal fun LazyGridItemScope.ChannelMediaAttachmentsItem( item: ChannelAttachmentsViewState.Content.Item, onClick: () -> Unit, ) { - val data = item.attachment.upload - ?: if (item.attachment.isImage()) item.attachment.imageUrl else item.attachment.thumbUrl + val attachment = item.attachment + val data = attachment.upload ?: when { + attachment.isImage() -> attachment.imageUrl + attachment.isVideo() && (attachment.thumbUrl != null || attachment.assetUrl != null) -> + VideoThumbnailImageData(thumbnailUrl = attachment.thumbUrl, videoUrl = attachment.assetUrl) + else -> attachment.thumbUrl + } Box( modifier = Modifier .clickable(onClick = onClick), diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt index 8e415297201..788341c05cf 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt @@ -21,6 +21,7 @@ import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.attachment.isVideo import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.ui.common.images.internal.VideoThumbnailImageData import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled /** @@ -30,7 +31,9 @@ import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageRe * Otherwise, it returns null. * * For image attachments, [Attachment.imageUrl] is used. - * For video attachments when thumbnails are enabled, [Attachment.thumbUrl] is used. + * For video attachments when thumbnails are enabled, the server [Attachment.thumbUrl] is used, + * falling back to a frame extracted from the [Attachment.assetUrl] video when the thumbnail is + * missing or fails to load (see [VideoThumbnailImageData]). */ @get:Composable internal val Attachment.imagePreviewData: Any? @@ -39,9 +42,13 @@ internal val Attachment.imagePreviewData: Any? imageUrl ?.applyStreamCdnImageResizingIfEnabled(ChatTheme.streamCdnImageResizing) ?: upload - isVideo() && ChatTheme.videoThumbnailsEnabled -> - thumbUrl - ?.applyStreamCdnImageResizingIfEnabled(ChatTheme.streamCdnImageResizing) - ?: upload + isVideo() && ChatTheme.videoThumbnailsEnabled -> { + val thumbnailUrl = thumbUrl?.applyStreamCdnImageResizingIfEnabled(ChatTheme.streamCdnImageResizing) + if (thumbnailUrl != null || assetUrl != null) { + VideoThumbnailImageData(thumbnailUrl = thumbnailUrl, videoUrl = assetUrl) + } else { + upload + } + } else -> null } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt index bd538893a6f..5e34cb25123 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt @@ -30,6 +30,7 @@ import coil3.request.allowHardware import coil3.request.crossfade import coil3.video.VideoFrameDecoder import io.getstream.chat.android.client.internal.file.StreamFileManager +import io.getstream.chat.android.ui.common.images.internal.VideoThumbnailFallbackInterceptor import okio.Path.Companion.toOkioPath private const val DEFAULT_MEMORY_PERCENTAGE = 0.25 @@ -82,6 +83,7 @@ public class StreamImageLoaderFactory( .build() } .components { + add(VideoThumbnailFallbackInterceptor()) interceptors.forEach { add(it) } add(OkHttpNetworkFetcherFactory()) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt new file mode 100644 index 00000000000..e61d4ffcd44 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt @@ -0,0 +1,91 @@ +/* + * 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.ui.common.images.internal + +import coil3.intercept.Interceptor +import coil3.request.ImageResult +import coil3.request.SuccessResult +import coil3.video.videoFrameMillis +import io.getstream.chat.android.core.internal.InternalStreamChatApi + +/** + * Image request data for a video preview that should be loaded from the server thumbnail when + * available, and fall back to a frame extracted from the video itself when the thumbnail is + * missing or fails to load. + * + * Pass an instance of this as the Coil request data (instead of a raw URL) to opt into the + * fallback handled by [VideoThumbnailFallbackInterceptor]. + * + * @param thumbnailUrl The server-provided thumbnail URL, already transformed for CDN resizing if enabled. + * @param videoUrl The URL of the video asset, used to extract a preview frame when [thumbnailUrl] fails. + */ +@InternalStreamChatApi +public data class VideoThumbnailImageData( + public val thumbnailUrl: String?, + public val videoUrl: String?, +) + +/** + * A Coil [Interceptor] that resolves a [VideoThumbnailImageData] request by loading the server + * thumbnail first and, when that is missing or fails, extracting a frame from the video asset. + * + * Server-side thumbnail generation is asynchronous, so right after a video is sent the thumbnail + * URL can return 404 until generation finishes. Without a fallback the preview stays blank until + * the item is rendered again. This mirrors the iOS behaviour of generating a frame from the video + * when the thumbnail is not yet available. + * + * The interceptor rewrites the request data to a plain URL before proceeding, so the rest of the + * pipeline (CDN signing, network fetching, frame decoding) is reused for both the thumbnail and + * the video frame. It must be registered as the outermost interceptor so URL-rewriting + * interceptors still apply to the fallback video URL. + */ +@InternalStreamChatApi +public class VideoThumbnailFallbackInterceptor : Interceptor { + + override suspend fun intercept(chain: Interceptor.Chain): ImageResult { + val data = chain.request.data as? VideoThumbnailImageData ?: return chain.proceed() + val thumbnailUrl = data.thumbnailUrl + val videoUrl = data.videoUrl + + if (thumbnailUrl != null) { + val thumbnailResult = chain + .withRequest(chain.request.newBuilder().data(thumbnailUrl).build()) + .proceed() + if (thumbnailResult is SuccessResult || videoUrl == null) { + return thumbnailResult + } + } + + if (videoUrl != null) { + val videoRequest = chain.request.newBuilder() + .data(videoUrl) + .videoFrameMillis(VIDEO_FRAME_MILLIS) + .build() + return chain.withRequest(videoRequest).proceed() + } + + return chain.proceed() + } + + private companion object { + /** + * Offset of the extracted frame, kept slightly above zero so the very first frame + * (often black) is skipped. + */ + private const val VIDEO_FRAME_MILLIS = 100L + } +} diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt new file mode 100644 index 00000000000..45c664c63ab --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt @@ -0,0 +1,136 @@ +/* + * 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.ui.common.images.internal + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil3.intercept.Interceptor +import coil3.request.ErrorResult +import coil3.request.ImageRequest +import coil3.request.ImageResult +import coil3.request.SuccessResult +import coil3.size.Size +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +internal class VideoThumbnailFallbackInterceptorTest { + + private val context: Context get() = RuntimeEnvironment.getApplication() + private val interceptor = VideoThumbnailFallbackInterceptor() + + @Test + fun `loads the thumbnail and skips the video when the thumbnail succeeds`() = runTest { + val chain = chainFor( + VideoThumbnailImageData(thumbnailUrl = THUMB_URL, videoUrl = VIDEO_URL), + resultFor = { url -> if (url == THUMB_URL) success else error }, + ) + + val result = interceptor.intercept(chain) + + assertEquals(listOf(THUMB_URL), chain.proceeded) + assertTrue(result is SuccessResult) + } + + @Test + fun `falls back to the video frame when the thumbnail fails`() = runTest { + val chain = chainFor( + VideoThumbnailImageData(thumbnailUrl = THUMB_URL, videoUrl = VIDEO_URL), + resultFor = { url -> if (url == VIDEO_URL) success else error }, + ) + + val result = interceptor.intercept(chain) + + assertEquals(listOf(THUMB_URL, VIDEO_URL), chain.proceeded) + assertTrue(result is SuccessResult) + } + + @Test + fun `loads the video frame directly when there is no thumbnail`() = runTest { + val chain = chainFor( + VideoThumbnailImageData(thumbnailUrl = null, videoUrl = VIDEO_URL), + resultFor = { success }, + ) + + val result = interceptor.intercept(chain) + + assertEquals(listOf(VIDEO_URL), chain.proceeded) + assertTrue(result is SuccessResult) + } + + @Test + fun `returns the thumbnail error when there is no video to fall back to`() = runTest { + val chain = chainFor( + VideoThumbnailImageData(thumbnailUrl = THUMB_URL, videoUrl = null), + resultFor = { error }, + ) + + val result = interceptor.intercept(chain) + + assertEquals(listOf(THUMB_URL), chain.proceeded) + assertTrue(result is ErrorResult) + } + + @Test + fun `passes through requests that are not video thumbnails`() = runTest { + val chain = chainFor(THUMB_URL, resultFor = { success }) + + interceptor.intercept(chain) + + assertEquals(listOf(THUMB_URL), chain.proceeded) + } + + private val success: ImageResult get() = mock() + private val error: ImageResult get() = mock() + + private fun chainFor(data: Any, resultFor: (String?) -> ImageResult): FakeCoilChain { + val request = ImageRequest.Builder(context).data(data).build() + return FakeCoilChain(request, resultFor) + } + + @Suppress("EmptyFunctionBlock") + private class FakeCoilChain( + override val request: ImageRequest, + private val resultFor: (String?) -> ImageResult, + val proceeded: MutableList = mutableListOf(), + ) : Interceptor.Chain { + override val size: Size get() = Size.ORIGINAL + + override suspend fun proceed(): ImageResult { + val key = request.data.toString() + proceeded.add(key) + return resultFor(key) + } + + override fun withRequest(request: ImageRequest): Interceptor.Chain = + FakeCoilChain(request, resultFor, proceeded) + + override fun withSize(size: Size): Interceptor.Chain = this + } + + private companion object { + private const val THUMB_URL = "https://cdn.example.com/thumb.jpg" + private const val VIDEO_URL = "https://cdn.example.com/video.mp4" + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt index a6224952d52..d752d98e963 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt @@ -26,6 +26,7 @@ import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.attachment.isVideo import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.ui.ChatUI +import io.getstream.chat.android.ui.common.images.internal.VideoThumbnailImageData import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled import io.getstream.chat.android.ui.databinding.StreamUiItemMediaAttachmentBinding import io.getstream.chat.android.ui.feature.gallery.AttachmentGalleryItem @@ -95,10 +96,20 @@ internal class MediaAttachmentAdapter( val imageData = if (shouldLoadImage) { val attachment = attachmentGalleryItem.attachment - val url = if (attachment.isImage()) attachment.imageUrl else attachment.thumbUrl - url?.applyStreamCdnImageResizingIfEnabled( - streamCdnImageResizing = ChatUI.streamCdnImageResizing, - ) + if (attachment.isVideo()) { + val thumbnailUrl = attachment.thumbUrl?.applyStreamCdnImageResizingIfEnabled( + streamCdnImageResizing = ChatUI.streamCdnImageResizing, + ) + if (thumbnailUrl != null || attachment.assetUrl != null) { + VideoThumbnailImageData(thumbnailUrl = thumbnailUrl, videoUrl = attachment.assetUrl) + } else { + null + } + } else { + attachment.imageUrl?.applyStreamCdnImageResizingIfEnabled( + streamCdnImageResizing = ChatUI.streamCdnImageResizing, + ) + } } else { null } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt index f0e5f723846..8c83d411f3c 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt @@ -28,6 +28,7 @@ import io.getstream.chat.android.client.utils.attachment.isVideo import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.R +import io.getstream.chat.android.ui.common.images.internal.VideoThumbnailImageData import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled import io.getstream.chat.android.ui.databinding.StreamUiMediaAttachmentViewBinding import io.getstream.chat.android.ui.feature.messages.list.adapter.view.MediaAttachmentViewStyle @@ -134,9 +135,14 @@ internal class MediaAttachmentView : ConstraintLayout { if (attachment.isImage()) { attachment.imageUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing) ?: attachment.upload ?: return - } else if (attachment.isVideo() && ChatUI.videoThumbnailsEnabled && attachment.thumbUrl != null) { - attachment.thumbUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing) - ?: return + } else if (attachment.isVideo() && ChatUI.videoThumbnailsEnabled) { + val thumbnailUrl = + attachment.thumbUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing) + if (thumbnailUrl != null || attachment.assetUrl != null) { + VideoThumbnailImageData(thumbnailUrl = thumbnailUrl, videoUrl = attachment.assetUrl) + } else { + null + } } else { null } From 88bed1ec793f70aa20a945ff0ee14b65f748f05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 26 Jun 2026 10:41:34 +0100 Subject: [PATCH 2/6] Apply the video thumbnail fallback to XML file and quoted previews loadAttachmentThumb is used by the XML file rows (FileAttachmentsView) and quoted previews (DefaultQuotedAttachmentView). It loaded only the server thumbnail, so a video showed a blank thumbnail when the thumbnail was not yet generated. The Compose equivalents already recover through imagePreviewData. Pass VideoThumbnailImageData here too so both kits behave the same. --- .../io/getstream/chat/android/ui/utils/AttachmentUtils.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/AttachmentUtils.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/AttachmentUtils.kt index 5ba2721d6ed..0f74d1c160a 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/AttachmentUtils.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/AttachmentUtils.kt @@ -25,6 +25,7 @@ import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.common.disposable.Disposable import io.getstream.chat.android.ui.common.images.internal.StreamImageLoader.ImageTransformation.RoundedCorners +import io.getstream.chat.android.ui.common.images.internal.VideoThumbnailImageData import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled import io.getstream.chat.android.ui.common.internal.file.ShareableUriProvider import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData @@ -36,9 +37,12 @@ private val FILE_THUMB_TRANSFORMATION = RoundedCorners(3.dpToPxPrecise()) internal fun ImageView.loadAttachmentThumb(attachment: Attachment): Disposable { return with(attachment) { when { - isVideo() && ChatUI.videoThumbnailsEnabled && !thumbUrl.isNullOrBlank() -> + isVideo() && ChatUI.videoThumbnailsEnabled && (!thumbUrl.isNullOrBlank() || !assetUrl.isNullOrBlank()) -> load( - data = thumbUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing), + data = VideoThumbnailImageData( + thumbnailUrl = thumbUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing), + videoUrl = assetUrl, + ), transformation = FILE_THUMB_TRANSFORMATION, ) isImage() && !imageUrl.isNullOrBlank() -> From bc99d1bbbb7f72dce78112ef6ac069d2cfa594be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 26 Jun 2026 11:02:52 +0100 Subject: [PATCH 3/6] Extract the fallback video frame with MediaMetadataRetriever The fallback previously routed the video URL through VideoFrameDecoder, which needs the whole file downloaded first and writes it to the image disk cache. Replace it with a VideoFrameFetcher backed by MediaMetadataRetriever, which seeks with HTTP range requests and reads only the bytes needed for the frame, and never stores the full video. This matches the cost profile of iOS, which uses AVAssetImageGenerator on a remote asset. The interceptor marks the fallback request and still runs before the CDN interceptor, so the CDN-signed URL and headers reach the fetcher. Requests that are not marked fall through to the default network fetcher. --- .../common/images/StreamImageLoaderFactory.kt | 2 + .../images/internal/VideoFrameFetcher.kt | 117 ++++++++++++++++++ .../VideoThumbnailFallbackInterceptor.kt | 11 +- .../images/internal/VideoFrameFetcherTest.kt | 59 +++++++++ .../VideoThumbnailFallbackInterceptorTest.kt | 7 +- 5 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcher.kt create mode 100644 stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcherTest.kt diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt index 5e34cb25123..2d13aad3fbf 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt @@ -30,6 +30,7 @@ import coil3.request.allowHardware import coil3.request.crossfade import coil3.video.VideoFrameDecoder import io.getstream.chat.android.client.internal.file.StreamFileManager +import io.getstream.chat.android.ui.common.images.internal.VideoFrameFetcher import io.getstream.chat.android.ui.common.images.internal.VideoThumbnailFallbackInterceptor import okio.Path.Companion.toOkioPath @@ -85,6 +86,7 @@ public class StreamImageLoaderFactory( .components { add(VideoThumbnailFallbackInterceptor()) interceptors.forEach { add(it) } + add(VideoFrameFetcher.Factory()) add(OkHttpNetworkFetcherFactory()) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { add(AnimatedImageDecoder.Factory(enforceMinimumFrameDelay = true)) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcher.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcher.kt new file mode 100644 index 00000000000..45cebe3d56e --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcher.kt @@ -0,0 +1,117 @@ +/* + * 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.ui.common.images.internal + +import android.media.MediaMetadataRetriever +import android.media.MediaMetadataRetriever.OPTION_CLOSEST_SYNC +import android.os.Build +import coil3.Extras +import coil3.ImageLoader +import coil3.Uri +import coil3.asImage +import coil3.decode.DataSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.ImageFetchResult +import coil3.getExtra +import coil3.network.httpHeaders +import coil3.request.ImageRequest +import coil3.request.Options +import coil3.size.pxOrElse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Offset of the extracted frame, kept slightly above zero so the very first frame + * (often black) is skipped. Matches the iOS preview offset. + */ +private const val VIDEO_FRAME_MICROS = 100_000L + +/** + * Marks a request so [VideoFrameFetcher] extracts a preview frame from the video instead of + * downloading it through the default network fetcher. + */ +internal val videoFramePreviewKey: Extras.Key = Extras.Key(default = false) + +/** + * Marks this request as a video preview, so the frame is extracted with [MediaMetadataRetriever] + * range reads rather than downloading the whole file. + */ +internal fun ImageRequest.Builder.videoFramePreview(): ImageRequest.Builder = apply { + extras[videoFramePreviewKey] = true +} + +/** + * A Coil [Fetcher] that extracts a preview frame from a remote video using [MediaMetadataRetriever]. + * + * Decoding through `VideoFrameDecoder` requires the whole file to be downloaded first. + * [MediaMetadataRetriever] instead seeks with HTTP range requests and reads only the bytes needed + * for the requested frame, and the full video is never written to the image disk cache. Only + * requests marked with [videoFramePreview] are handled; all others fall through to the default + * network fetcher. + * + * The data and headers reaching this fetcher are already resolved by the upstream interceptors + * (including CDN signing), so the URL is used as-is. + */ +internal class VideoFrameFetcher( + private val data: Uri, + private val options: Options, +) : Fetcher { + + override suspend fun fetch(): FetchResult = withContext(Dispatchers.IO) { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(data.toString(), options.requestHeaders()) + val bitmap = retriever.extractFrame() + ?: error("Could not extract a preview frame from video: $data") + ImageFetchResult( + image = bitmap.asImage(), + isSampled = false, + dataSource = DataSource.NETWORK, + ) + } finally { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + retriever.close() + } else { + @Suppress("DEPRECATION") + retriever.release() + } + } + } + + private fun Options.requestHeaders(): Map = + httpHeaders.asMap().mapNotNull { (name, values) -> + values.lastOrNull()?.let { name to it } + }.toMap() + + private fun MediaMetadataRetriever.extractFrame(): android.graphics.Bitmap? { + val dstWidth = options.size.width.pxOrElse { 0 } + val dstHeight = options.size.height.pxOrElse { 0 } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && dstWidth > 0 && dstHeight > 0) { + getScaledFrameAtTime(VIDEO_FRAME_MICROS, OPTION_CLOSEST_SYNC, dstWidth, dstHeight) + } else { + getFrameAtTime(VIDEO_FRAME_MICROS, OPTION_CLOSEST_SYNC) + } + } + + class Factory : Fetcher.Factory { + override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { + if (!options.getExtra(videoFramePreviewKey)) return null + return VideoFrameFetcher(data, options) + } + } +} diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt index e61d4ffcd44..3cec6b178c8 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt @@ -19,7 +19,6 @@ package io.getstream.chat.android.ui.common.images.internal import coil3.intercept.Interceptor import coil3.request.ImageResult import coil3.request.SuccessResult -import coil3.video.videoFrameMillis import io.getstream.chat.android.core.internal.InternalStreamChatApi /** @@ -73,19 +72,11 @@ public class VideoThumbnailFallbackInterceptor : Interceptor { if (videoUrl != null) { val videoRequest = chain.request.newBuilder() .data(videoUrl) - .videoFrameMillis(VIDEO_FRAME_MILLIS) + .videoFramePreview() .build() return chain.withRequest(videoRequest).proceed() } return chain.proceed() } - - private companion object { - /** - * Offset of the extracted frame, kept slightly above zero so the very first frame - * (often black) is skipped. - */ - private const val VIDEO_FRAME_MILLIS = 100L - } } diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcherTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcherTest.kt new file mode 100644 index 00000000000..a1ddb692d5c --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcherTest.kt @@ -0,0 +1,59 @@ +/* + * 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.ui.common.images.internal + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil3.Extras +import coil3.ImageLoader +import coil3.request.Options +import coil3.toUri +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +internal class VideoFrameFetcherTest { + + private val context: Context get() = RuntimeEnvironment.getApplication() + private val factory = VideoFrameFetcher.Factory() + private val uri = "https://cdn.example.com/video.mp4".toUri() + + @Test + fun `creates a fetcher when the request is marked as a video preview`() { + val fetcher = factory.create(uri, optionsWith(videoPreview = true), mock()) + + assertNotNull(fetcher) + } + + @Test + fun `skips requests that are not marked as a video preview`() { + val fetcher = factory.create(uri, optionsWith(videoPreview = false), mock()) + + assertNull(fetcher) + } + + private fun optionsWith(videoPreview: Boolean): Options { + val extras = Extras.Builder().set(videoFramePreviewKey, videoPreview).build() + return Options(context = context, extras = extras) + } +} diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt index 45c664c63ab..aa9d2cb6ec6 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.ui.common.images.internal import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil3.getExtra import coil3.intercept.Interceptor import coil3.request.ErrorResult import coil3.request.ImageRequest @@ -64,6 +65,8 @@ internal class VideoThumbnailFallbackInterceptorTest { assertEquals(listOf(THUMB_URL, VIDEO_URL), chain.proceeded) assertTrue(result is SuccessResult) + // The video fallback request must be marked so VideoFrameFetcher handles it. + assertTrue(chain.proceededRequests.last().getExtra(videoFramePreviewKey)) } @Test @@ -114,17 +117,19 @@ internal class VideoThumbnailFallbackInterceptorTest { override val request: ImageRequest, private val resultFor: (String?) -> ImageResult, val proceeded: MutableList = mutableListOf(), + val proceededRequests: MutableList = mutableListOf(), ) : Interceptor.Chain { override val size: Size get() = Size.ORIGINAL override suspend fun proceed(): ImageResult { val key = request.data.toString() proceeded.add(key) + proceededRequests.add(request) return resultFor(key) } override fun withRequest(request: ImageRequest): Interceptor.Chain = - FakeCoilChain(request, resultFor, proceeded) + FakeCoilChain(request, resultFor, proceeded, proceededRequests) override fun withSize(size: Size): Interceptor.Chain = this } From 59a7e03703186d51b566a5cd871cf139817a4165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 26 Jun 2026 11:51:26 +0100 Subject: [PATCH 4/6] Tighten interceptor visibility and keep the quoted preview snapshot stable - VideoThumbnailFallbackInterceptor is only used inside ui-common, so it is internal rather than @InternalStreamChatApi public. - The MediaAttachmentQuotedContent preview handler matched the raw "video" string, but a video preview's data is now VideoThumbnailImageData. Match that type so the video cells keep their preview color and the snapshot is unchanged. --- .../ui/attachments/content/MediaAttachmentQuotedContent.kt | 3 ++- .../images/internal/VideoThumbnailFallbackInterceptor.kt | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentQuotedContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentQuotedContent.kt index 6ff916a3e2f..5586a07cd26 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentQuotedContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentQuotedContent.kt @@ -48,6 +48,7 @@ import io.getstream.chat.android.compose.ui.util.StreamAsyncImage import io.getstream.chat.android.compose.ui.util.extensions.internal.imagePreviewData import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.AttachmentType +import io.getstream.chat.android.ui.common.images.internal.VideoThumbnailImageData import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled import io.getstream.chat.android.ui.common.utils.extensions.giphyFallbackPreviewUrl import java.io.File @@ -173,7 +174,7 @@ internal fun MediaAttachmentQuotedContent() { "image" -> Color.Red.toArgb() "imgur" -> Color.Green.toArgb() "giphy" -> Color.Blue.toArgb() - "video" -> Color.Yellow.toArgb() + is VideoThumbnailImageData -> Color.Yellow.toArgb() is File -> Color.Magenta.toArgb() else -> Color.LightGray.toArgb() }, diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt index 3cec6b178c8..e5682528aaa 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt @@ -52,8 +52,7 @@ public data class VideoThumbnailImageData( * the video frame. It must be registered as the outermost interceptor so URL-rewriting * interceptors still apply to the fallback video URL. */ -@InternalStreamChatApi -public class VideoThumbnailFallbackInterceptor : Interceptor { +internal class VideoThumbnailFallbackInterceptor : Interceptor { override suspend fun intercept(chain: Interceptor.Chain): ImageResult { val data = chain.request.data as? VideoThumbnailImageData ?: return chain.proceed() From f555db4c4e7bbca042fff8ee380ef7d687d8c791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 26 Jun 2026 12:07:54 +0100 Subject: [PATCH 5/6] Cover video frame extraction and the empty fallback case with tests Add VideoFrameFetcher.fetch() tests using Robolectric's MediaMetadataRetriever shadow for the scaled, unscaled, and no-frame paths, and a VideoThumbnailFallbackInterceptor test for the case with neither a thumbnail nor a video URL. --- .../images/internal/VideoFrameFetcherTest.kt | 70 ++++++++++++++++++- .../VideoThumbnailFallbackInterceptorTest.kt | 10 +++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcherTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcherTest.kt index a1ddb692d5c..5c743445cd9 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcherTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcherTest.kt @@ -17,18 +17,28 @@ package io.getstream.chat.android.ui.common.images.internal import android.content.Context +import android.graphics.Bitmap import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil3.BitmapImage import coil3.Extras import coil3.ImageLoader +import coil3.fetch.ImageFetchResult import coil3.request.Options +import coil3.size.Size import coil3.toUri +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowMediaMetadataRetriever +import org.robolectric.shadows.util.DataSource @RunWith(AndroidJUnit4::class) @Config(sdk = [33]) @@ -36,24 +46,78 @@ internal class VideoFrameFetcherTest { private val context: Context get() = RuntimeEnvironment.getApplication() private val factory = VideoFrameFetcher.Factory() - private val uri = "https://cdn.example.com/video.mp4".toUri() + + @After + fun tearDown() { + ShadowMediaMetadataRetriever.reset() + } @Test fun `creates a fetcher when the request is marked as a video preview`() { - val fetcher = factory.create(uri, optionsWith(videoPreview = true), mock()) + val fetcher = factory.create(VIDEO_URL.toUri(), optionsWith(videoPreview = true), mock()) assertNotNull(fetcher) } @Test fun `skips requests that are not marked as a video preview`() { - val fetcher = factory.create(uri, optionsWith(videoPreview = false), mock()) + val fetcher = factory.create(VIDEO_URL.toUri(), optionsWith(videoPreview = false), mock()) assertNull(fetcher) } + @Test + fun `extracts a scaled frame when a target size is set`() = runTest { + val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888) + ShadowMediaMetadataRetriever.addScaledFrame( + DataSource.toDataSource(VIDEO_URL, emptyMap()), + FRAME_MICROS, + FRAME_SIZE, + FRAME_SIZE, + bitmap, + ) + val options = Options(context = context, size = Size(FRAME_SIZE, FRAME_SIZE)) + + val result = VideoFrameFetcher(VIDEO_URL.toUri(), options).fetch() + + assertTrue(result is ImageFetchResult) + assertEquals(bitmap, (result as ImageFetchResult).image.let { (it as BitmapImage).bitmap }) + } + + @Test + fun `extracts an unscaled frame when the size is original`() = runTest { + val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888) + ShadowMediaMetadataRetriever.addFrame(VIDEO_URL, emptyMap(), FRAME_MICROS, bitmap) + val options = Options(context = context, size = Size.ORIGINAL) + + val result = VideoFrameFetcher(VIDEO_URL.toUri(), options).fetch() + + assertTrue(result is ImageFetchResult) + assertEquals(bitmap, (result as ImageFetchResult).image.let { (it as BitmapImage).bitmap }) + } + + @Test + fun `throws when no frame can be extracted`() = runTest { + val options = Options(context = context, size = Size.ORIGINAL) + + var thrown = false + try { + VideoFrameFetcher(VIDEO_URL.toUri(), options).fetch() + } catch (_: IllegalStateException) { + thrown = true + } + + assertTrue(thrown) + } + private fun optionsWith(videoPreview: Boolean): Options { val extras = Extras.Builder().set(videoFramePreviewKey, videoPreview).build() return Options(context = context, extras = extras) } + + private companion object { + private const val VIDEO_URL = "https://cdn.example.com/video.mp4" + private const val FRAME_MICROS = 100_000L + private const val FRAME_SIZE = 100 + } } diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt index aa9d2cb6ec6..96edd0f4aa7 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt @@ -95,6 +95,16 @@ internal class VideoThumbnailFallbackInterceptorTest { assertTrue(result is ErrorResult) } + @Test + fun `proceeds unchanged when there is neither a thumbnail nor a video`() = runTest { + val data = VideoThumbnailImageData(thumbnailUrl = null, videoUrl = null) + val chain = chainFor(data, resultFor = { success }) + + interceptor.intercept(chain) + + assertEquals(listOf(data.toString()), chain.proceeded) + } + @Test fun `passes through requests that are not video thumbnails`() = runTest { val chain = chainFor(THUMB_URL, resultFor = { success }) From 2acc1f87f8ed4565989418178fa152631b9a416f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 26 Jun 2026 12:18:55 +0100 Subject: [PATCH 6/6] Cover Attachment.imagePreviewData with tests Add a Compose test that captures the value imagePreviewData returns for image, video (with thumbnail, asset-only, and upload fallback), thumbnails-disabled, and non-media attachments. --- .../AttachmentImagePreviewDataTest.kt | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentImagePreviewDataTest.kt diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentImagePreviewDataTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentImagePreviewDataTest.kt new file mode 100644 index 00000000000..b03d080da0c --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentImagePreviewDataTest.kt @@ -0,0 +1,113 @@ +/* + * 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.util.extensions.internal + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.getstream.chat.android.client.test.MockedChatClientTest +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.models.AttachmentType +import io.getstream.chat.android.ui.common.images.internal.VideoThumbnailImageData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +internal class AttachmentImagePreviewDataTest : MockedChatClientTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `image uses the image url`() { + val data = previewDataOf(Attachment(type = AttachmentType.IMAGE, imageUrl = IMAGE_URL)) + + assertEquals(IMAGE_URL, data) + } + + @Test + fun `image without a url falls back to the local upload`() { + val upload = File("image") + val data = previewDataOf(Attachment(type = AttachmentType.IMAGE, imageUrl = null, upload = upload)) + + assertEquals(upload, data) + } + + @Test + fun `video uses the thumbnail and the asset as fallback`() { + val data = previewDataOf(Attachment(type = AttachmentType.VIDEO, thumbUrl = THUMB_URL, assetUrl = VIDEO_URL)) + + assertEquals(VideoThumbnailImageData(thumbnailUrl = THUMB_URL, videoUrl = VIDEO_URL), data) + } + + @Test + fun `video without a thumbnail still uses the asset`() { + val data = previewDataOf(Attachment(type = AttachmentType.VIDEO, thumbUrl = null, assetUrl = VIDEO_URL)) + + assertEquals(VideoThumbnailImageData(thumbnailUrl = null, videoUrl = VIDEO_URL), data) + } + + @Test + fun `video without a thumbnail or asset falls back to the local upload`() { + val upload = File("video") + val data = previewDataOf( + Attachment(type = AttachmentType.VIDEO, thumbUrl = null, assetUrl = null, upload = upload), + ) + + assertEquals(upload, data) + } + + @Test + fun `video returns null when video thumbnails are disabled`() { + val data = previewDataOf( + attachment = Attachment(type = AttachmentType.VIDEO, thumbUrl = THUMB_URL, assetUrl = VIDEO_URL), + videoThumbnailsEnabled = false, + ) + + assertNull(data) + } + + @Test + fun `non-media attachment returns null`() { + val data = previewDataOf(Attachment(type = AttachmentType.FILE, assetUrl = VIDEO_URL)) + + assertNull(data) + } + + private fun previewDataOf(attachment: Attachment, videoThumbnailsEnabled: Boolean = true): Any? { + var data: Any? = UNSET + composeTestRule.setContent { + ChatTheme(videoThumbnailsEnabled = videoThumbnailsEnabled) { + data = attachment.imagePreviewData + } + } + return data + } + + private companion object { + private val UNSET = Any() + private const val IMAGE_URL = "https://cdn.example.com/image.jpg" + private const val THUMB_URL = "https://cdn.example.com/thumb.jpg" + private const val VIDEO_URL = "https://cdn.example.com/video.mp4" + } +}