diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index e762a39864..921e851090 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -1,6 +1,8 @@ package chat.simplex.app.views.chat import android.content.res.Configuration +import android.graphics.Bitmap +import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.gestures.* @@ -28,6 +30,8 @@ import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.* +import androidx.core.content.FileProvider +import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* @@ -42,6 +46,8 @@ import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.datetime.Clock +import java.io.File +import kotlin.math.sign @Composable fun ChatView(chatModel: ChatModel) { @@ -497,21 +503,15 @@ fun BoxWithConstraintsScope.ChatItemsList( } } } - val provider = remember(chatItems) { - if (cItem.content.msgContent is MsgContent.MCImage) { - val itemsWithImages by lazy { chatItems.filter { it.content.msgContent is MsgContent.MCImage && it.file?.loaded == true } } - ImageGalleryProvider.from(cItem.id, { itemsWithImages }) { dismissedIndex -> - val indexInReversed = reversedChatItems.indexOfFirst { it.id == itemsWithImages[dismissedIndex].id } - // Do not scroll to this item, just to different items - if (indexInReversed == i) return@from - scope.launch { - listState.scrollToItem( - kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1), - -maxHeightRounded / 2 - ) - } + val provider = { + providerForGallery(i, chatItems, cItem.id) { indexInReversed -> + scope.launch { + listState.scrollToItem( + kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1), + -maxHeightRounded + ) } - } else null + } } if (chat.chatInfo is ChatInfo.Group) { if (cItem.chatDir is CIDirection.GroupRcv) { @@ -777,6 +777,69 @@ private fun bottomEndFloatingButton( } } +private fun providerForGallery( + listStateIndex: Int, + chatItems: List, + cItemId: Long, + scrollTo: (Int) -> Unit +): ImageGalleryProvider { + fun canShowImage(item: ChatItem): Boolean = + item.content.msgContent is MsgContent.MCImage && item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null + + fun item(skipInternalIndex: Int, initialChatId: Long): Pair? { + var processedInternalIndex = -skipInternalIndex.sign + val indexOfFirst = chatItems.indexOfFirst { it.id == initialChatId } + for (chatItemsIndex in if (skipInternalIndex >= 0) indexOfFirst downTo 0 else indexOfFirst..chatItems.lastIndex) { + val item = chatItems[chatItemsIndex] + if (canShowImage(item)) { + processedInternalIndex += skipInternalIndex.sign + } + if (processedInternalIndex == skipInternalIndex) { + return chatItemsIndex to item + } + } + return null + } + + var initialIndex = Int.MAX_VALUE / 2 + var initialChatId = cItemId + return object: ImageGalleryProvider { + override val initialIndex: Int = initialIndex + override val totalImagesSize = mutableStateOf(Int.MAX_VALUE) + override fun getImage(index: Int): Pair? { + val internalIndex = initialIndex - index + val file = item(internalIndex, initialChatId)?.second?.file + val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file) + val filePath = getLoadedFilePath(SimplexApp.context, file) + return if (imageBitmap != null && filePath != null) { + val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) + imageBitmap to uri + } else null + } + + override fun currentPageChanged(index: Int) { + val internalIndex = initialIndex - index + val item = item(internalIndex, initialChatId) ?: return + initialIndex = index + initialChatId = item.second.id + } + + override fun scrollToStart() { + initialIndex = 0 + initialChatId = chatItems.first { canShowImage(it) }.id + } + + override fun onDismiss(index: Int) { + val internalIndex = initialIndex - index + val indexInChatItems = item(internalIndex, initialChatId)?.first ?: return + val indexInReversed = chatItems.lastIndex - indexInChatItems + // Do not scroll to active item, just to different items + if (indexInReversed == listStateIndex) return + scrollTo(indexInReversed) + } + } +} + private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration { override val longPressTimeoutMillis get() = diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt index 2afe4ab0d1..f30719be87 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt @@ -40,7 +40,7 @@ import java.io.File fun CIImageView( image: String, file: CIFile?, - provider: ImageGalleryProvider, + imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, receiveFile: (Long) -> Unit ) { @@ -157,7 +157,7 @@ fun CIImageView( imageView(imagePainter, onClick = { if (getLoadedFilePath(context, file) != null) { ModalManager.shared.showCustomModal(animated = false) { close -> - ImageFullScreenView(provider, close) + ImageFullScreenView(imageProvider, close) } } }) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 281b911ecf..68959afc84 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -34,7 +34,7 @@ fun ChatItemView( composeState: MutableState, cxt: Context, uriHandler: UriHandler? = null, - imageProvider: ImageGalleryProvider? = null, + imageProvider: (() -> ImageGalleryProvider)? = null, showMember: Boolean = false, chatModelIncognito: Boolean, useLinkPreviews: Boolean, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 66bb9600b0..0a16fac998 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -37,7 +37,7 @@ fun FramedItemView( chatInfo: ChatInfo, ci: ChatItem, uriHandler: UriHandler? = null, - imageProvider: ImageGalleryProvider? = null, + imageProvider: (() -> ImageGalleryProvider)? = null, showMember: Boolean = false, showMenu: MutableState, receiveFile: (Long) -> Unit, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt index b0891a2f45..86c3dfe9ad 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt @@ -6,8 +6,7 @@ import android.os.Build import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.* @@ -16,10 +15,7 @@ import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.core.content.FileProvider -import chat.simplex.app.* import chat.simplex.app.R -import chat.simplex.app.model.ChatItem import chat.simplex.app.views.helpers.* import coil.ImageLoader import coil.compose.rememberAsyncImagePainter @@ -28,101 +24,100 @@ import coil.decode.ImageDecoderDecoder import coil.request.ImageRequest import coil.size.Size import com.google.accompanist.pager.* -import java.io.File +import kotlinx.coroutines.launch interface ImageGalleryProvider { - val currentItem: Int - val totalImagesSize: Int - fun uniqueKey(index: Int): Long + val initialIndex: Int + val totalImagesSize: MutableState fun getImage(index: Int): Pair? + fun currentPageChanged(index: Int) + fun scrollToStart() fun onDismiss(index: Int) - - companion object { - fun from(chatItemId: Long, items: () -> List, onDismiss: (Int) -> Unit): ImageGalleryProvider = object: ImageGalleryProvider { - override val currentItem: Int - get() = items().indexOfFirst { it.id == chatItemId } - override val totalImagesSize: Int - get() = items().size - - override fun uniqueKey(index: Int): Long = items()[index].id - override fun getImage(index: Int): Pair? { - val file = items().getOrNull(index)?.file - val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file) - val filePath = getLoadedFilePath(SimplexApp.context, file) - return if (imageBitmap != null && filePath != null) { - val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) - imageBitmap to uri - } else null - } - override fun onDismiss(index: Int) { onDismiss(index) } - } - } } @OptIn(ExperimentalPagerApi::class) @Composable -fun ImageFullScreenView(provider: ImageGalleryProvider, close: () -> Unit) { - val pagerState = rememberPagerState(provider.currentItem) +fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) { + val provider = remember { imageProvider() } + val pagerState = rememberPagerState(provider.initialIndex) val goBack = { provider.onDismiss(pagerState.currentPage); close() } BackHandler(onBack = goBack) - HorizontalPager(count = provider.totalImagesSize, state = pagerState, key = { provider.uniqueKey(it) }) { index -> - val (imageBitmap: Bitmap, uri: Uri) = provider.getImage(index) ?: return@HorizontalPager + val scope = rememberCoroutineScope() + HorizontalPager(count = remember { provider.totalImagesSize }.value, state = pagerState) { index -> Column( Modifier .fillMaxSize() .background(Color.Black) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = goBack) ) { - var scale by remember { mutableStateOf(1f) } - var translationX by remember { mutableStateOf(0f) } - var translationY by remember { mutableStateOf(0f) } - LaunchedEffect(pagerState.currentPage) { - scale = 1f - translationX = 0f - translationY = 0f + LaunchedEffect(currentPage) { + // Make this pager with infinity scrolling with only 3 pages at a time when left and right pages constructs in real time + if (currentPage != provider.initialIndex) + provider.currentPageChanged(index) } - // I'm making a new instance of imageLoader here because if I use one instance in multiple places - // after end of composition here a GIF from the first instance will be paused automatically which isn't what I want - val imageLoader = ImageLoader.Builder(LocalContext.current) - .components { - if (Build.VERSION.SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) + val image = provider.getImage(index) + if (image == null) { + // No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically + scope.launch { + if (currentPage == index - 1) provider.totalImagesSize.value = currentPage + 1 + else if (currentPage == index + 1) { + provider.scrollToStart() + pagerState.scrollToPage(0) } } - .build() - Image( - rememberAsyncImagePainter( - ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(), - placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil - imageLoader = imageLoader - ), - contentDescription = stringResource(R.string.image_descr), - contentScale = ContentScale.Fit, - modifier = Modifier - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = translationX, - translationY = translationY, - ) - .pointerInput(Unit) { - detectTransformGestures ( - onGesture = { _, pan, gestureZoom, _ -> - scale = (scale * gestureZoom).coerceIn(1f, 20f) - if (scale > 1) { - translationX += pan.x * scale - translationY += pan.y * scale - } else { - translationX = 0f - translationY = 0f - } - } - ) + } else { + val (imageBitmap: Bitmap, uri: Uri) = image + var scale by remember { mutableStateOf(1f) } + var translationX by remember { mutableStateOf(0f) } + var translationY by remember { mutableStateOf(0f) } + LaunchedEffect(pagerState.currentPage) { + scale = 1f + translationX = 0f + translationY = 0f + } + // I'm making a new instance of imageLoader here because if I use one instance in multiple places + // after end of composition here a GIF from the first instance will be paused automatically which isn't what I want + val imageLoader = ImageLoader.Builder(LocalContext.current) + .components { + if (Build.VERSION.SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } } - .fillMaxSize(), - ) + .build() + Image( + rememberAsyncImagePainter( + ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(), + placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil + imageLoader = imageLoader + ), + contentDescription = stringResource(R.string.image_descr), + contentScale = ContentScale.Fit, + modifier = Modifier + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = translationX, + translationY = translationY, + ) + .pointerInput(Unit) { + detectTransformGestures( + onGesture = { _, pan, gestureZoom, _ -> + scale = (scale * gestureZoom).coerceIn(1f, 20f) + if (scale > 1) { + translationX += pan.x * scale + translationY += pan.y * scale + } else { + translationX = 0f + translationY = 0f + } + } + ) + } + .fillMaxSize(), + ) + } } } }