From 5c198bc279e6dd49ed7c33a56ee7f81e31761180 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 23 May 2023 16:58:22 +0200 Subject: [PATCH] Media: fix zoomable image with non content uri --- .../messages/impl/media/local/LocalMedia.kt | 9 +++- .../impl/media/local/LocalMediaView.kt | 22 +++++--- .../impl/media/local/UriToFileMapper.kt | 50 +++++++++++++++++++ .../androidutils/uri/UriExtensions.kt | 4 ++ 4 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/UriToFileMapper.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt index c26a4baaff..5270621c2d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt @@ -18,10 +18,17 @@ package io.element.android.features.messages.impl.media.local import android.net.Uri import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize data class LocalMedia( val uri: Uri, val mimeType: String, -) : Parcelable +) : Parcelable { + + /** + * This tries to convert the uri to a file if applicable, otherwise keep it as uri. + */ + @IgnoredOnParcel val model: Any = UriToFileMapper.map(uri) ?: uri +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt index b5591c14f2..f814dd6662 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt @@ -17,7 +17,6 @@ package io.element.android.features.messages.impl.media.local import android.annotation.SuppressLint -import android.net.Uri import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.compose.foundation.layout.fillMaxSize @@ -36,7 +35,10 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage +import me.saket.telephoto.zoomable.rememberZoomableImageState +import me.saket.telephoto.zoomable.rememberZoomableState @SuppressLint("UnsafeOptInUsageError") @Composable @@ -46,11 +48,11 @@ fun LocalMediaView( ) { when { MimeTypes.isImage(localMedia.mimeType) -> MediaImageView( - uri = localMedia.uri, + localMedia = localMedia, modifier = modifier ) MimeTypes.isVideo(localMedia.mimeType) -> MediaVideoView( - uri = localMedia.uri, + localMedia = localMedia, modifier = modifier ) else -> Unit @@ -59,12 +61,16 @@ fun LocalMediaView( @Composable private fun MediaImageView( - uri: Uri, + localMedia: LocalMedia, modifier: Modifier = Modifier, ) { + val zoomableState = rememberZoomableState( + zoomSpec = ZoomSpec(maxZoomFactor = 3f) + ) ZoomableAsyncImage( modifier = modifier.fillMaxSize(), - model = uri, + state = rememberZoomableImageState(zoomableState), + model = localMedia.model, contentDescription = "Image", contentScale = ContentScale.Fit, ) @@ -73,7 +79,7 @@ private fun MediaImageView( @UnstableApi @Composable fun MediaVideoView( - uri: Uri, + localMedia: LocalMedia, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -84,8 +90,8 @@ fun MediaVideoView( this.prepare() } } - LaunchedEffect(uri) { - val mediaItem = MediaItem.fromUri(uri) + LaunchedEffect(localMedia.uri) { + val mediaItem = MediaItem.fromUri(localMedia.uri) exoPlayer.setMediaItem(mediaItem) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/UriToFileMapper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/UriToFileMapper.kt new file mode 100644 index 0000000000..417219c60e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/UriToFileMapper.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.media.local + +import android.content.ContentResolver +import android.net.Uri +import io.element.android.libraries.androidutils.uri.ASSET_FILE_PATH_ROOT +import io.element.android.libraries.androidutils.uri.firstPathSegment +import java.io.File + +/** + * Tries to convert a URI to a File. + * Extracted from Coil [coil.map.FileUriMapper] + */ +object UriToFileMapper { + + fun map(data: Uri): File? { + if (!isApplicable(data)) return null + return if (data.scheme == ContentResolver.SCHEME_FILE) { + data.path?.let(::File) + } else { + // If the scheme is not "file", it's null, representing a literal path on disk. + // Assume the entire input, regardless of any reserved characters, is valid. + File(data.toString()) + } + } + + private fun isApplicable(data: Uri): Boolean { + return data.scheme.let { it == null || it == ContentResolver.SCHEME_FILE } && + data.path.orEmpty().startsWith('/') && data.firstPathSegment != null + } + + private fun isAssetUri(uri: Uri): Boolean { + return uri.scheme == ContentResolver.SCHEME_FILE && uri.firstPathSegment == ASSET_FILE_PATH_ROOT + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt index 485a103b5b..1375104b79 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt @@ -18,8 +18,12 @@ package io.element.android.libraries.androidutils.uri import android.net.Uri +const val ASSET_FILE_PATH_ROOT = "android_asset" const val IGNORED_SCHEMA = "ignored" fun Uri.isIgnored() = scheme == IGNORED_SCHEMA fun createIgnoredUri(path: String): Uri = Uri.parse("$IGNORED_SCHEMA://$path") + +val Uri.firstPathSegment: String? + get() = pathSegments.firstOrNull()