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 remoteHostsDir: File = File(tmpDir.absolutePath + File.separator + "remote_hosts")
actual fun desktopOpenDatabaseDir() {}
@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()
// 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 {

View File

@ -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")

View File

@ -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"

View File

@ -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
* */

View File

@ -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)

View File

@ -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)

View File

@ -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 -> {}

View File

@ -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 = {

View File

@ -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)

View File

@ -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)

View File

@ -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) {

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(
title: StringResource,
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
// 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)
}

View File

@ -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>

View File

@ -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

View File

@ -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 {

View File

@ -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(

View File

@ -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 {}

View File

@ -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