diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index e3dd258f85..f9c274c6f6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -398,10 +398,10 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) { chatModel.sharedContent.value = SharedContent.Text(it) } intent.type?.startsWith("image/") == true -> (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { - chatModel.sharedContent.value = SharedContent.Images(listOf(it)) + chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(it)) } // All other mime types else -> (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { - chatModel.sharedContent.value = SharedContent.File(it) + chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it) } } } @@ -411,7 +411,7 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) { chatModel.clearOverlays.value = true when { intent.type?.startsWith("image/") == true -> (intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) as? List)?.let { - chatModel.sharedContent.value = SharedContent.Images(it) + chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it) } // All other mime types else -> {} } 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 026940cafc..848cfc0488 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 @@ -10,7 +10,6 @@ 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 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 9106b249fd..ca69ad2247 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 @@ -194,7 +194,7 @@ fun ComposeView( Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show() } } - val processPickedImage = { uris: List -> + val processPickedImage = { uris: List, text: String? -> val content = ArrayList() val imagesPreview = ArrayList() uris.forEach { uri -> @@ -223,17 +223,17 @@ fun ComposeView( if (imagesPreview.isNotEmpty()) { chosenContent.value = content - composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagesPreview)) + composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview)) } } - val processPickedFile = { uri: Uri? -> + val processPickedFile = { uri: Uri?, text: String? -> 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)) + composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName)) } } else { AlertManager.shared.showAlertMsg( @@ -243,9 +243,9 @@ fun ComposeView( } } } - val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery(), processPickedImage) - val galleryLauncherFallback = rememberGetMultipleContentsLauncher(processPickedImage) - val filesLauncher = rememberGetContentLauncher(processPickedFile) + val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery()) { processPickedImage(it, null) } + val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) } + val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) } LaunchedEffect(attachmentOption.value) { when (attachmentOption.value) { @@ -503,8 +503,8 @@ fun ComposeView( LaunchedEffect(chatModel.sharedContent.value) { when (val shared = chatModel.sharedContent.value) { is SharedContent.Text -> onMessageChange(shared.text) - is SharedContent.Images -> processPickedImage(shared.uris) - is SharedContent.File -> processPickedFile(shared.uri) + is SharedContent.Images -> processPickedImage(shared.uris, shared.text) + is SharedContent.File -> processPickedFile(shared.uri, shared.text) null -> {} } chatModel.sharedContent.value = 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 ebe9e6e83d..436419f761 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 @@ -84,7 +84,7 @@ fun SendMsgView( } catch (e: Exception) { return@OnCommitContentListener false } - SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images(listOf(inputContentInfo.contentUri)) + SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri)) true } return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit) 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 e4db88208a..9ce4dfe5b1 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 @@ -1,6 +1,7 @@ package chat.simplex.app.views.chat.item import android.content.* +import android.net.Uri import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -18,6 +19,7 @@ import androidx.compose.ui.platform.* import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme @@ -81,7 +83,11 @@ fun ChatItemView( showMenu.value = false }) ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = { - shareText(cxt, cItem.content.text) + val filePath = getLoadedFilePath(SimplexApp.context, cItem.file) + when { + filePath != null -> shareFile(cxt, cItem.text, filePath) + else -> shareText(cxt, cItem.content.text) + } showMenu.value = false }) ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = { 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 8c64a182ee..726b4ad37f 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 @@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap @@ -69,7 +70,10 @@ fun FramedItemView( Modifier .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight) .fillMaxWidth() - .clickable { scrollToItem(qi.itemId?: return@clickable) } + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = { scrollToItem(qi.itemId?: return@combinedClickable) } + ) ) { when (qi.content) { is MsgContent.MCImage -> { @@ -102,10 +106,16 @@ fun FramedItemView( } } - Surface( - shape = RoundedCornerShape(18.dp), - color = if (sent) SentColorLight else ReceivedColorLight - ) { + val transparentBackground = ci.content.msgContent is MsgContent.MCImage && ci.content.text.isEmpty() && ci.quotedItem == null + Box(Modifier + .clip(RoundedCornerShape(18.dp)) + .background( + when { + transparentBackground -> Color.Transparent + sent -> SentColorLight + else -> ReceivedColorLight + } + )) { var metaColor = HighOrLowlight Box(contentAlignment = Alignment.BottomEnd) { Column(Modifier.width(IntrinsicSize.Max)) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt index df93727123..ba40dad853 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt @@ -58,13 +58,13 @@ fun DatabaseView( val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) } val chatArchiveFile = remember { mutableStateOf(null) } val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, chatArchiveFile) + val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(getAppFilesDirectory(context))) } val importArchiveLauncher = rememberGetContentLauncher { uri: Uri? -> if (uri != null) { - importArchiveAlert(m, context, uri, progressIndicator) + importArchiveAlert(m, context, uri, appFilesCountAndSize, progressIndicator) } } val chatDbDeleted = remember { m.chatDbDeleted } - val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(getAppFilesDirectory(context))) } LaunchedEffect(m.chatRunning) { runChat.value = m.chatRunning.value ?: true } @@ -506,16 +506,28 @@ private fun rememberSaveArchiveLauncher(cxt: Context, chatArchiveFile: MutableSt } ) -private fun importArchiveAlert(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState) { +private fun importArchiveAlert( + m: ChatModel, + context: Context, + importedArchiveUri: Uri, + appFilesCountAndSize: MutableState>, + progressIndicator: MutableState +) { AlertManager.shared.showAlertDialog( title = generalGetString(R.string.import_database_question), text = generalGetString(R.string.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one), confirmText = generalGetString(R.string.import_database_confirmation), - onConfirm = { importArchive(m, context, importedArchiveUri, progressIndicator) } + onConfirm = { importArchive(m, context, importedArchiveUri, appFilesCountAndSize, progressIndicator) } ) } -private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState) { +private fun importArchive( + m: ChatModel, + context: Context, + importedArchiveUri: Uri, + appFilesCountAndSize: MutableState>, + progressIndicator: MutableState +) { progressIndicator.value = true val archivePath = saveArchiveFromUri(context, importedArchiveUri) if (archivePath != null) { @@ -526,6 +538,7 @@ private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Ur val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString()) m.controller.apiImportArchive(config) DatabaseUtils.removeDatabaseKey() + appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context)) operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database)) } 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 38511ba3ed..ae741e3725 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 @@ -7,8 +7,8 @@ import kotlinx.coroutines.flow.MutableStateFlow sealed class SharedContent { data class Text(val text: String): SharedContent() - data class Images(val uris: List): SharedContent() - data class File(val uri: Uri): SharedContent() + data class Images(val text: String, val uris: List): SharedContent() + data class File(val text: String, val uri: Uri): SharedContent() } enum class NewChatSheetState { 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 75665419e2..66dfbf763b 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 @@ -65,26 +65,28 @@ fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String { } private fun compressImageStr(bitmap: Bitmap): String { - return "data:image/jpg;base64," + Base64.encodeToString(compressImageData(bitmap).toByteArray(), Base64.NO_WRAP) + val usePng = bitmap.hasAlpha() + val ext = if (usePng) "png" else "jpg" + return "data:image/$ext;base64," + Base64.encodeToString(compressImageData(bitmap, usePng).toByteArray(), Base64.NO_WRAP) } -fun resizeImageToDataSize(image: Bitmap, maxDataSize: Long): ByteArrayOutputStream { +fun resizeImageToDataSize(image: Bitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream { var img = image - var stream = compressImageData(img) + var stream = compressImageData(img, usePng) while (stream.size() > maxDataSize) { val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble()) val clippedRatio = min(ratio, 2.0) val width = (img.width.toDouble() / clippedRatio).toInt() val height = img.height * width / img.width img = Bitmap.createScaledBitmap(img, width, height, true) - stream = compressImageData(img) + stream = compressImageData(img, usePng) } return stream } -private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream { +private fun compressImageData(bitmap: Bitmap, usePng: Boolean): ByteArrayOutputStream { val stream = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream) + bitmap.compress(if (!usePng) Bitmap.CompressFormat.JPEG else Bitmap.CompressFormat.PNG, 85, stream) return stream } 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 43e5d264bd..4a1b8a4f82 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 @@ -3,13 +3,15 @@ package chat.simplex.app.views.helpers import android.content.* import android.net.Uri import android.provider.MediaStore +import android.webkit.MimeTypeMap import android.widget.Toast import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.core.content.ContextCompat -import chat.simplex.app.R +import androidx.core.content.FileProvider +import chat.simplex.app.* import chat.simplex.app.model.CIFile import java.io.BufferedOutputStream import java.io.File @@ -24,6 +26,22 @@ fun shareText(cxt: Context, text: String) { cxt.startActivity(shareIntent) } +fun shareFile(cxt: Context, text: String, filePath: String) { + val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) + val ext = filePath.substringAfterLast(".") + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + /*if (text.isNotEmpty()) { + putExtra(Intent.EXTRA_TEXT, text) + }*/ + putExtra(Intent.EXTRA_STREAM, uri) + type = mimeType + } + val shareIntent = Intent.createChooser(sendIntent, null) + cxt.startActivity(shareIntent) +} + fun copyText(cxt: Context, text: String) { val clipboard = ContextCompat.getSystemService(cxt, ClipboardManager::class.java) clipboard?.setPrimaryClip(ClipData.newPlainText("text", text)) @@ -51,22 +69,25 @@ fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResu } ) +fun imageMimeType(fileName: String): String { + val lowercaseName = fileName.lowercase() + return 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" + } +} + fun saveImage(cxt: Context, ciFile: CIFile?) { val filePath = getLoadedFilePath(cxt, 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, mimeType) + values.put(MediaStore.Images.Media.MIME_TYPE, imageMimeType(fileName)) 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 76b533ed1e..cb6344f28f 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 @@ -308,9 +308,10 @@ fun getFileSize(context: Context, uri: Uri): Long? { fun saveImage(context: Context, image: Bitmap): String? { return try { - val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE) + val ext = if (image.hasAlpha()) "png" else "jpg" + val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val fileToSave = uniqueCombine(context, "IMG_${timestamp}.jpg") + val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext") val file = File(getAppFilePath(context, fileToSave)) val output = FileOutputStream(file) dataResized.writeTo(output) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/CreateLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/CreateLinkView.kt index 175597935e..2259b78104 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/CreateLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/CreateLinkView.kt @@ -5,6 +5,7 @@ import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.sp @@ -22,8 +23,8 @@ enum class CreateLinkTab { @Composable fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { val selection = remember { mutableStateOf(initialSelection) } - val connReqInvitation = remember { mutableStateOf("") } - val creatingConnReq = remember { mutableStateOf(false) } + val connReqInvitation = rememberSaveable { mutableStateOf("") } + val creatingConnReq = rememberSaveable { mutableStateOf(false) } LaunchedEffect(selection.value) { if (selection.value == CreateLinkTab.ONE_TIME && connReqInvitation.value.isEmpty() && !creatingConnReq.value) { createInvitation(m, creatingConnReq, connReqInvitation) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 9bc0af66df..c1c4d43897 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -207,8 +207,17 @@ private struct MetaColorPreferenceKey: PreferenceKey { } } +func onlyImage(_ ci: ChatItem) -> Bool { + if case let .image(text, _) = ci.content.msgContent { + return ci.quotedItem == nil && text == "" + } + return false +} + func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color { - ci.chatDir.sent + onlyImage(ci) + ? Color.clear + : ci.chatDir.sent ? (colorScheme == .light ? sentColorLight : sentColorDark) : Color(uiColor: .tertiarySystemGroupedBackground) }