Pdf: first iteration of pdf renderer
This commit is contained in:
parent
7dbd2c628e
commit
dfacb42d76
6 changed files with 342 additions and 0 deletions
|
|
@ -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.attachments.preview.AttachmentsPreviewNode
|
||||||
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
|
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.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.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
|
|
@ -129,6 +130,16 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
)
|
)
|
||||||
backstack.push(navTarget)
|
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
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,34 @@ import android.annotation.SuppressLint
|
||||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.compose.foundation.Image
|
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.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.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.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.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
|
|
@ -38,8 +57,13 @@ import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
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.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.R
|
||||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
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.ZoomSpec
|
||||||
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
|
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
|
||||||
import me.saket.telephoto.zoomable.rememberZoomableImageState
|
import me.saket.telephoto.zoomable.rememberZoomableImageState
|
||||||
|
|
@ -64,6 +88,9 @@ fun LocalMediaView(
|
||||||
onReady = onReady,
|
onReady = onReady,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
|
mimeType == io.element.android.libraries.core.mimetype.MimeTypes.Pdf -> {
|
||||||
|
MediaPDFView(localMedia = localMedia, onReady = onReady, modifier = modifier)
|
||||||
|
}
|
||||||
else -> Unit
|
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<PdfRendererManager?>(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<PdfPage>,
|
||||||
|
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() }
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>(
|
||||||
|
State.Loading(
|
||||||
|
width = renderWidth,
|
||||||
|
height = renderHeight
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val stateFlow: StateFlow<State> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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<List<PdfPage>>(emptyList())
|
||||||
|
val pdfPages: StateFlow<List<PdfPage>> = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ object MimeTypes {
|
||||||
const val Any: String = "*/*"
|
const val Any: String = "*/*"
|
||||||
const val OctetStream = "application/octet-stream"
|
const val OctetStream = "application/octet-stream"
|
||||||
const val Apk = "application/vnd.android.package-archive"
|
const val Apk = "application/vnd.android.package-archive"
|
||||||
|
const val Pdf = "application/pdf"
|
||||||
|
|
||||||
const val Images = "image/*"
|
const val Images = "image/*"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue