diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt index 161bc51e6..dfc8c1d4e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt @@ -23,6 +23,8 @@ actual val agentDatabaseFileName: String = "files_agent.db" actual val databaseExportDir: File = androidAppContext.cacheDir +actual val remoteHostsDir: File = File(tmpDir.absolutePath + File.separator + "remote_hosts") + actual fun desktopOpenDatabaseDir() {} @Composable diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index f2c2f393a..5c7273ecc 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -167,7 +167,7 @@ actual fun getAppFileUri(fileName: String): URI = FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", if (File(fileName).isAbsolute) File(fileName) else File(getAppFilePath(fileName))).toURI() // https://developer.android.com/training/data-storage/shared/documents-files#bitmap -actual fun getLoadedImage(file: CIFile?): Pair? { +actual suspend fun getLoadedImage(file: CIFile?): Pair? { val filePath = getLoadedFilePath(file) return if (filePath != null && file != null) { try { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index c3ec33e0c..86e507230 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1,7 +1,8 @@ package chat.simplex.common.model -import androidx.compose.material.MaterialTheme +import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* @@ -596,6 +597,8 @@ object ChatModel { } terminalItems.add(item) } + + fun connectedToRemote(): Boolean = currentRemoteHost.value != null } enum class ChatType(val type: String) { @@ -2224,7 +2227,7 @@ enum class MREmojiChar(val value: String) { } @Serializable -class CIFile( +data class CIFile( val fileId: Long, val fileName: String, val fileSize: Long, @@ -2268,6 +2271,39 @@ class CIFile( is CIFileStatus.Invalid -> null } + /** + * DO NOT CALL this function in compose scope, [LaunchedEffect], [DisposableEffect] and so on. Only with [withBGApi] or [runBlocking]. + * Otherwise, it will be canceled when moving to another screen/item/view, etc + * */ + suspend fun loadRemoteFile(allowToShowAlert: Boolean): Boolean { + val rh = chatModel.currentRemoteHost.value + val user = chatModel.currentUser.value + if (rh == null || user == null || fileSource == null || !loaded) return false + if (getLoadedFilePath(this) != null) return true + if (cachedRemoteFileRequests.contains(fileSource)) return false + + val rf = RemoteFile( + userId = user.userId, + fileId = fileId, + sent = fileStatus.sent, + fileSource = fileSource + ) + cachedRemoteFileRequests.add(fileSource) + val showAlert = fileSize > 5_000_000 && allowToShowAlert + if (showAlert) { + AlertManager.shared.showAlertMsgWithProgress( + title = generalGetString(MR.strings.loading_remote_file_title), + text = generalGetString(MR.strings.loading_remote_file_desc) + ) + } + val res = chatModel.controller.getRemoteFile(rh.remoteHostId, rf) + cachedRemoteFileRequests.remove(fileSource) + if (showAlert) { + AlertManager.shared.hideAlert() + } + return res + } + companion object { fun getSample( fileId: Long = 1, @@ -2277,6 +2313,8 @@ class CIFile( fileStatus: CIFileStatus = CIFileStatus.RcvComplete ): CIFile = CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, fileSource = if (filePath == null) null else CryptoFile.plain(filePath), fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP) + + val cachedRemoteFileRequests = SnapshotStateList() } } @@ -2308,6 +2346,8 @@ data class CryptoFile( companion object { fun plain(f: String): CryptoFile = CryptoFile(f, null) + + fun desktopPlain(f: URI): CryptoFile = CryptoFile(f.rawPath, null) } } @@ -2370,6 +2410,21 @@ sealed class CIFileStatus { @Serializable @SerialName("rcvCancelled") object RcvCancelled: CIFileStatus() @Serializable @SerialName("rcvError") object RcvError: CIFileStatus() @Serializable @SerialName("invalid") class Invalid(val text: String): CIFileStatus() + + val sent: Boolean get() = when (this) { + is SndStored -> true + is SndTransfer -> true + is SndComplete -> true + is SndCancelled -> true + is SndError -> true + is RcvInvitation -> false + is RcvAccepted -> false + is RcvTransfer -> false + is RcvComplete -> false + is RcvCancelled -> false + is RcvError -> false + is Invalid -> false + } } @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 98c48dbfb..0d3b16fa8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -24,6 +24,7 @@ import kotlinx.serialization.* import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.* +import java.io.File import java.util.Date typealias ChatCtrl = Long @@ -339,6 +340,9 @@ object ChatController { apiSetNetworkConfig(getNetCfg()) apiSetTempFolder(coreTmpDir.absolutePath) apiSetFilesFolder(appFilesDir.absolutePath) + if (appPlatform.isDesktop) { + apiSetRemoteHostsFolder(remoteHostsDir.absolutePath) + } apiSetXFTPConfig(getXFTPCfg()) apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) val justStarted = apiStartChat() @@ -418,14 +422,14 @@ object ChatController { } } - suspend fun sendCmd(cmd: CC): CR { + suspend fun sendCmd(cmd: CC, customRhId: Long? = null): CR { val ctrl = ctrl ?: throw Exception("Controller is not initialized") return withContext(Dispatchers.IO) { val c = cmd.cmdString chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated)) Log.d(TAG, "sendCmd: ${cmd.cmdType}") - val rhId = chatModel.currentRemoteHost.value?.remoteHostId?.toInt() ?: -1 + val rhId = customRhId?.toInt() ?: chatModel.currentRemoteHost.value?.remoteHostId?.toInt() ?: -1 val json = if (rhId == -1) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId, c) val r = APIResponse.decodeStr(json) Log.d(TAG, "sendCmd response type ${r.resp.responseType}") @@ -559,6 +563,12 @@ object ChatController { throw Error("failed to set files folder: ${r.responseType} ${r.details}") } + private suspend fun apiSetRemoteHostsFolder(remoteHostsFolder: String) { + val r = sendCmd(CC.SetRemoteHostsFolder(remoteHostsFolder)) + if (r is CR.CmdOk) return + throw Error("failed to set remote hosts folder: ${r.responseType} ${r.details}") + } + suspend fun apiSetXFTPConfig(cfg: XFTPFileConfig?) { val r = sendCmd(CC.ApiSetXFTPConfig(cfg)) if (r is CR.CmdOk) return @@ -609,9 +619,9 @@ object ChatController { return null } - suspend fun apiSendMessage(type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { + suspend fun apiSendMessage(rhId: Long?, type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl) - val r = sendCmd(cmd) + val r = sendCmd(cmd, rhId) return when (r) { is CR.NewChatItem -> r.chatItem else -> { @@ -1142,8 +1152,9 @@ object ChatController { return false } - suspend fun apiReceiveFile(fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { - val r = sendCmd(CC.ReceiveFile(fileId, encrypted, inline)) + suspend fun apiReceiveFile(rhId: Long?, fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { + // -1 here is to override default behavior of providing current remote host id because file can be asked by local device while remote is connected + val r = sendCmd(CC.ReceiveFile(fileId, encrypted, inline), rhId ?: -1) return when (r) { is CR.RcvFileAccepted -> r.chatItem is CR.RcvFileAcceptedSndCancelled -> { @@ -1868,7 +1879,7 @@ object ChatController { } suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) { - val chatItem = apiReceiveFile(fileId, encrypted = encrypted, auto = auto) + val chatItem = apiReceiveFile(rhId, fileId, encrypted = encrypted, auto = auto) if (chatItem != null) { chatItemSimpleUpdate(rhId, user, chatItem) } @@ -2035,6 +2046,7 @@ sealed class CC { class ApiStopChat: CC() class SetTempFolder(val tempFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC() + class SetRemoteHostsFolder(val remoteHostsFolder: String): CC() class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() class ApiSetEncryptLocalFiles(val enable: Boolean): CC() class ApiExportArchive(val config: ArchiveConfig): CC() @@ -2161,6 +2173,7 @@ sealed class CC { is ApiStopChat -> "/_stop" is SetTempFolder -> "/_temp_folder $tempFolder" is SetFilesFolder -> "/_files_folder $filesFolder" + is SetRemoteHostsFolder -> "/remote_hosts_folder $remoteHostsFolder" is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off" is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" @@ -2259,7 +2272,7 @@ sealed class CC { is DeleteRemoteHost -> "/delete remote host $remoteHostId" is StoreRemoteFile -> "/store remote file $remoteHostId " + - (if (storeEncrypted == null) "" else " encrypt=${onOff(storeEncrypted)}") + + (if (storeEncrypted == null) "" else " encrypt=${onOff(storeEncrypted)} ") + localPath is GetRemoteFile -> "/get remote file $remoteHostId ${json.encodeToString(file)}" is ConnectRemoteCtrl -> "/connect remote ctrl $xrcpInvitation" @@ -2290,6 +2303,7 @@ sealed class CC { is ApiStopChat -> "apiStopChat" is SetTempFolder -> "setTempFolder" is SetFilesFolder -> "setFilesFolder" + is SetRemoteHostsFolder -> "setRemoteHostsFolder" is ApiSetXFTPConfig -> "apiSetXFTPConfig" is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles" is ApiExportArchive -> "apiExportArchive" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index 71a9f204f..877356e43 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -24,6 +24,8 @@ expect val agentDatabaseFileName: String * */ expect val databaseExportDir: File +expect val remoteHostsDir: File + expect fun desktopOpenDatabaseDir() fun copyFileToFile(from: File, to: URI, finally: () -> Unit) { @@ -59,14 +61,20 @@ fun copyBytesToFile(bytes: ByteArrayInputStream, to: URI, finally: () -> Unit) { } fun getAppFilePath(fileName: String): String { - return appFilesDir.absolutePath + File.separator + fileName + val rh = chatModel.currentRemoteHost.value + val s = File.separator + return if (rh == null) { + appFilesDir.absolutePath + s + fileName + } else { + remoteHostsDir.absolutePath + s + rh.storePath + s + "simplex_v1_files" + s + fileName + } } fun getLoadedFilePath(file: CIFile?): String? { val f = file?.fileSource?.filePath return if (f != null && file.loaded) { val filePath = getAppFilePath(f) - if (File(filePath).exists()) filePath else null + if (fileReady(file, filePath)) filePath else null } else { null } @@ -76,12 +84,17 @@ fun getLoadedFileSource(file: CIFile?): CryptoFile? { val f = file?.fileSource?.filePath return if (f != null && file.loaded) { val filePath = getAppFilePath(f) - if (File(filePath).exists()) file.fileSource else null + if (fileReady(file, filePath)) file.fileSource else null } else { null } } +private fun fileReady(file: CIFile, filePath: String) = + File(filePath).exists() && + !CIFile.cachedRemoteFileRequests.contains(file.fileSource) + && File(filePath).length() >= file.fileSize + /** * [rememberedValue] is used in `remember(rememberedValue)`. So when the value changes, file saver will update a callback function * */ diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 527d68a52..7097c77d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -499,6 +499,7 @@ fun ChatLayout( enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value, onFiles = { paths -> composeState.onFilesAttached(paths.map { URI.create(it) }) }, onImage = { + // TODO: file is not saved anywhere?! val tmpFile = File.createTempFile("image", ".bmp", tmpDir) tmpFile.deleteOnExit() chatModel.filesToDelete.add(tmpFile) @@ -1300,7 +1301,7 @@ private fun providerForGallery( scrollTo: (Int) -> Unit ): ImageGalleryProvider { fun canShowMedia(item: ChatItem): Boolean = - (item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && getLoadedFilePath(item.file) != null) + (item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && (getLoadedFilePath(item.file) != null || chatModel.connectedToRemote())) fun item(skipInternalIndex: Int, initialChatId: Long): Pair? { var processedInternalIndex = -skipInternalIndex.sign @@ -1327,7 +1328,7 @@ private fun providerForGallery( val item = item(internalIndex, initialChatId)?.second ?: return null return when (item.content.msgContent) { is MsgContent.MCImage -> { - val res = getLoadedImage(item.file) + val res = runBlocking { getLoadedImage(item.file) } val filePath = getLoadedFilePath(item.file) if (res != null && filePath != null) { val (imageBitmap: ImageBitmap, data: ByteArray) = res @@ -1335,7 +1336,7 @@ private fun providerForGallery( } else null } is MsgContent.MCVideo -> { - val filePath = getLoadedFilePath(item.file) + val filePath = if (chatModel.connectedToRemote() && item.file?.loaded == true) getAppFilePath(item.file.fileName) else getLoadedFilePath(item.file) if (filePath != null) { val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 959ded42b..a9b7014d5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -15,6 +15,8 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.Indigo import chat.simplex.common.ui.theme.isSystemInDarkTheme @@ -349,8 +351,9 @@ fun ComposeView( } } - suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { + suspend fun send(rhId: Long?, cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { val aChatItem = chatModel.controller.apiSendMessage( + rhId = rhId, type = cInfo.chatType, id = cInfo.apiId, file = file, @@ -447,15 +450,23 @@ fun ComposeView( } else { val msgs: ArrayList = ArrayList() val files: ArrayList = ArrayList() + val remoteHost = chatModel.currentRemoteHost.value when (val preview = cs.preview) { ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) is ComposePreview.MediaPreview -> { preview.content.forEachIndexed { index, it -> + val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get() val file = when (it) { - is UploadContent.SimpleImage -> saveImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) - is UploadContent.AnimatedImage -> saveAnimImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) - is UploadContent.Video -> saveFileFromUri(it.uri, encrypted = false) + is UploadContent.SimpleImage -> + if (remoteHost == null) saveImage(it.uri, encrypted = encrypted) + else desktopSaveImageInTmp(it.uri) + is UploadContent.AnimatedImage -> + if (remoteHost == null) saveAnimImage(it.uri, encrypted = encrypted) + else CryptoFile.desktopPlain(it.uri) + is UploadContent.Video -> + if (remoteHost == null) saveFileFromUri(it.uri, encrypted = false) + else CryptoFile.desktopPlain(it.uri) } if (file != null) { files.add(file) @@ -470,22 +481,32 @@ fun ComposeView( is ComposePreview.VoicePreview -> { val tmpFile = File(preview.voice) AudioPlayer.stop(tmpFile.absolutePath) - val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, ""))) - files.add(withContext(Dispatchers.IO) { - if (chatController.appPrefs.privacyEncryptLocalFiles.get()) { - val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath) - tmpFile.delete() - CryptoFile(actualFile.name, args) - } else { - Files.move(tmpFile.toPath(), actualFile.toPath()) - CryptoFile.plain(actualFile.name) - } - }) - deleteUnusedFiles() + if (remoteHost == null) { + val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, ""))) + files.add(withContext(Dispatchers.IO) { + if (chatController.appPrefs.privacyEncryptLocalFiles.get()) { + val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath) + tmpFile.delete() + CryptoFile(actualFile.name, args) + } else { + Files.move(tmpFile.toPath(), actualFile.toPath()) + CryptoFile.plain(actualFile.name) + } + }) + deleteUnusedFiles() + } else { + files.add(CryptoFile.plain(tmpFile.absolutePath)) + // It will be deleted on JVM shutdown or next start (if the app crashes unexpectedly) + filesToDelete.remove(tmpFile) + } msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000)) } is ComposePreview.FilePreview -> { - val file = saveFileFromUri(preview.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) + val file = if (remoteHost == null) { + saveFileFromUri(preview.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) + } else { + CryptoFile.desktopPlain(preview.uri) + } if (file != null) { files.add((file)) msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else "")) @@ -499,7 +520,15 @@ fun ComposeView( sent = null msgs.forEachIndexed { index, content -> if (index > 0) delay(100) - sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index), + var file = files.getOrNull(index) + if (remoteHost != null && file != null) { + file = controller.storeRemoteFile( + rhId = remoteHost.remoteHostId, + storeEncrypted = if (content is MsgContent.MCVideo) false else null, + localPath = file.filePath + ) + } + sent = send(remoteHost?.remoteHostId, cInfo, content, if (index == 0) quotedItemId else null, file, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, ttl = ttl ) @@ -509,7 +538,7 @@ fun ComposeView( cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview) ) { - sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) + sent = send(remoteHost?.remoteHostId, cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) } } clearState(live) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 57dcd16cb..0d439f123 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -94,13 +94,19 @@ fun CIFileView( ) } is CIFileStatus.RcvComplete -> { - val filePath = getLoadedFilePath(file) - if (filePath != null) { - withApi { - saveFileLauncher.launch(file.fileName) + withBGApi { + var filePath = getLoadedFilePath(file) + if (chatModel.connectedToRemote() && filePath == null) { + file.loadRemoteFile(true) + filePath = getLoadedFilePath(file) + } + if (filePath != null) { + withApi { + saveFileLauncher.launch(file.fileName) + } + } else { + showToast(generalGetString(MR.strings.file_not_found)) } - } else { - showToast(generalGetString(MR.strings.file_not_found)) } } else -> {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 23d1f1d0c..8b0b2debc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -22,6 +22,9 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.runBlocking import java.io.File import java.net.URI @@ -134,7 +137,7 @@ fun CIImageView( return false } - fun imageAndFilePath(file: CIFile?): Triple? { + suspend fun imageAndFilePath(file: CIFile?): Triple? { val res = getLoadedImage(file) if (res != null) { val (imageBitmap: ImageBitmap, data: ByteArray) = res @@ -148,9 +151,23 @@ fun CIImageView( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), contentAlignment = Alignment.TopEnd ) { - val res = remember(file) { imageAndFilePath(file) } - if (res != null) { - val (imageBitmap, data, _) = res + val res: MutableState?> = remember { + mutableStateOf( + if (chatModel.connectedToRemote()) null else runBlocking { imageAndFilePath(file) } + ) + } + if (chatModel.connectedToRemote()) { + LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) { + withBGApi { + if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { + res.value = imageAndFilePath(file) + } + } + } + } + val loaded = res.value + if (loaded != null) { + val (imageBitmap, data, _) = loaded SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) }) } else { imageView(base64ToBitmap(image), onClick = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt index 996dc819f..04ec30735 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt @@ -21,6 +21,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.flow.* import java.io.File import java.net.URI @@ -37,10 +38,21 @@ fun CIVideoView( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), contentAlignment = Alignment.TopEnd ) { - val filePath = remember(file) { getLoadedFilePath(file) } val preview = remember(image) { base64ToBitmap(image) } - if (file != null && filePath != null) { - val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) } + val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) } + if (chatModel.connectedToRemote()) { + LaunchedEffect(file) { + withBGApi { + if (file != null && file.loaded && getLoadedFilePath(file) == null) { + file.loadRemoteFile(false) + filePath.value = getLoadedFilePath(file) + } + } + } + } + val f = filePath.value + if (file != null && f != null) { + val uri = remember(filePath) { getAppFileUri(f.substringAfterLast(File.separator)) } val view = LocalMultiplatformView() VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = { hideKeyboard(view) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 941bc315b..0c8487458 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -22,7 +22,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.res.MR -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.* // TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901 @@ -44,16 +44,25 @@ fun CIVoiceView( ) { if (file != null) { val f = file.fileSource?.filePath - val fileSource = remember(f, file.fileStatus) { getLoadedFileSource(file) } + val fileSource = remember(f, file.fileStatus, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFileSource(file)) } var brokenAudio by rememberSaveable(f) { mutableStateOf(false) } val audioPlaying = rememberSaveable(f) { mutableStateOf(false) } val progress = rememberSaveable(f) { mutableStateOf(0) } val duration = rememberSaveable(f) { mutableStateOf(providedDurationSec * 1000) } - val play = { - if (fileSource != null) { - AudioPlayer.play(fileSource, audioPlaying, progress, duration, true) - brokenAudio = !audioPlaying.value + val play: () -> Unit = { + val playIfExists = { + if (fileSource.value != null) { + AudioPlayer.play(fileSource.value!!, audioPlaying, progress, duration, true) + brokenAudio = !audioPlaying.value + } } + if (chatModel.connectedToRemote() && fileSource.value == null) { + withBGApi { + file.loadRemoteFile(true) + fileSource.value = getLoadedFileSource(file) + playIfExists() + } + } else playIfExists() } val pause = { AudioPlayer.pause(audioPlaying, progress) @@ -68,7 +77,7 @@ fun CIVoiceView( } } VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) { - AudioPlayer.seekTo(it, progress, fileSource?.filePath) + AudioPlayer.seekTo(it, progress, fileSource.value?.filePath) } } else { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 095723a18..17e2fe044 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -194,19 +194,34 @@ fun ChatItemView( }) } val clipboard = LocalClipboardManager.current - ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - val fileSource = getLoadedFileSource(cItem.file) - when { - fileSource != null -> shareFile(cItem.text, fileSource) - else -> clipboard.shareText(cItem.content.text) - } - showMenu.value = false - }) - ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyItemToClipboard(cItem, clipboard) - showMenu.value = false - }) - if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { + val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } + val copyAndShareAllowed = cItem.file == null || !chatModel.connectedToRemote() || getLoadedFilePath(cItem.file) != null || !cachedRemoteReqs.contains(cItem.file.fileSource) + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { + var fileSource = getLoadedFileSource(cItem.file) + val shareIfExists = { + when (val f = fileSource) { + null -> clipboard.shareText(cItem.content.text) + else -> shareFile(cItem.text, f) + } + showMenu.value = false + } + if (chatModel.connectedToRemote() && fileSource == null) { + withBGApi { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + shareIfExists() + } + } else shareIfExists() + }) + } + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { + copyItemToClipboard(cItem, clipboard) + showMenu.value = false + }) + } + if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && !cachedRemoteReqs.contains(cItem.file?.fileSource)))) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) } if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 35d5b8b3e..fa9c89384 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -188,6 +188,25 @@ class AlertManager { ) } } + + fun showAlertMsgWithProgress( + title: String, + text: String? = null + ) { + showAlert { + AlertDialog( + onDismissRequest = this::hideAlert, + title = alertTitle(title), + text = alertText(text), + buttons = { + Box(Modifier.fillMaxWidth().height(72.dp).padding(bottom = DEFAULT_PADDING * 2), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 3.dp) + } + } + ) + } + } + fun showAlertMsg( title: StringResource, text: StringResource? = null, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 5e64de2c5..7128d2185 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -67,7 +67,7 @@ const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB expect fun getAppFileUri(fileName: String): URI // https://developer.android.com/training/data-storage/shared/documents-files#bitmap -expect fun getLoadedImage(file: CIFile?): Pair? +expect suspend fun getLoadedImage(file: CIFile?): Pair? expect fun getFileName(uri: URI): String? @@ -106,7 +106,7 @@ fun saveImage(image: ImageBitmap, encrypted: Boolean): CryptoFile? { return try { val ext = if (image.hasAlpha()) "png" else "jpg" val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) - val destFileName = generateNewFileName("IMG", ext) + val destFileName = generateNewFileName("IMG", ext, File(getAppFilePath(""))) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { val args = writeCryptoFile(destFile.absolutePath, dataResized.toByteArray()) @@ -124,6 +124,24 @@ fun saveImage(image: ImageBitmap, encrypted: Boolean): CryptoFile? { } } +fun desktopSaveImageInTmp(uri: URI): CryptoFile? { + val image = getBitmapFromUri(uri) ?: return null + return try { + val ext = if (image.hasAlpha()) "png" else "jpg" + val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) + val destFileName = generateNewFileName("IMG", ext, tmpDir) + val destFile = File(tmpDir, destFileName) + val output = FileOutputStream(destFile) + dataResized.writeTo(output) + output.flush() + output.close() + CryptoFile.plain(destFile.absolutePath) + } catch (e: Exception) { + Log.e(TAG, "Util.kt desktopSaveImageInTmp error: ${e.stackTraceToString()}") + null + } +} + fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? { return try { val filename = getFileName(uri)?.lowercase() @@ -134,7 +152,7 @@ fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? { } // Just in case the image has a strange extension if (ext.length < 3 || ext.length > 4) ext = "gif" - val destFileName = generateNewFileName("IMG", ext) + val destFileName = generateNewFileName("IMG", ext, File(getAppFilePath(""))) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readBytes() ?: return null) @@ -156,7 +174,7 @@ fun saveFileFromUri(uri: URI, encrypted: Boolean, withAlertOnException: Boolean val inputStream = uri.inputStream() val fileToSave = getFileName(uri) return if (inputStream != null && fileToSave != null) { - val destFileName = uniqueCombine(fileToSave) + val destFileName = uniqueCombine(fileToSave, File(getAppFilePath(""))) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { createTmpFileAndDelete { tmpFile -> @@ -193,21 +211,21 @@ fun createTmpFileAndDelete(onCreated: (File) -> T): T { } } -fun generateNewFileName(prefix: String, ext: String): String { +fun generateNewFileName(prefix: String, ext: String, dir: File): String { val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) sdf.timeZone = TimeZone.getTimeZone("GMT") val timestamp = sdf.format(Date()) - return uniqueCombine("${prefix}_$timestamp.$ext") + return uniqueCombine("${prefix}_$timestamp.$ext", dir) } -fun uniqueCombine(fileName: String): String { +fun uniqueCombine(fileName: String, dir: File): String { val orig = File(fileName) val name = orig.nameWithoutExtension val ext = orig.extension fun tryCombine(n: Int): String { val suffix = if (n == 0) "" else "_$n" val f = "$name$suffix.$ext" - return if (File(getAppFilePath(f)).exists()) tryCombine(n + 1) else f + return if (File(dir, f).exists()) tryCombine(n + 1) else f } return tryCombine(0) } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index b2f3e2f63..eb3b9207b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -350,6 +350,8 @@ File saved File not found Error saving file + Loading the file + Please, wait while the file is being loaded from the linked mobile Voice message diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt index 94e985328..cb34bdb3b 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt @@ -113,7 +113,7 @@ object NtfManager { private fun prepareIconPath(icon: ImageBitmap?): String? = if (icon != null) { tmpDir.mkdir() - val newFile = File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", "png")) + val newFile = File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", "png", tmpDir)) try { ImageIO.write(icon.toAwtImage(), "PNG", newFile.outputStream()) newFile.absolutePath diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index 9042a6283..0f7c13186 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -21,6 +21,8 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db" actual val databaseExportDir: File = tmpDir +actual val remoteHostsDir: File = File(dataDir.absolutePath + File.separator + "remote_hosts") + actual fun desktopOpenDatabaseDir() { if (Desktop.isDesktopSupported()) { try { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index 711e09267..6da207856 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -2,12 +2,10 @@ package chat.simplex.common.views.chat.item import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.* -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import chat.simplex.common.model.CIFile import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.ModalManager -import java.net.URI @Composable actual fun SimpleAndAnimatedImageView( diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index f602dd577..91efdf790 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -34,35 +34,51 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = { - when (cItem.content.msgContent) { - is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } - else -> {} + val saveIfExists = { + when (cItem.content.msgContent) { + is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } + else -> {} + } + showMenu.value = false } - showMenu.value = false + var fileSource = getLoadedFileSource(cItem.file) + if (chatModel.connectedToRemote() && fileSource == null) { + withBGApi { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + saveIfExists() + } + } else saveIfExists() }) } -actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) { - val fileSource = getLoadedFileSource(cItem.file) +actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) = withBGApi { + var fileSource = getLoadedFileSource(cItem.file) + if (chatModel.connectedToRemote() && fileSource == null) { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + } + if (fileSource != null) { val filePath: String = if (fileSource.cryptoArgs != null) { val tmpFile = File(tmpDir, fileSource.filePath) tmpFile.deleteOnExit() try { - decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs ?: return@withBGApi, tmpFile.absolutePath) } catch (e: Exception) { Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) - return + return@withBGApi } tmpFile.absolutePath } else { getAppFilePath(fileSource.filePath) } - when { + when { desktopPlatform.isWindows() -> clipboard.setText(AnnotatedString("\"${File(filePath).absolutePath}\"")) else -> clipboard.setText(AnnotatedString(filePath)) } } else { clipboard.setText(AnnotatedString(cItem.content.text)) } -} + showToast(MR.strings.copied.localized()) +}.run {} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 7478e22a4..9413cbb40 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -5,8 +5,7 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density -import chat.simplex.common.model.CIFile -import chat.simplex.common.model.readCryptoFile +import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.simplexWindowState import chat.simplex.res.MR @@ -88,11 +87,21 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat AnnotatedString(text) } -actual fun getAppFileUri(fileName: String): URI = - URI(appFilesDir.toURI().toString() + "/" + fileName) +actual fun getAppFileUri(fileName: String): URI { + val rh = chatModel.currentRemoteHost.value + return if (rh == null) { + URI(appFilesDir.toURI().toString() + "/" + fileName) + } else { + URI(dataDir.absolutePath + "/remote_hosts/" + rh.storePath + "/simplex_v1_files/" + fileName) + } +} -actual fun getLoadedImage(file: CIFile?): Pair? { - val filePath = getLoadedFilePath(file) +actual suspend fun getLoadedImage(file: CIFile?): Pair? { + var filePath = getLoadedFilePath(file) + if (chatModel.connectedToRemote() && filePath == null) { + file?.loadRemoteFile(false) + filePath = getLoadedFilePath(file) + } return if (filePath != null) { try { val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() @@ -141,7 +150,7 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) return if (file != null) { try { val ext = if (asPng) "png" else "jpg" - val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext)) + val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext, File(getAppFilePath("")))) // LALAL FILE IS EMPTY ImageIO.write(image.toAwtImage(), ext.uppercase(), newFile.outputStream()) newFile