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-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-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" + } +} 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..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,8 @@ 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 private const val DEFAULT_MEMORY_PERCENTAGE = 0.25 @@ -82,7 +84,9 @@ public class StreamImageLoaderFactory( .build() } .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 new file mode 100644 index 00000000000..e5682528aaa --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptor.kt @@ -0,0 +1,81 @@ +/* + * 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 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. + */ +internal 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) + .videoFramePreview() + .build() + return chain.withRequest(videoRequest).proceed() + } + + return chain.proceed() + } +} 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..5c743445cd9 --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcherTest.kt @@ -0,0 +1,123 @@ +/* + * 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 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]) +internal class VideoFrameFetcherTest { + + private val context: Context get() = RuntimeEnvironment.getApplication() + private val factory = VideoFrameFetcher.Factory() + + @After + fun tearDown() { + ShadowMediaMetadataRetriever.reset() + } + + @Test + fun `creates a fetcher when the request is marked as a video preview`() { + 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(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 new file mode 100644 index 00000000000..96edd0f4aa7 --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoThumbnailFallbackInterceptorTest.kt @@ -0,0 +1,151 @@ +/* + * 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.getExtra +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) + // The video fallback request must be marked so VideoFrameFetcher handles it. + assertTrue(chain.proceededRequests.last().getExtra(videoFramePreviewKey)) + } + + @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 `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 }) + + 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(), + 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, proceededRequests) + + 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 } 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() ->