Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-19 01:20:27 +03:00
Unverified
parent 928d00b665
commit 323ec99c12
9 changed files with 478 additions and 166 deletions
@@ -107,7 +107,9 @@ data class Message(
/** Aspect ratios (width/height) for image files (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileAspectRatios: List<Float>? = null,
/** File sizes in bytes (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileSizes: List<Long>? = null
@kotlinx.serialization.Transient val fileSizes: List<Long>? = null,
/** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileDimensions: List<Pair<Int, Int>>? = null
)
@Serializable
@@ -269,7 +269,7 @@ private fun DecryptedImageContent(
animatedVisibilityScope: AnimatedVisibilityScope? = null,
onFullyLoaded: (Boolean) -> Unit = {}
) {
var fullBytes by remember(messageId, fileIndex, file.path) {
var cachedPath by remember(messageId, fileIndex, file.path) {
mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, file.path))
}
val thumbnailBytes = remember(thumbnailBase64) {
@@ -277,14 +277,39 @@ private fun DecryptedImageContent(
}
LaunchedEffect(messageId, fileIndex, file.path, envelope) {
fullBytes = DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId)
cachedPath = DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId)
}
LaunchedEffect(fullBytes) {
onFullyLoaded(fullBytes != null)
LaunchedEffect(cachedPath) {
onFullyLoaded(cachedPath != null)
}
Box(modifier = Modifier.fillMaxSize()) {
when {
cachedPath != null -> {
val fullPainter = rememberAsyncImagePainter(
model = cachedPath!!,
contentScale = ContentScale.FillWidth
)
val fullState by fullPainter.state.collectAsState()
when (fullState) {
is coil3.compose.AsyncImagePainter.State.Success -> {
Image(
painter = fullPainter,
contentDescription = file.name,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(IMAGE_RADIUS)),
contentScale = ContentScale.FillWidth
)
}
is coil3.compose.AsyncImagePainter.State.Loading -> {
Box(modifier = Modifier.fillMaxSize())
}
else -> {
Box(modifier = Modifier.fillMaxSize())
}
}
}
thumbnailBytes == null -> {
Box(
modifier = Modifier.fillMaxSize(),
@@ -299,9 +324,8 @@ private fun DecryptedImageContent(
contentScale = ContentScale.Crop
)
val thumbState by thumbPainter.state.collectAsState()
val showThumbnailLoading = fullBytes == null && thumbState is coil3.compose.AsyncImagePainter.State.Loading
when {
showThumbnailLoading -> {
when (thumbState) {
is coil3.compose.AsyncImagePainter.State.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
@@ -309,7 +333,7 @@ private fun DecryptedImageContent(
InfiniteCircularProgress()
}
}
thumbState is coil3.compose.AsyncImagePainter.State.Success -> {
is coil3.compose.AsyncImagePainter.State.Success -> {
Image(
painter = thumbPainter,
contentDescription = file.name,
@@ -319,9 +343,9 @@ private fun DecryptedImageContent(
.blur(8.dp),
contentScale = ContentScale.Crop
)
if (fullBytes != null) {
if (cachedPath != null) {
val fullPainter = rememberAsyncImagePainter(
model = fullBytes,
model = cachedPath!!,
contentScale = ContentScale.FillWidth
)
val fullState by fullPainter.state.collectAsState()
@@ -346,15 +370,11 @@ private fun DecryptedImageContent(
}
}
else -> {
if (fullBytes != null) {
Box(modifier = Modifier.fillMaxSize())
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
InfiniteCircularProgress()
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
InfiniteCircularProgress()
}
}
}
@@ -3,6 +3,7 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -304,7 +305,7 @@ fun ChatScreen(
targetState = expandedImage,
modifier = Modifier.fillMaxSize(),
transitionSpec = {
fadeIn(animationSpec = tween(300)) togetherWith fadeOut(animationSpec = tween(300))
fadeIn(animationSpec = tween(300)) togetherWith ExitTransition.None
},
label = "image_fullscreen"
) { expanded ->
@@ -1,24 +1,45 @@
package ru.fromchat.ui.chat
import com.pr0gramm3r101.utils.files.PlatformFileSystem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile
import ru.fromchat.crypto.decryptFile
/**
* Cache for decrypted image bytes. Key includes messageId, fileIndex, and file.path
* Disk cache for decrypted image bytes. Key includes messageId, fileIndex, and file.path
* so that server updates (e.g. image replacement) produce cache misses via path change.
*/
object DecryptedImageCache {
private val cache = mutableMapOf<String, ByteArray>()
private val lock = Any()
private var cacheDir: String? = null
private val mutex = Mutex()
private fun key(messageId: Int, fileIndex: Int, filePath: String): String =
"img_${messageId}_${fileIndex}_$filePath"
fun init(cacheDirPath: String) {
cacheDir = cacheDirPath
}
fun getCached(messageId: Int, fileIndex: Int, filePath: String): ByteArray? =
synchronized(lock) { cache[key(messageId, fileIndex, filePath)] }
private fun ensureCacheDir(): String? {
if (cacheDir == null) {
val base = PlatformFileSystem.getAppCacheDirectory()
if (base.isEmpty()) return null
cacheDir = PlatformFileSystem.ensureDirectory("$base/decrypted_images")
}
return cacheDir
}
private fun key(messageId: Int, fileIndex: Int, filePath: String): String {
val safePath = filePath.hashCode().toString(36).replace("-", "m")
return "img_${messageId}_${fileIndex}_$safePath"
}
fun getCached(messageId: Int, fileIndex: Int, filePath: String): String? {
val dir = ensureCacheDir() ?: return null
val path = "$dir/${key(messageId, fileIndex, filePath)}"
return if (PlatformFileSystem.exists(path)) "file://$path" else null
}
suspend fun getOrDecrypt(
messageId: Int,
@@ -26,32 +47,40 @@ object DecryptedImageCache {
file: DmFile,
envelope: DmEnvelope?,
currentUserId: Int?
): ByteArray? {
): String? {
if (envelope == null) return null
val dir = ensureCacheDir() ?: return null
val k = key(messageId, fileIndex, file.path)
synchronized(lock) {
cache[k]?.let { return it }
val path = "$dir/$k"
mutex.withLock {
if (PlatformFileSystem.exists(path)) return "file://$path"
}
val bytes = runCatching {
withContext(Dispatchers.Default) {
decryptFile(file, envelope, currentUserId)
}
}.getOrNull() ?: return null
synchronized(lock) {
cache[k] = bytes
mutex.withLock {
PlatformFileSystem.writeBytes(path, bytes)
}
return bytes
return "file://$path"
}
suspend fun invalidateForMessage(messageId: Int) {
synchronized(lock) {
cache.keys.removeAll { it.startsWith("img_${messageId}_") }
val dir = ensureCacheDir() ?: return
withContext(Dispatchers.Default) {
PlatformFileSystem.deleteFilesWithPrefix(dir, "img_${messageId}_")
}
}
suspend fun invalidateForFile(messageId: Int, fileIndex: Int, filePath: String) {
synchronized(lock) {
cache.remove(key(messageId, fileIndex, filePath))
val dir = ensureCacheDir() ?: return
val path = "$dir/${key(messageId, fileIndex, filePath)}"
withContext(Dispatchers.Default) {
PlatformFileSystem.delete(path)
}
}
}
@@ -1,18 +1,25 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
@@ -39,15 +46,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import ru.fromchat.api.Message
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@@ -87,7 +101,7 @@ fun ImageFullscreenPreview(
val envelope = message.dmEnvelope
val thumbnailBase64 = message.fileThumbnails?.getOrNull(fileIndex)
var fullBytes by remember(message.id, fileIndex, file.path) {
var cachedPath by remember(message.id, fileIndex, file.path) {
mutableStateOf(DecryptedImageCache.getCached(message.id, fileIndex, file.path))
}
val thumbnailBytes = remember(thumbnailBase64) {
@@ -95,162 +109,267 @@ fun ImageFullscreenPreview(
}
LaunchedEffect(message.id, fileIndex, file.path, envelope) {
fullBytes = DecryptedImageCache.getOrDecrypt(message.id, fileIndex, file, envelope, currentUserId)
cachedPath = DecryptedImageCache.getOrDecrypt(message.id, fileIndex, file, envelope, currentUserId)
}
val imageModel = cachedPath ?: thumbnailBytes
var menusVisible by remember { mutableStateOf(true) }
var dismissRequested by remember { mutableStateOf(false) }
Box(
modifier = modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
.background(Color.Black)
.pointerInput(Unit) {
detectTapGestures(onTap = { onDismiss() })
}
) {
val fileAspectRatio = message.fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }
// Center image - scale to width when aspect ratio known
Box(
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.clip(RectangleShape)
.align(Alignment.Center),
contentAlignment = Alignment.Center
) {
when (val bytes = fullBytes ?: thumbnailBytes) {
null -> androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = Color.White
)
else -> {
val imageModifier = if (sharedImageKey != null && sharedTransitionScope != null && animatedVisibilityScope != null) {
with(sharedTransitionScope) {
Modifier
.then(
if (fileAspectRatio != null) Modifier.fillMaxWidth().aspectRatio(fileAspectRatio)
else Modifier.fillMaxSize()
)
.sharedElement(
rememberSharedContentState(key = sharedImageKey),
animatedVisibilityScope = animatedVisibilityScope
)
}
} else if (fileAspectRatio != null) Modifier.fillMaxWidth().aspectRatio(fileAspectRatio)
else Modifier.fillMaxSize()
AsyncImage(
model = bytes,
contentDescription = file.name,
modifier = imageModifier,
contentScale = ContentScale.FillWidth
val containerWidth = constraints.maxWidth.toFloat()
val containerHeight = constraints.maxHeight.toFloat()
val fileAspectRatio = message.fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }
val contentHeightAtScale1 = if (fileAspectRatio != null) containerWidth / fileAspectRatio else containerHeight
when (val model = imageModel) {
null -> {
LaunchedEffect(dismissRequested) {
if (dismissRequested) onDismiss()
}
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = Color.White
)
}
else -> {
var scale by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val scaleAnim = remember { Animatable(1f) }
val offsetXAnim = remember { Animatable(0f) }
val offsetYAnim = remember { Animatable(0f) }
val state = rememberTransformableState { zoomChange, offsetChange, _ ->
scale = (scale * zoomChange).coerceIn(1f, 12f)
offset += offsetChange
}
LaunchedEffect(dismissRequested) {
if (!dismissRequested) return@LaunchedEffect
val hasTransform = scaleAnim.value != 1f ||
offsetXAnim.value != 0f ||
offsetYAnim.value != 0f
if (hasTransform) {
coroutineScope {
launch { scaleAnim.animateTo(1f, tween(220)) }
launch { offsetXAnim.animateTo(0f, tween(220)) }
launch { offsetYAnim.animateTo(0f, tween(220)) }
}
}
onDismiss()
}
LaunchedEffect(scale, offset, state.isTransformInProgress) {
if (state.isTransformInProgress) {
scaleAnim.snapTo(scale)
offsetXAnim.snapTo(offset.x)
offsetYAnim.snapTo(offset.y)
}
}
LaunchedEffect(state.isTransformInProgress) {
if (!state.isTransformInProgress) {
val clampedScale = scale.coerceIn(1f, 10f)
val scaledW = containerWidth * clampedScale
val scaledH = contentHeightAtScale1 * clampedScale
val maxOffsetX = max(0f, (scaledW - containerWidth) / 2f)
val minOffsetX = -maxOffsetX
val maxOffsetY = max(0f, (scaledH - containerHeight) / 2f)
val minOffsetY = -maxOffsetY
val clampedOffset = Offset(
offset.x.coerceIn(minOffsetX, maxOffsetX),
offset.y.coerceIn(minOffsetY, maxOffsetY)
)
scaleAnim.snapTo(scale)
offsetXAnim.snapTo(offset.x)
offsetYAnim.snapTo(offset.y)
coroutineScope {
launch { scaleAnim.animateTo(clampedScale, tween(300)) }
launch { offsetXAnim.animateTo(clampedOffset.x, tween(300)) }
launch { offsetYAnim.animateTo(clampedOffset.y, tween(300)) }
}
scale = scaleAnim.value
offset = Offset(offsetXAnim.value, offsetYAnim.value)
}
}
val sharedElementModifier = if (sharedImageKey != null && sharedTransitionScope != null && animatedVisibilityScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
rememberSharedContentState(key = sharedImageKey),
animatedVisibilityScope = animatedVisibilityScope
)
}
} else Modifier
val sizeModifier = if (fileAspectRatio != null) Modifier.fillMaxWidth().aspectRatio(fileAspectRatio)
else Modifier.fillMaxSize()
Box(
modifier = Modifier
.fillMaxSize()
.transformable(state = state, lockRotationOnZoomPan = true)
.pointerInput(Unit) {
detectTapGestures(onTap = { menusVisible = !menusVisible })
},
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.then(sizeModifier)
.then(sharedElementModifier)
.offset { IntOffset(offsetXAnim.value.roundToInt(), offsetYAnim.value.roundToInt()) }
) {
AsyncImage(
model = model,
contentDescription = file.name,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scaleAnim.value
scaleY = scaleAnim.value
},
contentScale = ContentScale.FillWidth
)
}
}
}
}
}
// Top bar: back, display name + date/time, 3-dot menu
Row(
AnimatedVisibility(
visible = menusVisible,
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.padding(horizontal = 8.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.windowInsetsPadding(WindowInsets.systemBars)
.padding(horizontal = 8.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = message.username,
style = MaterialTheme.typography.titleMedium,
color = Color.White
)
Text(
text = formatDateTime(message.timestamp),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f)
)
}
var menuExpanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { menuExpanded = true }) {
IconButton(onClick = { dismissRequested = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Menu",
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
modifier = Modifier.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.AutoMirrored.Filled.Reply, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text("Reply", color = Color.White)
}
},
Text(
text = message.username,
style = MaterialTheme.typography.titleMedium,
color = Color.White
)
Text(
text = formatDateTime(message.timestamp),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f)
)
}
var menuExpanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { menuExpanded = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Menu",
tint = Color.White
)
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
modifier = Modifier.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.AutoMirrored.Filled.Reply, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text("Reply", color = Color.White)
}
},
onClick = {
menuExpanded = false
onReply(message)
onDismiss()
dismissRequested = true
}
)
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.SaveAlt, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text("Save", color = Color.White)
)
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.SaveAlt, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text("Save", color = Color.White)
}
},
onClick = {
menuExpanded = false
onSave(message, fileIndex)
}
},
onClick = {
menuExpanded = false
onSave(message, fileIndex)
}
)
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Delete, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text("Delete", color = Color.White)
}
},
)
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Delete, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text("Delete", color = Color.White)
}
},
onClick = {
menuExpanded = false
onDelete(message)
onDismiss()
dismissRequested = true
}
)
)
}
}
}
}
// Bottom: message text
if (message.content.isNotBlank()) {
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.padding(16.dp)
) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = Color.White
)
AnimatedVisibility(
visible = menusVisible && message.content.isNotBlank(),
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
) {
if (message.content.isNotBlank()) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.windowInsetsPadding(WindowInsets.systemBars)
.padding(16.dp)
) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = Color.White
)
}
}
}
}
@@ -185,7 +185,8 @@ class DmPanel(
is_edited = true,
fileThumbnails = dec.thumbnails ?: it.fileThumbnails,
fileAspectRatios = dec.aspectRatios ?: it.fileAspectRatios,
fileSizes = dec.fileSizes ?: it.fileSizes
fileSizes = dec.fileSizes ?: it.fileSizes,
fileDimensions = dec.fileDimensions ?: it.fileDimensions
)
}
} else {
@@ -198,31 +199,34 @@ class DmPanel(
val text: String,
val thumbnails: List<String>?,
val aspectRatios: List<Float>?,
val fileSizes: List<Long>?
val fileSizes: List<Long>?,
val fileDimensions: List<Pair<Int, Int>>?
)
private fun parseDecryptedContent(plaintext: String): DecryptedContent {
return runCatching {
val obj = json.parseToJsonElement(plaintext).jsonObject
val text = obj["text"]?.jsonPrimitive?.content ?: return@runCatching DecryptedContent(plaintext, null, null, null)
val thumbArr = obj["fileThumbnails"]?.jsonArray ?: return@runCatching DecryptedContent(text, null, null, null)
val text = obj["text"]?.jsonPrimitive?.content ?: return@runCatching DecryptedContent(plaintext, null, null, null, null)
val thumbArr = obj["fileThumbnails"]?.jsonArray ?: return@runCatching DecryptedContent(text, null, null, null, null)
val thumbnails = thumbArr.map { it.jsonPrimitive.content }
val arArr = obj["fileAspectRatios"]?.jsonArray
val aspectRatios = arArr?.mapNotNull { elem ->
val arr = elem as? JsonArray ?: return@mapNotNull null
if (arr.size == 2) {
val w = (arr.getOrNull(0) as? JsonPrimitive)?.content?.toIntOrNull()
val h = (arr.getOrNull(1) as? JsonPrimitive)?.content?.toIntOrNull()
if (w != null && h != null && h > 0) w.toFloat() / h else null
val parsed = arArr?.mapNotNull { elem ->
val a = elem as? JsonArray ?: return@mapNotNull null
if (a.size == 2) {
val w = (a.getOrNull(0) as? JsonPrimitive)?.content?.toIntOrNull()
val h = (a.getOrNull(1) as? JsonPrimitive)?.content?.toIntOrNull()
if (w != null && h != null && h > 0) Triple(w, h, w.toFloat() / h) else null
} else null
}?.takeIf { it.size == thumbnails.size }
val aspectRatios = parsed?.map { it.third }
val fileDimensions = parsed?.map { it.first to it.second }
val sizesArr = obj["fileSizes"]?.jsonArray
val fileSizes = sizesArr?.mapNotNull { (it as? JsonPrimitive)?.content?.toLongOrNull() }?.takeIf { it.size == thumbnails.size }
Logger.d("DmPanel", "parseDecryptedContent: thumbnails=${thumbnails.size}, aspectRatios=${aspectRatios?.size}, fileSizes=${fileSizes?.size}")
DecryptedContent(text, thumbnails.ifEmpty { null }, aspectRatios, fileSizes)
DecryptedContent(text, thumbnails.ifEmpty { null }, aspectRatios, fileSizes, fileDimensions)
}.getOrElse {
Logger.d("DmPanel", "parseDecryptedContent: parse failed, using plaintext fallback")
DecryptedContent(plaintext, null, null, null)
DecryptedContent(plaintext, null, null, null, null)
}
}
@@ -250,7 +254,8 @@ class DmPanel(
dmEnvelope = envelope,
fileThumbnails = dec.thumbnails,
fileAspectRatios = dec.aspectRatios,
fileSizes = dec.fileSizes
fileSizes = dec.fileSizes,
fileDimensions = dec.fileDimensions
)
}