diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index a25d56a337..74a5667b43 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -2042,7 +2042,11 @@ enum class CICallStatus { } } -fun durationText(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60) +fun durationText(sec: Int): String { + val s = sec % 60 + val m = sec / 60 + return if (m < 60) "%02d:%02d".format(m, s) else "%02d:%02d:%02d".format(m / 60, m % 60, s) +} @Serializable sealed class MsgErrorType() { 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 a113a7c5ca..121c5a6cd3 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,5 @@ package chat.simplex.app.views.chat -import android.app.Activity import android.content.res.Configuration import android.graphics.Bitmap import android.net.Uri @@ -134,7 +133,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { searchText, useLinkPreviews = useLinkPreviews, linkMode = chatModel.simplexLinkMode.value, - allowVideoAttachment = chatModel.controller.appPrefs.xftpSendEnabled.get(), chatModelIncognito = chatModel.incognito.value, back = { hideKeyboard(view) @@ -308,7 +306,6 @@ fun ChatLayout( searchValue: State, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, - allowVideoAttachment: Boolean, chatModelIncognito: Boolean, back: () -> Unit, info: () -> Unit, @@ -340,7 +337,6 @@ fun ChatLayout( sheetContent = { ChooseAttachmentView( attachmentOption, - allowVideoAttachment, hide = { scope.launch { attachmentBottomSheetState.hide() } } ) }, @@ -1083,7 +1079,6 @@ fun PreviewChatLayout() { searchValue, useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, - allowVideoAttachment = true, chatModelIncognito = false, back = {}, info = {}, @@ -1144,7 +1139,6 @@ fun PreviewGroupChatLayout() { searchValue, useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, - allowVideoAttachment = true, chatModelIncognito = false, back = {}, info = {}, 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 848cfc0488..f22c04bd98 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 @@ -4,22 +4,29 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.outlined.Close 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 androidx.compose.ui.unit.sp import chat.simplex.app.R +import chat.simplex.app.model.durationText import chat.simplex.app.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.views.chat.item.SentColorLight +import chat.simplex.app.views.helpers.UploadContent import chat.simplex.app.views.helpers.base64ToBitmap @Composable -fun ComposeImageView(images: List, cancelImages: () -> Unit, cancelEnabled: Boolean) { +fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Unit, cancelEnabled: Boolean) { Row( Modifier .padding(top = 8.dp) @@ -31,13 +38,32 @@ fun ComposeImageView(images: List, cancelImages: () -> Unit, cancelEnabl 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) - ) + itemsIndexed(media.images) { index, item -> + val content = media.content[index] + if (content is UploadContent.Video) { + Box(contentAlignment = Alignment.Center) { + val imageBitmap = base64ToBitmap(item).asImageBitmap() + Image( + imageBitmap, + "preview video", + modifier = Modifier.widthIn(max = 80.dp).height(60.dp) + ) + Icon( + Icons.Default.Videocam, + "preview video", + Modifier + .size(20.dp), + tint = Color.White + ) + } + } else { + val imageBitmap = base64ToBitmap(item).asImageBitmap() + Image( + imageBitmap, + "preview image", + modifier = Modifier.widthIn(max = 80.dp).height(60.dp) + ) + } } } if (cancelEnabled) { 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 a47fa76195..9d3d674724 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 @@ -9,12 +9,11 @@ import android.content.* import android.content.pm.PackageManager import android.graphics.* import android.graphics.drawable.AnimatedImageDrawable -import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build import android.provider.MediaStore +import android.webkit.MimeTypeMap import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContract import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -49,8 +48,7 @@ import java.nio.file.Files sealed class ComposePreview { @Serializable object NoPreview: ComposePreview() @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() - @Serializable class ImagePreview(val images: List, val content: List): ComposePreview() - @Serializable class VideoPreview(val images: List, val content: List): ComposePreview() + @Serializable class MediaPreview(val images: List, val content: List): ComposePreview() @Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview() @Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview() } @@ -96,8 +94,7 @@ data class ComposeState( val sendEnabled: () -> Boolean get() = { val hasContent = when (preview) { - is ComposePreview.ImagePreview -> true - is ComposePreview.VideoPreview -> true + is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true else -> message.isNotEmpty() || liveMessage != null @@ -110,8 +107,7 @@ data class ComposeState( val linkPreviewAllowed: Boolean get() = when (preview) { - is ComposePreview.ImagePreview -> false - is ComposePreview.VideoPreview -> false + is ComposePreview.MediaPreview -> false is ComposePreview.VoicePreview -> false is ComposePreview.FilePreview -> false else -> useLinkPreviews @@ -161,8 +157,8 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview { is MsgContent.MCText -> ComposePreview.NoPreview is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview) // TODO: include correct type - is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) - is MsgContent.MCVideo -> ComposePreview.VideoPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) + is MsgContent.MCImage -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) + is MsgContent.MCVideo -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true) is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName)) is MsgContent.MCUnknown, null -> ComposePreview.NoPreview @@ -192,7 +188,7 @@ fun ComposeView( val bitmap: Bitmap? = getBitmapFromUri(uri) if (bitmap != null) { val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) - composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri)))) + composeState.value = composeState.value.copy(preview = ComposePreview.MediaPreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri)))) } } } @@ -203,52 +199,50 @@ fun ComposeView( Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show() } } - val processPickedImage = { uris: List, text: String? -> + val processPickedMedia = { uris: List, text: String? -> val content = ArrayList() val imagesPreview = ArrayList() uris.forEach { uri -> - val drawable = getDrawableFromUri(uri) - var bitmap: Bitmap? = if (drawable != null) getBitmapFromUri(uri) else null - val isAnimNewApi = Build.VERSION.SDK_INT >= 28 && drawable is AnimatedImageDrawable - val isAnimOldApi = Build.VERSION.SDK_INT < 28 && - (getFileName(SimplexApp.context, uri)?.endsWith(".gif") == true || getFileName(SimplexApp.context, uri)?.endsWith(".webp") == true) - if (isAnimNewApi || isAnimOldApi) { - // It's a gif or webp - val fileSize = getFileSize(context, uri) - if (fileSize != null && fileSize <= maxFileSize) { - 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(maxFileSize)) - ) + var bitmap: Bitmap? = null + val isImage = MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileName(SimplexApp.context, uri)?.split(".")?.last())?.contains("image/") == true + when { + isImage -> { + // Image + val drawable = getDrawableFromUri(uri) + bitmap = if (drawable != null) getBitmapFromUri(uri) else null + val isAnimNewApi = Build.VERSION.SDK_INT >= 28 && drawable is AnimatedImageDrawable + val isAnimOldApi = Build.VERSION.SDK_INT < 28 && + (getFileName(SimplexApp.context, uri)?.endsWith(".gif") == true || getFileName(SimplexApp.context, uri)?.endsWith(".webp") == true) + if (isAnimNewApi || isAnimOldApi) { + // It's a gif or webp + val fileSize = getFileSize(context, uri) + if (fileSize != null && fileSize <= maxFileSize) { + 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(maxFileSize)) + ) + } + } else { + content.add(UploadContent.SimpleImage(uri)) + } + } + else -> { + // Video + val res = getBitmapFromVideo(uri) + bitmap = res.preview + val durationMs = res.duration + content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0)) } - } else { - content.add(UploadContent.SimpleImage(uri)) } if (bitmap != null) { imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) } } - if (imagesPreview.isNotEmpty()) { - composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview, content)) - } - } - val processPickedVideo = { uris: List, text: String? -> - val content = ArrayList() - val imagesPreview = ArrayList() - uris.forEach { uri -> - val (bitmap: Bitmap?, durationMs: Long?) = getBitmapFromVideo(uri) - content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0)) - if (bitmap != null) { - imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) - } - } - - if (imagesPreview.isNotEmpty()) { - composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.VideoPreview(imagesPreview, content)) + composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) } } val processPickedFile = { uri: Uri?, text: String? -> @@ -267,10 +261,7 @@ fun ComposeView( } } } - val galleryImageLauncher = rememberLauncherForActivityResult(contract = PickMultipleImagesFromGallery()) { processPickedImage(it, null) } - val galleryImageLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) } - val galleryVideoLauncher = rememberLauncherForActivityResult(contract = PickMultipleVideosFromGallery()) { processPickedVideo(it, null) } - val galleryVideoLauncherFallback = rememberGetMultipleContentsLauncher { processPickedVideo(it, null) } + val mediaLauncherWithFiles = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) } val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) } val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) } @@ -287,20 +278,8 @@ fun ComposeView( } attachmentOption.value = null } - AttachmentOption.PickImage -> { - try { - galleryImageLauncher.launch(0) - } catch (e: ActivityNotFoundException) { - galleryImageLauncherFallback.launch("image/*") - } - attachmentOption.value = null - } - AttachmentOption.PickVideo -> { - try { - galleryVideoLauncher.launch(0) - } catch (e: ActivityNotFoundException) { - galleryVideoLauncherFallback.launch("video/*") - } + AttachmentOption.PickMedia -> { + mediaLauncherWithFiles.launch(if (xftpSendEnabled) "image/*;video/*" else "image/*") attachmentOption.value = null } AttachmentOption.PickFile -> { @@ -460,28 +439,20 @@ fun ComposeView( when (val preview = cs.preview) { ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) - is ComposePreview.ImagePreview -> { + is ComposePreview.MediaPreview -> { preview.content.forEachIndexed { index, it -> val file = when (it) { is UploadContent.SimpleImage -> saveImage(context, it.uri) is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri) - else -> return@forEachIndexed - } - if (file != null) { - files.add(file) - msgs.add(MsgContent.MCImage(if (preview.content.lastIndex == index) msgText else "", preview.images[index])) - } - } - } - is ComposePreview.VideoPreview -> { - preview.content.forEachIndexed { index, it -> - val file = when (it) { is UploadContent.Video -> saveFileFromUri(context, it.uri) - else -> return@forEachIndexed } if (file != null) { files.add(file) - msgs.add(MsgContent.MCVideo(if (preview.content.lastIndex == index) msgText else "", preview.images[index], it.duration)) + if (it is UploadContent.Video) { + msgs.add(MsgContent.MCVideo(if (preview.content.lastIndex == index) msgText else "", preview.images[index], it.duration)) + } else { + msgs.add(MsgContent.MCImage(if (preview.content.lastIndex == index) msgText else "", preview.images[index])) + } } } } @@ -516,8 +487,7 @@ fun ComposeView( ) } if (sent == null && - (cs.preview is ComposePreview.ImagePreview || - cs.preview is ComposePreview.VideoPreview || + (cs.preview is ComposePreview.MediaPreview || cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview) ) { @@ -642,13 +612,8 @@ fun ComposeView( when (val preview = composeState.value.preview) { ComposePreview.NoPreview -> {} is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview) - is ComposePreview.ImagePreview -> ComposeImageView( - preview.images, - ::cancelImages, - cancelEnabled = !composeState.value.editing - ) - is ComposePreview.VideoPreview -> ComposeImageView( - preview.images, + is ComposePreview.MediaPreview -> ComposeImageView( + preview, ::cancelImages, cancelEnabled = !composeState.value.editing ) @@ -686,7 +651,7 @@ fun ComposeView( when (val shared = chatModel.sharedContent.value) { is SharedContent.Text -> onMessageChange(shared.text) - is SharedContent.Images -> processPickedImage(shared.uris, shared.text) + is SharedContent.Images -> processPickedMedia(shared.uris, shared.text) is SharedContent.File -> processPickedFile(shared.uri, shared.text) null -> {} } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 3136c239f8..31c9c34211 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -75,7 +75,7 @@ fun SendMsgView( ) { Box(Modifier.padding(vertical = 8.dp)) { val cs = composeState.value - val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VideoPreview || cs.preview is ComposePreview.FilePreview) + val showProgress = cs.inProgress && (cs.preview is ComposePreview.MediaPreview || cs.preview is ComposePreview.FilePreview) val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index ad8e8ee2f4..30c4a360c3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -86,7 +86,7 @@ fun ChatPreviewView( fun attachment(): Pair? = when (draft.preview) { is ComposePreview.FilePreview -> Icons.Filled.InsertDriveFile to draft.preview.fileName - is ComposePreview.ImagePreview -> Icons.Outlined.Image to null + is ComposePreview.MediaPreview -> Icons.Outlined.Image to null is ComposePreview.VoicePreview -> Icons.Filled.PlayArrow to durationText(draft.preview.durationMs / 1000) else -> null } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt index feec62780e..9d8121f3ab 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt @@ -14,15 +14,13 @@ import chat.simplex.app.views.newchat.ActionButton sealed class AttachmentOption { object TakePhoto: AttachmentOption() - object PickImage: AttachmentOption() - object PickVideo: AttachmentOption() + object PickMedia: AttachmentOption() object PickFile: AttachmentOption() } @Composable fun ChooseAttachmentView( attachmentOption: MutableState, - allowVideoAttachment: Boolean, hide: () -> Unit ) { Box( @@ -44,15 +42,9 @@ fun ChooseAttachmentView( hide() } ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) { - attachmentOption.value = AttachmentOption.PickImage + attachmentOption.value = AttachmentOption.PickMedia hide() } - if (allowVideoAttachment) { - ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Videocam) { - attachmentOption.value = AttachmentOption.PickVideo - hide() - } - } ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) { attachmentOption.value = AttachmentOption.PickFile hide() 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 9356ebfa65..d05b863903 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 @@ -174,7 +174,18 @@ fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLaunche @Composable fun rememberGetMultipleContentsLauncher(cb: (List) -> Unit): ManagedActivityResultLauncher> = - rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb) + rememberLauncherForActivityResult(contract = GetMultipleContentsAndMimeTypes(), cb) + +class GetMultipleContentsAndMimeTypes: ActivityResultContracts.GetMultipleContents() { + override fun createIntent(context: Context, input: String): Intent { + val mimeTypes = input.split(";") + return super.createIntent(context, mimeTypes[0]).apply { + if (mimeTypes.isNotEmpty()) { + putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toTypedArray()) + } + } + } +} fun ManagedActivityResultLauncher.launchWithFallback() { try { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index c68704df49..b05b9996c7 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2542,7 +2542,11 @@ public enum CICallStatus: String, Decodable { } public func durationText(_ sec: Int) -> String { - String(format: "%02d:%02d", sec / 60, sec % 60) + let s = sec % 60 + let m = sec / 60 + return m < 60 + ? String(format: "%02d:%02d", m, s) + : String(format: "%02d:%02d:%02d", m / 60, m % 60, s) } public enum MsgErrorType: Decodable {