Implement image saving

This commit is contained in:
2026-05-19 16:17:40 +03:00
Unverified
parent 1a93145ee5
commit d9b2545c68
7 changed files with 414 additions and 14 deletions
@@ -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<SavableMessageImage, Uri?>() {
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<ByteArray?>(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)
}
}
}
@@ -129,6 +129,7 @@ fun ChatScreen(
LazyListState(0, 0) LazyListState(0, 0)
} }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val saveMessageImage = rememberSaveMessageImage { /* best-effort */ }
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
val navController = LocalNavController.current val navController = LocalNavController.current
val profileUserId = panelState.profileUserId val profileUserId = panelState.profileUserId
@@ -757,6 +758,11 @@ fun ChatScreen(
scope.launch { clipboard.setText(text) } scope.launch { clipboard.setText(text) }
} }
}, },
onSave = { message ->
resolveSavableMessageImage(message)?.let { savable ->
saveMessageImage(savable)
}
},
onCancelSend = { message -> onCancelSend = { message ->
scope.launch { panel.cancelQueuedMessage(message) } scope.launch { panel.cancelQueuedMessage(message) }
}, },
@@ -790,7 +796,20 @@ fun ChatScreen(
panel.handleDeleteMessage(m.id) 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, sharedTransitionScope = null,
animatedVisibilityScope = null, animatedVisibilityScope = null,
sharedImageKey = null, sharedImageKey = null,
@@ -710,6 +710,7 @@ fun ImageFullscreenPreview(
dismissRequested = true dismissRequested = true
} }
) )
if (isMessageImageFullyLoaded(message, fileIndex)) {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -721,8 +722,9 @@ fun ImageFullscreenPreview(
onClick = { onClick = {
menuExpanded = false menuExpanded = false
onSave(message, fileIndex) onSave(message, fileIndex)
} },
) )
}
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.SaveAlt
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -72,6 +73,7 @@ internal fun messageContextMenuFingerprint(
return buildString { return buildString {
append("q=").append(isQueued) append("q=").append(isQueued)
append("|copy=").append(!corrupted) append("|copy=").append(!corrupted)
append("|save=").append(resolveSavableMessageImage(message) != null)
if (!isQueued && !isReadOnly) { if (!isQueued && !isReadOnly) {
append("|reply=1") append("|reply=1")
append("|edit=").append(isAuthor && !corrupted) append("|edit=").append(isAuthor && !corrupted)
@@ -90,6 +92,7 @@ fun MessageContextMenu(
onEdit: (Message) -> Unit, onEdit: (Message) -> Unit,
onDelete: (Message) -> Unit, onDelete: (Message) -> Unit,
onCopy: (Message) -> Unit, onCopy: (Message) -> Unit,
onSave: (Message) -> Unit,
onCancelSend: (Message) -> Unit, onCancelSend: (Message) -> Unit,
isReadOnly: Boolean = false, isReadOnly: Boolean = false,
screenWidthPx: Int, screenWidthPx: Int,
@@ -151,6 +154,7 @@ fun MessageContextMenu(
onEdit = {}, onEdit = {},
onDelete = {}, onDelete = {},
onCopy = {}, onCopy = {},
onSave = {},
onCancelSend = {}, onCancelSend = {},
modifier = modifier.graphicsLayer(alpha = 0f), modifier = modifier.graphicsLayer(alpha = 0f),
animated = false, animated = false,
@@ -242,6 +246,10 @@ fun MessageContextMenu(
onCopy(it) onCopy(it)
onDismiss() onDismiss()
}, },
onSave = {
onSave(it)
onDismiss()
},
onCancelSend = { onCancelSend = {
onCancelSend(it) onCancelSend(it)
onDismiss() onDismiss()
@@ -267,6 +275,7 @@ private fun ContextMenuContent(
onEdit: (Message) -> Unit, onEdit: (Message) -> Unit,
onDelete: (Message) -> Unit, onDelete: (Message) -> Unit,
onCopy: (Message) -> Unit, onCopy: (Message) -> Unit,
onSave: (Message) -> Unit,
onCancelSend: (Message) -> Unit, onCancelSend: (Message) -> Unit,
isReadOnly: Boolean = false, isReadOnly: Boolean = false,
modifier: Modifier, modifier: Modifier,
@@ -313,8 +322,10 @@ private fun ContextMenuContent(
val labelEdit = stringResource(Res.string.action_edit) val labelEdit = stringResource(Res.string.action_edit)
val labelDelete = stringResource(Res.string.action_delete) val labelDelete = stringResource(Res.string.action_delete)
val labelCopy = stringResource(Res.string.action_copy) val labelCopy = stringResource(Res.string.action_copy)
val labelSave = stringResource(Res.string.action_save)
val labelCancelSend = stringResource(Res.string.action_cancel_send) val labelCancelSend = stringResource(Res.string.action_cancel_send)
val isQueued = message.isQueuedOutbound() && isAuthor val isQueued = message.isQueuedOutbound() && isAuthor
val savableImage = resolveSavableMessageImage(message)
Box(modifier = containerModifier) { Box(modifier = containerModifier) {
Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape)) Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape))
@@ -331,6 +342,13 @@ private fun ContextMenuContent(
onClick = { onCopy(message) } onClick = { onCopy(message) }
) )
} }
if (savableImage != null) {
ContextMenuItem(
icon = Icons.Rounded.SaveAlt,
text = labelSave,
onClick = { onSave(message) }
)
}
if (isQueued) { if (isQueued) {
ContextMenuItem( ContextMenuItem(
icon = Icons.Rounded.Close, icon = Icons.Rounded.Close,
@@ -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
@@ -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
}
@@ -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<String>().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<ExportDocumentPickerDelegate?>(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)
}
}
}