desktop: send images and files (#2691)

* desktop: send images and files

* expect/actual

* file filter on non-Linux OSes

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko
2023-07-19 12:26:37 +03:00
committed by GitHub
parent 2389e870b3
commit 27f4661ac4
16 changed files with 248 additions and 61 deletions

View File

@@ -32,6 +32,15 @@ actual fun rememberFileChooserLauncher(getContent: Boolean, onResult: (URI?) ->
return FileChooserLauncher(launcher)
}
@Composable
actual fun rememberFileChooserMultipleLauncher(onResult: (List<URI>) -> Unit): FileChooserMultipleLauncher {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents(),
onResult = { onResult(it.map { it.toURI() }) }
)
return FileChooserMultipleLauncher(launcher)
}
actual class FileChooserLauncher actual constructor() {
private lateinit var launcher: ManagedActivityResultLauncher<String, Uri?>
@@ -44,5 +53,17 @@ actual class FileChooserLauncher actual constructor() {
}
}
actual class FileChooserMultipleLauncher actual constructor() {
private lateinit var launcher: ManagedActivityResultLauncher<String, List<Uri>>
constructor(launcher: ManagedActivityResultLauncher<String, List<Uri>>): this() {
this.launcher = launcher
}
actual suspend fun launch(input: String) {
launcher.launch(input)
}
}
actual fun URI.inputStream(): InputStream? = androidAppContext.contentResolver.openInputStream(toUri())
actual fun URI.outputStream(): OutputStream = androidAppContext.contentResolver.openOutputStream(toUri())!!

View File

@@ -0,0 +1,30 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import chat.simplex.common.views.newchat.ActionButton
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
actual fun ChooseAttachmentButtons(attachmentOption: MutableState<AttachmentOption?>, hide: () -> Unit) {
ActionButton(Modifier.fillMaxWidth(0.25f), null, stringResource(MR.strings.use_camera_button), icon = painterResource(MR.images.ic_camera_enhance)) {
attachmentOption.value = AttachmentOption.CameraPhoto
hide()
}
ActionButton(Modifier.fillMaxWidth(0.33f), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) {
attachmentOption.value = AttachmentOption.GalleryImage
hide()
}
ActionButton(Modifier.fillMaxWidth(0.50f), null, stringResource(MR.strings.gallery_video_button), icon = painterResource(MR.images.ic_smart_display)) {
attachmentOption.value = AttachmentOption.GalleryVideo
hide()
}
ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(MR.strings.choose_file), icon = painterResource(MR.images.ic_note_add)) {
attachmentOption.value = AttachmentOption.File
hide()
}
}

View File

@@ -71,9 +71,15 @@ fun getLoadedFilePath(file: CIFile?): String? {
@Composable
expect fun rememberFileChooserLauncher(getContent: Boolean, onResult: (URI?) -> Unit): FileChooserLauncher
expect fun rememberFileChooserMultipleLauncher(onResult: (List<URI>) -> Unit): FileChooserMultipleLauncher
expect class FileChooserLauncher() {
suspend fun launch(input: String)
}
expect class FileChooserMultipleLauncher() {
suspend fun launch(input: String)
}
expect fun URI.inputStream(): InputStream?
expect fun URI.outputStream(): OutputStream

View File

@@ -175,12 +175,12 @@ fun ComposeView(
val content = ArrayList<UploadContent>()
val imagesPreview = ArrayList<String>()
uris.forEach { uri ->
var bitmap: ImageBitmap? = null
var bitmap: ImageBitmap?
when {
isImage(uri) -> {
// Image
val drawable = getDrawableFromUri(uri)
bitmap = if (drawable != null) getBitmapFromUri(uri) else null
bitmap = getBitmapFromUri(uri)
if (isAnimImage(uri, drawable)) {
// It's a gif or webp
val fileSize = getFileSize(uri)

View File

@@ -34,7 +34,7 @@ fun ChatArchiveView(m: ChatModel, title: String, archiveName: String, archiveTim
ChatArchiveLayout(
title,
archiveTime,
saveArchive = { withApi { saveArchiveLauncher.launch(archivePath.substringAfterLast("/")) }},
saveArchive = { withApi { saveArchiveLauncher.launch(archivePath.substringAfterLast(File.separator)) }},
deleteArchiveAlert = { deleteArchiveAlert(m, archivePath) }
)
}

View File

@@ -457,7 +457,7 @@ private fun exportArchive(
try {
val archiveFile = exportChatArchive(m, chatArchiveName, chatArchiveTime, chatArchiveFile)
chatArchiveFile.value = archiveFile
saveArchiveLauncher.launch(archiveFile.substringAfterLast("/"))
saveArchiveLauncher.launch(archiveFile.substringAfterLast(File.separator))
progressIndicator.value = false
} catch (e: Error) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_exporting_chat_database), e.toString())

View File

@@ -5,11 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.newchat.ActionButton
import chat.simplex.res.MR
sealed class AttachmentOption {
object CameraPhoto: AttachmentOption()
@@ -19,10 +15,7 @@ sealed class AttachmentOption {
}
@Composable
fun ChooseAttachmentView(
attachmentOption: MutableState<AttachmentOption?>,
hide: () -> Unit
) {
fun ChooseAttachmentView(attachmentOption: MutableState<AttachmentOption?>, hide: () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -37,22 +30,10 @@ fun ChooseAttachmentView(
.padding(horizontal = 8.dp, vertical = 30.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
ActionButton(Modifier.fillMaxWidth(0.25f), null, stringResource(MR.strings.use_camera_button), icon = painterResource(MR.images.ic_camera_enhance)) {
attachmentOption.value = AttachmentOption.CameraPhoto
hide()
}
ActionButton(Modifier.fillMaxWidth(0.33f), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) {
attachmentOption.value = AttachmentOption.GalleryImage
hide()
}
ActionButton(Modifier.fillMaxWidth(0.50f), null, stringResource(MR.strings.gallery_video_button), icon = painterResource(MR.images.ic_smart_display)) {
attachmentOption.value = AttachmentOption.GalleryVideo
hide()
}
ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(MR.strings.choose_file), icon = painterResource(MR.images.ic_note_add)) {
attachmentOption.value = AttachmentOption.File
hide()
}
ChooseAttachmentButtons(attachmentOption, hide)
}
}
}
@Composable
expect fun ChooseAttachmentButtons(attachmentOption: MutableState<AttachmentOption?>, hide: () -> Unit)

View File

@@ -394,6 +394,7 @@
<string name="use_camera_button">Camera</string>
<string name="from_gallery_button">From Gallery</string>
<string name="choose_file">File</string>
<string name="choose_file_title">Choose a file</string>
<string name="gallery_image_button">Image</string>
<string name="gallery_video_button">Video</string>

View File

@@ -38,8 +38,20 @@ fun showApp() = application {
FileDialogChooser(
title = "SimpleX",
isLoad = true,
params = simplexWindowState.openDialog.params,
onResult = {
simplexWindowState.openDialog.onResult(it)
simplexWindowState.openDialog.onResult(it.firstOrNull())
}
)
}
if (simplexWindowState.openMultipleDialog.isAwaiting) {
FileDialogChooser(
title = "SimpleX",
isLoad = true,
params = simplexWindowState.openMultipleDialog.params,
onResult = {
simplexWindowState.openMultipleDialog.onResult(it)
}
)
}
@@ -48,7 +60,8 @@ fun showApp() = application {
FileDialogChooser(
title = "SimpleX",
isLoad = false,
onResult = { simplexWindowState.saveDialog.onResult(it) }
params = simplexWindowState.saveDialog.params,
onResult = { simplexWindowState.saveDialog.onResult(it.firstOrNull()) }
)
}
val toasts = remember { simplexWindowState.toasts }
@@ -97,16 +110,25 @@ fun showApp() = application {
class SimplexWindowState {
val backstack = mutableStateListOf<() -> Unit>()
val openDialog = DialogState<File?>()
val openMultipleDialog = DialogState<List<File>>()
val saveDialog = DialogState<File?>()
val toasts = mutableStateListOf<Pair<String, Long>>()
}
data class DialogParams(
val allowMultiple: Boolean = false,
val fileFilter: ((File?) -> Boolean)? = null,
val fileFilterDescription: String = "",
)
class DialogState<T> {
private var onResult: CompletableDeferred<T>? by mutableStateOf(null)
var params = DialogParams()
val isAwaiting get() = onResult != null
suspend fun awaitResult(): T {
suspend fun awaitResult(params: DialogParams = DialogParams()): T {
onResult = CompletableDeferred()
this.params = params
val result = onResult!!.await()
onResult = null
return result

View File

@@ -1,8 +1,9 @@
package chat.simplex.common.platform
import androidx.compose.runtime.*
import chat.simplex.common.DesktopApp
import chat.simplex.common.simplexWindowState
import chat.simplex.common.*
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import java.io.*
import java.net.URI
@@ -31,6 +32,10 @@ actual val databaseExportDir: File = tmpDir
actual fun rememberFileChooserLauncher(getContent: Boolean, onResult: (URI?) -> Unit): FileChooserLauncher =
remember { FileChooserLauncher(getContent, onResult) }
@Composable
actual fun rememberFileChooserMultipleLauncher(onResult: (List<URI>) -> Unit): FileChooserMultipleLauncher =
remember { FileChooserMultipleLauncher(onResult) }
actual class FileChooserLauncher actual constructor() {
var getContent: Boolean = false
lateinit var onResult: (URI?) -> Unit
@@ -41,10 +46,50 @@ actual class FileChooserLauncher actual constructor() {
}
actual suspend fun launch(input: String) {
val res = if (getContent) simplexWindowState.openDialog.awaitResult() else simplexWindowState.saveDialog.awaitResult()
onResult(if (!getContent && input.isNotEmpty() && res != null) File(res, input).toURI() else res?.toURI())
val res = if (getContent) {
val params = DialogParams(
allowMultiple = false,
fileFilter = fileFilter(input),
fileFilterDescription = fileFilterDescription(input),
)
simplexWindowState.openDialog.awaitResult(params)
} else {
simplexWindowState.saveDialog.awaitResult()
}
onResult(res?.toURI())
}
}
actual class FileChooserMultipleLauncher actual constructor() {
lateinit var onResult: (List<URI>) -> Unit
constructor(onResult: (List<URI>) -> Unit): this() {
this.onResult = onResult
}
actual suspend fun launch(input: String) {
val params = DialogParams(
allowMultiple = true,
fileFilter = fileFilter(input),
fileFilterDescription = fileFilterDescription(input),
)
onResult(simplexWindowState.openMultipleDialog.awaitResult(params).map { it.toURI() })
}
}
private fun fileFilter(input: String): (File?) -> Boolean = when(input) {
"image/*" -> { file -> if (file?.isDirectory == true) true else if (file != null) isImage(file.toURI()) else false }
"video/*" -> { file -> if (file?.isDirectory == true) true else if (file != null) isVideo(file.toURI()) else false }
"*/*" -> { _ -> true }
else -> { _ -> true }
}
private fun fileFilterDescription(input: String): String = when(input) {
"image/*" -> generalGetString(MR.strings.gallery_image_button)
"video/*" -> generalGetString(MR.strings.gallery_video_button)
"*/*" -> generalGetString(MR.strings.choose_file)
else -> ""
}
actual fun URI.inputStream(): InputStream? = File(URI("file:" + toString().removePrefix("file:"))).inputStream()
actual fun URI.outputStream(): OutputStream = File(URI("file:" + toString().removePrefix("file:"))).outputStream()

View File

@@ -41,7 +41,20 @@ actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String {
}
return str
}
actual fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream = TODO()
actual fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream {
var img = image
var stream = compressImageData(img, usePng)
while (stream.size() > maxDataSize) {
val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble())
val clippedRatio = kotlin.math.min(ratio, 2.0)
val width = (img.width.toDouble() / clippedRatio).toInt()
val height = img.height * width / img.width
img = img.scale(width, height)
stream = compressImageData(img, usePng)
}
return stream
}
actual fun cropToSquare(image: ImageBitmap): ImageBitmap {
var xOffset = 0
var yOffset = 0

View File

@@ -0,0 +1,13 @@
package chat.simplex.common.platform
import java.net.URI
fun isVideo(uri: URI): Boolean {
val path = uri.path.lowercase()
return path.endsWith(".mov") ||
path.endsWith(".avi") ||
path.endsWith(".mp4") ||
path.endsWith(".mpg") ||
path.endsWith(".mpeg") ||
path.endsWith(".mkv")
}

View File

@@ -1,7 +1,8 @@
package chat.simplex.common.views.chat
import androidx.compose.runtime.*
import chat.simplex.common.views.helpers.AttachmentOption
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.*
import java.net.URI
@Composable
@@ -11,12 +12,27 @@ actual fun AttachmentSelection(
processPickedFile: (URI?, String?) -> Unit,
processPickedMedia: (List<URI>, String?) -> Unit
) {
val imageLauncher = rememberFileChooserMultipleLauncher {
processPickedMedia(it, null)
}
val videoLauncher = rememberFileChooserMultipleLauncher {
processPickedMedia(it, null)
}
val filesLauncher = rememberFileChooserLauncher(true) {
if (it != null) processPickedFile(it, null)
}
LaunchedEffect(attachmentOption.value) {
when (attachmentOption.value) {
AttachmentOption.CameraPhoto -> {}
AttachmentOption.GalleryImage -> {}
AttachmentOption.GalleryVideo -> {}
AttachmentOption.File -> {}
AttachmentOption.GalleryImage -> {
imageLauncher.launch("image/*")
}
AttachmentOption.GalleryVideo -> {
videoLauncher.launch("video/*")
}
AttachmentOption.File -> {
filesLauncher.launch("*/*")
}
else -> {}
}
attachmentOption.value = null

View File

@@ -0,0 +1,22 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import chat.simplex.common.views.newchat.ActionButton
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
actual fun ChooseAttachmentButtons(attachmentOption: MutableState<AttachmentOption?>, hide: () -> Unit) {
ActionButton(Modifier.fillMaxWidth(0.5f), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) {
attachmentOption.value = AttachmentOption.GalleryImage
hide()
}
ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(MR.strings.choose_file), icon = painterResource(MR.images.ic_note_add)) {
attachmentOption.value = AttachmentOption.File
hide()
}
}

View File

@@ -3,6 +3,8 @@ package chat.simplex.common.views.helpers
import androidx.compose.runtime.*
import androidx.compose.ui.input.key.*
import androidx.compose.ui.window.*
import chat.simplex.common.DialogParams
import chat.simplex.res.MR
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.awt.FileDialog
@@ -35,13 +37,13 @@ actual fun DefaultDialog(
fun FrameWindowScope.FileDialogChooser(
title: String,
isLoad: Boolean,
extensions: List<FileFilter> = emptyList(),
onResult: (result: File?) -> Unit
params: DialogParams,
onResult: (result: List<File>) -> Unit
) {
if (isLinux()) {
FileDialogChooserMultiple(title, isLoad, extensions) { onResult(it.firstOrNull()) }
FileDialogChooserMultiple(title, isLoad, params.allowMultiple, params.fileFilter, params.fileFilterDescription, onResult)
} else {
FileDialogAwt(title, isLoad, onResult)
FileDialogAwt(title, isLoad, params.allowMultiple, params.fileFilter, onResult)
}
}
@@ -49,7 +51,9 @@ fun FrameWindowScope.FileDialogChooser(
fun FrameWindowScope.FileDialogChooserMultiple(
title: String,
isLoad: Boolean,
extensions: List<FileFilter> = emptyList(),
allowMultiple: Boolean,
fileFilter: ((File?) -> Boolean)? = null,
fileFilterDescription: String? = null,
onResult: (result: List<File>) -> Unit
) {
val scope = rememberCoroutineScope()
@@ -57,9 +61,15 @@ fun FrameWindowScope.FileDialogChooserMultiple(
val job = scope.launch(Dispatchers.Main) {
val fileChooser = JFileChooser()
fileChooser.dialogTitle = title
fileChooser.isMultiSelectionEnabled = isLoad
fileChooser.isAcceptAllFileFilterUsed = extensions.isEmpty()
extensions.forEach { fileChooser.addChoosableFileFilter(it) }
fileChooser.isMultiSelectionEnabled = allowMultiple && isLoad
fileChooser.isAcceptAllFileFilterUsed = fileFilter == null
if (fileFilter != null && fileFilterDescription != null) {
fileChooser.addChoosableFileFilter(object: FileFilter() {
override fun accept(file: File?): Boolean = fileFilter(file)
override fun getDescription(): String = fileFilterDescription
})
}
val returned = if (isLoad) {
fileChooser.showOpenDialog(window)
} else {
@@ -69,7 +79,11 @@ fun FrameWindowScope.FileDialogChooserMultiple(
val result = when (returned) {
JFileChooser.APPROVE_OPTION -> {
if (isLoad) {
fileChooser.selectedFiles.filter { it.canRead() }
when {
allowMultiple -> fileChooser.selectedFiles.filter { it.canRead() }
fileChooser.selectedFile != null && fileChooser.selectedFile.canRead() -> listOf(fileChooser.selectedFile)
else -> emptyList()
}
} else {
if (!fileChooser.fileFilter.accept(fileChooser.selectedFile)) {
val ext = (fileChooser.fileFilter as FileNameExtensionFilter).extensions[0]
@@ -78,7 +92,7 @@ fun FrameWindowScope.FileDialogChooserMultiple(
listOf(fileChooser.selectedFile)
}
}
else -> listOf();
else -> emptyList()
}
onResult(result)
}
@@ -95,22 +109,30 @@ fun FrameWindowScope.FileDialogChooserMultiple(
private fun FrameWindowScope.FileDialogAwt(
title: String,
isLoad: Boolean,
onResult: (result: File?) -> Unit
allowMultiple: Boolean,
fileFilter: ((File?) -> Boolean)? = null,
onResult: (result: List<File>) -> Unit
) = AwtWindow(
create = {
object: FileDialog(window, "Choose a file", if (isLoad) LOAD else SAVE) {
object: FileDialog(window, generalGetString(MR.strings.choose_file_title), if (isLoad) LOAD else SAVE) {
override fun setVisible(value: Boolean) {
super.setVisible(value)
if (value) {
if (file != null) {
onResult(File(directory).resolve(file))
if (files != null) {
onResult(files.toList())
} else {
onResult(null)
onResult(emptyList())
}
}
}
}.apply {
this.title = title
this.isMultipleMode = allowMultiple && isLoad
if (fileFilter != null) {
this.setFilenameFilter { dir, file ->
fileFilter(File(dir.absolutePath + File.separator + file))
}
}
}
},
dispose = FileDialog::dispose

View File

@@ -44,13 +44,8 @@ actual fun GetImageBottomSheet(
}
}
val pickImageLauncher = rememberFileChooserLauncher(true, processPickedImage)
// LALAL
/*ActionButton(null, stringResource(MR.strings.use_camera_button), icon = painterResource(MR.images.ic_photo_camera)) {
hideBottomSheet()
}*/
ActionButton(null, stringResource(MR.strings.from_gallery_button), icon = painterResource(MR.images.ic_image)) {
// LALAL support providing file extensions
withApi { pickImageLauncher.launch("") }
withApi { pickImageLauncher.launch("image/*") }
}
}
}