desktop: sending and receiving files via connected mobile (#3365)

* desktop: support remote files (WIP)

* working with remote files locally

* better

* working with remote file downloads

* sending files

* fixes of loading files in some situations

* image compression

* constant for remote hosts

* refactor

---------

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
This commit is contained in:
Evgeny Poberezkin 2023-11-18 20:11:30 +00:00 committed by GitHub
parent e95d9d0b49
commit ca8833c0c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 334 additions and 97 deletions

View File

@ -23,6 +23,8 @@ actual val agentDatabaseFileName: String = "files_agent.db"
actual val databaseExportDir: File = androidAppContext.cacheDir actual val databaseExportDir: File = androidAppContext.cacheDir
actual val remoteHostsDir: File = File(tmpDir.absolutePath + File.separator + "remote_hosts")
actual fun desktopOpenDatabaseDir() {} actual fun desktopOpenDatabaseDir() {}
@Composable @Composable

View File

@ -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() 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 // https://developer.android.com/training/data-storage/shared/documents-files#bitmap
actual fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? { actual suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? {
val filePath = getLoadedFilePath(file) val filePath = getLoadedFilePath(file)
return if (filePath != null && file != null) { return if (filePath != null && file != null) {
try { try {

View File

@ -1,7 +1,8 @@
package chat.simplex.common.model package chat.simplex.common.model
import androidx.compose.material.MaterialTheme import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.* import androidx.compose.ui.text.font.*
@ -596,6 +597,8 @@ object ChatModel {
} }
terminalItems.add(item) terminalItems.add(item)
} }
fun connectedToRemote(): Boolean = currentRemoteHost.value != null
} }
enum class ChatType(val type: String) { enum class ChatType(val type: String) {
@ -2224,7 +2227,7 @@ enum class MREmojiChar(val value: String) {
} }
@Serializable @Serializable
class CIFile( data class CIFile(
val fileId: Long, val fileId: Long,
val fileName: String, val fileName: String,
val fileSize: Long, val fileSize: Long,
@ -2268,6 +2271,39 @@ class CIFile(
is CIFileStatus.Invalid -> null 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 { companion object {
fun getSample( fun getSample(
fileId: Long = 1, fileId: Long = 1,
@ -2277,6 +2313,8 @@ class CIFile(
fileStatus: CIFileStatus = CIFileStatus.RcvComplete fileStatus: CIFileStatus = CIFileStatus.RcvComplete
): CIFile = ): CIFile =
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, fileSource = if (filePath == null) null else CryptoFile.plain(filePath), fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP) CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, fileSource = if (filePath == null) null else CryptoFile.plain(filePath), fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP)
val cachedRemoteFileRequests = SnapshotStateList<CryptoFile>()
} }
} }
@ -2308,6 +2346,8 @@ data class CryptoFile(
companion object { companion object {
fun plain(f: String): CryptoFile = CryptoFile(f, null) 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("rcvCancelled") object RcvCancelled: CIFileStatus()
@Serializable @SerialName("rcvError") object RcvError: CIFileStatus() @Serializable @SerialName("rcvError") object RcvError: CIFileStatus()
@Serializable @SerialName("invalid") class Invalid(val text: String): 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") @Suppress("SERIALIZER_TYPE_INCOMPATIBLE")

View File

@ -24,6 +24,7 @@ import kotlinx.serialization.*
import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import java.io.File
import java.util.Date import java.util.Date
typealias ChatCtrl = Long typealias ChatCtrl = Long
@ -339,6 +340,9 @@ object ChatController {
apiSetNetworkConfig(getNetCfg()) apiSetNetworkConfig(getNetCfg())
apiSetTempFolder(coreTmpDir.absolutePath) apiSetTempFolder(coreTmpDir.absolutePath)
apiSetFilesFolder(appFilesDir.absolutePath) apiSetFilesFolder(appFilesDir.absolutePath)
if (appPlatform.isDesktop) {
apiSetRemoteHostsFolder(remoteHostsDir.absolutePath)
}
apiSetXFTPConfig(getXFTPCfg()) apiSetXFTPConfig(getXFTPCfg())
apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get())
val justStarted = apiStartChat() 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") val ctrl = ctrl ?: throw Exception("Controller is not initialized")
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val c = cmd.cmdString val c = cmd.cmdString
chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated)) chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated))
Log.d(TAG, "sendCmd: ${cmd.cmdType}") 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 json = if (rhId == -1) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId, c)
val r = APIResponse.decodeStr(json) val r = APIResponse.decodeStr(json)
Log.d(TAG, "sendCmd response type ${r.resp.responseType}") 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}") 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?) { suspend fun apiSetXFTPConfig(cfg: XFTPFileConfig?) {
val r = sendCmd(CC.ApiSetXFTPConfig(cfg)) val r = sendCmd(CC.ApiSetXFTPConfig(cfg))
if (r is CR.CmdOk) return if (r is CR.CmdOk) return
@ -609,9 +619,9 @@ object ChatController {
return null 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 cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl)
val r = sendCmd(cmd) val r = sendCmd(cmd, rhId)
return when (r) { return when (r) {
is CR.NewChatItem -> r.chatItem is CR.NewChatItem -> r.chatItem
else -> { else -> {
@ -1142,8 +1152,9 @@ object ChatController {
return false return false
} }
suspend fun apiReceiveFile(fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { suspend fun apiReceiveFile(rhId: Long?, fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? {
val r = sendCmd(CC.ReceiveFile(fileId, encrypted, inline)) // -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) { return when (r) {
is CR.RcvFileAccepted -> r.chatItem is CR.RcvFileAccepted -> r.chatItem
is CR.RcvFileAcceptedSndCancelled -> { is CR.RcvFileAcceptedSndCancelled -> {
@ -1868,7 +1879,7 @@ object ChatController {
} }
suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) { 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) { if (chatItem != null) {
chatItemSimpleUpdate(rhId, user, chatItem) chatItemSimpleUpdate(rhId, user, chatItem)
} }
@ -2035,6 +2046,7 @@ sealed class CC {
class ApiStopChat: CC() class ApiStopChat: CC()
class SetTempFolder(val tempFolder: String): CC() class SetTempFolder(val tempFolder: String): CC()
class SetFilesFolder(val filesFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC()
class SetRemoteHostsFolder(val remoteHostsFolder: String): CC()
class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC()
class ApiSetEncryptLocalFiles(val enable: Boolean): CC() class ApiSetEncryptLocalFiles(val enable: Boolean): CC()
class ApiExportArchive(val config: ArchiveConfig): CC() class ApiExportArchive(val config: ArchiveConfig): CC()
@ -2161,6 +2173,7 @@ sealed class CC {
is ApiStopChat -> "/_stop" is ApiStopChat -> "/_stop"
is SetTempFolder -> "/_temp_folder $tempFolder" is SetTempFolder -> "/_temp_folder $tempFolder"
is SetFilesFolder -> "/_files_folder $filesFolder" 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 ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off"
is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}" is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}"
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
@ -2259,7 +2272,7 @@ sealed class CC {
is DeleteRemoteHost -> "/delete remote host $remoteHostId" is DeleteRemoteHost -> "/delete remote host $remoteHostId"
is StoreRemoteFile -> is StoreRemoteFile ->
"/store remote file $remoteHostId " + "/store remote file $remoteHostId " +
(if (storeEncrypted == null) "" else " encrypt=${onOff(storeEncrypted)}") + (if (storeEncrypted == null) "" else " encrypt=${onOff(storeEncrypted)} ") +
localPath localPath
is GetRemoteFile -> "/get remote file $remoteHostId ${json.encodeToString(file)}" is GetRemoteFile -> "/get remote file $remoteHostId ${json.encodeToString(file)}"
is ConnectRemoteCtrl -> "/connect remote ctrl $xrcpInvitation" is ConnectRemoteCtrl -> "/connect remote ctrl $xrcpInvitation"
@ -2290,6 +2303,7 @@ sealed class CC {
is ApiStopChat -> "apiStopChat" is ApiStopChat -> "apiStopChat"
is SetTempFolder -> "setTempFolder" is SetTempFolder -> "setTempFolder"
is SetFilesFolder -> "setFilesFolder" is SetFilesFolder -> "setFilesFolder"
is SetRemoteHostsFolder -> "setRemoteHostsFolder"
is ApiSetXFTPConfig -> "apiSetXFTPConfig" is ApiSetXFTPConfig -> "apiSetXFTPConfig"
is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles" is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles"
is ApiExportArchive -> "apiExportArchive" is ApiExportArchive -> "apiExportArchive"

View File

@ -24,6 +24,8 @@ expect val agentDatabaseFileName: String
* */ * */
expect val databaseExportDir: File expect val databaseExportDir: File
expect val remoteHostsDir: File
expect fun desktopOpenDatabaseDir() expect fun desktopOpenDatabaseDir()
fun copyFileToFile(from: File, to: URI, finally: () -> Unit) { 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 { 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? { fun getLoadedFilePath(file: CIFile?): String? {
val f = file?.fileSource?.filePath val f = file?.fileSource?.filePath
return if (f != null && file.loaded) { return if (f != null && file.loaded) {
val filePath = getAppFilePath(f) val filePath = getAppFilePath(f)
if (File(filePath).exists()) filePath else null if (fileReady(file, filePath)) filePath else null
} else { } else {
null null
} }
@ -76,12 +84,17 @@ fun getLoadedFileSource(file: CIFile?): CryptoFile? {
val f = file?.fileSource?.filePath val f = file?.fileSource?.filePath
return if (f != null && file.loaded) { return if (f != null && file.loaded) {
val filePath = getAppFilePath(f) val filePath = getAppFilePath(f)
if (File(filePath).exists()) file.fileSource else null if (fileReady(file, filePath)) file.fileSource else null
} else { } else {
null 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 * [rememberedValue] is used in `remember(rememberedValue)`. So when the value changes, file saver will update a callback function
* */ * */

View File

@ -499,6 +499,7 @@ fun ChatLayout(
enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value, enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value,
onFiles = { paths -> composeState.onFilesAttached(paths.map { URI.create(it) }) }, onFiles = { paths -> composeState.onFilesAttached(paths.map { URI.create(it) }) },
onImage = { onImage = {
// TODO: file is not saved anywhere?!
val tmpFile = File.createTempFile("image", ".bmp", tmpDir) val tmpFile = File.createTempFile("image", ".bmp", tmpDir)
tmpFile.deleteOnExit() tmpFile.deleteOnExit()
chatModel.filesToDelete.add(tmpFile) chatModel.filesToDelete.add(tmpFile)
@ -1300,7 +1301,7 @@ private fun providerForGallery(
scrollTo: (Int) -> Unit scrollTo: (Int) -> Unit
): ImageGalleryProvider { ): ImageGalleryProvider {
fun canShowMedia(item: ChatItem): Boolean = 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<Int, ChatItem>? { fun item(skipInternalIndex: Int, initialChatId: Long): Pair<Int, ChatItem>? {
var processedInternalIndex = -skipInternalIndex.sign var processedInternalIndex = -skipInternalIndex.sign
@ -1327,7 +1328,7 @@ private fun providerForGallery(
val item = item(internalIndex, initialChatId)?.second ?: return null val item = item(internalIndex, initialChatId)?.second ?: return null
return when (item.content.msgContent) { return when (item.content.msgContent) {
is MsgContent.MCImage -> { is MsgContent.MCImage -> {
val res = getLoadedImage(item.file) val res = runBlocking { getLoadedImage(item.file) }
val filePath = getLoadedFilePath(item.file) val filePath = getLoadedFilePath(item.file)
if (res != null && filePath != null) { if (res != null && filePath != null) {
val (imageBitmap: ImageBitmap, data: ByteArray) = res val (imageBitmap: ImageBitmap, data: ByteArray) = res
@ -1335,7 +1336,7 @@ private fun providerForGallery(
} else null } else null
} }
is MsgContent.MCVideo -> { 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) { if (filePath != null) {
val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image) ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image)

View File

@ -15,6 +15,8 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.simplex.common.model.* 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.platform.*
import chat.simplex.common.ui.theme.Indigo import chat.simplex.common.ui.theme.Indigo
import chat.simplex.common.ui.theme.isSystemInDarkTheme 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( val aChatItem = chatModel.controller.apiSendMessage(
rhId = rhId,
type = cInfo.chatType, type = cInfo.chatType,
id = cInfo.apiId, id = cInfo.apiId,
file = file, file = file,
@ -447,15 +450,23 @@ fun ComposeView(
} else { } else {
val msgs: ArrayList<MsgContent> = ArrayList() val msgs: ArrayList<MsgContent> = ArrayList()
val files: ArrayList<CryptoFile> = ArrayList() val files: ArrayList<CryptoFile> = ArrayList()
val remoteHost = chatModel.currentRemoteHost.value
when (val preview = cs.preview) { when (val preview = cs.preview) {
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
is ComposePreview.MediaPreview -> { is ComposePreview.MediaPreview -> {
preview.content.forEachIndexed { index, it -> preview.content.forEachIndexed { index, it ->
val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()
val file = when (it) { val file = when (it) {
is UploadContent.SimpleImage -> saveImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) is UploadContent.SimpleImage ->
is UploadContent.AnimatedImage -> saveAnimImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) if (remoteHost == null) saveImage(it.uri, encrypted = encrypted)
is UploadContent.Video -> saveFileFromUri(it.uri, encrypted = false) 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) { if (file != null) {
files.add(file) files.add(file)
@ -470,22 +481,32 @@ fun ComposeView(
is ComposePreview.VoicePreview -> { is ComposePreview.VoicePreview -> {
val tmpFile = File(preview.voice) val tmpFile = File(preview.voice)
AudioPlayer.stop(tmpFile.absolutePath) AudioPlayer.stop(tmpFile.absolutePath)
val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, ""))) if (remoteHost == null) {
files.add(withContext(Dispatchers.IO) { val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, "")))
if (chatController.appPrefs.privacyEncryptLocalFiles.get()) { files.add(withContext(Dispatchers.IO) {
val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath) if (chatController.appPrefs.privacyEncryptLocalFiles.get()) {
tmpFile.delete() val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath)
CryptoFile(actualFile.name, args) tmpFile.delete()
} else { CryptoFile(actualFile.name, args)
Files.move(tmpFile.toPath(), actualFile.toPath()) } else {
CryptoFile.plain(actualFile.name) Files.move(tmpFile.toPath(), actualFile.toPath())
} CryptoFile.plain(actualFile.name)
}) }
deleteUnusedFiles() })
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)) msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000))
} }
is ComposePreview.FilePreview -> { 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) { if (file != null) {
files.add((file)) files.add((file))
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else "")) msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
@ -499,7 +520,15 @@ fun ComposeView(
sent = null sent = null
msgs.forEachIndexed { index, content -> msgs.forEachIndexed { index, content ->
if (index > 0) delay(100) 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, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false,
ttl = ttl ttl = ttl
) )
@ -509,7 +538,7 @@ fun ComposeView(
cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.FilePreview ||
cs.preview is ComposePreview.VoicePreview) 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) clearState(live)

View File

@ -94,13 +94,19 @@ fun CIFileView(
) )
} }
is CIFileStatus.RcvComplete -> { is CIFileStatus.RcvComplete -> {
val filePath = getLoadedFilePath(file) withBGApi {
if (filePath != null) { var filePath = getLoadedFilePath(file)
withApi { if (chatModel.connectedToRemote() && filePath == null) {
saveFileLauncher.launch(file.fileName) 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 -> {} else -> {}

View File

@ -22,6 +22,9 @@ import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource 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.io.File
import java.net.URI import java.net.URI
@ -134,7 +137,7 @@ fun CIImageView(
return false return false
} }
fun imageAndFilePath(file: CIFile?): Triple<ImageBitmap, ByteArray, String>? { suspend fun imageAndFilePath(file: CIFile?): Triple<ImageBitmap, ByteArray, String>? {
val res = getLoadedImage(file) val res = getLoadedImage(file)
if (res != null) { if (res != null) {
val (imageBitmap: ImageBitmap, data: ByteArray) = res val (imageBitmap: ImageBitmap, data: ByteArray) = res
@ -148,9 +151,23 @@ fun CIImageView(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
contentAlignment = Alignment.TopEnd contentAlignment = Alignment.TopEnd
) { ) {
val res = remember(file) { imageAndFilePath(file) } val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember {
if (res != null) { mutableStateOf(
val (imageBitmap, data, _) = res 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) }) SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) })
} else { } else {
imageView(base64ToBitmap(image), onClick = { imageView(base64ToBitmap(image), onClick = {

View File

@ -21,6 +21,7 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.flow.*
import java.io.File import java.io.File
import java.net.URI import java.net.URI
@ -37,10 +38,21 @@ fun CIVideoView(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
contentAlignment = Alignment.TopEnd contentAlignment = Alignment.TopEnd
) { ) {
val filePath = remember(file) { getLoadedFilePath(file) }
val preview = remember(image) { base64ToBitmap(image) } val preview = remember(image) { base64ToBitmap(image) }
if (file != null && filePath != null) { val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) }
val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) } 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() val view = LocalMultiplatformView()
VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = { VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = {
hideKeyboard(view) hideKeyboard(view)

View File

@ -22,7 +22,7 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.res.MR 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 // TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
@ -44,16 +44,25 @@ fun CIVoiceView(
) { ) {
if (file != null) { if (file != null) {
val f = file.fileSource?.filePath 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) } var brokenAudio by rememberSaveable(f) { mutableStateOf(false) }
val audioPlaying = rememberSaveable(f) { mutableStateOf(false) } val audioPlaying = rememberSaveable(f) { mutableStateOf(false) }
val progress = rememberSaveable(f) { mutableStateOf(0) } val progress = rememberSaveable(f) { mutableStateOf(0) }
val duration = rememberSaveable(f) { mutableStateOf(providedDurationSec * 1000) } val duration = rememberSaveable(f) { mutableStateOf(providedDurationSec * 1000) }
val play = { val play: () -> Unit = {
if (fileSource != null) { val playIfExists = {
AudioPlayer.play(fileSource, audioPlaying, progress, duration, true) if (fileSource.value != null) {
brokenAudio = !audioPlaying.value 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 = { val pause = {
AudioPlayer.pause(audioPlaying, progress) 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) { 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 { } else {
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile) VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile)

View File

@ -194,19 +194,34 @@ fun ChatItemView(
}) })
} }
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests }
val fileSource = getLoadedFileSource(cItem.file) val copyAndShareAllowed = cItem.file == null || !chatModel.connectedToRemote() || getLoadedFilePath(cItem.file) != null || !cachedRemoteReqs.contains(cItem.file.fileSource)
when { if (copyAndShareAllowed) {
fileSource != null -> shareFile(cItem.text, fileSource) ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = {
else -> clipboard.shareText(cItem.content.text) var fileSource = getLoadedFileSource(cItem.file)
} val shareIfExists = {
showMenu.value = false when (val f = fileSource) {
}) null -> clipboard.shareText(cItem.content.text)
ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { else -> shareFile(cItem.text, f)
copyItemToClipboard(cItem, clipboard) }
showMenu.value = false 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) { 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) SaveContentItemAction(cItem, saveFileLauncher, showMenu)
} }
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {

View File

@ -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( fun showAlertMsg(
title: StringResource, title: StringResource,
text: StringResource? = null, text: StringResource? = null,

View File

@ -67,7 +67,7 @@ const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB
expect fun getAppFileUri(fileName: String): URI expect fun getAppFileUri(fileName: String): URI
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap // https://developer.android.com/training/data-storage/shared/documents-files#bitmap
expect fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? expect suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>?
expect fun getFileName(uri: URI): String? expect fun getFileName(uri: URI): String?
@ -106,7 +106,7 @@ fun saveImage(image: ImageBitmap, encrypted: Boolean): CryptoFile? {
return try { return try {
val ext = if (image.hasAlpha()) "png" else "jpg" val ext = if (image.hasAlpha()) "png" else "jpg"
val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) 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)) val destFile = File(getAppFilePath(destFileName))
if (encrypted) { if (encrypted) {
val args = writeCryptoFile(destFile.absolutePath, dataResized.toByteArray()) 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? { fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? {
return try { return try {
val filename = getFileName(uri)?.lowercase() 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 // Just in case the image has a strange extension
if (ext.length < 3 || ext.length > 4) ext = "gif" 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)) val destFile = File(getAppFilePath(destFileName))
if (encrypted) { if (encrypted) {
val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readBytes() ?: return null) 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 inputStream = uri.inputStream()
val fileToSave = getFileName(uri) val fileToSave = getFileName(uri)
return if (inputStream != null && fileToSave != null) { return if (inputStream != null && fileToSave != null) {
val destFileName = uniqueCombine(fileToSave) val destFileName = uniqueCombine(fileToSave, File(getAppFilePath("")))
val destFile = File(getAppFilePath(destFileName)) val destFile = File(getAppFilePath(destFileName))
if (encrypted) { if (encrypted) {
createTmpFileAndDelete { tmpFile -> createTmpFileAndDelete { tmpFile ->
@ -193,21 +211,21 @@ fun <T> 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) val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("GMT") sdf.timeZone = TimeZone.getTimeZone("GMT")
val timestamp = sdf.format(Date()) 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 orig = File(fileName)
val name = orig.nameWithoutExtension val name = orig.nameWithoutExtension
val ext = orig.extension val ext = orig.extension
fun tryCombine(n: Int): String { fun tryCombine(n: Int): String {
val suffix = if (n == 0) "" else "_$n" val suffix = if (n == 0) "" else "_$n"
val f = "$name$suffix.$ext" 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) return tryCombine(0)
} }

View File

@ -350,6 +350,8 @@
<string name="file_saved">File saved</string> <string name="file_saved">File saved</string>
<string name="file_not_found">File not found</string> <string name="file_not_found">File not found</string>
<string name="error_saving_file">Error saving file</string> <string name="error_saving_file">Error saving file</string>
<string name="loading_remote_file_title">Loading the file </string>
<string name="loading_remote_file_desc">Please, wait while the file is being loaded from the linked mobile</string>
<!-- Voice messages --> <!-- Voice messages -->
<string name="voice_message">Voice message</string> <string name="voice_message">Voice message</string>

View File

@ -113,7 +113,7 @@ object NtfManager {
private fun prepareIconPath(icon: ImageBitmap?): String? = if (icon != null) { private fun prepareIconPath(icon: ImageBitmap?): String? = if (icon != null) {
tmpDir.mkdir() tmpDir.mkdir()
val newFile = File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", "png")) val newFile = File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", "png", tmpDir))
try { try {
ImageIO.write(icon.toAwtImage(), "PNG", newFile.outputStream()) ImageIO.write(icon.toAwtImage(), "PNG", newFile.outputStream())
newFile.absolutePath newFile.absolutePath

View File

@ -21,6 +21,8 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db"
actual val databaseExportDir: File = tmpDir actual val databaseExportDir: File = tmpDir
actual val remoteHostsDir: File = File(dataDir.absolutePath + File.separator + "remote_hosts")
actual fun desktopOpenDatabaseDir() { actual fun desktopOpenDatabaseDir() {
if (Desktop.isDesktopSupported()) { if (Desktop.isDesktopSupported()) {
try { try {

View File

@ -2,12 +2,10 @@ package chat.simplex.common.views.chat.item
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import chat.simplex.common.model.CIFile import chat.simplex.common.model.CIFile
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.ModalManager import chat.simplex.common.views.helpers.ModalManager
import java.net.URI
@Composable @Composable
actual fun SimpleAndAnimatedImageView( actual fun SimpleAndAnimatedImageView(

View File

@ -34,35 +34,51 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) {
@Composable @Composable
actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) { actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) {
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 = { 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) { val saveIfExists = {
is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } when (cItem.content.msgContent) {
else -> {} 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) { actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) = withBGApi {
val fileSource = getLoadedFileSource(cItem.file) var fileSource = getLoadedFileSource(cItem.file)
if (chatModel.connectedToRemote() && fileSource == null) {
cItem.file?.loadRemoteFile(true)
fileSource = getLoadedFileSource(cItem.file)
}
if (fileSource != null) { if (fileSource != null) {
val filePath: String = if (fileSource.cryptoArgs != null) { val filePath: String = if (fileSource.cryptoArgs != null) {
val tmpFile = File(tmpDir, fileSource.filePath) val tmpFile = File(tmpDir, fileSource.filePath)
tmpFile.deleteOnExit() tmpFile.deleteOnExit()
try { try {
decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs ?: return@withBGApi, tmpFile.absolutePath)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString())
return return@withBGApi
} }
tmpFile.absolutePath tmpFile.absolutePath
} else { } else {
getAppFilePath(fileSource.filePath) getAppFilePath(fileSource.filePath)
} }
when { when {
desktopPlatform.isWindows() -> clipboard.setText(AnnotatedString("\"${File(filePath).absolutePath}\"")) desktopPlatform.isWindows() -> clipboard.setText(AnnotatedString("\"${File(filePath).absolutePath}\""))
else -> clipboard.setText(AnnotatedString(filePath)) else -> clipboard.setText(AnnotatedString(filePath))
} }
} else { } else {
clipboard.setText(AnnotatedString(cItem.content.text)) clipboard.setText(AnnotatedString(cItem.content.text))
} }
} showToast(MR.strings.copied.localized())
}.run {}

View File

@ -5,8 +5,7 @@ import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import chat.simplex.common.model.CIFile import chat.simplex.common.model.*
import chat.simplex.common.model.readCryptoFile
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.simplexWindowState import chat.simplex.common.simplexWindowState
import chat.simplex.res.MR import chat.simplex.res.MR
@ -88,11 +87,21 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat
AnnotatedString(text) AnnotatedString(text)
} }
actual fun getAppFileUri(fileName: String): URI = actual fun getAppFileUri(fileName: String): URI {
URI(appFilesDir.toURI().toString() + "/" + fileName) 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<ImageBitmap, ByteArray>? { actual suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? {
val filePath = getLoadedFilePath(file) var filePath = getLoadedFilePath(file)
if (chatModel.connectedToRemote() && filePath == null) {
file?.loadRemoteFile(false)
filePath = getLoadedFilePath(file)
}
return if (filePath != null) { return if (filePath != null) {
try { try {
val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() 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) { return if (file != null) {
try { try {
val ext = if (asPng) "png" else "jpg" 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 // LALAL FILE IS EMPTY
ImageIO.write(image.toAwtImage(), ext.uppercase(), newFile.outputStream()) ImageIO.write(image.toAwtImage(), ext.uppercase(), newFile.outputStream())
newFile newFile