From 323ec99c12da1a44b7b9211e9a44356ac290817b Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 19 Feb 2026 01:20:27 +0300 Subject: [PATCH] Fix bugs Signed-off-by: denis0001-dev --- .../kotlin/ru/fromchat/api/Models.kt | 4 +- .../ru/fromchat/ui/chat/AttachmentPreview.kt | 58 ++- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 3 +- .../fromchat/ui/chat/DecryptedImageCache.kt | 63 +++- .../ui/chat/ImageFullscreenPreview.kt | 349 ++++++++++++------ .../kotlin/ru/fromchat/ui/dm/DmPanel.kt | 31 +- .../utils/files/PlatformFileSystem.android.kt | 32 ++ .../utils/files/PlatformFileSystem.kt | 57 +++ .../utils/files/PlatformFileSystem.ios.kt | 47 +++ 9 files changed, 478 insertions(+), 166 deletions(-) create mode 100644 utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.android.kt create mode 100644 utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.kt create mode 100644 utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.ios.kt diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index a366ca6..2c3fbef 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -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? = null, /** File sizes in bytes (by index); from decrypted message JSON. */ - @kotlinx.serialization.Transient val fileSizes: List? = null + @kotlinx.serialization.Transient val fileSizes: List? = null, + /** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */ + @kotlinx.serialization.Transient val fileDimensions: List>? = null ) @Serializable diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index e93f250..2235e40 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -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() } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 3c5ea76..7a18752 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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 -> diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt index cb33483..69e1cfe 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt @@ -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() - 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) } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt index 1be5fde..a526f03 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt @@ -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 + ) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt index c9cb458..06d9af7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt @@ -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?, val aspectRatios: List?, - val fileSizes: List? + val fileSizes: List?, + val fileDimensions: List>? ) 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 ) } diff --git a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.android.kt b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.android.kt new file mode 100644 index 0000000..89b220c --- /dev/null +++ b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.android.kt @@ -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() +} diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.kt new file mode 100644 index 0000000..9ba2d79 --- /dev/null +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.kt @@ -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) diff --git a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.ios.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.ios.kt new file mode 100644 index 0000000..a6da7f3 --- /dev/null +++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.ios.kt @@ -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().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) +}