From 3030799649d46269dc60262a20a19c9ca4fe2376 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 31 May 2023 23:20:49 +0200 Subject: [PATCH] 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/*"