diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 78fd7e7ce6..d2c02be251 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -112,6 +112,7 @@ dependencies { //Camera Permission implementation "com.google.accompanist:accompanist-permissions:0.23.0" + implementation "com.google.accompanist:accompanist-pager:0.25.1" // Link Previews implementation 'org.jsoup:jsoup:1.13.1' 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 64872cd70d..cca53b3f33 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 @@ -33,8 +33,7 @@ import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.call.* import chat.simplex.app.views.chat.group.* -import chat.simplex.app.views.chat.item.ChatItemView -import chat.simplex.app.views.chat.item.ItemAction +import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.chatlist.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.helpers.AppBarHeight @@ -470,6 +469,7 @@ fun BoxWithConstraintsScope.ChatItemsList( Spacer(Modifier.size(8.dp)) val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } } + val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() } LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) { itemsIndexed(reversedChatItems) { i, cItem -> CompositionLocalProvider( @@ -497,7 +497,19 @@ 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 -> + scope.launch { + listState.scrollToItem( + kotlin.math.min(reversedChatItems.lastIndex, reversedChatItems.indexOfFirst { it.id == itemsWithImages[dismissedIndex].id } + 1), + -maxHeightRounded / 2 + ) + } + } + } else null + } if (chat.chatInfo is ChatInfo.Group) { if (cItem.chatDir is CIDirection.GroupRcv) { val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null @@ -523,11 +535,11 @@ fun BoxWithConstraintsScope.ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) } } else { Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) } } } else { // direct message @@ -538,7 +550,7 @@ fun BoxWithConstraintsScope.ChatItemsList( end = if (sent) 12.dp else 76.dp, ).then(swipeableModifier) ) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall) } } 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 5a63805049..2afe4ab0d1 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 @@ -7,7 +7,6 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.outlined.ArrowDownward import androidx.compose.material.icons.outlined.MoreHoriz import androidx.compose.runtime.Composable @@ -27,6 +26,8 @@ import chat.simplex.app.BuildConfig import chat.simplex.app.R import chat.simplex.app.model.CIFile import chat.simplex.app.model.CIFileStatus +import chat.simplex.app.views.chat.item.ImageFullScreenView +import chat.simplex.app.views.chat.item.ImageGalleryProvider import chat.simplex.app.views.helpers.* import coil.ImageLoader import coil.compose.rememberAsyncImagePainter @@ -39,6 +40,7 @@ import java.io.File fun CIImageView( image: String, file: CIFile?, + provider: ImageGalleryProvider, showMenu: MutableState, receiveFile: (Long) -> Unit ) { @@ -154,7 +156,9 @@ fun CIImageView( ) imageView(imagePainter, onClick = { if (getLoadedFilePath(context, file) != null) { - ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, uri, close) } + ModalManager.shared.showCustomModal(animated = false) { close -> + ImageFullScreenView(provider, close) + } } }) } else { 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 7e21417cd7..281b911ecf 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,6 +34,7 @@ fun ChatItemView( composeState: MutableState, cxt: Context, uriHandler: UriHandler? = null, + imageProvider: ImageGalleryProvider? = null, showMember: Boolean = false, chatModelIncognito: Boolean, useLinkPreviews: Boolean, @@ -63,7 +64,7 @@ fun ChatItemView( EmojiItemView(cItem) } else { val onLinkLongClick = { _: String -> showMenu.value = true } - FramedItemView(cInfo, cItem, uriHandler, showMember = showMember, showMenu, receiveFile, onLinkLongClick) + FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, showMenu, receiveFile, onLinkLongClick) } DropdownMenu( expanded = showMenu.value, 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 1b6ddba94f..66bb9600b0 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,6 +37,7 @@ fun FramedItemView( chatInfo: ChatInfo, ci: ChatItem, uriHandler: UriHandler? = null, + imageProvider: ImageGalleryProvider? = null, showMember: Boolean = false, showMenu: MutableState, receiveFile: (Long) -> Unit, @@ -126,7 +127,7 @@ fun FramedItemView( Column(Modifier.fillMaxWidth()) { when (val mc = ci.content.msgContent) { is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, showMenu, receiveFile) + CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@Box, showMenu, receiveFile) if (mc.text == "") { metaColor = Color.White } else { 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 13608278b9..b0891a2f45 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 @@ -1,80 +1,128 @@ +package chat.simplex.app.views.chat.item + import android.graphics.Bitmap import android.net.Uri import android.os.Build import androidx.activity.compose.BackHandler import androidx.compose.foundation.* -import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.input.pointer.pointerInput +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 import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.request.ImageRequest import coil.size.Size +import com.google.accompanist.pager.* +import java.io.File -@Composable -fun ImageFullScreenView(imageBitmap: Bitmap, uri: Uri, close: () -> Unit) { - BackHandler(onBack = close) - Column( - Modifier - .fillMaxSize() - .background(Color.Black) - .clickable(onClick = close) - ) { - var scale by remember { mutableStateOf(1f) } - var translationX by remember { mutableStateOf(0f) } - var translationY by remember { mutableStateOf(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()) - } +interface ImageGalleryProvider { + val currentItem: Int + val totalImagesSize: Int + fun uniqueKey(index: Int): Long + fun getImage(index: Int): Pair? + 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 } - .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(), - ) + override fun onDismiss(index: Int) { onDismiss(index) } + } + } +} + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun ImageFullScreenView(provider: ImageGalleryProvider, close: () -> Unit) { + val pagerState = rememberPagerState(provider.currentItem) + 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 + 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 + } + // 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()) + } + } + .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(), + ) + } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt index e19f00f971..bb81d1974e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt @@ -19,24 +19,13 @@ package chat.simplex.app.views.helpers import android.util.Log import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.interaction.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.AwaitPointerEventScope -import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException -import androidx.compose.ui.input.pointer.PointerEvent -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerInputChange -import androidx.compose.ui.input.pointer.PointerInputScope -import androidx.compose.ui.input.pointer.changedToDown -import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed -import androidx.compose.ui.input.pointer.changedToUp -import androidx.compose.ui.input.pointer.consumeAllChanges -import androidx.compose.ui.input.pointer.consumeDownChange -import androidx.compose.ui.input.pointer.isOutOfBounds -import androidx.compose.ui.input.pointer.positionChangeConsumed +import androidx.compose.ui.input.pointer.* import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.Density import androidx.compose.ui.util.fastAll @@ -45,6 +34,8 @@ import androidx.compose.ui.util.fastForEach import chat.simplex.app.TAG import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex +import kotlin.math.PI +import kotlin.math.abs /** * See original code here: [androidx.compose.foundation.gestures.detectTapGestures] @@ -221,3 +212,65 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit) } return interactionSource } + +suspend fun PointerInputScope.detectTransformGestures( + panZoomLock: Boolean = false, + onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit +) { + forEachGesture { + awaitPointerEventScope { + var rotation = 0f + var zoom = 1f + var pan = Offset.Zero + var pastTouchSlop = false + val touchSlop = viewConfiguration.touchSlop + var lockedToPanZoom = false + + awaitFirstDown(requireUnconsumed = false) + do { + val event = awaitPointerEvent() + val canceled = event.changes.fastAny { it.isConsumed } + if (!canceled) { + val zoomChange = event.calculateZoom() + val rotationChange = event.calculateRotation() + val panChange = event.calculatePan() + + if (!pastTouchSlop) { + zoom *= zoomChange + rotation += rotationChange + pan += panChange + + val centroidSize = event.calculateCentroidSize(useCurrent = false) + val zoomMotion = abs(1 - zoom) * centroidSize + val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f) + val panMotion = pan.getDistance() + + if (zoomMotion > touchSlop || + rotationMotion > touchSlop || + panMotion > touchSlop + ) { + pastTouchSlop = true + lockedToPanZoom = panZoomLock && rotationMotion < touchSlop + } + } + + if (pastTouchSlop) { + val centroid = event.calculateCentroid(useCurrent = false) + val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange + if (effectiveRotation != 0f || + zoomChange != 1f || + panChange != Offset.Zero + ) { + onGesture(centroid, panChange, zoomChange, effectiveRotation) + } + event.changes.fastForEach { + if (it.positionChanged() && zoomChange != 1f) { + it.consume() + } + } + } + } + } while (!canceled && event.changes.fastAny { it.pressed }) + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt index 7350b4339a..9fc15c843d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt @@ -32,7 +32,7 @@ fun ModalView( } class ModalManager { - private val modalViews = arrayListOf<(@Composable (close: () -> Unit) -> Unit)?>() + private val modalViews = arrayListOf Unit) -> Unit)>>() private val modalCount = mutableStateOf(0) private val toRemove = mutableSetOf() private var oldViewChanging = AtomicBoolean(false) @@ -49,21 +49,21 @@ class ModalManager { } } - fun showCustomModal(modal: @Composable (close: () -> Unit) -> Unit) { + fun showCustomModal(animated: Boolean = true, modal: @Composable (close: () -> Unit) -> Unit) { Log.d(TAG, "ModalManager.showModal") // Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen. // This is useful when invoking close() and ShowCustomModal one after another without delay. Otherwise, screen will hold prev view if (toRemove.isNotEmpty()) { runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } } } - modalViews.add(modal) + modalViews.add(animated to modal) modalCount.value = modalViews.size - toRemove.size } fun closeModal() { if (modalViews.isNotEmpty()) { - //modalViews.removeAt(modalViews.lastIndex) - runAtomically { toRemove.add(modalViews.lastIndex - toRemove.size) } + if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex) + else runAtomically { toRemove.add(modalViews.lastIndex - toRemove.size) } } modalCount.value = modalViews.size - toRemove.size } @@ -75,6 +75,11 @@ class ModalManager { @OptIn(ExperimentalAnimationApi::class) @Composable fun showInView() { + // Without animation + if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) { + modalViews.lastOrNull()?.second?.invoke(::closeModal) + return + } AnimatedContent(targetState = modalCount.value, transitionSpec = { if (targetState > initialState) { @@ -84,7 +89,7 @@ class ModalManager { }.using(SizeTransform(clip = false)) } ) { - modalViews.getOrNull(it - 1)?.invoke(::closeModal) + modalViews.getOrNull(it - 1)?.second?.invoke(::closeModal) // This is needed because if we delete from modalViews immediately on request, animation will be bad if (toRemove.isNotEmpty() && it == modalCount.value && transition.currentState == EnterExitState.Visible && !transition.isRunning) { runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } }