diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.android.kt new file mode 100644 index 0000000..8f14fa9 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.android.kt @@ -0,0 +1,80 @@ +package ru.fromchat.ui.chat + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.pr0gramm3r101.utils.UtilsLibrary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private class CreateImageInDcimContract : ActivityResultContract() { + override fun createIntent(context: Context, input: SavableMessageImage): Intent { + return Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = input.mimeType + putExtra(Intent.EXTRA_TITLE, input.filename) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + runCatching { + putExtra( + DocumentsContract.EXTRA_INITIAL_URI, + DocumentsContract.buildDocumentUri( + "com.android.externalstorage.documents", + "primary:DCIM", + ), + ) + } + } + } + } + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? { + if (resultCode != Activity.RESULT_OK || intent == null) return null + return intent.data + } +} + +@Composable +actual fun rememberPlatformSaveMessageImage( + onComplete: (Boolean) -> Unit, +): (SavableMessageImage, ByteArray) -> Unit { + val scope = rememberCoroutineScope() + var pendingBytes by remember { mutableStateOf(null) } + + val launcher = rememberLauncherForActivityResult(CreateImageInDcimContract()) { destination -> + val bytes = pendingBytes + pendingBytes = null + if (destination == null || bytes == null) { + onComplete(false) + return@rememberLauncherForActivityResult + } + scope.launch { + val ok = withContext(Dispatchers.IO) { + runCatching { + UtilsLibrary.context.contentResolver.openOutputStream(destination)?.use { out -> + out.write(bytes) + } != null + }.getOrDefault(false) + } + onComplete(ok) + } + } + + return remember(launcher) { + { savable: SavableMessageImage, bytes: ByteArray -> + pendingBytes = bytes + launcher.launch(savable) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index d41e653..cc5b075 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -129,6 +129,7 @@ fun ChatScreen( LazyListState(0, 0) } val scope = rememberCoroutineScope() + val saveMessageImage = rememberSaveMessageImage { /* best-effort */ } val haptic = rememberHapticFeedback() val navController = LocalNavController.current val profileUserId = panelState.profileUserId @@ -757,6 +758,11 @@ fun ChatScreen( scope.launch { clipboard.setText(text) } } }, + onSave = { message -> + resolveSavableMessageImage(message)?.let { savable -> + saveMessageImage(savable) + } + }, onCancelSend = { message -> scope.launch { panel.cancelQueuedMessage(message) } }, @@ -790,7 +796,20 @@ fun ChatScreen( panel.handleDeleteMessage(m.id) } }, - onSave = { _, _ -> /* TODO: platform-specific save to gallery */ }, + onSave = { msg, fileIndex -> + if (!isMessageImageFullyLoaded(msg, fileIndex)) return@ImageFullscreenPreview + val file = msg.files?.getOrNull(fileIndex) ?: return@ImageFullscreenPreview + resolveImageSourceUri(msg, fileIndex)?.let { source -> + saveMessageImage( + SavableMessageImage( + fileIndex = fileIndex, + sourceUri = source, + filename = file.name, + mimeType = mimeTypeForImageFilename(file.name), + ), + ) + } + }, sharedTransitionScope = null, animatedVisibilityScope = null, sharedImageKey = null, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt index 8665ab4..be1af6c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt @@ -710,19 +710,21 @@ fun ImageFullscreenPreview( dismissRequested = true } ) - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Rounded.SaveAlt, null, tint = Color.White) - Spacer(Modifier.width(8.dp)) - Text(labelSave, color = Color.White) - } - }, - onClick = { - menuExpanded = false - onSave(message, fileIndex) - } - ) + if (isMessageImageFullyLoaded(message, fileIndex)) { + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.SaveAlt, null, tint = Color.White) + Spacer(Modifier.width(8.dp)) + Text(labelSave, color = Color.White) + } + }, + onClick = { + menuExpanded = false + onSave(message, fileIndex) + }, + ) + } DropdownMenuItem( text = { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt index 7e983a4..790b6e8 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.SaveAlt import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -72,6 +73,7 @@ internal fun messageContextMenuFingerprint( return buildString { append("q=").append(isQueued) append("|copy=").append(!corrupted) + append("|save=").append(resolveSavableMessageImage(message) != null) if (!isQueued && !isReadOnly) { append("|reply=1") append("|edit=").append(isAuthor && !corrupted) @@ -90,6 +92,7 @@ fun MessageContextMenu( onEdit: (Message) -> Unit, onDelete: (Message) -> Unit, onCopy: (Message) -> Unit, + onSave: (Message) -> Unit, onCancelSend: (Message) -> Unit, isReadOnly: Boolean = false, screenWidthPx: Int, @@ -151,6 +154,7 @@ fun MessageContextMenu( onEdit = {}, onDelete = {}, onCopy = {}, + onSave = {}, onCancelSend = {}, modifier = modifier.graphicsLayer(alpha = 0f), animated = false, @@ -242,6 +246,10 @@ fun MessageContextMenu( onCopy(it) onDismiss() }, + onSave = { + onSave(it) + onDismiss() + }, onCancelSend = { onCancelSend(it) onDismiss() @@ -267,6 +275,7 @@ private fun ContextMenuContent( onEdit: (Message) -> Unit, onDelete: (Message) -> Unit, onCopy: (Message) -> Unit, + onSave: (Message) -> Unit, onCancelSend: (Message) -> Unit, isReadOnly: Boolean = false, modifier: Modifier, @@ -313,8 +322,10 @@ private fun ContextMenuContent( val labelEdit = stringResource(Res.string.action_edit) val labelDelete = stringResource(Res.string.action_delete) val labelCopy = stringResource(Res.string.action_copy) + val labelSave = stringResource(Res.string.action_save) val labelCancelSend = stringResource(Res.string.action_cancel_send) val isQueued = message.isQueuedOutbound() && isAuthor + val savableImage = resolveSavableMessageImage(message) Box(modifier = containerModifier) { Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape)) @@ -331,6 +342,13 @@ private fun ContextMenuContent( onClick = { onCopy(message) } ) } + if (savableImage != null) { + ContextMenuItem( + icon = Icons.Rounded.SaveAlt, + text = labelSave, + onClick = { onSave(message) } + ) + } if (isQueued) { ContextMenuItem( icon = Icons.Rounded.Close, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.kt new file mode 100644 index 0000000..340a191 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.kt @@ -0,0 +1,124 @@ +package ru.fromchat.ui.chat + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.fromchat.api.Message +import ru.fromchat.core.cache.readOutboundFileBytes + +data class SavableMessageImage( + val fileIndex: Int, + val sourceUri: String, + val filename: String, + val mimeType: String, +) + +fun mimeTypeForImageFilename(filename: String): String { + val ext = filename.substringAfterLast('.').lowercase() + return when (ext) { + "png" -> "image/png" + "gif" -> "image/gif" + "webp" -> "image/webp" + "heic", "heif" -> "image/heic" + "bmp" -> "image/bmp" + else -> "image/jpeg" + } +} + +/** Local decrypted (or staged) image ready to copy to user storage. */ +fun isMessageImageFullyLoaded(message: Message, fileIndex: Int): Boolean { + val file = message.files?.getOrNull(fileIndex) + if (file != null && isImageFilename(file.name)) { + if (message.dmEnvelope != null) { + return DecryptedImageCache.getCached( + messageId = message.id, + fileIndex = fileIndex, + clientMessageId = message.client_message_id, + ) != null + } + return false + } + if (fileIndex != 0) return false + if (!message.files.isNullOrEmpty()) return false + val pendingUri = message.pendingFileUri?.trim().orEmpty() + if (pendingUri.isEmpty()) return false + val name = message.pendingFilename?.trim().orEmpty() + .ifBlank { pendingUri.substringAfterLast('/').substringBefore('?') } + if (!isImageFilename(name)) return false + if (message.uploadProgress != null) return false + return true +} + +fun resolveImageSourceUri(message: Message, fileIndex: Int): String? { + DecryptedImageCache.getCached( + messageId = message.id, + fileIndex = fileIndex, + clientMessageId = message.client_message_id, + )?.let { return it } + if (fileIndex == 0) { + message.pendingFileUri?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + } + return null +} + +/** First fully loaded image attachment on [message], if any. */ +fun resolveSavableMessageImage(message: Message): SavableMessageImage? { + message.files?.forEachIndexed { index, file -> + if (!isImageFilename(file.name)) return@forEachIndexed + if (!isMessageImageFullyLoaded(message, index)) return@forEachIndexed + val source = resolveImageSourceUri(message, index) ?: return@forEachIndexed + return SavableMessageImage( + fileIndex = index, + sourceUri = source, + filename = file.name, + mimeType = mimeTypeForImageFilename(file.name), + ) + } + if (isMessageImageFullyLoaded(message, 0)) { + val source = resolveImageSourceUri(message, 0) ?: return null + val name = message.pendingFilename?.trim().orEmpty() + .ifBlank { source.substringAfterLast('/').substringBefore('?') } + .ifBlank { "image.jpg" } + return SavableMessageImage( + fileIndex = 0, + sourceUri = source, + filename = name, + mimeType = mimeTypeForImageFilename(name), + ) + } + return null +} + +/** + * Opens the platform save/create-document UI with [SavableMessageImage.filename] + * and writes decrypted bytes to the user-chosen location. + */ +@Composable +fun rememberSaveMessageImage(onComplete: (Boolean) -> Unit): (SavableMessageImage) -> Unit { + val scope = rememberCoroutineScope() + val platformLaunch = rememberPlatformSaveMessageImage(onComplete) + return remember(platformLaunch, scope) { + { savable: SavableMessageImage -> + scope.launch { + val bytes = runCatching { + withContext(Dispatchers.Default) { + readOutboundFileBytes(savable.sourceUri) + } + }.getOrNull() + if (bytes == null || bytes.isEmpty()) { + onComplete(false) + return@launch + } + platformLaunch(savable, bytes) + } + } + } +} + +@Composable +expect fun rememberPlatformSaveMessageImage( + onComplete: (Boolean) -> Unit, +): (SavableMessageImage, ByteArray) -> Unit diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosViewController.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosViewController.kt new file mode 100644 index 0000000..39256e7 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosViewController.kt @@ -0,0 +1,26 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package ru.fromchat.platform + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIApplication +import platform.UIKit.UIViewController +import platform.UIKit.UIWindow +import platform.UIKit.UIWindowScene + +/** Topmost view controller suitable for presenting UIKit sheets. */ +fun iosTopViewController(): UIViewController? { + val application = UIApplication.sharedApplication + val window = application.connectedScenes + .mapNotNull { it as? UIWindowScene } + .flatMap { scene -> + scene.windows.mapNotNull { it as? UIWindow } + } + .firstOrNull { it.isKeyWindow() } + ?: application.keyWindow + var controller = window?.rootViewController + while (controller?.presentedViewController != null) { + controller = controller.presentedViewController + } + return controller +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.ios.kt new file mode 100644 index 0000000..8d0577c --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.ios.kt @@ -0,0 +1,131 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package ru.fromchat.ui.chat + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.NSFileManager +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask +import platform.Foundation.create +import platform.Foundation.writeToFile +import platform.UIKit.UIDocumentPickerDelegateProtocol +import platform.UIKit.UIDocumentPickerViewController +import platform.darwin.NSObject +import ru.fromchat.platform.iosTopViewController + +private const val EXPORT_SUBDIR = "save_export" + +internal fun sanitizeExportFilename(filename: String): String { + val trimmed = filename.trim().replace('/', '_').replace('\\', '_') + return trimmed.ifBlank { "image.jpg" } +} + +private fun exportStagingDirectory(): String { + val caches = NSSearchPathForDirectoriesInDomains( + platform.Foundation.NSCachesDirectory, + NSUserDomainMask, + true, + ).filterIsInstance().firstOrNull().orEmpty() + val dir = "$caches/$EXPORT_SUBDIR" + NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null) + return dir +} + +private fun writeExportStagingFile(filename: String, bytes: ByteArray): String? { + val dir = exportStagingDirectory() + val path = "$dir/${sanitizeExportFilename(filename)}" + val nsData = bytes.usePinned { pinned -> + NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) + } ?: return null + return if (nsData.writeToFile(path, true)) path else null +} + +/** Default folder in the Files app (Downloads when available, else Documents). */ +private fun defaultExportDirectoryUrl(): NSURL? { + val manager = NSFileManager.defaultManager + manager.URLForDirectory( + platform.Foundation.NSDownloadsDirectory, + NSUserDomainMask, + null, + false, + null, + )?.let { return it } + return manager.URLForDirectory( + platform.Foundation.NSDocumentDirectory, + NSUserDomainMask, + null, + false, + null, + ) +} + +private class ExportDocumentPickerDelegate( + private val stagingPath: String, + private val onFinished: (Boolean) -> Unit, +) : NSObject(), UIDocumentPickerDelegateProtocol { + + private var finished = false + + private fun finish(success: Boolean) { + if (finished) return + finished = true + NSFileManager.defaultManager.removeItemAtPath(stagingPath, null) + onFinished(success) + } + + override fun documentPicker( + controller: UIDocumentPickerViewController, + didPickDocumentsAtURLs: List<*>, + ) { + finish(didPickDocumentsAtURLs.isNotEmpty()) + } + + override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { + finish(false) + } +} + +@Composable +actual fun rememberPlatformSaveMessageImage( + onComplete: (Boolean) -> Unit, +): (SavableMessageImage, ByteArray) -> Unit { + var activeDelegate by remember { mutableStateOf(null) } + + return remember(onComplete) { + { savable: SavableMessageImage, bytes: ByteArray -> + val stagingPath = writeExportStagingFile(savable.filename, bytes) + if (stagingPath == null) { + onComplete(false) + return@remember + } + val host = iosTopViewController() + if (host == null) { + NSFileManager.defaultManager.removeItemAtPath(stagingPath, null) + onComplete(false) + return@remember + } + val delegate = ExportDocumentPickerDelegate(stagingPath) { success -> + activeDelegate = null + onComplete(success) + } + activeDelegate = delegate + val fileUrl = NSURL.fileURLWithPath(stagingPath) + val picker = UIDocumentPickerViewController( + forExportingURLs = listOf(fileUrl), + asCopy = true, + ) + defaultExportDirectoryUrl()?.let { picker.directoryURL = it } + picker.delegate = delegate + host.presentViewController(picker, animated = true, completion = null) + } + } +}