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. */ /** Aspect ratios (width/height) for image files (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileAspectRatios: List<Float>? = null, @kotlinx.serialization.Transient val fileAspectRatios: List<Float>? = null,
/** File sizes in bytes (by index); from decrypted message JSON. */ /** 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 @Serializable
@@ -269,7 +269,7 @@ private fun DecryptedImageContent(
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
onFullyLoaded: (Boolean) -> Unit = {} 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)) mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, file.path))
} }
val thumbnailBytes = remember(thumbnailBase64) { val thumbnailBytes = remember(thumbnailBase64) {
@@ -277,14 +277,39 @@ private fun DecryptedImageContent(
} }
LaunchedEffect(messageId, fileIndex, file.path, envelope) { LaunchedEffect(messageId, fileIndex, file.path, envelope) {
fullBytes = DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId) cachedPath = DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId)
} }
LaunchedEffect(fullBytes) { LaunchedEffect(cachedPath) {
onFullyLoaded(fullBytes != null) onFullyLoaded(cachedPath != null)
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
when { 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 -> { thumbnailBytes == null -> {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -299,9 +324,8 @@ private fun DecryptedImageContent(
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
val thumbState by thumbPainter.state.collectAsState() val thumbState by thumbPainter.state.collectAsState()
val showThumbnailLoading = fullBytes == null && thumbState is coil3.compose.AsyncImagePainter.State.Loading when (thumbState) {
when { is coil3.compose.AsyncImagePainter.State.Loading -> {
showThumbnailLoading -> {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -309,7 +333,7 @@ private fun DecryptedImageContent(
InfiniteCircularProgress() InfiniteCircularProgress()
} }
} }
thumbState is coil3.compose.AsyncImagePainter.State.Success -> { is coil3.compose.AsyncImagePainter.State.Success -> {
Image( Image(
painter = thumbPainter, painter = thumbPainter,
contentDescription = file.name, contentDescription = file.name,
@@ -319,9 +343,9 @@ private fun DecryptedImageContent(
.blur(8.dp), .blur(8.dp),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
if (fullBytes != null) { if (cachedPath != null) {
val fullPainter = rememberAsyncImagePainter( val fullPainter = rememberAsyncImagePainter(
model = fullBytes, model = cachedPath!!,
contentScale = ContentScale.FillWidth contentScale = ContentScale.FillWidth
) )
val fullState by fullPainter.state.collectAsState() val fullState by fullPainter.state.collectAsState()
@@ -346,9 +370,6 @@ private fun DecryptedImageContent(
} }
} }
else -> { else -> {
if (fullBytes != null) {
Box(modifier = Modifier.fillMaxSize())
} else {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -361,7 +382,6 @@ private fun DecryptedImageContent(
} }
} }
} }
}
private fun formatFileSize(bytes: Long): String { private fun formatFileSize(bytes: Long): String {
return when { return when {
@@ -3,6 +3,7 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -304,7 +305,7 @@ fun ChatScreen(
targetState = expandedImage, targetState = expandedImage,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
transitionSpec = { transitionSpec = {
fadeIn(animationSpec = tween(300)) togetherWith fadeOut(animationSpec = tween(300)) fadeIn(animationSpec = tween(300)) togetherWith ExitTransition.None
}, },
label = "image_fullscreen" label = "image_fullscreen"
) { expanded -> ) { expanded ->
@@ -1,24 +1,45 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import com.pr0gramm3r101.utils.files.PlatformFileSystem
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.DmEnvelope import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile import ru.fromchat.api.DmFile
import ru.fromchat.crypto.decryptFile 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. * so that server updates (e.g. image replacement) produce cache misses via path change.
*/ */
object DecryptedImageCache { object DecryptedImageCache {
private val cache = mutableMapOf<String, ByteArray>() private var cacheDir: String? = null
private val lock = Any() private val mutex = Mutex()
private fun key(messageId: Int, fileIndex: Int, filePath: String): String = fun init(cacheDirPath: String) {
"img_${messageId}_${fileIndex}_$filePath" cacheDir = cacheDirPath
}
fun getCached(messageId: Int, fileIndex: Int, filePath: String): ByteArray? = private fun ensureCacheDir(): String? {
synchronized(lock) { cache[key(messageId, fileIndex, filePath)] } 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( suspend fun getOrDecrypt(
messageId: Int, messageId: Int,
@@ -26,32 +47,40 @@ object DecryptedImageCache {
file: DmFile, file: DmFile,
envelope: DmEnvelope?, envelope: DmEnvelope?,
currentUserId: Int? currentUserId: Int?
): ByteArray? { ): String? {
if (envelope == null) return null if (envelope == null) return null
val dir = ensureCacheDir() ?: return null
val k = key(messageId, fileIndex, file.path) val k = key(messageId, fileIndex, file.path)
synchronized(lock) { val path = "$dir/$k"
cache[k]?.let { return it }
mutex.withLock {
if (PlatformFileSystem.exists(path)) return "file://$path"
} }
val bytes = runCatching { val bytes = runCatching {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
decryptFile(file, envelope, currentUserId) decryptFile(file, envelope, currentUserId)
} }
}.getOrNull() ?: return null }.getOrNull() ?: return null
synchronized(lock) {
cache[k] = bytes mutex.withLock {
PlatformFileSystem.writeBytes(path, bytes)
} }
return bytes return "file://$path"
} }
suspend fun invalidateForMessage(messageId: Int) { suspend fun invalidateForMessage(messageId: Int) {
synchronized(lock) { val dir = ensureCacheDir() ?: return
cache.keys.removeAll { it.startsWith("img_${messageId}_") } withContext(Dispatchers.Default) {
PlatformFileSystem.deleteFilesWithPrefix(dir, "img_${messageId}_")
} }
} }
suspend fun invalidateForFile(messageId: Int, fileIndex: Int, filePath: String) { suspend fun invalidateForFile(messageId: Int, fileIndex: Int, filePath: String) {
synchronized(lock) { val dir = ensureCacheDir() ?: return
cache.remove(key(messageId, fileIndex, filePath)) val path = "$dir/${key(messageId, fileIndex, filePath)}"
withContext(Dispatchers.Default) {
PlatformFileSystem.delete(path)
} }
} }
} }
@@ -1,18 +1,25 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope 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.background
import androidx.compose.foundation.gestures.detectTapGestures 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
@@ -39,15 +46,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import ru.fromchat.api.Message import ru.fromchat.api.Message
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.Instant import kotlin.time.Instant
@@ -87,7 +101,7 @@ fun ImageFullscreenPreview(
val envelope = message.dmEnvelope val envelope = message.dmEnvelope
val thumbnailBase64 = message.fileThumbnails?.getOrNull(fileIndex) 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)) mutableStateOf(DecryptedImageCache.getCached(message.id, fileIndex, file.path))
} }
val thumbnailBytes = remember(thumbnailBase64) { val thumbnailBytes = remember(thumbnailBase64) {
@@ -95,68 +109,163 @@ fun ImageFullscreenPreview(
} }
LaunchedEffect(message.id, fileIndex, file.path, envelope) { 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( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
.background(Color.Black) .background(Color.Black)
.pointerInput(Unit) {
detectTapGestures(onTap = { onDismiss() })
}
) { ) {
val fileAspectRatio = message.fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f } BoxWithConstraints(
// Center image - scale to width when aspect ratio known
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(RectangleShape) .clip(RectangleShape)
.align(Alignment.Center), .align(Alignment.Center),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
when (val bytes = fullBytes ?: thumbnailBytes) { val containerWidth = constraints.maxWidth.toFloat()
null -> androidx.compose.material3.CircularProgressIndicator( 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), modifier = Modifier.size(48.dp),
color = Color.White color = Color.White
) )
}
else -> { else -> {
val imageModifier = if (sharedImageKey != null && sharedTransitionScope != null && animatedVisibilityScope != null) { var scale by remember { mutableStateOf(1f) }
with(sharedTransitionScope) { var offset by remember { mutableStateOf(Offset.Zero) }
Modifier val scaleAnim = remember { Animatable(1f) }
.then( val offsetXAnim = remember { Animatable(0f) }
if (fileAspectRatio != null) Modifier.fillMaxWidth().aspectRatio(fileAspectRatio) val offsetYAnim = remember { Animatable(0f) }
else Modifier.fillMaxSize() 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)
) )
.sharedElement( 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), rememberSharedContentState(key = sharedImageKey),
animatedVisibilityScope = animatedVisibilityScope animatedVisibilityScope = animatedVisibilityScope
) )
} }
} else if (fileAspectRatio != null) Modifier.fillMaxWidth().aspectRatio(fileAspectRatio) } else Modifier
val sizeModifier = if (fileAspectRatio != null) Modifier.fillMaxWidth().aspectRatio(fileAspectRatio)
else Modifier.fillMaxSize() 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( AsyncImage(
model = bytes, model = model,
contentDescription = file.name, contentDescription = file.name,
modifier = imageModifier, modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scaleAnim.value
scaleY = scaleAnim.value
},
contentScale = ContentScale.FillWidth contentScale = ContentScale.FillWidth
) )
} }
} }
} }
}
}
// Top bar: back, display name + date/time, 3-dot menu // 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 modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.fillMaxWidth() .fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA)) .background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.windowInsetsPadding(WindowInsets.systemBars)
.padding(horizontal = 8.dp, vertical = 12.dp), .padding(horizontal = 8.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
IconButton(onClick = onDismiss) { IconButton(onClick = { dismissRequested = true }) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back", contentDescription = "Back",
@@ -203,7 +312,7 @@ fun ImageFullscreenPreview(
onClick = { onClick = {
menuExpanded = false menuExpanded = false
onReply(message) onReply(message)
onDismiss() dismissRequested = true
} }
) )
DropdownMenuItem( DropdownMenuItem(
@@ -230,20 +339,29 @@ fun ImageFullscreenPreview(
onClick = { onClick = {
menuExpanded = false menuExpanded = false
onDelete(message) onDelete(message)
onDismiss() dismissRequested = true
} }
) )
} }
} }
} }
}
// Bottom: message text // Bottom: message text
if (message.content.isNotBlank()) { AnimatedVisibility(
Box( visible = menusVisible && message.content.isNotBlank(),
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
) {
if (message.content.isNotBlank()) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA)) .background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.windowInsetsPadding(WindowInsets.systemBars)
.padding(16.dp) .padding(16.dp)
) { ) {
Text( Text(
@@ -255,3 +373,4 @@ fun ImageFullscreenPreview(
} }
} }
} }
}
@@ -185,7 +185,8 @@ class DmPanel(
is_edited = true, is_edited = true,
fileThumbnails = dec.thumbnails ?: it.fileThumbnails, fileThumbnails = dec.thumbnails ?: it.fileThumbnails,
fileAspectRatios = dec.aspectRatios ?: it.fileAspectRatios, fileAspectRatios = dec.aspectRatios ?: it.fileAspectRatios,
fileSizes = dec.fileSizes ?: it.fileSizes fileSizes = dec.fileSizes ?: it.fileSizes,
fileDimensions = dec.fileDimensions ?: it.fileDimensions
) )
} }
} else { } else {
@@ -198,31 +199,34 @@ class DmPanel(
val text: String, val text: String,
val thumbnails: List<String>?, val thumbnails: List<String>?,
val aspectRatios: List<Float>?, val aspectRatios: List<Float>?,
val fileSizes: List<Long>? val fileSizes: List<Long>?,
val fileDimensions: List<Pair<Int, Int>>?
) )
private fun parseDecryptedContent(plaintext: String): DecryptedContent { private fun parseDecryptedContent(plaintext: String): DecryptedContent {
return runCatching { return runCatching {
val obj = json.parseToJsonElement(plaintext).jsonObject val obj = json.parseToJsonElement(plaintext).jsonObject
val text = obj["text"]?.jsonPrimitive?.content ?: return@runCatching DecryptedContent(plaintext, 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) val thumbArr = obj["fileThumbnails"]?.jsonArray ?: return@runCatching DecryptedContent(text, null, null, null, null)
val thumbnails = thumbArr.map { it.jsonPrimitive.content } val thumbnails = thumbArr.map { it.jsonPrimitive.content }
val arArr = obj["fileAspectRatios"]?.jsonArray val arArr = obj["fileAspectRatios"]?.jsonArray
val aspectRatios = arArr?.mapNotNull { elem -> val parsed = arArr?.mapNotNull { elem ->
val arr = elem as? JsonArray ?: return@mapNotNull null val a = elem as? JsonArray ?: return@mapNotNull null
if (arr.size == 2) { if (a.size == 2) {
val w = (arr.getOrNull(0) as? JsonPrimitive)?.content?.toIntOrNull() val w = (a.getOrNull(0) as? JsonPrimitive)?.content?.toIntOrNull()
val h = (arr.getOrNull(1) as? JsonPrimitive)?.content?.toIntOrNull() val h = (a.getOrNull(1) as? JsonPrimitive)?.content?.toIntOrNull()
if (w != null && h != null && h > 0) w.toFloat() / h else null if (w != null && h != null && h > 0) Triple(w, h, w.toFloat() / h) else null
} else null } else null
}?.takeIf { it.size == thumbnails.size } }?.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 sizesArr = obj["fileSizes"]?.jsonArray
val fileSizes = sizesArr?.mapNotNull { (it as? JsonPrimitive)?.content?.toLongOrNull() }?.takeIf { it.size == thumbnails.size } 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}") 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 { }.getOrElse {
Logger.d("DmPanel", "parseDecryptedContent: parse failed, using plaintext fallback") 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, dmEnvelope = envelope,
fileThumbnails = dec.thumbnails, fileThumbnails = dec.thumbnails,
fileAspectRatios = dec.aspectRatios, fileAspectRatios = dec.aspectRatios,
fileSizes = dec.fileSizes fileSizes = dec.fileSizes,
fileDimensions = dec.fileDimensions
) )
} }
@@ -0,0 +1,32 @@
package com.pr0gramm3r101.utils.files
import com.pr0gramm3r101.utils.UtilsLibrary
import java.io.File
internal actual fun expectExists(path: String): Boolean =
File(path).exists()
internal actual fun expectWriteBytes(path: String, bytes: ByteArray) {
File(path).writeBytes(bytes)
}
internal actual fun expectDelete(path: String) {
File(path).delete()
}
internal actual fun expectDeleteFilesWithPrefix(dirPath: String, namePrefix: String) {
val dir = File(dirPath)
if (!dir.exists()) return
dir.listFiles()?.forEach { file ->
if (file.name.startsWith(namePrefix)) {
file.delete()
}
}
}
internal actual fun expectGetAppCacheDirectory(): String =
UtilsLibrary.context.cacheDir.absolutePath
internal actual fun expectEnsureDirectory(path: String) {
File(path).mkdirs()
}
@@ -0,0 +1,57 @@
package com.pr0gramm3r101.utils.files
/**
* Multiplatform file system API for basic file operations.
* Use [getAppCacheDirectory] to obtain the platform cache directory.
*/
object PlatformFileSystem {
/**
* Returns true if a file exists at the given path.
*/
fun exists(path: String): Boolean = expectExists(path)
/**
* Writes [bytes] to the file at [path], overwriting if it exists.
*/
fun writeBytes(path: String, bytes: ByteArray) {
expectWriteBytes(path, bytes)
}
/**
* Deletes the file at [path]. No-op if the file does not exist.
*/
fun delete(path: String) {
expectDelete(path)
}
/**
* Deletes all files in the directory [dirPath] whose filename starts with [namePrefix].
*/
fun deleteFilesWithPrefix(dirPath: String, namePrefix: String) {
expectDeleteFilesWithPrefix(dirPath, namePrefix)
}
/**
* Returns the platform-specific application cache directory path.
* On Android: context.cacheDir (requires UtilsLibrary.init to be called).
* On iOS: NSCachesDirectory.
*/
fun getAppCacheDirectory(): String = expectGetAppCacheDirectory()
/**
* Creates the directory at [path] if it does not exist.
* Returns the absolute path.
*/
fun ensureDirectory(path: String): String {
expectEnsureDirectory(path)
return path
}
}
internal expect fun expectExists(path: String): Boolean
internal expect fun expectWriteBytes(path: String, bytes: ByteArray)
internal expect fun expectDelete(path: String)
internal expect fun expectDeleteFilesWithPrefix(dirPath: String, namePrefix: String)
internal expect fun expectGetAppCacheDirectory(): String
internal expect fun expectEnsureDirectory(path: String)
@@ -0,0 +1,47 @@
package com.pr0gramm3r101.utils.files
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
import platform.Foundation.NSCachesDirectory
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSUserDomainMask
import platform.Foundation.create
import platform.Foundation.writeToFile
@OptIn(ExperimentalForeignApi::class)
internal actual fun expectExists(path: String): Boolean =
NSFileManager.defaultManager.fileExistsAtPath(path)
@OptIn(ExperimentalForeignApi::class)
internal actual fun expectWriteBytes(path: String, bytes: ByteArray) {
val nsData = bytes.usePinned { pinned ->
NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
}
nsData?.writeToFile(path, true)
}
internal actual fun expectDelete(path: String) {
NSFileManager.defaultManager.removeItemAtPath(path, null)
}
internal actual fun expectDeleteFilesWithPrefix(dirPath: String, namePrefix: String) {
val contents = NSFileManager.defaultManager.contentsOfDirectoryAtPath(dirPath, null)
?: return
(contents as List<*>).filterIsInstance<String>().forEach { name ->
if (name.startsWith(namePrefix)) {
NSFileManager.defaultManager.removeItemAtPath("$dirPath/$name", null)
}
}
}
internal actual fun expectGetAppCacheDirectory(): String {
val paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, true)
return (paths.firstOrNull() as? String) ?: ""
}
internal actual fun expectEnsureDirectory(path: String) {
NSFileManager.defaultManager.createDirectoryAtPath(path, true, null, null)
}