mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
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:
committed by
GitHub
parent
2389e870b3
commit
27f4661ac4
@@ -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
|
||||
|
||||
+49
-4
@@ -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()
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
+13
@@ -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")
|
||||
}
|
||||
+20
-4
@@ -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
|
||||
|
||||
+22
@@ -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()
|
||||
}
|
||||
}
|
||||
+37
-15
@@ -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
|
||||
|
||||
+1
-6
@@ -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/*") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user