android, desktop: better handling of URI's (#3450)

This commit is contained in:
Stanislav Dmitrenko 2023-11-25 00:19:31 +08:00 committed by GitHub
parent 8ce9dd7ab6
commit f9b5c673c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 47 additions and 39 deletions

View File

@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import com.google.accompanist.insets.navigationBarsWithImePadding import com.google.accompanist.insets.navigationBarsWithImePadding
import java.io.File
actual fun Modifier.navigationBarsWithImePadding(): Modifier = navigationBarsWithImePadding() actual fun Modifier.navigationBarsWithImePadding(): Modifier = navigationBarsWithImePadding()
@ -19,7 +20,7 @@ actual fun ProvideWindowInsets(
@Composable @Composable
actual fun Modifier.desktopOnExternalDrag( actual fun Modifier.desktopOnExternalDrag(
enabled: Boolean, enabled: Boolean,
onFiles: (List<String>) -> Unit, onFiles: (List<File>) -> Unit,
onImage: (Painter) -> Unit, onImage: (Painter) -> Unit,
onText: (String) -> Unit onText: (String) -> Unit
): Modifier = this ): Modifier = this

View File

@ -71,7 +71,7 @@ actual class VideoPlayer actual constructor(
private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean { private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean {
val filepath = getAppFilePath(uri) val filepath = getAppFilePath(uri)
if (filepath == null || !File(filepath).exists()) { if (filepath == null || !File(filepath).exists()) {
Log.e(TAG, "No such file: $uri") Log.e(TAG, "No such file: $filepath")
brokenVideo.value = true brokenVideo.value = true
return false return false
} }

View File

@ -27,7 +27,6 @@ import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import java.io.File import java.io.File
import java.net.URI import java.net.URI
import java.net.URLDecoder
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.util.* import java.util.*
@ -2363,7 +2362,7 @@ data class CryptoFile(
companion object { companion object {
fun plain(f: String): CryptoFile = CryptoFile(f, null) fun plain(f: String): CryptoFile = CryptoFile(f, null)
fun desktopPlain(f: URI): CryptoFile = CryptoFile(URLDecoder.decode(f.rawPath, "UTF-8"), null) fun desktopPlain(f: URI): CryptoFile = CryptoFile(f.toFile().absolutePath, null)
} }
} }

View File

@ -7,6 +7,8 @@ import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR import chat.simplex.res.MR
import java.io.* import java.io.*
import java.net.URI import java.net.URI
import java.net.URLDecoder
import java.net.URLEncoder
expect val dataDir: File expect val dataDir: File
expect val tmpDir: File expect val tmpDir: File
@ -28,6 +30,10 @@ expect val remoteHostsDir: File
expect fun desktopOpenDatabaseDir() expect fun desktopOpenDatabaseDir()
fun createURIFromPath(absolutePath: String): URI = URI.create(URLEncoder.encode(absolutePath, "UTF-8"))
fun URI.toFile(): File = File(URLDecoder.decode(rawPath, "UTF-8").removePrefix("file:"))
fun copyFileToFile(from: File, to: URI, finally: () -> Unit) { fun copyFileToFile(from: File, to: URI, finally: () -> Unit) {
try { try {
to.outputStream().use { stream -> to.outputStream().use { stream ->

View File

@ -3,6 +3,7 @@ package chat.simplex.common.platform
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import java.io.File
expect fun Modifier.navigationBarsWithImePadding(): Modifier expect fun Modifier.navigationBarsWithImePadding(): Modifier
@ -16,7 +17,7 @@ expect fun ProvideWindowInsets(
@Composable @Composable
expect fun Modifier.desktopOnExternalDrag( expect fun Modifier.desktopOnExternalDrag(
enabled: Boolean = true, enabled: Boolean = true,
onFiles: (List<String>) -> Unit = {}, onFiles: (List<File>) -> Unit = {},
onImage: (Painter) -> Unit = {}, onImage: (Painter) -> Unit = {},
onText: (String) -> Unit = {} onText: (String) -> Unit = {}
): Modifier ): Modifier

View File

@ -43,16 +43,13 @@ object VideoPlayerHolder {
): VideoPlayer = ): VideoPlayer =
players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled) } players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled) }
fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean = private fun player(uri: URI?, gallery: Boolean): VideoPlayer? {
player(fileName, gallery)?.enableSound(enable) == true uri ?: return null
return players.values.firstOrNull { player -> player.uri == uri && player.gallery == gallery }
private fun player(fileName: String?, gallery: Boolean): VideoPlayer? {
fileName ?: return null
return players.values.firstOrNull { player -> player.uri.path?.endsWith(fileName) == true && player.gallery == gallery }
} }
fun release(uri: URI, gallery: Boolean, remove: Boolean) = fun release(uri: URI, gallery: Boolean, remove: Boolean) =
player(uri.path, gallery)?.release(remove).run { } player(uri, gallery)?.release(remove).run { }
fun stopAll() { fun stopAll() {
players.values.forEach { it.stop() } players.values.forEach { it.stop() }

View File

@ -501,7 +501,7 @@ fun ChatLayout(
.fillMaxWidth() .fillMaxWidth()
.desktopOnExternalDrag( .desktopOnExternalDrag(
enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value, enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value,
onFiles = { paths -> composeState.onFilesAttached(paths.map { URI.create(it) }) }, onFiles = { paths -> composeState.onFilesAttached(paths.map { it.toURI() }) },
onImage = { onImage = {
// TODO: file is not saved anywhere?! // TODO: file is not saved anywhere?!
val tmpFile = File.createTempFile("image", ".bmp", tmpDir) val tmpFile = File.createTempFile("image", ".bmp", tmpDir)

View File

@ -104,5 +104,5 @@ private fun fileFilterDescription(input: String): String = when(input) {
else -> "" else -> ""
} }
actual fun URI.inputStream(): InputStream? = File(URI("file:" + toString().removePrefix("file:"))).inputStream() actual fun URI.inputStream(): InputStream? = toFile().inputStream()
actual fun URI.outputStream(): OutputStream = File(URI("file:" + toString().removePrefix("file:"))).outputStream() actual fun URI.outputStream(): OutputStream = toFile().outputStream()

View File

@ -157,7 +157,7 @@ actual fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap {
// LALAL // LALAL
actual fun isImage(uri: URI): Boolean { actual fun isImage(uri: URI): Boolean {
val path = uri.path.lowercase() val path = uri.toFile().path.lowercase()
return path.endsWith(".gif") || return path.endsWith(".gif") ||
path.endsWith(".webp") || path.endsWith(".webp") ||
path.endsWith(".png") || path.endsWith(".png") ||
@ -166,7 +166,7 @@ actual fun isImage(uri: URI): Boolean {
} }
actual fun isAnimImage(uri: URI, drawable: Any?): Boolean { actual fun isAnimImage(uri: URI, drawable: Any?): Boolean {
val path = uri.path.lowercase() val path = uri.toFile().path.lowercase()
return path.endsWith(".gif") || path.endsWith(".webp") return path.endsWith(".gif") || path.endsWith(".webp")
} }

View File

@ -4,6 +4,8 @@ import androidx.compose.foundation.contextMenuOpenDetector
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.* import androidx.compose.ui.*
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import java.io.File
import java.net.URI
actual fun Modifier.navigationBarsWithImePadding(): Modifier = this actual fun Modifier.navigationBarsWithImePadding(): Modifier = this
@ -19,13 +21,15 @@ actual fun ProvideWindowInsets(
@Composable @Composable
actual fun Modifier.desktopOnExternalDrag( actual fun Modifier.desktopOnExternalDrag(
enabled: Boolean, enabled: Boolean,
onFiles: (List<String>) -> Unit, onFiles: (List<File>) -> Unit,
onImage: (Painter) -> Unit, onImage: (Painter) -> Unit,
onText: (String) -> Unit onText: (String) -> Unit
): Modifier = ): Modifier =
onExternalDrag(enabled) { onExternalDrag(enabled) {
when(val data = it.dragData) { when(val data = it.dragData) {
is DragData.FilesList -> onFiles(data.readFiles()) // data.readFiles() returns filePath in URI format (where spaces replaces with %20). But it's an error-prone idea to work later
// with such format when everywhere we use absolutePath in File() format
is DragData.FilesList -> onFiles(data.readFiles().map { URI.create(it).toFile() })
is DragData.Image -> onImage(data.readImage()) is DragData.Image -> onImage(data.readImage())
is DragData.Text -> onText(data.readText()) is DragData.Text -> onText(data.readText())
} }

View File

@ -46,16 +46,16 @@ actual object AudioPlayer: AudioPlayerInterface {
VideoPlayerHolder.stopAll() VideoPlayerHolder.stopAll()
RecorderInterface.stopRecording?.invoke() RecorderInterface.stopRecording?.invoke()
val current = currentlyPlaying.value val current = currentlyPlaying.value
if (current == null || current.first != fileSource) { if (current == null || current.first != fileSource || !player.status().isPlayable) {
stopListener() stopListener()
player.stop() player.stop()
runCatching { runCatching {
if (fileSource.cryptoArgs != null) { if (fileSource.cryptoArgs != null) {
val tmpFile = fileSource.createTmpFileIfNeeded() val tmpFile = fileSource.createTmpFileIfNeeded()
decryptCryptoFile(absoluteFilePath, fileSource.cryptoArgs, tmpFile.absolutePath) decryptCryptoFile(absoluteFilePath, fileSource.cryptoArgs, tmpFile.absolutePath)
player.media().prepare(tmpFile.toURI().toString().replaceFirst("file:", "file://")) player.media().prepare(tmpFile.absolutePath)
} else { } else {
player.media().prepare(File(absoluteFilePath).toURI().toString().replaceFirst("file:", "file://")) player.media().prepare(absoluteFilePath)
} }
}.onFailure { }.onFailure {
Log.e(TAG, it.stackTraceToString()) Log.e(TAG, it.stackTraceToString())
@ -171,7 +171,7 @@ actual object AudioPlayer: AudioPlayerInterface {
var res: Int? = null var res: Int? = null
try { try {
val helperPlayer = AudioPlayerComponent().mediaPlayer() val helperPlayer = AudioPlayerComponent().mediaPlayer()
helperPlayer.media().startPaused(File(unencryptedFilePath).toURI().toString().replaceFirst("file:", "file://")) helperPlayer.media().startPaused(unencryptedFilePath)
res = helperPlayer.duration res = helperPlayer.duration
helperPlayer.stop() helperPlayer.stop()
helperPlayer.release() helperPlayer.release()

View File

@ -4,6 +4,7 @@ import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.common.views.helpers.withApi import chat.simplex.common.views.helpers.withApi
import java.io.File import java.io.File
import java.net.URI import java.net.URI
@ -25,14 +26,16 @@ actual fun shareFile(text: String, fileSource: CryptoFile) {
withApi { withApi {
FileChooserLauncher(false) { to: URI? -> FileChooserLauncher(false) { to: URI? ->
if (to != null) { if (to != null) {
val absolutePath = if (fileSource.isAbsolutePath) fileSource.filePath else getAppFilePath(fileSource.filePath)
if (fileSource.cryptoArgs != null) { if (fileSource.cryptoArgs != null) {
try { try {
decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, to.path) decryptCryptoFile(absolutePath, fileSource.cryptoArgs, to.toFile().absolutePath)
showToast(generalGetString(MR.strings.file_saved))
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString())
} }
} else { } else {
copyFileToFile(File(fileSource.filePath), to) {} copyFileToFile(File(absolutePath), to) {}
} }
} }
}.launch(fileSource.filePath) }.launch(fileSource.filePath)

View File

@ -52,7 +52,7 @@ actual class VideoPlayer actual constructor(
private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean { private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean {
val filepath = getAppFilePath(uri) val filepath = getAppFilePath(uri)
if (filepath == null || !File(filepath).exists()) { if (filepath == null || !File(filepath).exists()) {
Log.e(TAG, "No such file: $uri") Log.e(TAG, "No such file: $filepath")
brokenVideo.value = true brokenVideo.value = true
return false return false
} }
@ -62,10 +62,9 @@ actual class VideoPlayer actual constructor(
} }
AudioPlayer.stop() AudioPlayer.stop()
VideoPlayerHolder.stopAll() VideoPlayerHolder.stopAll()
val playerFilePath = uri.toString().replaceFirst("file:", "file://")
if (listener.value == null) { if (listener.value == null) {
runCatching { runCatching {
player.media().prepare(playerFilePath) player.media().prepare(uri.toFile().absolutePath)
if (seek != null) { if (seek != null) {
player.seekTo(seek.toInt()) player.seekTo(seek.toInt())
} }
@ -217,12 +216,12 @@ actual class VideoPlayer actual constructor(
suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration = withContext(playerThread.asCoroutineDispatcher()) { suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration = withContext(playerThread.asCoroutineDispatcher()) {
val mediaComponent = getOrCreateHelperPlayer() val mediaComponent = getOrCreateHelperPlayer()
val player = mediaComponent.mediaPlayer() val player = mediaComponent.mediaPlayer()
if (uri == null || !File(uri.rawPath).exists()) { if (uri == null || !uri.toFile().exists()) {
if (withAlertOnException) showVideoDecodingException() if (withAlertOnException) showVideoDecodingException()
return@withContext VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L) return@withContext VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L)
} }
player.media().startPaused(uri.toString().replaceFirst("file:", "file://")) player.media().startPaused(uri.toFile().absolutePath)
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
var snap: BufferedImage? = null var snap: BufferedImage? = null
while (snap == null && start + 5000 > System.currentTimeMillis()) { while (snap == null && start + 5000 > System.currentTimeMillis()) {

View File

@ -3,7 +3,7 @@ package chat.simplex.common.platform
import java.net.URI import java.net.URI
fun isVideo(uri: URI): Boolean { fun isVideo(uri: URI): Boolean {
val path = uri.path.lowercase() val path = uri.toFile().path.lowercase()
return path.endsWith(".mov") || return path.endsWith(".mov") ||
path.endsWith(".avi") || path.endsWith(".avi") ||
path.endsWith(".mp4") || path.endsWith(".mp4") ||

View File

@ -8,14 +8,12 @@ import androidx.compose.ui.unit.Density
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.simplexWindowState import chat.simplex.common.simplexWindowState
import chat.simplex.res.MR
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.net.URI import java.net.URI
import javax.imageio.ImageIO import javax.imageio.ImageIO
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.io.path.toPath
private val bStyle = SpanStyle(fontWeight = FontWeight.Bold) private val bStyle = SpanStyle(fontWeight = FontWeight.Bold)
private val iStyle = SpanStyle(fontStyle = FontStyle.Italic) private val iStyle = SpanStyle(fontStyle = FontStyle.Italic)
@ -90,9 +88,9 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat
actual fun getAppFileUri(fileName: String): URI { actual fun getAppFileUri(fileName: String): URI {
val rh = chatModel.currentRemoteHost.value val rh = chatModel.currentRemoteHost.value
return if (rh == null) { return if (rh == null) {
URI(appFilesDir.toURI().toString() + "/" + fileName) createURIFromPath(appFilesDir.absolutePath + "/" + fileName)
} else { } else {
URI(dataDir.absolutePath + "/remote_hosts/" + rh.storePath + "/simplex_v1_files/" + fileName) createURIFromPath(dataDir.absolutePath + "/remote_hosts/" + rh.storePath + "/simplex_v1_files/" + fileName)
} }
} }
@ -116,11 +114,11 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>?
} }
} }
actual fun getFileName(uri: URI): String? = uri.toPath().toFile().name actual fun getFileName(uri: URI): String? = uri.toFile().name
actual fun getAppFilePath(uri: URI): String? = uri.path actual fun getAppFilePath(uri: URI): String? = uri.toFile().absolutePath
actual fun getFileSize(uri: URI): Long? = uri.toPath().toFile().length() actual fun getFileSize(uri: URI): Long? = uri.toFile().length()
actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitmap? = actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitmap? =
try { try {