diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 9d3d94390d..7cbf6ab6bd 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ @@ -15,7 +16,8 @@ - + = 0) { - chats[i] = chats[i]?.copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats()) + chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats()) } // clear current chat if (chatId.value == cInfo.id) { 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 8a2eecb865..93b4ace721 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,8 +1,6 @@ package chat.simplex.app.views.chat import android.content.res.Configuration -import android.graphics.Bitmap -import android.net.Uri import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.* @@ -28,7 +26,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.SimplexApp.Companion.context import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* @@ -46,10 +43,9 @@ fun ChatView(chatModel: ChatModel) { val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value } val user = chatModel.currentUser.value val composeState = remember { mutableStateOf(ComposeState()) } + val attachmentOption = remember { mutableStateOf(null) } val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() - val chosenImage = remember { mutableStateOf(null) } - val chosenFile = remember { mutableStateOf(null) } if (chat == null || user == null) { chatModel.chatId.value = null @@ -77,15 +73,11 @@ fun ChatView(chatModel: ChatModel) { composeState, composeView = { ComposeView( - chatModel, - chat, - composeState, - chosenImage, - chosenFile, - showAttachmentBottomSheet = { scope.launch { attachmentBottomSheetState.show() } }) + chatModel, chat, composeState, attachmentOption, + showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } } + ) }, - chosenImage, - chosenFile, + attachmentOption, scope, attachmentBottomSheetState, chatModel.chatItems, @@ -137,8 +129,7 @@ fun ChatLayout( chat: Chat, composeState: MutableState, composeView: (@Composable () -> Unit), - chosenImage: MutableState, - chosenFile: MutableState, + attachmentOption: MutableState, scope: CoroutineScope, attachmentBottomSheetState: ModalBottomSheetState, chatItems: List, @@ -149,17 +140,6 @@ fun ChatLayout( receiveFile: (Long) -> Unit, startCall: (CallMediaType) -> Unit ) { - fun onImageChange(bitmap: Bitmap) { - val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) - composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview)) - } - fun onFileChange(uri: Uri) { - val fileName = getFileName(context, uri) - if (fileName != null) { - composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName)) - } - } - Surface( Modifier .fillMaxWidth() @@ -170,14 +150,10 @@ fun ChatLayout( scrimColor = Color.Black.copy(alpha = 0.12F), modifier = Modifier.navigationBarsWithImePadding(), sheetContent = { - GetImageBottomSheet( - chosenImage, - ::onImageChange, - chosenFile, - ::onFileChange, - hideBottomSheet = { - scope.launch { attachmentBottomSheetState.hide() } - }) + ChooseAttachmentView( + attachmentOption, + hide = { scope.launch { attachmentBottomSheetState.hide() } } + ) }, sheetState = attachmentBottomSheetState, sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) @@ -389,8 +365,7 @@ fun PreviewChatLayout() { ), composeState = remember { mutableStateOf(ComposeState()) }, composeView = {}, - chosenFile = remember { mutableStateOf(null) }, - chosenImage = remember { mutableStateOf(null) }, + attachmentOption = remember { mutableStateOf(null) }, scope = rememberCoroutineScope(), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), chatItems = chatItems, @@ -435,8 +410,7 @@ fun PreviewGroupChatLayout() { ), composeState = remember { mutableStateOf(ComposeState()) }, composeView = {}, - chosenImage = remember { mutableStateOf(null) }, - chosenFile = remember { mutableStateOf(null) }, + attachmentOption = remember { mutableStateOf(null) }, scope = rememberCoroutineScope(), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), chatItems = chatItems, 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 34995d8e9e..aa0f6c4085 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 @@ -2,8 +2,20 @@ package chat.simplex.app.views.chat import ComposeFileView import ComposeImageView +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.graphics.Bitmap +import android.graphics.ImageDecoder import android.net.Uri +import android.provider.MediaStore +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContract +import androidx.annotation.CallSuper import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -12,7 +24,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.outlined.Reply +import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -21,12 +33,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.helpers.* -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.* +import java.io.File sealed class ComposePreview { object NoPreview: ComposePreview() @@ -101,9 +116,8 @@ fun ComposeView( chatModel: ChatModel, chat: Chat, composeState: MutableState, - chosenImage: MutableState, - chosenFile: MutableState, - showAttachmentBottomSheet: () -> Unit + attachmentOption: MutableState, + showChooseAttachment: () -> Unit ) { val context = LocalContext.current val linkUrl = remember { mutableStateOf(null) } @@ -112,6 +126,107 @@ fun ComposeView( val cancelledLinks = remember { mutableSetOf() } val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember { mutableStateOf(smallFont) } + // attachments + val chosenImage = remember { mutableStateOf(null) } + val chosenFile = remember { mutableStateOf(null) } + val photoUri = remember { mutableStateOf(null) } + val photoTmpFile = remember { mutableStateOf(null) } + + class ComposeTakePicturePreview: ActivityResultContract() { + @CallSuper + override fun createIntent(context: Context, input: Void?): Intent { + photoTmpFile.value = File.createTempFile("image", ".bmp", SimplexApp.context.filesDir) + photoUri.value = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", photoTmpFile.value!!) + return Intent(MediaStore.ACTION_IMAGE_CAPTURE) + .putExtra(MediaStore.EXTRA_OUTPUT, photoUri.value) + } + + override fun getSynchronousResult( + context: Context, + input: Void? + ): SynchronousResult? = null + + override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? { + val photoUriVal = photoUri.value + val photoTmpFileVal = photoTmpFile.value + return if (resultCode == Activity.RESULT_OK && photoUriVal != null && photoTmpFileVal != null) { + val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, photoUriVal) + val bitmap = ImageDecoder.decodeBitmap(source) + photoTmpFileVal.delete() + bitmap + } else { + Log.e(TAG, "Getting image from camera cancelled or failed.") + photoTmpFile.value?.delete() + null + } + } + } + + 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)) + } + } + val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> + if (isGranted) { + cameraLauncher.launch(null) + } else { + Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show() + } + } + val galleryLauncher = rememberGetContentLauncher { uri: Uri? -> + if (uri != null) { + val source = ImageDecoder.createSource(context.contentResolver, uri) + val bitmap = ImageDecoder.decodeBitmap(source) + chosenImage.value = bitmap + val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) + composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview)) + } + } + val filesLauncher = rememberGetContentLauncher { uri: Uri? -> + if (uri != null) { + val fileSize = getFileSize(context, uri) + if (fileSize != null && fileSize <= MAX_FILE_SIZE) { + val fileName = getFileName(SimplexApp.context, uri) + if (fileName != null) { + chosenFile.value = uri + composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName)) + } + } else { + AlertManager.shared.showAlertMsg( + generalGetString(R.string.large_file), + String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE)) + ) + } + } + } + + LaunchedEffect(attachmentOption.value) { + when (attachmentOption.value) { + AttachmentOption.TakePhoto -> { + when (PackageManager.PERMISSION_GRANTED) { + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> { + cameraLauncher.launch(null) + } + else -> { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + attachmentOption.value = null + } + AttachmentOption.PickImage -> { + galleryLauncher.launch("image/*") + attachmentOption.value = null + } + AttachmentOption.PickFile -> { + filesLauncher.launch("*/*") + attachmentOption.value = null + } + else -> {} + } + } fun isSimplexLink(link: String): Boolean = link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) @@ -195,15 +310,15 @@ fun ComposeView( } fun sendMessage() { - withApi { - composeState.value = composeState.value.copy(inProgress = true) - val cInfo = chat.chatInfo - val cs = composeState.value - when (val contextItem = cs.contextItem) { - is ComposeContextItem.EditingItem -> { - val ei = contextItem.chatItem - val oldMsgContent = ei.content.msgContent - if (oldMsgContent != null) { + composeState.value = composeState.value.copy(inProgress = true) + val cInfo = chat.chatInfo + val cs = composeState.value + when (val contextItem = cs.contextItem) { + is ComposeContextItem.EditingItem -> { + val ei = contextItem.chatItem + val oldMsgContent = ei.content.msgContent + if (oldMsgContent != null) { + withApi { val updatedItem = chatModel.controller.apiUpdateChatItem( type = cInfo.chatType, id = cInfo.apiId, @@ -213,37 +328,39 @@ fun ComposeView( if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) } } - else -> { - var mc: MsgContent? = null - var file: String? = null - when (val preview = cs.preview) { - ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message) - is ComposePreview.CLinkPreview -> mc = 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) - } - } - } - is ComposePreview.FilePreview -> { - val chosenFileVal = chosenFile.value - if (chosenFileVal != null) { - file = saveFileFromUri(context, chosenFileVal) - if (file != null) { - mc = MsgContent.MCFile(cs.message) - } + } + else -> { + var mc: MsgContent? = null + var file: String? = null + when (val preview = cs.preview) { + ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message) + is ComposePreview.CLinkPreview -> mc = 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) } } } - val quotedItemId: Long? = when (contextItem) { - is ComposeContextItem.QuotedItem -> contextItem.chatItem.id - else -> null + is ComposePreview.FilePreview -> { + val chosenFileVal = chosenFile.value + if (chosenFileVal != null) { + file = saveFileFromUri(context, chosenFileVal) + if (file != null) { + mc = MsgContent.MCFile(cs.message) + } + } } + } + val quotedItemId: Long? = when (contextItem) { + is ComposeContextItem.QuotedItem -> contextItem.chatItem.id + else -> null + } - if (mc != null) { + if (mc != null) { + withApi { val aChatItem = chatModel.controller.apiSendMessage( type = cInfo.chatType, id = cInfo.apiId, @@ -255,8 +372,8 @@ fun ComposeView( } } } - clearState() } + clearState() } fun onMessageChange(s: String) { @@ -343,7 +460,7 @@ fun ComposeView( .clip(CircleShape) .clickable { if (attachEnabled) { - showAttachmentBottomSheet() + showChooseAttachment() } } ) 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 new file mode 100644 index 0000000000..f437e382bd --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt @@ -0,0 +1,54 @@ +package chat.simplex.app.views.helpers + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.views.newchat.ActionButton + +sealed class AttachmentOption { + object TakePhoto: AttachmentOption() + object PickImage: AttachmentOption() + object PickFile: AttachmentOption() +} + +@Composable +fun ChooseAttachmentView( + attachmentOption: MutableState, + hide: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .onFocusChanged { focusState -> + if (!focusState.hasFocus) hide() + } + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 30.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) { + attachmentOption.value = AttachmentOption.TakePhoto + hide() + } + ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) { + attachmentOption.value = AttachmentOption.PickImage + 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 476300fa1b..58079f4d3b 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 @@ -127,18 +127,15 @@ class CustomTakePicturePreview: ActivityResultContract() { } } } - //class GetGalleryContent: ActivityResultContracts.GetContent() { // override fun createIntent(context: Context, input: String): Intent { // super.createIntent(context, input) // return Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) // } //} - //@Composable //fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = // rememberLauncherForActivityResult(contract = GetGalleryContent(), cb) - @Composable fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher = rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt index 84b51dc91b..eabb7da66f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt @@ -29,7 +29,7 @@ fun SMPServersView(chatModel: ChatModel) { if (userSMPServers != null) { var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) } var editSMPServers by remember { mutableStateOf(!isUserSMPServers) } - var userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") } + val userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") } fun saveSMPServers(smpServers: List) { withApi { val r = chatModel.controller.setUserSMPServers(smpServers = smpServers) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 1561426845..3c3b1c1454 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -124,9 +124,8 @@ struct ComposeView: View { @State private var showChooseSource = false @State private var showImagePicker = false - @State private var imageSource: ImageSource = .imageLibrary + @State private var showTakePhoto = false @State var chosenImage: UIImage? = nil - @State private var showFileImporter = false @State var chosenFile: URL? = nil @@ -171,27 +170,26 @@ struct ComposeView: View { } .confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { - imageSource = .camera - showImagePicker = true + showTakePhoto = true } Button("Choose from library") { - imageSource = .imageLibrary showImagePicker = true } Button("Choose file") { showFileImporter = true } } - .sheet(isPresented: $showImagePicker) { - switch imageSource { - case .imageLibrary: - LibraryImagePicker(image: $chosenImage) { - didSelectItem in showImagePicker = false - } - case .camera: + .fullScreenCover(isPresented: $showTakePhoto) { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) CameraImagePicker(image: $chosenImage) } } + .sheet(isPresented: $showImagePicker) { + LibraryImagePicker(image: $chosenImage) { + didSelectItem in showImagePicker = false + } + } .onChange(of: chosenImage) { image in if let image = image, let imagePreview = resizeImageToStrSize(image, maxDataSize: 14000) { diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 8c2b68b8bc..7fc77c03b5 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -9,11 +9,6 @@ import SwiftUI import PhotosUI -enum ImageSource { - case imageLibrary - case camera -} - struct LibraryImagePicker: UIViewControllerRepresentable { typealias UIViewControllerType = PHPickerViewController @Binding var image: UIImage? diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 3ac68f75dc..13238971bf 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -14,7 +14,7 @@ struct UserProfile: View { @State private var editProfile = false @State private var showChooseSource = false @State private var showImagePicker = false - @State private var imageSource: ImageSource = .imageLibrary + @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil var body: some View { @@ -87,24 +87,23 @@ struct UserProfile: View { .frame(maxHeight: .infinity, alignment: .top) .confirmationDialog("Profile image", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { - imageSource = .camera - showImagePicker = true + showTakePhoto = true } Button("Choose from library") { - imageSource = .imageLibrary showImagePicker = true } } - .sheet(isPresented: $showImagePicker) { - switch imageSource { - case .imageLibrary: - LibraryImagePicker(image: $chosenImage) { - didSelectItem in showImagePicker = false - } - case .camera: + .fullScreenCover(isPresented: $showTakePhoto) { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) CameraImagePicker(image: $chosenImage) } } + .sheet(isPresented: $showImagePicker) { + LibraryImagePicker(image: $chosenImage) { + didSelectItem in showImagePicker = false + } + } .onChange(of: chosenImage) { image in if let image = image { profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)