From dfacb42d76411e816cf99de6ea58913da7157599 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 31 May 2023 23:20:49 +0200 Subject: [PATCH 1/4] Pdf: first iteration of pdf renderer --- .../messages/impl/MessagesFlowNode.kt | 11 ++ .../impl/media/local/LocalMediaView.kt | 119 ++++++++++++++++++ .../local/pdf/ParcelFileDescriptorFactory.kt | 33 +++++ .../messages/impl/media/local/pdf/PdfPage.kt | 110 ++++++++++++++++ .../media/local/pdf/PdfRendererManager.kt | 68 ++++++++++ .../libraries/core/mimetype/MimeTypes.kt | 1 + 6 files changed, 342 insertions(+) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/ParcelFileDescriptorFactory.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfPage.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt 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 1e10a553f1..c07bbeff12 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 @@ -129,6 +130,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..d0e4669396 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 @@ -20,15 +20,34 @@ import android.annotation.SuppressLint import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +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.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.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.media3.common.MediaItem @@ -38,8 +57,13 @@ 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.ParcelFileDescriptorFactory +import io.element.android.features.messages.impl.media.local.pdf.PdfPage +import io.element.android.features.messages.impl.media.local.pdf.PdfRendererManager import io.element.android.libraries.designsystem.R import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage import me.saket.telephoto.zoomable.rememberZoomableImageState @@ -64,6 +88,9 @@ fun LocalMediaView( onReady = onReady, modifier = modifier ) + mimeType == io.element.android.libraries.core.mimetype.MimeTypes.Pdf -> { + MediaPDFView(localMedia = localMedia, onReady = onReady, modifier = modifier) + } else -> Unit } } @@ -154,3 +181,95 @@ fun MediaVideoView( } } } + +@UnstableApi +@Composable +fun MediaPDFView( + localMedia: LocalMedia?, + onReady: () -> Unit, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.TopCenter + ) { + val maxWidth = this.maxWidth.dpToPx() + val lazyState = rememberLazyListState() + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + var pdfRendererManager by remember { + mutableStateOf(null) + } + DisposableEffect(localMedia) { + ParcelFileDescriptorFactory(context).create(localMedia?.model) + .onSuccess { + pdfRendererManager = PdfRendererManager(it, maxWidth, coroutineScope).apply { + open() + } + onReady() + } + onDispose { + pdfRendererManager?.close() + } + } + pdfRendererManager?.run { + val pdfPages = pdfPages.collectAsState().value + PdfPagesView(pdfPages.toImmutableList(), lazyState) + } + } +} + +@Composable +private fun PdfPagesView( + pdfPages: ImmutableList, + lazyListState: LazyListState, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + state = lazyListState + ) { + 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/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..bc62e852d5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfPage.kt @@ -0,0 +1,110 @@ +/* + * 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 { + 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/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/*" From e754d17c13c09013517cef60df3b6eca5d43153d Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 31 May 2023 23:48:23 +0200 Subject: [PATCH 2/4] Pdf: improve rendering with zoom and spaces between pages --- .../impl/media/local/LocalMediaView.kt | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) 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 d0e4669396..5f9717e53d 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 @@ -21,6 +21,7 @@ import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout 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 @@ -48,10 +49,10 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp 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 @@ -60,14 +61,19 @@ import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayer import io.element.android.features.messages.impl.media.local.pdf.ParcelFileDescriptorFactory import io.element.android.features.messages.impl.media.local.pdf.PdfPage import io.element.android.features.messages.impl.media.local.pdf.PdfRendererManager +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 kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList 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 +import me.saket.telephoto.zoomable.zoomable @SuppressLint("UnsafeOptInUsageError") @Composable @@ -77,19 +83,28 @@ fun LocalMediaView( mimeType: String? = localMedia?.mimeType, onReady: () -> Unit = {}, ) { + val zoomableState = rememberZoomableState( + zoomSpec = ZoomSpec(maxZoomFactor = 3f) + ) 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( - localMedia = localMedia, - onReady = onReady, - modifier = modifier - ) - mimeType == io.element.android.libraries.core.mimetype.MimeTypes.Pdf -> { - MediaPDFView(localMedia = localMedia, onReady = onReady, modifier = modifier) + mimeType == MimeTypes.Pdf -> { + MediaPDFView( + localMedia = localMedia, + zoomableState = zoomableState, + onReady = onReady, + modifier = modifier + ) } else -> Unit } @@ -98,6 +113,7 @@ fun LocalMediaView( @Composable private fun MediaImageView( localMedia: LocalMedia?, + zoomableState: ZoomableState, onReady: () -> Unit, modifier: Modifier = Modifier, ) { @@ -108,9 +124,6 @@ private fun MediaImageView( contentDescription = null, ) } else { - val zoomableState = rememberZoomableState( - zoomSpec = ZoomSpec(maxZoomFactor = 3f) - ) val zoomableImageState = rememberZoomableImageState(zoomableState) LaunchedEffect(zoomableImageState.isImageDisplayed) { if (zoomableImageState.isImageDisplayed) { @@ -186,15 +199,16 @@ fun MediaVideoView( @Composable fun MediaPDFView( localMedia: LocalMedia?, + zoomableState: ZoomableState, onReady: () -> Unit, modifier: Modifier = Modifier, ) { BoxWithConstraints( - modifier = modifier, - contentAlignment = Alignment.TopCenter + modifier = modifier.zoomable(zoomableState), + contentAlignment = Alignment.Center ) { val maxWidth = this.maxWidth.dpToPx() - val lazyState = rememberLazyListState() + val lazyListState = rememberLazyListState() val context = LocalContext.current val coroutineScope = rememberCoroutineScope() var pdfRendererManager by remember { @@ -214,7 +228,7 @@ fun MediaPDFView( } pdfRendererManager?.run { val pdfPages = pdfPages.collectAsState().value - PdfPagesView(pdfPages.toImmutableList(), lazyState) + PdfPagesView(pdfPages.toImmutableList(), lazyListState) } } } @@ -227,7 +241,9 @@ private fun PdfPagesView( ) { LazyColumn( modifier = modifier.fillMaxSize(), - state = lazyListState + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically) + ) { items(pdfPages.size) { index -> val pdfPage = pdfPages[index] From d8fe22a6a16dce4b2634d003581ab83418dfff6f Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 1 Jun 2023 17:42:05 +0200 Subject: [PATCH 3/4] Pdf: rework pdf viewer a bit --- .../impl/media/local/LocalMediaView.kt | 137 +++--------------- .../impl/media/local/pdf/PdfViewer.kt | 121 ++++++++++++++++ .../impl/media/local/pdf/PdfViewerState.kt | 87 +++++++++++ 3 files changed, 225 insertions(+), 120 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewer.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewerState.kt 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 5f9717e53d..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 @@ -20,36 +20,15 @@ import android.annotation.SuppressLint import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout 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.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -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.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.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.media3.common.MediaItem @@ -58,22 +37,18 @@ 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.ParcelFileDescriptorFactory -import io.element.android.features.messages.impl.media.local.pdf.PdfPage -import io.element.android.features.messages.impl.media.local.pdf.PdfRendererManager +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 kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList 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 -import me.saket.telephoto.zoomable.zoomable @SuppressLint("UnsafeOptInUsageError") @Composable @@ -84,7 +59,7 @@ fun LocalMediaView( onReady: () -> Unit = {}, ) { val zoomableState = rememberZoomableState( - zoomSpec = ZoomSpec(maxZoomFactor = 3f) + zoomSpec = ZoomSpec(maxZoomFactor = 5f) ) when { mimeType.isMimeTypeImage() -> MediaImageView( @@ -98,14 +73,12 @@ fun LocalMediaView( onReady = onReady, modifier = modifier ) - mimeType == MimeTypes.Pdf -> { - MediaPDFView( - localMedia = localMedia, - zoomableState = zoomableState, - onReady = onReady, - modifier = modifier - ) - } + mimeType == MimeTypes.Pdf -> MediaPDFView( + localMedia = localMedia, + zoomableState = zoomableState, + onReady = onReady, + modifier = modifier + ) else -> Unit } } @@ -195,7 +168,6 @@ fun MediaVideoView( } } -@UnstableApi @Composable fun MediaPDFView( localMedia: LocalMedia?, @@ -203,89 +175,14 @@ fun MediaPDFView( onReady: () -> Unit, modifier: Modifier = Modifier, ) { - BoxWithConstraints( - modifier = modifier.zoomable(zoomableState), - contentAlignment = Alignment.Center - ) { - val maxWidth = this.maxWidth.dpToPx() - val lazyListState = rememberLazyListState() - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - var pdfRendererManager by remember { - mutableStateOf(null) - } - DisposableEffect(localMedia) { - ParcelFileDescriptorFactory(context).create(localMedia?.model) - .onSuccess { - pdfRendererManager = PdfRendererManager(it, maxWidth, coroutineScope).apply { - open() - } - onReady() - } - onDispose { - pdfRendererManager?.close() - } - } - pdfRendererManager?.run { - val pdfPages = pdfPages.collectAsState().value - PdfPagesView(pdfPages.toImmutableList(), lazyListState) + val pdfViewerState = rememberPdfViewerState( + model = localMedia?.model, + zoomableState = zoomableState + ) + LaunchedEffect(pdfViewerState.isLoaded) { + if (pdfViewerState.isLoaded) { + onReady() } } + PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier) } - -@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/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 + ) + } +} From ba3b0eed80fb99093bc03db489b51ad6d9edfc34 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 16:48:17 +0200 Subject: [PATCH 4/4] Pdf : add small comment --- .../android/features/messages/impl/media/local/pdf/PdfPage.kt | 1 + 1 file changed, 1 insertion(+) 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 index bc62e852d5..0b8caed968 100644 --- 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 @@ -50,6 +50,7 @@ class PdfPage( 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() }