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 @@ -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
Expand Down Expand Up @@ -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()
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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?
Expand All @@ -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
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else -> null
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Boolean> = 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) {

Check warning on line 75 in stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/VideoFrameFetcher.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Avoid hardcoded dispatchers.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-android&issues=AZ8Dl2HJKHt8tcMIG-8E&open=AZ8Dl2HJKHt8tcMIG-8E&pullRequest=6524
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<String, String> =
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<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
if (!options.getExtra(videoFramePreviewKey)) return null
return VideoFrameFetcher(data, options)
}
}
}
Loading
Loading