mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
@@ -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,15 +370,11 @@ private fun DecryptedImageContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
if (fullBytes != null) {
|
Box(
|
||||||
Box(modifier = Modifier.fillMaxSize())
|
modifier = Modifier.fillMaxSize(),
|
||||||
} else {
|
contentAlignment = Alignment.Center
|
||||||
Box(
|
) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
InfiniteCircularProgress()
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
InfiniteCircularProgress()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,162 +109,267 @@ 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()
|
||||||
modifier = Modifier.size(48.dp),
|
val fileAspectRatio = message.fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }
|
||||||
color = Color.White
|
val contentHeightAtScale1 = if (fileAspectRatio != null) containerWidth / fileAspectRatio else containerHeight
|
||||||
)
|
|
||||||
else -> {
|
when (val model = imageModel) {
|
||||||
val imageModifier = if (sharedImageKey != null && sharedTransitionScope != null && animatedVisibilityScope != null) {
|
null -> {
|
||||||
with(sharedTransitionScope) {
|
LaunchedEffect(dismissRequested) {
|
||||||
Modifier
|
if (dismissRequested) onDismiss()
|
||||||
.then(
|
}
|
||||||
if (fileAspectRatio != null) Modifier.fillMaxWidth().aspectRatio(fileAspectRatio)
|
androidx.compose.material3.CircularProgressIndicator(
|
||||||
else Modifier.fillMaxSize()
|
modifier = Modifier.size(48.dp),
|
||||||
)
|
color = Color.White
|
||||||
.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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
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
|
// 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()
|
||||||
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
|
|
||||||
.padding(horizontal = 8.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onDismiss) {
|
Row(
|
||||||
Icon(
|
modifier = Modifier
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
.fillMaxWidth()
|
||||||
contentDescription = "Back",
|
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
|
||||||
tint = Color.White
|
.windowInsetsPadding(WindowInsets.systemBars)
|
||||||
)
|
.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||||
}
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Column(
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
Text(
|
IconButton(onClick = { dismissRequested = true }) {
|
||||||
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(
|
Icon(
|
||||||
imageVector = Icons.Default.MoreVert,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = "Menu",
|
contentDescription = "Back",
|
||||||
tint = Color.White
|
tint = Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(
|
Column(
|
||||||
expanded = menuExpanded,
|
modifier = Modifier.weight(1f),
|
||||||
onDismissRequest = { menuExpanded = false },
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
modifier = Modifier.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
|
|
||||||
) {
|
) {
|
||||||
DropdownMenuItem(
|
Text(
|
||||||
text = {
|
text = message.username,
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
style = MaterialTheme.typography.titleMedium,
|
||||||
Icon(Icons.AutoMirrored.Filled.Reply, null, tint = Color.White)
|
color = Color.White
|
||||||
Spacer(Modifier.width(8.dp))
|
)
|
||||||
Text("Reply", 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 = {
|
onClick = {
|
||||||
menuExpanded = false
|
menuExpanded = false
|
||||||
onReply(message)
|
onReply(message)
|
||||||
onDismiss()
|
dismissRequested = true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = {
|
text = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(Icons.Default.SaveAlt, null, tint = Color.White)
|
Icon(Icons.Default.SaveAlt, null, tint = Color.White)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Save", color = Color.White)
|
Text("Save", color = Color.White)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onSave(message, fileIndex)
|
||||||
}
|
}
|
||||||
},
|
)
|
||||||
onClick = {
|
DropdownMenuItem(
|
||||||
menuExpanded = false
|
text = {
|
||||||
onSave(message, fileIndex)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
}
|
Icon(Icons.Default.Delete, null, tint = Color.White)
|
||||||
)
|
Spacer(Modifier.width(8.dp))
|
||||||
DropdownMenuItem(
|
Text("Delete", color = Color.White)
|
||||||
text = {
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
},
|
||||||
Icon(Icons.Default.Delete, null, tint = Color.White)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Delete", color = Color.White)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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(),
|
||||||
modifier = Modifier
|
enter = androidx.compose.animation.fadeIn(),
|
||||||
.align(Alignment.BottomStart)
|
exit = androidx.compose.animation.fadeOut(),
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
|
.align(Alignment.BottomStart)
|
||||||
.padding(16.dp)
|
.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
if (message.content.isNotBlank()) {
|
||||||
text = message.content,
|
Box(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
modifier = Modifier
|
||||||
color = Color.White
|
.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,
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
@@ -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()
|
||||||
|
}
|
||||||
+57
@@ -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)
|
||||||
+47
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user