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:
parent
e95d9d0b49
commit
ca8833c0c1
@ -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
|
||||
|
@ -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<ImageBitmap, ByteArray>? {
|
||||
actual suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? {
|
||||
val filePath = getLoadedFilePath(file)
|
||||
return if (filePath != null && file != null) {
|
||||
try {
|
||||
|
@ -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<CryptoFile>()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
* */
|
||||
|
@ -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<Int, ChatItem>? {
|
||||
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)
|
||||
|
@ -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<MsgContent> = ArrayList()
|
||||
val files: ArrayList<CryptoFile> = 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)
|
||||
|
@ -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 -> {}
|
||||
|
@ -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<ImageBitmap, ByteArray, String>? {
|
||||
suspend fun imageAndFilePath(file: CIFile?): Triple<ImageBitmap, ByteArray, String>? {
|
||||
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<Triple<ImageBitmap, ByteArray, String>?> = 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 = {
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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<ImageBitmap, ByteArray>?
|
||||
expect suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>?
|
||||
|
||||
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 <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)
|
||||
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)
|
||||
}
|
||||
|
@ -350,6 +350,8 @@
|
||||
<string name="file_saved">File saved</string>
|
||||
<string name="file_not_found">File not found</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 -->
|
||||
<string name="voice_message">Voice message</string>
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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(
|
||||
|
@ -34,35 +34,51 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) {
|
||||
@Composable
|
||||
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 = {
|
||||
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 {}
|
||||
|
@ -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<ImageBitmap, ByteArray>? {
|
||||
val filePath = getLoadedFilePath(file)
|
||||
actual suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? {
|
||||
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
|
||||
|
Loading…
Reference in New Issue
Block a user