diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt index 587f59aa777..6b2a7b712b0 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt @@ -69,9 +69,9 @@ import io.getstream.chat.android.compose.state.messages.attachments.AttachmentSt import io.getstream.chat.android.compose.ui.attachments.preview.MediaGalleryPreviewContract import io.getstream.chat.android.compose.ui.components.ShimmerProgressIndicator import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.util.ImageRequestTimeoutHandler import io.getstream.chat.android.compose.ui.util.RetryHash import io.getstream.chat.android.compose.ui.util.StreamAsyncImage -import io.getstream.chat.android.compose.ui.util.onImageNeedsToReload import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.models.Message @@ -438,9 +438,8 @@ internal fun MediaAttachmentContentItem( onResult = { result -> onMediaGalleryPreviewResult(result) }, ) - // Used to refresh the request for the current page - // if it has previously failed. - onImageNeedsToReload( + // Used to refresh the request for the current page if it has previously failed. + ImageRequestTimeoutHandler( data = data, connectionState = connectionState, imageState = imageState, @@ -524,7 +523,7 @@ internal fun MediaAttachmentContentItem( modifier = Modifier.fillMaxSize(0.4f), painter = painterResource(R.drawable.stream_compose_ic_image_picker), contentDescription = stringResource( - id = R.string.stream_ui_message_list_attachment_load_failed + id = R.string.stream_ui_message_list_attachment_load_failed, ), ) overlayContent(attachment.type) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivity.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivity.kt index 6218eca55c4..a2fd33b8f9d 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivity.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivity.kt @@ -40,6 +40,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -114,18 +115,13 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope +import coil.compose.AsyncImagePainter import coil.request.ImageRequest import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.PagerState import com.google.accompanist.pager.rememberPagerState import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState -import com.skydoves.landscapist.ImageOptions -import com.skydoves.landscapist.coil.CoilImageState -import com.skydoves.landscapist.coil.rememberCoilImageState -import com.skydoves.landscapist.components.rememberImageComponent -import com.skydoves.landscapist.placeholder.shimmer.Shimmer -import com.skydoves.landscapist.placeholder.shimmer.ShimmerPlugin import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.attachment.isVideo @@ -145,13 +141,16 @@ import io.getstream.chat.android.compose.state.mediagallerypreview.toMessage import io.getstream.chat.android.compose.ui.attachments.content.PlayButton import io.getstream.chat.android.compose.ui.components.LoadingIndicator import io.getstream.chat.android.compose.ui.components.NetworkLoadingIndicator +import io.getstream.chat.android.compose.ui.components.ShimmerProgressIndicator import io.getstream.chat.android.compose.ui.components.SimpleDialog import io.getstream.chat.android.compose.ui.components.Timestamp import io.getstream.chat.android.compose.ui.components.avatar.Avatar import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.util.ImageRequestTimeoutHandler import io.getstream.chat.android.compose.ui.util.RetryHash +import io.getstream.chat.android.compose.ui.util.StreamAsyncImage import io.getstream.chat.android.compose.ui.util.StreamImage -import io.getstream.chat.android.compose.ui.util.onImageNeedsToReload +import io.getstream.chat.android.compose.ui.util.isCompleted import io.getstream.chat.android.compose.util.attachmentDownloadState import io.getstream.chat.android.compose.util.onDownloadHandleRequest import io.getstream.chat.android.compose.viewmodel.mediapreview.MediaGalleryPreviewViewModel @@ -810,12 +809,11 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { val imageRequest = remember(retryHash) { ImageRequest.Builder(context) .data(data) - .crossfade(true) .setParameter(key = RetryHash, value = retryHash) .build() } - var imageState by rememberCoilImageState() + var imageState by remember { mutableStateOf(AsyncImagePainter.State.Empty) } val density = LocalDensity.current val parentSize = Size(density.run { maxWidth.toPx() }, density.run { maxHeight.toPx() }) @@ -826,21 +824,20 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { val scale by animateFloatAsState(targetValue = currentScale, label = "") - // Used to refresh the request for the current page - // if it has previously failed. - onImageNeedsToReload( + // Used to refresh the request if it has previously failed. + ImageRequestTimeoutHandler( data = data, connectionState = mediaGalleryPreviewViewModel.connectionState, - coilImageState = imageState, + imageState = imageState, ) { retryHash++ } - val transformModifier = if (imageState is CoilImageState.Success) { - val state = imageState as CoilImageState.Success + val transformModifier = if (imageState is AsyncImagePainter.State.Success) { + val state = imageState as AsyncImagePainter.State.Success val size = Size( - state.drawable?.intrinsicWidth?.toFloat() ?: 0f, - state.drawable?.intrinsicHeight?.toFloat() ?: 0f, + width = state.result.drawable.intrinsicWidth.toFloat(), + height = state.result.drawable.intrinsicHeight.toFloat(), ) Modifier .aspectRatio(size.width / size.height, true) @@ -853,7 +850,8 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center, ) { - StreamImage( + StreamAsyncImage( + imageRequest = imageRequest, modifier = transformModifier .graphicsLayer( scaleY = scale, @@ -924,26 +922,25 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { } } }, - imageRequest = { imageRequest }, - component = rememberImageComponent { - +ShimmerPlugin( - Shimmer.Resonate( - baseColor = ChatTheme.colors.mediaShimmerBase, - highlightColor = ChatTheme.colors.mediaShimmerHighlights, - ), - ) - }, - failure = { - Icon( - tint = ChatTheme.colors.textLowEmphasis, - modifier = Modifier.fillMaxSize(0.4f), - painter = painterResource( - id = R.drawable.stream_compose_ic_image_picker, - ), + ) { asyncImageState -> + imageState = asyncImageState + + when (asyncImageState) { + is AsyncImagePainter.State.Empty, + is AsyncImagePainter.State.Loading, + -> ShimmerProgressIndicator(modifier = Modifier.fillMaxSize()) + + is AsyncImagePainter.State.Success, + -> Image( + modifier = Modifier.fillMaxSize(), + painter = asyncImageState.painter, contentDescription = null, ) - }, - ) + + is AsyncImagePainter.State.Error, + -> ErrorIcon(modifier = Modifier.fillMaxSize()) + } + } Log.d("isCurrentPage", "${page != pagerState.currentPage}") @@ -1580,9 +1577,7 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { // Used as a workaround for Coil's lack of a retry policy. // See: https://github.com/coil-kt/coil/issues/884#issuecomment-975932886 - var retryHash by remember { - mutableStateOf(0) - } + var retryHash by remember { mutableIntStateOf(0) } val coroutineScope = rememberCoroutineScope() @@ -1606,21 +1601,20 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { } val context = LocalContext.current - val imageRequest = remember(retryHash) { + val imageRequest = remember { ImageRequest.Builder(context) .data(data) .setParameter(RetryHash, retryHash.toString()) .build() } - var imageState by rememberCoilImageState() + var imageState by remember { mutableStateOf(AsyncImagePainter.State.Empty) } - // Used to refresh the request for the current page - // if it has previously failed. - onImageNeedsToReload( + // Used to refresh the request if it has previously failed. + ImageRequestTimeoutHandler( data = data, connectionState = mediaGalleryPreviewViewModel.connectionState, - coilImageState = imageState, + imageState = imageState, ) { retryHash++ } @@ -1631,32 +1625,33 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { ChatTheme.colors.videoBackgroundMediaGalleryPicker } - StreamImage( + StreamAsyncImage( + imageRequest = imageRequest, modifier = Modifier .padding(1.dp) .fillMaxSize() .background(color = backgroundColor), - imageRequest = { imageRequest }, - imageOptions = ImageOptions(contentScale = ContentScale.Crop), - component = rememberImageComponent { - +ShimmerPlugin( - Shimmer.Resonate( - baseColor = ChatTheme.colors.mediaShimmerBase, - highlightColor = ChatTheme.colors.mediaShimmerHighlights, - ), - ) - }, - failure = { - Icon( - tint = ChatTheme.colors.textLowEmphasis, - modifier = Modifier.fillMaxSize(0.4f), - painter = painterResource( - id = R.drawable.stream_compose_ic_image_picker, - ), + contentScale = ContentScale.Crop, + ) { asyncImageState -> + imageState = asyncImageState + + when (asyncImageState) { + is AsyncImagePainter.State.Empty, + is AsyncImagePainter.State.Loading, + -> ShimmerProgressIndicator(modifier = Modifier.fillMaxSize()) + + is AsyncImagePainter.State.Success, + -> Image( + modifier = Modifier.fillMaxSize(), + painter = asyncImageState.painter, contentDescription = null, + contentScale = ContentScale.Crop, ) - }, - ) + + is AsyncImagePainter.State.Error, + -> ErrorIcon(Modifier.fillMaxSize()) + } + } Avatar( modifier = Modifier @@ -1676,7 +1671,7 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { initials = user.initials, ) - if (isVideo && imageState !is CoilImageState.Loading) { + if (isVideo && imageState.isCompleted) { PlayButton( modifier = Modifier .shadow(6.dp, shape = CircleShape) @@ -1688,6 +1683,21 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { } } + @Composable + private fun ErrorIcon(modifier: Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Icon( + tint = ChatTheme.colors.disabled, + modifier = Modifier.fillMaxSize(0.4f), + painter = painterResource(R.drawable.stream_compose_ic_image_picker), + contentDescription = stringResource(R.string.stream_ui_message_list_attachment_load_failed), + ) + } + } + /** * Fetches individual image resizing options from the bundle and * packs them into [StreamCdnImageResizing]. diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/CoilReloadingMechanism.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/CoilReloadingMechanism.kt deleted file mode 100644 index e9444a6bb37..00000000000 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/CoilReloadingMechanism.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2014-2023 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 - -import coil.compose.AsyncImagePainter -import coil.network.HttpException -import com.skydoves.landscapist.coil.CoilImageState -import io.getstream.chat.android.models.ConnectionState -import java.net.SocketTimeoutException - -/** - * Used to automatically reload the image once all conditions are satisfied, such as - * internet connection regained and the painter being in an error state. - * - * @param data The data containing the image. - * @param connectionState The state of the network connection - * @param coilImageState The state of the async image painter - * @param onReload The lambda function called when the conditions have been met and the image needs to be reloaded. - */ -internal fun onImageNeedsToReload( - data: Any?, - connectionState: ConnectionState, - coilImageState: CoilImageState, - onReload: () -> Unit, -) { - if (data != null && connectionState is ConnectionState.Connected && - coilImageState is CoilImageState.Failure - ) { - val errorCode = (coilImageState.reason as? HttpException)?.response?.code - - if (errorCode == UnsatisfiableRequest) { - onReload() - } - } -} - -/** - * Triggers [onReload] when the connection is established and the image load has failed due to a timeout. - * - * @param data The data to load. - * @param connectionState The state of the network connection. - * @param imageState The state of the async image painter. - * @param onReload The lambda function called when the conditions to reload have been met. - */ -internal fun onImageNeedsToReload( - data: Any?, - connectionState: ConnectionState, - imageState: AsyncImagePainter.State, - onReload: () -> Unit, -) { - if (data != null && connectionState is ConnectionState.Connected && - imageState is AsyncImagePainter.State.Error - ) { - if (imageState.result.throwable is SocketTimeoutException) { - onReload() - } - } -} - -/** - * Represents the HTTP code thrown when the COIL image loader has timed out and is unable to fetch the - * image from the web. - */ -private const val UnsatisfiableRequest = 504 diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageRequestTimeoutHandler.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageRequestTimeoutHandler.kt new file mode 100644 index 00000000000..a2ebd6523c8 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageRequestTimeoutHandler.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2014-2023 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 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import coil.compose.AsyncImagePainter +import io.getstream.chat.android.models.ConnectionState +import java.net.SocketTimeoutException + +/** + * Triggers [onTimeout] when the connection is established and the image load has failed due to a timeout. + * + * @param data The data to load. + * @param connectionState The state of the network connection. + * @param imageState The state of the async image painter. + * @param onTimeout The lambda function called when the timeout conditions have been met. + */ +@Composable +internal fun ImageRequestTimeoutHandler( + data: Any?, + connectionState: ConnectionState, + imageState: AsyncImagePainter.State, + onTimeout: () -> Unit, +) { + LaunchedEffect(data, connectionState, imageState) { + if (data != null && + connectionState is ConnectionState.Connected && + imageState is AsyncImagePainter.State.Error + ) { + if (imageState.result.throwable is SocketTimeoutException) { + onTimeout() + } + } + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StreamAsyncImage.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StreamAsyncImage.kt index 4cb9b7ad610..7901ede43bc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StreamAsyncImage.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StreamAsyncImage.kt @@ -41,7 +41,8 @@ import io.getstream.chat.android.ui.common.helper.ImageHeadersProvider * * @param data The data to load the image from. Can be a URL, URI, resource ID, etc. * @param modifier Modifier for styling. - * @param contentScale The scale to be used for the content. + * @param contentScale Used to determine the aspect ratio scaling to be used + * if the canvas bounds are a different size from the intrinsic size of the image loaded by model. * @param content A composable function that defines the content to be displayed based on the image loading state. * * @see ImageAssetTransformer @@ -73,7 +74,8 @@ internal fun StreamAsyncImage( * * @param imageRequest The request to load the image. * @param modifier Modifier for styling. - * @param contentScale The scale to be used for the content. + * @param contentScale Used to determine the aspect ratio scaling to be used + * if the canvas bounds are a different size from the intrinsic size of the image loaded by model. * @param content A composable function that defines the content to be displayed based on the image loading state. */ @Composable @@ -105,3 +107,9 @@ internal fun StreamAsyncImage( } } } + +/** + * A completed state is when the image is either successfully loaded or failed to load. + */ +internal val AsyncImagePainter.State.isCompleted: Boolean + get() = this is AsyncImagePainter.State.Success || this is AsyncImagePainter.State.Error