diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index eba863a3b..1d95cfedd 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -119,6 +119,10 @@ dependencies { // Biometric authentication implementation 'androidx.biometric:biometric:1.2.0-alpha04' + // GIFs support + implementation "io.coil-kt:coil-compose:2.1.0" + implementation "io.coil-kt:coil-gif:2.1.0" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 3eec9ba19..10321ac91 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -777,7 +777,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager val cItem = r.chatItem.chatItem chatModel.addChatItem(cInfo, cItem) val file = cItem.file - if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE && appPrefs.privacyAcceptImages.get()) { + if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) { withApi { receiveFile(file.fileId) } } if (!cItem.chatDir.sent && !cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) { 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 d96b8a75c..b3c5b7335 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 @@ -8,6 +8,7 @@ import android.content.* import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.ImageDecoder +import android.graphics.drawable.AnimatedImageDrawable import android.net.Uri import android.provider.MediaStore import android.util.Log @@ -146,6 +147,7 @@ fun ComposeView( val textStyle = remember { mutableStateOf(smallFont) } // attachments val chosenImage = remember { mutableStateOf(null) } + val chosenAnimImage = remember { mutableStateOf(null) } val chosenFile = remember { mutableStateOf(null) } val photoUri = remember { mutableStateOf(null) } val photoTmpFile = remember { mutableStateOf(null) } @@ -194,24 +196,23 @@ fun ComposeView( Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show() } } - val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery()) { uri: Uri? -> + val processPickedImage = { uri: Uri? -> if (uri != null) { val source = ImageDecoder.createSource(context.contentResolver, uri) + val drawable = ImageDecoder.decodeDrawable(source) val bitmap = ImageDecoder.decodeBitmap(source) - chosenImage.value = bitmap - val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) - composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview)) - } - } - val galleryLauncherFallback = rememberGetContentLauncher { uri: Uri? -> - if (uri != null) { - val source = ImageDecoder.createSource(context.contentResolver, uri) - val bitmap = ImageDecoder.decodeBitmap(source) - chosenImage.value = bitmap + if (drawable is AnimatedImageDrawable) { + // It's a gif or webp + chosenAnimImage.value = uri + } else { + chosenImage.value = bitmap + } val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview)) } } + val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery(), processPickedImage) + val galleryLauncherFallback = rememberGetContentLauncher(processPickedImage) val filesLauncher = rememberGetContentLauncher { uri: Uri? -> if (uri != null) { val fileSize = getFileSize(context, uri) @@ -334,6 +335,7 @@ fun ComposeView( composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) textStyle.value = smallFont chosenImage.value = null + chosenAnimImage.value = null chosenFile.value = null linkUrl.value = null prevLinkUrl.value = null @@ -376,6 +378,13 @@ fun ComposeView( mc = MsgContent.MCImage(cs.message, preview.image) } } + val chosenGifImageVal = chosenAnimImage.value + if (chosenGifImageVal != null) { + file = saveAnimImage(context, chosenGifImageVal) + if (file != null) { + mc = MsgContent.MCImage(cs.message, preview.image) + } + } } is ComposePreview.FilePreview -> { val chosenFileVal = chosenFile.value @@ -436,6 +445,7 @@ fun ComposeView( fun cancelImage() { composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) chosenImage.value = null + chosenAnimImage.value = null } fun cancelFile() { 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 139e8e31a..4e5da7a85 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 @@ -1,4 +1,5 @@ import android.graphics.Bitmap +import android.os.Build.VERSION.SDK_INT import androidx.compose.foundation.Image import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* @@ -13,14 +14,24 @@ 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.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +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.helpers.* +import coil.ImageLoader +import coil.compose.rememberAsyncImagePainter +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder +import coil.request.ImageRequest +import java.io.File @Composable fun CIImageView( @@ -88,13 +99,46 @@ fun CIImageView( ) } + @Composable + fun imageView(painter: Painter, onClick: () -> Unit) { + Image( + painter, + contentDescription = stringResource(R.string.image_descr), + // .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView + // if text is short and take all available width if text is long + modifier = Modifier + .width(1000.dp) + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = onClick + ), + contentScale = ContentScale.FillWidth, + ) + } + Box(contentAlignment = Alignment.TopEnd) { val context = LocalContext.current val imageBitmap: Bitmap? = getLoadedImage(context, file) - if (imageBitmap != null) { - imageView(imageBitmap, onClick = { + val filePath = getLoadedFilePath(context, file) + if (imageBitmap != null && filePath != null) { + val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) + val imageLoader = ImageLoader.Builder(context) + .components { + if (SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + } + .build() + val imagePainter = rememberAsyncImagePainter( + ImageRequest.Builder(context).data(data = uri).size(coil.size.Size.ORIGINAL).build(), + placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil + imageLoader = imageLoader + ) + imageView(imagePainter, onClick = { if (getLoadedFilePath(context, file) != null) { - ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) } + ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, uri, close) } } }) } 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 46051fed5..13608278b 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,4 +1,6 @@ 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 @@ -7,13 +9,21 @@ 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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import chat.simplex.app.R +import coil.ImageLoader +import coil.compose.rememberAsyncImagePainter +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder +import coil.request.ImageRequest +import coil.size.Size @Composable -fun ImageFullScreenView(imageBitmap: Bitmap, close: () -> Unit) { +fun ImageFullScreenView(imageBitmap: Bitmap, uri: Uri, close: () -> Unit) { BackHandler(onBack = close) Column( Modifier @@ -24,8 +34,23 @@ fun ImageFullScreenView(imageBitmap: Bitmap, close: () -> Unit) { 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()) + } + } + .build() Image( - imageBitmap.asImageBitmap(), + 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 diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt index 1045605cd..2b46110d5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt @@ -57,8 +57,17 @@ fun saveImage(cxt: Context, ciFile: CIFile?) { val fileName = ciFile?.fileName if (filePath != null && fileName != null) { val values = ContentValues() + val lowercaseName = fileName.lowercase() + val mimeType = when { + lowercaseName.endsWith(".png") -> "image/png" + lowercaseName.endsWith(".gif") -> "image/gif" + lowercaseName.endsWith(".webp") -> "image/webp" + lowercaseName.endsWith(".avif") -> "image/avif" + lowercaseName.endsWith(".svg") -> "image/svg+xml" + else -> "image/jpeg" + } values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) - values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + values.put(MediaStore.Images.Media.MIME_TYPE, mimeType) values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) values.put(MediaStore.MediaColumns.TITLE, fileName) val uri = cxt.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index 1e635aba6..d09fcc4ce 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -212,6 +212,7 @@ private fun spannableStringToAnnotatedString( // maximum image file size to be auto-accepted const val MAX_IMAGE_SIZE: Long = 236700 +const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 const val MAX_FILE_SIZE: Long = 8000000 fun getFilesDirectory(context: Context): String { @@ -320,6 +321,32 @@ fun saveImage(context: Context, image: Bitmap): String? { } } +fun saveAnimImage(context: Context, uri: Uri): String? { + return try { + val filename = getFileName(context, uri)?.lowercase() + var ext = when { + // remove everything but extension + filename?.contains(".") == true -> filename.replaceBeforeLast('.', "").replace(".", "") + else -> "gif" + } + // Just in case the image has a strange extension + if (ext.length < 3 || ext.length > 4) ext = "gif" + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext") + val file = File(getAppFilePath(context, fileToSave)) + val output = FileOutputStream(file) + context.contentResolver.openInputStream(uri)!!.use { input -> + output.use { output -> + input.copyTo(output) + } + } + fileToSave + } catch (e: Exception) { + Log.e(chat.simplex.app.TAG, "Util.kt saveAnimImage error: ${e.message}") + null + } +} + fun saveFileFromUri(context: Context, uri: Uri): String? { return try { val inputStream = context.contentResolver.openInputStream(uri)