fix(app/logged_stories): coil preview decoder

This commit is contained in:
rhunk
2024-04-01 19:47:09 +02:00
parent b1860ed29f
commit 3d2dcec985
9 changed files with 168 additions and 158 deletions

View File

@@ -186,20 +186,16 @@ class DownloadProcessor (
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
createMediaTempFile().apply {
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
var read: Int
var totalRead = 0L
var lastTotalRead = 0L
outputStream().use { outputStream ->
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
totalRead += read
inputMediaDownloadedBytes[inputMedia] = totalRead
if (totalRead - lastTotalRead > 1024 * 1024) {
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
lastTotalRead = totalRead
}
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
}
}
}.also { downloadedMedias[inputMedia] = it }
@@ -228,12 +224,14 @@ class DownloadProcessor (
}
DownloadMediaType.DIRECT_MEDIA -> {
val decoded = Base64.UrlSafe.decode(inputMedia.content)
createMediaTempFile().apply {
writeBytes(decoded)
}.also { downloadedMedias[inputMedia] = it }
totalSize += decoded.size.toLong()
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
}
else -> {
downloadedMedias[inputMedia] = File(inputMedia.content)
File(inputMedia.content).inputStream().use {
totalSize += it.available().toLong()
handleInputStream(it, estimatedSize = it.available().toLong())
}
}
}
}.also { jobs.add(it) }

View File

@@ -15,7 +15,7 @@ import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.bridge.ForceStartActivity
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.ui.util.ImageRequestHelper
import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes

View File

@@ -1,56 +1,42 @@
package me.rhunk.snapenhance.ui.manager.pages.social
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.navigation.NavBackStackEntry
import coil.annotation.ExperimentalCoilApi
import coil.disk.DiskCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import coil.compose.rememberAsyncImagePainter
import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.common.data.FileType
import me.rhunk.snapenhance.common.data.StoryData
import me.rhunk.snapenhance.common.data.download.*
import me.rhunk.snapenhance.common.util.ktx.longHashCode
import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.core.util.media.PreviewUtils
import me.rhunk.snapenhance.download.DownloadProcessor
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.Dialog
import okhttp3.HttpUrl.Companion.toHttpUrl
import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.text.DateFormat
import java.util.Date
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.math.absoluteValue
class LoggedStories : Routes.Route() {
@@ -58,24 +44,18 @@ class LoggedStories : Routes.Route() {
override val content: @Composable (NavBackStackEntry) -> Unit = content@{ navBackStackEntry ->
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
val stories = remember {
mutableStateListOf<StoryData>()
}
val friendInfo = remember {
context.modDatabase.getFriendInfo(userId)
}
val httpClient = remember { OkHttpClient() }
val stories = remember { mutableStateListOf<StoryData>() }
val friendInfo = remember { context.modDatabase.getFriendInfo(userId) }
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
var selectedStory by remember { mutableStateOf<StoryData?>(null) }
var coilCachedFile by remember { mutableStateOf<File?>(null) }
selectedStory?.let { story ->
fun downloadSelectedStory(
inputMedia: InputMedia,
) {
val mediaAuthor = friendInfo?.mutableUsername ?: userId
val uniqueHash = (selectedStory?.url ?: UUID.randomUUID().toString()).longHashCode().absoluteValue.toString(16)
val uniqueHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16)
DownloadProcessor(
remoteSideContext = context,
@@ -131,24 +111,41 @@ class LoggedStories : Routes.Route() {
) {
Button(onClick = {
context.androidContext.externalCacheDir?.let { cacheDir ->
val cacheFile = coilCachedFile ?: run {
context.imageLoader.diskCache?.openSnapshot(story.url)?.use { diskCacheSnapshot ->
val cacheFile = diskCacheSnapshot.data.toFile()
val targetFile = File(cacheDir, cacheFile.name).also {
it.deleteOnExit()
}
runCatching {
cacheFile.inputStream().let {
story.getEncryptionKeyPair()?.decryptInputStream(it) ?: it
}.use { inputStream ->
targetFile.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
context.androidContext.startActivity(Intent().apply {
action = Intent.ACTION_VIEW
setDataAndType(
FileProvider.getUriForFile(
context.androidContext,
"me.rhunk.snapenhance.fileprovider",
targetFile
),
FileType.fromFile(targetFile).mimeType
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
})
}.onFailure {
context.shortToast("Failed to open file. Check logs for more info")
context.log.error("Failed to open file", it)
}
} ?: run {
context.shortToast("Failed to get file")
return@Button
}
val targetFile = File(cacheDir, cacheFile.name)
cacheFile.copyTo(targetFile, overwrite = true)
context.androidContext.startActivity(Intent().apply {
action = Intent.ACTION_VIEW
setDataAndType(
FileProvider.getUriForFile(
context.androidContext,
"me.rhunk.snapenhance.fileprovider",
targetFile
),
FileType.fromFile(targetFile).mimeType
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
})
}
}) {
Text(text = "Open")
@@ -159,22 +156,27 @@ class LoggedStories : Routes.Route() {
InputMedia(
content = story.url,
type = DownloadMediaType.REMOTE_MEDIA,
encryption = story.key?.let { it to story.iv!! }?.toKeyPair()
encryption = story.getEncryptionKeyPair()
)
)
}) {
Text(text = "Download")
}
if (coilCachedFile != null) {
if (remember {
context.imageLoader.diskCache?.openSnapshot(story.url)?.also { it.close() } != null
}) {
Button(onClick = {
downloadSelectedStory(
InputMedia(
content = coilCachedFile?.absolutePath ?: run {
context.shortToast("Failed to get file from cache")
content = context.imageLoader.diskCache?.openSnapshot(story.url)?.use {
it.data.toFile().absolutePath
} ?: run {
context.shortToast("Failed to get file")
return@Button
},
type = DownloadMediaType.LOCAL_MEDIA
type = DownloadMediaType.LOCAL_MEDIA,
encryption = story.getEncryptionKeyPair()
)
)
}) {
@@ -195,107 +197,41 @@ class LoggedStories : Routes.Route() {
columns = GridCells.Adaptive(100.dp),
contentPadding = PaddingValues(8.dp),
) {
items(stories) { story ->
var isFailed by remember { mutableStateOf(false) }
var imageBitmap by remember { mutableStateOf<ImageBitmap?>(null) }
val uniqueHash = remember { story.url.hashCode().absoluteValue.toString(16) }
fun openDiskCacheSnapshot(snapshot: DiskCache.Snapshot): Boolean {
runCatching {
val mediaList = mutableMapOf<SplitMediaAssetType, ByteArray>()
snapshot.data.toFile().inputStream().use { inputStream ->
MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream ->
mediaList[type] = splitInputStream.readBytes()
}
}
val originalMedia = mediaList[SplitMediaAssetType.ORIGINAL] ?: return@runCatching false
val overlay = mediaList[SplitMediaAssetType.OVERLAY]
var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo)
overlay?.also {
bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size))
}
imageBitmap = bitmap?.asImageBitmap()
return true
}.onFailure {
context.log.error("Failed to open disk cache snapshot", it)
}
return false
}
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
withTimeout(10000L) {
context.imageLoader.diskCache?.openSnapshot(uniqueHash)?.use {
if (!openDiskCacheSnapshot(it)) isFailed = true
return@withTimeout
}
runCatching {
val response = httpClient.newCall(Request(
url = story.url.toHttpUrl()
)).execute()
response.body.byteStream().use {
val decrypted = story.key?.let { _ ->
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(story.key, "AES"), IvParameterSpec(story.iv))
CipherInputStream(it, cipher)
} ?: it
context.imageLoader.diskCache?.openEditor(uniqueHash)?.apply {
data.toFile().outputStream().use { fos ->
decrypted.copyTo(fos)
}
commitAndOpenSnapshot()?.use { snapshot ->
if (!openDiskCacheSnapshot(snapshot)) isFailed = true
}
}
}
}.onFailure {
isFailed = true
context.log.error("Failed to load story", it)
}
}
}
}
items(stories, key = { it.url }) { story ->
var hasFailed by remember(story.url) { mutableStateOf(false) }
Column(
modifier = Modifier
.padding(8.dp)
.clickable {
selectedStory = story
coilCachedFile = context.imageLoader.diskCache
?.openSnapshot(uniqueHash)
.use {
it?.data?.toFile()
}
}
.clip(MaterialTheme.shapes.medium)
.heightIn(min = 128.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
if (isFailed) {
Icon(
imageVector = Icons.Default.ErrorOutline,
contentDescription = "",
modifier = Modifier.size(48.dp)
)
if (hasFailed) {
Text(text = "Failed to load", Modifier.padding(8.dp), fontSize = 10.sp)
} else {
imageBitmap?.let {
Card {
Image(
bitmap = it,
modifier = Modifier.fillMaxSize(),
contentDescription = null,
)
}
} ?: run {
CircularProgressIndicator()
}
Image(
painter = rememberAsyncImagePainter(
model = ImageRequestHelper.newPreviewImageRequest(
context.androidContext,
story.url,
story.getEncryptionKeyPair(),
),
imageLoader = context.imageLoader,
onError = {
hasFailed = true
}
),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.height(128.dp)
)
}
}
}

View File

@@ -22,7 +22,7 @@ import me.rhunk.snapenhance.common.data.SocialScope
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.AlertDialogs
import me.rhunk.snapenhance.ui.util.BitmojiImage
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
import me.rhunk.snapenhance.ui.util.Dialog
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

View File

@@ -31,7 +31,7 @@ import me.rhunk.snapenhance.common.data.MessagingGroupInfo
import me.rhunk.snapenhance.common.data.SocialScope
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.BitmojiImage
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
class SocialRoot : Routes.Route() {

View File

@@ -0,0 +1,61 @@
package me.rhunk.snapenhance.ui.util.coil
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import coil.decode.DecodeResult
import coil.decode.Decoder
import coil.fetch.SourceResult
import me.rhunk.snapenhance.common.data.FileType
import me.rhunk.snapenhance.common.data.download.MediaEncryptionKeyPair
import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType
import me.rhunk.snapenhance.common.logger.AbstractLogger
import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.core.util.media.PreviewUtils
class CoilPreviewDecoder(
private val resources: Resources,
private val sourceResult: SourceResult,
private val encryptionKeyPair: MediaEncryptionKeyPair? = null,
private val mergeOverlay: Boolean = false
): Decoder {
override suspend fun decode(): DecodeResult {
return sourceResult.source.file().toFile().inputStream().use { fileInputStream ->
val cipherInputStream = encryptionKeyPair?.decryptInputStream(fileInputStream) ?: fileInputStream
var bitmap: Bitmap? = null
var overlayBitmap: Bitmap? = null
MediaDownloaderHelper.getSplitElements(cipherInputStream) { type, inputStream ->
if (inputStream.available() > 50 * 1024 * 1024) {
return@getSplitElements
}
if (type == SplitMediaAssetType.ORIGINAL || (mergeOverlay && type == SplitMediaAssetType.OVERLAY)) {
runCatching {
val bytes = inputStream.readBytes()
PreviewUtils.createPreview(bytes, isVideo = FileType.fromByteArray(bytes).isVideo)?.let {
if (type == SplitMediaAssetType.ORIGINAL) {
bitmap = it
} else {
overlayBitmap = it
}
}
}.onFailure {
AbstractLogger.directError("CoilPreviewDecoder", it)
}
}
}
if (mergeOverlay && overlayBitmap != null) {
bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, overlayBitmap!!)
}
cipherInputStream.close()
DecodeResult(
drawable = BitmapDrawable(resources, bitmap!!),
isSampled = true
)
}
}
}

View File

@@ -1,6 +1,7 @@
package me.rhunk.snapenhance.ui.util
package me.rhunk.snapenhance.ui.util.coil
import android.content.Context
import android.graphics.drawable.ColorDrawable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.requiredWidthIn
@@ -15,6 +16,7 @@ import coil.request.ImageRequest
import coil.size.Precision
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.common.data.download.MediaEncryptionKeyPair
@Composable
fun BitmojiImage(context: RemoteSideContext, modifier: Modifier = Modifier, size: Int = 48, url: String?) {
@@ -50,11 +52,20 @@ object ImageRequestHelper {
.cacheKey(url)
.build()
fun newDownloadPreviewImageRequest(context: Context, filePath: String?) = ImageRequest.Builder(context)
.data(filePath)
.cacheKey(filePath)
.memoryCacheKey(filePath)
fun newPreviewImageRequest(context: Context, url: String, mediaEncryptionKeyPair: MediaEncryptionKeyPair? = null) = ImageRequest.Builder(context)
.cacheKey(url)
.precision(Precision.INEXACT)
.crossfade(true)
.placeholder(ColorDrawable(0x1EFFFFFF))
.crossfade(200)
.data(url)
.decoderFactory { result, _, _ ->
CoilPreviewDecoder(
context.resources,
result,
mediaEncryptionKeyPair,
mergeOverlay = true
)
}
.build()
}