diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt index d139ba90bd..026940cafc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt @@ -1,45 +1,52 @@ +package chat.simplex.app.views.chat + import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Close -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import chat.simplex.app.R +import chat.simplex.app.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.app.views.chat.item.SentColorLight import chat.simplex.app.views.helpers.base64ToBitmap @Composable -fun ComposeImageView(image: String, cancelImage: () -> Unit, cancelEnabled: Boolean) { +fun ComposeImageView(images: List, cancelImages: () -> Unit, cancelEnabled: Boolean) { Row( Modifier - .fillMaxWidth() .padding(top = 8.dp) .background(SentColorLight), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { - val imageBitmap = base64ToBitmap(image).asImageBitmap() - Image( - imageBitmap, - "preview image", - modifier = Modifier - .width(80.dp) - .height(60.dp) - .padding(end = 8.dp) - ) - Spacer(Modifier.weight(1f)) + LazyRow( + Modifier.weight(1f).padding(start = DEFAULT_PADDING_HALF, end = if (cancelEnabled) 0.dp else DEFAULT_PADDING_HALF), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF), + ) { + items(images.size) { index -> + val imageBitmap = base64ToBitmap(images[index]).asImageBitmap() + Image( + imageBitmap, + "preview image", + modifier = Modifier.widthIn(max = 80.dp).height(60.dp) + ) + } + } if (cancelEnabled) { - IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) { + IconButton(onClick = cancelImages) { Icon( Icons.Outlined.Close, contentDescription = stringResource(R.string.icon_descr_cancel_image_preview), tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp) ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 5a873dccd4..3d2ef17dec 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,7 +1,6 @@ package chat.simplex.app.views.chat import ComposeFileView -import ComposeImageView import android.Manifest import android.app.Activity import android.content.* @@ -51,7 +50,7 @@ import java.io.File sealed class ComposePreview { @Serializable object NoPreview: ComposePreview() @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() - @Serializable class ImagePreview(val image: String): ComposePreview() + @Serializable class ImagePreview(val images: List): ComposePreview() @Serializable class FilePreview(val fileName: String): ComposePreview() } @@ -120,7 +119,7 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview { return when (val mc = chatItem.content.msgContent) { is MsgContent.MCText -> ComposePreview.NoPreview is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview) - is MsgContent.MCImage -> ComposePreview.ImagePreview(image = mc.image) + is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image)) is MsgContent.MCFile -> { val fileName = chatItem.file?.fileName ?: "" ComposePreview.FilePreview(fileName) @@ -146,8 +145,7 @@ fun ComposeView( val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember { mutableStateOf(smallFont) } // attachments - val chosenImage = remember { mutableStateOf(null) } - val chosenAnimImage = remember { mutableStateOf(null) } + val chosenContent = remember { mutableStateOf>(emptyList()) } val chosenFile = remember { mutableStateOf(null) } val photoUri = remember { mutableStateOf(null) } val photoTmpFile = remember { mutableStateOf(null) } @@ -184,9 +182,9 @@ fun ComposeView( val cameraLauncher = rememberLauncherForActivityResult(contract = ComposeTakePicturePreview()) { bitmap: Bitmap? -> if (bitmap != null) { - chosenImage.value = bitmap val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) - composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview)) + chosenContent.value = listOf(UploadContent.SimpleImage(bitmap)) + composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview))) } } val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> @@ -196,33 +194,39 @@ fun ComposeView( Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show() } } - val processPickedImage = { uri: Uri? -> - if (uri != null) { + val processPickedImage = { uris: List -> + val content = ArrayList() + val imagesPreview = ArrayList() + uris.forEach { uri -> val source = ImageDecoder.createSource(context.contentResolver, uri) val drawable = ImageDecoder.decodeDrawable(source) - val bitmap = ImageDecoder.decodeBitmap(source) + var bitmap: Bitmap? = ImageDecoder.decodeBitmap(source) if (drawable is AnimatedImageDrawable) { // It's a gif or webp val fileSize = getFileSize(context, uri) if (fileSize != null && fileSize <= MAX_FILE_SIZE) { - chosenAnimImage.value = uri + content.add(UploadContent.AnimatedImage(uri)) } else { + bitmap = null AlertManager.shared.showAlertMsg( generalGetString(R.string.large_file), String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE)) ) } } else { - chosenImage.value = bitmap + if (bitmap != null) content.add(UploadContent.SimpleImage(bitmap)) } - - if (chosenImage.value != null || chosenAnimImage.value != null) { - val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) - composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview)) + if (bitmap != null) { + imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) } } + + if (imagesPreview.isNotEmpty()) { + chosenContent.value = content + composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagesPreview)) + } } - val processPickedFile = { uri: Uri? -> + val processPickedFile = { uri: Uri? -> if (uri != null) { val fileSize = getFileSize(context, uri) if (fileSize != null && fileSize <= MAX_FILE_SIZE) { @@ -240,7 +244,7 @@ fun ComposeView( } } val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery(), processPickedImage) - val galleryLauncherFallback = rememberGetContentLauncher(processPickedImage) + val galleryLauncherFallback = rememberGetMultipleContentsLauncher(processPickedImage) val filesLauncher = rememberGetContentLauncher(processPickedFile) LaunchedEffect(attachmentOption.value) { @@ -346,8 +350,7 @@ fun ComposeView( fun clearState() { composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) textStyle.value = smallFont - chosenImage.value = null - chosenAnimImage.value = null + chosenContent.value = emptyList() chosenFile.value = null linkUrl.value = null prevLinkUrl.value = null @@ -377,33 +380,30 @@ fun ComposeView( } } else -> { - var mc: MsgContent? = null - var file: String? = null + val msgs: ArrayList = ArrayList() + val files: ArrayList = ArrayList() when (val preview = cs.preview) { - ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message) - is ComposePreview.CLinkPreview -> mc = checkLinkPreview() + ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(cs.message)) + is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) is ComposePreview.ImagePreview -> { - val chosenImageVal = chosenImage.value - if (chosenImageVal != null) { - file = saveImage(context, chosenImageVal) - if (file != null) { - mc = MsgContent.MCImage(cs.message, preview.image) + chosenContent.value.forEachIndexed { index, it -> + val file = when (it) { + is UploadContent.SimpleImage -> saveImage(context, it.bitmap) + is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri) } - } - val chosenGifImageVal = chosenAnimImage.value - if (chosenGifImageVal != null) { - file = saveAnimImage(context, chosenGifImageVal) if (file != null) { - mc = MsgContent.MCImage(cs.message, preview.image) + files.add(file) + msgs.add(MsgContent.MCImage(if (msgs.isEmpty()) cs.message else "", preview.images[index])) } } } is ComposePreview.FilePreview -> { val chosenFileVal = chosenFile.value if (chosenFileVal != null) { - file = saveFileFromUri(context, chosenFileVal) + val file = saveFileFromUri(context, chosenFileVal) if (file != null) { - mc = MsgContent.MCFile(cs.message) + files.add((file)) + msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) cs.message else "")) } } } @@ -412,17 +412,19 @@ fun ComposeView( is ComposeContextItem.QuotedItem -> contextItem.chatItem.id else -> null } - - if (mc != null) { + if (msgs.isNotEmpty()) { withApi { - val aChatItem = chatModel.controller.apiSendMessage( - type = cInfo.chatType, - id = cInfo.apiId, - file = file, - quotedItemId = quotedItemId, - mc = mc - ) - if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem) + msgs.forEachIndexed { index, content -> + if (index > 0) delay(100) + val aChatItem = chatModel.controller.apiSendMessage( + type = cInfo.chatType, + id = cInfo.apiId, + file = files.getOrNull(index), + quotedItemId = if (index == 0) quotedItemId else null, + mc = content + ) + if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem) + } clearState() } } else { @@ -454,10 +456,9 @@ fun ComposeView( composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) } - fun cancelImage() { + fun cancelImages() { composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) - chosenImage.value = null - chosenAnimImage.value = null + chosenContent.value = emptyList() } fun cancelFile() { @@ -471,8 +472,8 @@ fun ComposeView( ComposePreview.NoPreview -> {} is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview) is ComposePreview.ImagePreview -> ComposeImageView( - preview.image, - ::cancelImage, + preview.images, + ::cancelImages, cancelEnabled = !composeState.value.editing ) is ComposePreview.FilePreview -> ComposeFileView( @@ -499,7 +500,7 @@ fun ComposeView( LaunchedEffect(chatModel.sharedContent.value) { when (val shared = chatModel.sharedContent.value) { is SharedContent.Text -> onMessageChange(shared.text) - is SharedContent.Image -> processPickedImage(shared.uri) + is SharedContent.Image -> processPickedImage(listOf(shared.uri)) is SharedContent.File -> processPickedFile(shared.uri) null -> {} } @@ -546,8 +547,28 @@ fun ComposeView( } } -class PickFromGallery: ActivityResultContract() { - override fun createIntent(context: Context, input: Int) = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI) +class PickFromGallery: ActivityResultContract>() { + override fun createIntent(context: Context, input: Int) = + Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply { + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + type = "image/*" + } - override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data + override fun parseResult(resultCode: Int, intent: Intent?): List = + if (intent?.data != null) + listOf(intent.data!!) + else if (intent?.clipData != null) + with(intent.clipData!!) { + val uris = ArrayList() + for (i in 0 until kotlin.math.min(itemCount, 10)) { + val uri = getItemAt(i).uri + if (uri != null) uris.add(uri) + } + if (itemCount > 10) { + AlertManager.shared.showAlertMsg(R.string.images_limit_title, R.string.images_limit_desc) + } + uris + } + else + emptyList() } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt index b908e66e3f..9d5c1ef616 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt @@ -1,5 +1,6 @@ package chat.simplex.app.views.helpers +import android.graphics.Bitmap import android.net.Uri import androidx.compose.runtime.saveable.Saver import kotlinx.coroutines.flow.MutableStateFlow @@ -30,3 +31,8 @@ enum class NewChatSheetState { ) } } + +sealed class UploadContent { + data class SimpleImage(val bitmap: Bitmap): UploadContent() + data class AnimatedImage(val uri: Uri): UploadContent() +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index 5117bd453e..75665419e2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -157,6 +157,10 @@ fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLaun fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb) +@Composable +fun rememberGetMultipleContentsLauncher(cb: (List) -> Unit): ManagedActivityResultLauncher> = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb) + @Composable fun GetImageBottomSheet( imageBitmap: MutableState, diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index 9f051d3a6b..a6d511ff98 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -180,6 +180,8 @@ Kontextsymbol Bildervorschau abbrechen Dateivorschau abbrechen + Too many images! + Only 10 images can be sent at the same time Bild diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 7d0c921bd0..87b9340c26 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -180,6 +180,8 @@ Значок контекста Удалить превью изображения Удалить превью файла + Слишком много изображений! + Только 10 изображений могут быть отправлены одномоментно Изображение diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 0b4f023906..d8ae7f6ea6 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -180,6 +180,8 @@ Context icon Cancel image preview Cancel file preview + Too many images! + Only 10 images can be sent at the same time Image