fix(app/logged_stories): coil preview decoder
This commit is contained in:
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user