diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 00e74b026c..3dc77bc00a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -34,6 +34,7 @@ import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode import io.element.android.features.messages.impl.media.viewer.MediaViewerNode import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.libraries.architecture.BackstackNode @@ -134,6 +135,16 @@ class MessagesFlowNode @AssistedInject constructor( ) backstack.push(navTarget) } + is TimelineItemFileContent -> { + val mediaSource = event.content.fileSource + val navTarget = NavTarget.MediaViewer( + title = event.content.body, + mediaSource = mediaSource, + thumbnailSource = event.content.thumbnailSource, + mimeType = event.content.mimeType, + ) + backstack.push(navTarget) + } else -> Unit } } 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 3040f0bfbd..142fb57102 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 @@ -32,15 +32,20 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.media3.common.MediaItem -import androidx.media3.common.MimeTypes import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper +import io.element.android.features.messages.impl.media.local.pdf.PdfViewer +import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.designsystem.R import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import me.saket.telephoto.zoomable.ZoomSpec +import me.saket.telephoto.zoomable.ZoomableState import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage import me.saket.telephoto.zoomable.rememberZoomableImageState import me.saket.telephoto.zoomable.rememberZoomableState @@ -53,14 +58,24 @@ fun LocalMediaView( mimeType: String? = localMedia?.mimeType, onReady: () -> Unit = {}, ) { + val zoomableState = rememberZoomableState( + zoomSpec = ZoomSpec(maxZoomFactor = 5f) + ) when { - MimeTypes.isImage(mimeType) -> MediaImageView( + mimeType.isMimeTypeImage() -> MediaImageView( + localMedia = localMedia, + zoomableState = zoomableState, + onReady = onReady, + modifier = modifier + ) + mimeType.isMimeTypeVideo() -> MediaVideoView( localMedia = localMedia, onReady = onReady, modifier = modifier ) - MimeTypes.isVideo(mimeType) -> MediaVideoView( + mimeType == MimeTypes.Pdf -> MediaPDFView( localMedia = localMedia, + zoomableState = zoomableState, onReady = onReady, modifier = modifier ) @@ -71,6 +86,7 @@ fun LocalMediaView( @Composable private fun MediaImageView( localMedia: LocalMedia?, + zoomableState: ZoomableState, onReady: () -> Unit, modifier: Modifier = Modifier, ) { @@ -81,9 +97,6 @@ private fun MediaImageView( contentDescription = null, ) } else { - val zoomableState = rememberZoomableState( - zoomSpec = ZoomSpec(maxZoomFactor = 3f) - ) val zoomableImageState = rememberZoomableImageState(zoomableState) LaunchedEffect(zoomableImageState.isImageDisplayed) { if (zoomableImageState.isImageDisplayed) { @@ -154,3 +167,22 @@ fun MediaVideoView( } } } + +@Composable +fun MediaPDFView( + localMedia: LocalMedia?, + zoomableState: ZoomableState, + onReady: () -> Unit, + modifier: Modifier = Modifier, +) { + val pdfViewerState = rememberPdfViewerState( + model = localMedia?.model, + zoomableState = zoomableState + ) + LaunchedEffect(pdfViewerState.isLoaded) { + if (pdfViewerState.isLoaded) { + onReady() + } + } + PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/ParcelFileDescriptorFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/ParcelFileDescriptorFactory.kt new file mode 100644 index 0000000000..22233b313f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/ParcelFileDescriptorFactory.kt @@ -0,0 +1,33 @@ +/* + * 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.pdf + +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import java.io.File + +class ParcelFileDescriptorFactory(private val context: Context) { + + fun create(model: Any?) = runCatching { + when (model) { + is File -> ParcelFileDescriptor.open(model, ParcelFileDescriptor.MODE_READ_ONLY) + is Uri -> context.contentResolver.openFileDescriptor(model, "r")!! + else -> error(RuntimeException("Can't handle this model")) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfPage.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfPage.kt new file mode 100644 index 0000000000..0b8caed968 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfPage.kt @@ -0,0 +1,111 @@ +/* + * 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.pdf + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.pdf.PdfRenderer +import androidx.compose.runtime.Stable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +@Stable +class PdfPage( + maxWidth: Int, + val pageIndex: Int, + private val mutex: Mutex, + private val pdfRenderer: PdfRenderer, + private val coroutineScope: CoroutineScope, +) { + + sealed interface State { + data class Loading(val width: Int, val height: Int) : State + data class Loaded(val bitmap: Bitmap) : State + } + + private val renderWidth = maxWidth + private val renderHeight: Int + private var loadJob: Job? = null + + init { + // We are just opening and closing the page to extract data so we can build the Loading state with the correct dimensions. + pdfRenderer.openPage(pageIndex).use { page -> + renderHeight = (page.height * (renderWidth.toFloat() / page.width)).toInt() + } + } + + private val mutableStateFlow = MutableStateFlow( + State.Loading( + width = renderWidth, + height = renderHeight + ) + ) + val stateFlow: StateFlow = mutableStateFlow + + fun load() { + loadJob = coroutineScope.launch { + val bitmap = mutex.withLock { + withContext(Dispatchers.IO) { + pdfRenderer.openPageRenderAndClose(pageIndex, renderWidth, renderHeight) + } + } + mutableStateFlow.value = State.Loaded(bitmap) + } + } + + fun close() { + loadJob?.cancel() + when (val loadingState = stateFlow.value) { + is State.Loading -> return + is State.Loaded -> { + loadingState.bitmap.recycle() + mutableStateFlow.value = State.Loading( + width = renderWidth, + height = renderHeight + ) + } + } + } + + private fun PdfRenderer.openPageRenderAndClose(index: Int, bitmapWidth: Int, bitmapHeight: Int): Bitmap { + fun createBitmap(bitmapWidth: Int, bitmapHeight: Int): Bitmap { + val bitmap = Bitmap.createBitmap( + bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.WHITE) + canvas.drawBitmap(bitmap, 0f, 0f, null) + return bitmap + } + return openPage(index).use { page -> + createBitmap(bitmapWidth, bitmapHeight).apply { + page.render(this, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + } + } + } +} + + + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt new file mode 100644 index 0000000000..21eeaa652b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt @@ -0,0 +1,68 @@ +/* + * 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.pdf + +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +class PdfRendererManager( + private val parcelFileDescriptor: ParcelFileDescriptor, + private val width: Int, + private val coroutineScope: CoroutineScope, +) { + + private val mutex = Mutex() + private var pdfRenderer: PdfRenderer? = null + private val mutablePdfPages = MutableStateFlow>(emptyList()) + val pdfPages: StateFlow> = mutablePdfPages + + fun open() { + coroutineScope.launch { + mutex.withLock { + withContext(Dispatchers.IO) { + pdfRenderer = PdfRenderer(parcelFileDescriptor).apply { + (0 until pageCount).map { pageIndex -> + PdfPage(width, pageIndex, mutex, this, coroutineScope) + }.also { + mutablePdfPages.value = it + } + } + } + } + } + } + + fun close() { + coroutineScope.launch { + mutex.withLock { + mutablePdfPages.value.forEach { pdfPage -> + pdfPage.close() + } + pdfRenderer?.close() + parcelFileDescriptor.close() + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewer.kt new file mode 100644 index 0000000000..839bf9e622 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewer.kt @@ -0,0 +1,121 @@ +/* + * 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.pdf + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import me.saket.telephoto.zoomable.zoomable + +@Composable +fun PdfViewer( + pdfViewerState: PdfViewerState, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier = modifier.zoomable(pdfViewerState.zoomableState), + contentAlignment = Alignment.Center + ) { + val maxWidthInPx = maxWidth.dpToPx() + DisposableEffect(pdfViewerState) { + pdfViewerState.openForWidth(maxWidthInPx) + onDispose { + pdfViewerState.close() + } + } + val pdfPages = pdfViewerState.getPages() + PdfPagesView(pdfPages.toImmutableList(), pdfViewerState.lazyListState) + } +} + +@Composable +private fun PdfPagesView( + pdfPages: ImmutableList, + lazyListState: LazyListState, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically) + + ) { + items(pdfPages.size) { index -> + val pdfPage = pdfPages[index] + PdfPageView(pdfPage) + } + } +} + +@Composable +private fun PdfPageView( + pdfPage: PdfPage, + modifier: Modifier = Modifier, +) { + val pdfPageState by pdfPage.stateFlow.collectAsState() + DisposableEffect(pdfPage) { + pdfPage.load() + onDispose { + pdfPage.close() + } + } + when (val state = pdfPageState) { + is PdfPage.State.Loaded -> { + Image( + bitmap = state.bitmap.asImageBitmap(), + contentDescription = "Page ${pdfPage.pageIndex}", + contentScale = ContentScale.FillWidth, + modifier = modifier.fillMaxWidth() + ) + } + is PdfPage.State.Loading -> { + Box( + modifier = modifier + .fillMaxWidth() + .height(state.height.pxToDp()) + .background(color = Color.White) + ) + } + } +} + +@Composable +private fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() } + +@Composable +private fun Dp.dpToPx() = with(LocalDensity.current) { this@dpToPx.roundToPx() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewerState.kt new file mode 100644 index 0000000000..f64374d478 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewerState.kt @@ -0,0 +1,87 @@ +/* + * 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.pdf + +import android.content.Context +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.CoroutineScope +import me.saket.telephoto.zoomable.ZoomableState +import me.saket.telephoto.zoomable.rememberZoomableState + +@Stable +class PdfViewerState( + private val model: Any?, + private val coroutineScope: CoroutineScope, + private val context: Context, + val zoomableState: ZoomableState, + val lazyListState: LazyListState, +) { + + var isLoaded by mutableStateOf(false) + private var pdfRendererManager by mutableStateOf(null) + + @Composable + fun getPages(): List{ + return pdfRendererManager?.run { + pdfPages.collectAsState().value + }?: emptyList() + } + + fun openForWidth(maxWidth: Int) { + ParcelFileDescriptorFactory(context).create(model) + .onSuccess { + pdfRendererManager = PdfRendererManager(it, maxWidth, coroutineScope).apply { + open() + } + isLoaded = true + } + } + + fun close() { + pdfRendererManager?.close() + isLoaded = false + } +} + +@Composable +fun rememberPdfViewerState( + model: Any?, + zoomableState: ZoomableState = rememberZoomableState(), + lazyListState: LazyListState = rememberLazyListState(), + context: Context = LocalContext.current, + coroutineScope: CoroutineScope = rememberCoroutineScope(), +): PdfViewerState { + return remember(model) { + PdfViewerState( + model = model, + coroutineScope = coroutineScope, + context = context, + zoomableState = zoomableState, + lazyListState = lazyListState + ) + } +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt index 20e2a6d8ec..637d2c056c 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt @@ -23,6 +23,7 @@ object MimeTypes { const val Any: String = "*/*" const val OctetStream = "application/octet-stream" const val Apk = "application/vnd.android.package-archive" + const val Pdf = "application/pdf" const val Images = "image/*"