Browse Source

Pdf: first iteration of pdf renderer

feature/fga/small_timeline_improvements
ganfra 1 year ago
parent
commit
3030799649
  1. 11
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
  2. 119
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt
  3. 33
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/ParcelFileDescriptorFactory.kt
  4. 110
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfPage.kt
  5. 68
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt
  6. 1
      libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt

11
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 @@ -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( @@ -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
}
}

119
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt

@ -20,15 +20,34 @@ import android.annotation.SuppressLint @@ -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 @@ -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( @@ -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( @@ -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() }

33
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/ParcelFileDescriptorFactory.kt

@ -0,0 +1,33 @@ @@ -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"))
}
}
}

110
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfPage.kt

@ -0,0 +1,110 @@ @@ -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)
}
}
}
}

68
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt

@ -0,0 +1,68 @@ @@ -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()
}
}
}
}

1
libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt

@ -23,6 +23,7 @@ object MimeTypes { @@ -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/*"

Loading…
Cancel
Save