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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user