mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement image saving
This commit is contained in:
@@ -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)
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user