2020-04-16 15:44:53 +00:00
|
|
|
/*
|
|
|
|
* Copyright (c) 2020 Proton Technologies AG
|
2020-09-17 10:44:37 +00:00
|
|
|
*
|
2020-04-16 15:44:53 +00:00
|
|
|
* This file is part of ProtonMail.
|
2020-09-17 10:44:37 +00:00
|
|
|
*
|
2020-04-16 15:44:53 +00:00
|
|
|
* ProtonMail is free software: you can redistribute it and/or modify
|
|
|
|
* it under the terms of the GNU General Public License as published by
|
|
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
|
|
* (at your option) any later version.
|
2020-09-17 10:44:37 +00:00
|
|
|
*
|
2020-04-16 15:44:53 +00:00
|
|
|
* ProtonMail is distributed in the hope that it will be useful,
|
|
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
* GNU General Public License for more details.
|
2020-09-17 10:44:37 +00:00
|
|
|
*
|
2020-04-16 15:44:53 +00:00
|
|
|
* You should have received a copy of the GNU General Public License
|
|
|
|
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
|
|
|
|
*/
|
|
|
|
@file:Suppress("EXPERIMENTAL_API_USAGE")
|
|
|
|
|
|
|
|
package ch.protonmail.android.activities.messageDetails
|
|
|
|
|
|
|
|
import android.graphics.Bitmap
|
|
|
|
import android.graphics.BitmapFactory
|
|
|
|
import android.util.Base64
|
2020-09-17 10:44:37 +00:00
|
|
|
import ch.protonmail.android.di.AttachmentsDirectory
|
2020-04-16 15:44:53 +00:00
|
|
|
import ch.protonmail.android.jobs.helper.EmbeddedImage
|
|
|
|
import ch.protonmail.android.utils.extensions.forEachAsync
|
|
|
|
import kotlinx.coroutines.CoroutineScope
|
|
|
|
import kotlinx.coroutines.channels.Channel
|
|
|
|
import kotlinx.coroutines.channels.actor
|
|
|
|
import kotlinx.coroutines.channels.toList
|
|
|
|
import kotlinx.coroutines.delay
|
|
|
|
import kotlinx.coroutines.newSingleThreadContext
|
|
|
|
import kotlinx.coroutines.plus
|
2020-09-24 09:30:19 +00:00
|
|
|
import me.proton.core.util.kotlin.DispatcherProvider
|
2020-04-16 15:44:53 +00:00
|
|
|
import org.jsoup.Jsoup
|
|
|
|
import org.jsoup.nodes.Document
|
|
|
|
import org.jsoup.select.Elements
|
|
|
|
import java.io.ByteArrayOutputStream
|
|
|
|
import java.io.File
|
2020-09-17 10:44:37 +00:00
|
|
|
import javax.inject.Inject
|
2020-04-16 15:44:53 +00:00
|
|
|
import kotlin.math.pow
|
|
|
|
import kotlin.math.sqrt
|
2020-09-24 09:30:19 +00:00
|
|
|
import kotlin.time.milliseconds
|
2020-04-16 15:44:53 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* A class that will inline the images in the message's body.
|
|
|
|
* Implement [CoroutineScope] by the constructor scope
|
|
|
|
*
|
|
|
|
* @param scope [CoroutineScope] which this class inherit from, this should be our `ViewModel`s
|
|
|
|
* scope, so when `ViewModel` is cleared all the coroutines for this class will be canceled
|
|
|
|
*
|
|
|
|
*
|
|
|
|
* @author Davide Farella
|
|
|
|
*/
|
2020-09-17 10:44:37 +00:00
|
|
|
internal class MessageRenderer(
|
2020-09-24 09:30:19 +00:00
|
|
|
private val dispatchers: DispatcherProvider,
|
2020-09-17 10:44:37 +00:00
|
|
|
private val directory: File,
|
|
|
|
private val documentParser: DocumentParser,
|
|
|
|
private val bitmapImageDecoder: ImageDecoder,
|
|
|
|
scope: CoroutineScope
|
2020-09-24 09:30:19 +00:00
|
|
|
) : CoroutineScope by scope + dispatchers.Comp {
|
2020-04-16 15:44:53 +00:00
|
|
|
|
|
|
|
/** The [String] html of the message body */
|
|
|
|
var messageBody: String? = null
|
|
|
|
set(value) {
|
|
|
|
// Return if body is already set
|
|
|
|
if (field != null) return
|
|
|
|
|
|
|
|
// Update if value is not null
|
|
|
|
if (value != null) field = value
|
|
|
|
}
|
|
|
|
|
|
|
|
/** reference to the [Document] */
|
|
|
|
private val document by lazy { documentParser(messageBody!!) }
|
|
|
|
|
|
|
|
/** A [Channel] for receive new [EmbeddedImage] images to inline in [document] */
|
|
|
|
val images = actor<List<EmbeddedImage>> {
|
|
|
|
for (embeddedImages in channel) {
|
|
|
|
imageCompressor.send(embeddedImages)
|
|
|
|
// Workaround that ignore values for the next half second, since ViewModel is emitting
|
|
|
|
// too many times
|
2020-09-24 09:30:19 +00:00
|
|
|
delay(DebounceDelay)
|
2020-04-16 15:44:53 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** A [Channel] that will emits message body [String] with inlined images */
|
|
|
|
val renderedBody = Channel<String>()
|
|
|
|
|
|
|
|
/** [List] for keep track of ids of the already inlined images across the threads */
|
|
|
|
private val inlinedImageIds = mutableListOf<String>()
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Actor that will compress images.
|
|
|
|
*
|
|
|
|
* This actor will work concurrently: it will push all the [EmbeddedImage]s to be processed in
|
|
|
|
* `imageSelector`, then it will create a pool of tasks ( which pool's size is [WORKERS_COUNT] )
|
|
|
|
* and each task will collect and process items - concurrently - from `imageSelector` until it's
|
|
|
|
* empty.
|
|
|
|
*/
|
|
|
|
private val imageCompressor = actor<List<EmbeddedImage>> {
|
|
|
|
for (embeddedImages in channel) {
|
|
|
|
val outputs = Channel<ImageStream>(capacity = embeddedImages.size)
|
|
|
|
|
|
|
|
/** This [Channel] works as a queue for handle [EmbeddedImage]s concurrently */
|
|
|
|
val imageSelector = Channel<EmbeddedImage>(capacity = embeddedImages.size)
|
|
|
|
|
|
|
|
// Queue all the embeddedImages
|
|
|
|
for (embeddedImage in embeddedImages) {
|
|
|
|
val contentId = embeddedImage.contentId.formatContentId()
|
|
|
|
|
|
|
|
// Skip if we don't have a content id or already rendered
|
|
|
|
if (contentId.isNotBlank() && contentId !in inlinedImageIds)
|
|
|
|
imageSelector.send(embeddedImage)
|
|
|
|
}
|
|
|
|
|
|
|
|
// For each worker in WORKERS_COUNT start an "async task"
|
|
|
|
(1..WORKERS_COUNT).forEachAsync {
|
|
|
|
// Each "task" will iterate and collect a single embeddedImage until imageSelector
|
|
|
|
// is empty and process it asynchronously
|
|
|
|
while (!imageSelector.isEmpty) {
|
|
|
|
val embeddedImage = imageSelector.receive()
|
|
|
|
|
|
|
|
// Process the image
|
|
|
|
val file = File(directory, embeddedImage.localFileName)
|
|
|
|
// Skip if file does not exist
|
|
|
|
if (!file.exists() || file.length() == 0L) continue
|
|
|
|
|
|
|
|
val size = (MAX_IMAGES_TOTAL_SIZE / embeddedImages.size)
|
|
|
|
.coerceAtMost(MAX_IMAGE_SINGLE_SIZE)
|
|
|
|
|
|
|
|
val compressed = try {
|
|
|
|
ByteArrayOutputStream().also {
|
|
|
|
// The file could be corrupted even if exists and it's not empty
|
|
|
|
val bitmap = bitmapImageDecoder(file, size)
|
|
|
|
// Could throw `IllegalStateException` if for some reason the
|
|
|
|
// Bitmap is already recycled
|
|
|
|
bitmap.compress(Bitmap.CompressFormat.WEBP, 80, it)
|
|
|
|
}
|
|
|
|
} catch (t: Throwable) {
|
|
|
|
// Skip the image
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add the processed image to outputs
|
|
|
|
outputs.send(embeddedImage to compressed)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
imageSelector.close()
|
|
|
|
outputs.close()
|
|
|
|
imageStringifier.send(outputs.toList())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Actor that will stringify images */
|
|
|
|
private val imageStringifier = actor<List<ImageStream>> {
|
|
|
|
for (imageStreams in channel) {
|
|
|
|
|
|
|
|
val imageStrings = imageStreams.map { imageStream ->
|
|
|
|
val (embeddedImage, stream) = imageStream
|
|
|
|
embeddedImage to Base64.encodeToString(stream.toByteArray(), Base64.DEFAULT)
|
|
|
|
}
|
|
|
|
imageInliner.send(imageStrings)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Actor that will inline images into [Document] */
|
|
|
|
private val imageInliner = actor<List<ImageString>> {
|
|
|
|
for (imageStrings in channel) {
|
|
|
|
|
|
|
|
for (imageString in imageStrings) {
|
|
|
|
|
|
|
|
val (embeddedImage, image64) = imageString
|
|
|
|
val contentId = embeddedImage.contentId.formatContentId()
|
|
|
|
|
|
|
|
// Skip if we don't have a content id or already rendered
|
|
|
|
if (contentId.isBlank() || contentId in inlinedImageIds) continue
|
|
|
|
idsListUpdater.send(contentId)
|
|
|
|
|
|
|
|
val encoding = embeddedImage.encoding.formatEncoding()
|
|
|
|
val contentType = embeddedImage.contentType.formatContentType()
|
|
|
|
|
|
|
|
document.findImageElements(contentId)
|
|
|
|
?.attr("src", "data:$contentType;$encoding,$image64")
|
|
|
|
}
|
|
|
|
documentStringifier.send(Unit) // Deliver after all the elements for now
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Actor that will stringify the [document] */
|
|
|
|
private val documentStringifier = actor<Unit> {
|
|
|
|
for (unit in channel)
|
|
|
|
renderedBody.send(document.toString())
|
|
|
|
}
|
|
|
|
|
|
|
|
/** `CoroutineContext` for [idsListUpdater] for update [inlinedImageIds] of a single thread */
|
|
|
|
private val idsListContext = newSingleThreadContext("idsListContext")
|
|
|
|
|
|
|
|
/** Actor that will update [inlinedImageIds] */
|
|
|
|
private val idsListUpdater = actor<String>(idsListContext) {
|
|
|
|
for (id in channel) inlinedImageIds += id
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A Factory for create [MessageRenderer]
|
|
|
|
* We use this because [MessageRenderer] needs a message body that will be retrieved lazily,
|
|
|
|
* but we still can mock [MessageRenderer] by injecting a mocked [Factory] in the `ViewModel`
|
|
|
|
*
|
|
|
|
* @param imageDecoder [ImageDecoder]
|
|
|
|
*/
|
2020-09-17 10:44:37 +00:00
|
|
|
class Factory @Inject constructor(
|
2020-09-24 09:30:19 +00:00
|
|
|
private val dispatchers: DispatcherProvider,
|
2020-09-17 10:44:37 +00:00
|
|
|
@AttachmentsDirectory private val attachmentsDirectory: File,
|
|
|
|
private val documentParser: DocumentParser = DefaultDocumentParser(),
|
|
|
|
private val imageDecoder: ImageDecoder = DefaultImageDecoder()
|
2020-04-16 15:44:53 +00:00
|
|
|
) {
|
|
|
|
/** @return [File] directory for the current message */
|
|
|
|
private fun messageDirectory(messageId: String) = File(attachmentsDirectory, messageId)
|
|
|
|
|
|
|
|
/** @return new instance of [MessageRenderer] with the given [messageBody] */
|
|
|
|
fun create(scope: CoroutineScope, messageId: String) =
|
2020-09-24 09:30:19 +00:00
|
|
|
MessageRenderer(dispatchers, messageDirectory(messageId), documentParser, imageDecoder, scope)
|
|
|
|
}
|
|
|
|
|
|
|
|
companion object {
|
|
|
|
val DebounceDelay = 500.milliseconds
|
2020-04-16 15:44:53 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// region constants
|
|
|
|
/** A count of bytes representing the maximum total size of the images to inline */
|
|
|
|
private const val MAX_IMAGES_TOTAL_SIZE = 9437184 // 9 MB
|
|
|
|
|
|
|
|
/** A count of bytes representing the maximum size of a single images to inline */
|
|
|
|
private const val MAX_IMAGE_SINGLE_SIZE = 1048576 // 1 MB
|
|
|
|
|
|
|
|
/** Max number of concurrent workers. It represents the available processors */
|
|
|
|
private val WORKERS_COUNT get() = Runtime.getRuntime().availableProcessors()
|
|
|
|
|
|
|
|
/** Placeholder for image's id */
|
|
|
|
private const val ID_PLACEHOLDER = "%id"
|
|
|
|
|
|
|
|
/** [Array] of html attributes that could contain an image */
|
|
|
|
private val IMAGE_ATTRIBUTES =
|
|
|
|
arrayOf("img[src=$ID_PLACEHOLDER]", "img[src=cid:$ID_PLACEHOLDER]", "img[rel=$ID_PLACEHOLDER]")
|
|
|
|
// endregion
|
|
|
|
|
|
|
|
// region typealiases
|
|
|
|
private typealias ImageStream = Pair<EmbeddedImage, ByteArrayOutputStream>
|
|
|
|
private typealias ImageString = Pair<EmbeddedImage, String>
|
|
|
|
// endregion
|
|
|
|
|
|
|
|
// region extensions
|
|
|
|
private fun String.formatEncoding() = toLowerCase()
|
|
|
|
private fun String.formatContentId() = trimStart('<').trimEnd('>')
|
|
|
|
private fun String.formatContentType() = toLowerCase()
|
|
|
|
.replace("\r", "").replace("\n", "")
|
|
|
|
.replaceFirst(";.*$".toRegex(), "")
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Flatten the receiver [Document] by removing the indentation and disabling prettyPrint.
|
|
|
|
* @return [Document]
|
|
|
|
*/
|
|
|
|
private fun Document.flatten() = apply { outputSettings().indentAmount(0).prettyPrint(false) }
|
|
|
|
|
|
|
|
/** @return [Elements] matching the image attribute for the given [id] */
|
|
|
|
private fun Document.findImageElements(id: String): Elements? {
|
|
|
|
return IMAGE_ATTRIBUTES
|
|
|
|
.map { attr -> attr.replace(ID_PLACEHOLDER, id) }
|
|
|
|
// with `asSequence` iteration will stop when the first usable element
|
|
|
|
// is found and so avoid to make too many calls to document.select
|
|
|
|
.asSequence()
|
|
|
|
.map { select(it) }
|
|
|
|
.find { it.isNotEmpty() }
|
|
|
|
}
|
|
|
|
// endregion
|
|
|
|
|
|
|
|
// region DocumentParser
|
2020-09-17 10:44:37 +00:00
|
|
|
/**
|
|
|
|
* Parses a document as [String] and returns a [Document] model
|
|
|
|
*/
|
|
|
|
internal interface DocumentParser {
|
|
|
|
operator fun invoke(body: String): Document
|
|
|
|
}
|
2020-04-16 15:44:53 +00:00
|
|
|
|
2020-09-17 10:44:37 +00:00
|
|
|
/**
|
|
|
|
* Default implementation of [DocumentParser]
|
|
|
|
*/
|
|
|
|
internal class DefaultDocumentParser @Inject constructor() : DocumentParser {
|
2020-04-16 15:44:53 +00:00
|
|
|
override fun invoke(body: String): Document = Jsoup.parse(body).flatten()
|
|
|
|
}
|
|
|
|
// endregion
|
|
|
|
|
|
|
|
// region ImageDecoder
|
2020-09-17 10:44:37 +00:00
|
|
|
/**
|
|
|
|
* Decodes to [Bitmap] the image provided by the given [File] to fit the max size provided
|
|
|
|
*/
|
|
|
|
internal interface ImageDecoder {
|
|
|
|
operator fun invoke(file: File, maxBytes: Int): Bitmap
|
|
|
|
}
|
2020-04-16 15:44:53 +00:00
|
|
|
|
2020-09-17 10:44:37 +00:00
|
|
|
/**
|
|
|
|
* Default implementation of [ImageDecoder]
|
|
|
|
*/
|
|
|
|
internal class DefaultImageDecoder @Inject constructor() : ImageDecoder {
|
2020-04-16 15:44:53 +00:00
|
|
|
override fun invoke(file: File, maxBytes: Int): Bitmap {
|
|
|
|
// https://stackoverflow.com/a/8497703/6372379
|
|
|
|
|
|
|
|
// Decode image size
|
|
|
|
val boundOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
|
|
BitmapFactory.decodeFile(file.absolutePath, boundOptions)
|
|
|
|
|
|
|
|
var scale = 1
|
|
|
|
while (boundOptions.outWidth * boundOptions.outHeight * (1 / scale.toDouble().pow(2.0)) > maxBytes) {
|
|
|
|
scale++
|
|
|
|
}
|
|
|
|
|
|
|
|
return if (scale > 1) {
|
|
|
|
scale--
|
|
|
|
// scale to max possible inSampleSize that still yields an image larger than target
|
|
|
|
val options = BitmapFactory.Options().apply { inSampleSize = scale }
|
|
|
|
val tempBitmap = BitmapFactory.decodeFile(file.absolutePath, options)
|
|
|
|
|
|
|
|
// resize to desired dimensions
|
|
|
|
val height = tempBitmap.height
|
|
|
|
val width = tempBitmap.width
|
|
|
|
|
|
|
|
val y = sqrt(maxBytes / (width.toDouble() / height))
|
|
|
|
val x = (y / height) * width
|
|
|
|
|
|
|
|
val scaledBitmap = Bitmap.createScaledBitmap(tempBitmap, x.toInt(), y.toInt(), true)
|
|
|
|
tempBitmap.recycle()
|
|
|
|
|
|
|
|
scaledBitmap
|
|
|
|
|
|
|
|
} else {
|
|
|
|
BitmapFactory.decodeFile(file.absolutePath)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// endregion
|