Implement thumbnails

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-15 15:58:08 +03:00
Unverified
parent 7da7cf6018
commit ea2529f265
8 changed files with 192 additions and 87 deletions
-1
View File
@@ -40,7 +40,6 @@ kotlin {
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
// NaCl box implementation for transport encryption (Android/JVM only)
implementation("org.purejava:tweetnacl-java:1.1.3") implementation("org.purejava:tweetnacl-java:1.1.3")
} }
@@ -334,10 +334,9 @@ object ApiClient {
/** /**
* Fetch encrypted file bytes. Path is e.g. "/uploads/files/encrypted/xxx.jpg" or "/api/uploads/files/encrypted/xxx.jpg". * Fetch encrypted file bytes. Path is e.g. "/uploads/files/encrypted/xxx.jpg" or "/api/uploads/files/encrypted/xxx.jpg".
* Backend may return path with /api prefix; apiBaseUrl already includes /api, so we avoid double /api. * Backend may return path with /api prefix; apiBaseUrl already includes /api, so we avoid double /api.
* @param thumb if true, appends ?thumb=1 for thumbnail (JPEG, ~5 KB)
*/ */
suspend fun fetchEncryptedFile(path: String, thumb: Boolean = false): ByteArray { suspend fun fetchEncryptedFile(path: String): ByteArray {
val baseUrl = when { val url = when {
path.startsWith("http") -> path path.startsWith("http") -> path
path.startsWith("/api") -> { path.startsWith("/api") -> {
val serverBase = Config.apiBaseUrl.removeSuffix("/api") val serverBase = Config.apiBaseUrl.removeSuffix("/api")
@@ -345,17 +344,9 @@ object ApiClient {
} }
else -> "${Config.apiBaseUrl}$path" else -> "${Config.apiBaseUrl}$path"
} }
val url = if (thumb) "$baseUrl?thumb=1" else baseUrl
return http.get(url).body() return http.get(url).body()
} }
/**
* Fetch thumbnail for an image file. Returns null if thumbnail not available (404).
*/
suspend fun fetchThumbnail(path: String): ByteArray? = runCatching {
fetchEncryptedFile(path, thumb = true)
}.getOrNull()
/** /**
* Edit an existing direct message using the same transport encryption scheme as /dm/send. * Edit an existing direct message using the same transport encryption scheme as /dm/send.
*/ */
@@ -101,7 +101,11 @@ data class Message(
/** For optimistic UI: 0-100 upload progress, null when complete. */ /** For optimistic UI: 0-100 upload progress, null when complete. */
val uploadProgress: Int? = null, val uploadProgress: Int? = null,
/** For DM file decryption; not serialized over network. */ /** For DM file decryption; not serialized over network. */
@kotlinx.serialization.Transient val dmEnvelope: DmEnvelope? = null @kotlinx.serialization.Transient val dmEnvelope: DmEnvelope? = null,
/** Blurhashes for image files (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileThumbnails: List<String>? = null,
/** Aspect ratios (width/height) for image files (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileAspectRatios: List<Float>? = null
) )
@Serializable @Serializable
@@ -1,16 +1,19 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
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.sizeIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.AttachFile
@@ -21,26 +24,26 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.alpha
import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.rememberAsyncImagePainter
import com.pr0gramm3r101.utils.crypto.Base64
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.api.DmEnvelope import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile import ru.fromchat.api.DmFile
import ru.fromchat.core.Logger
import ru.fromchat.crypto.decryptFile import ru.fromchat.crypto.decryptFile
private val IMAGE_SIZE = 160.dp private val IMAGE_SIZE = 160.dp
@@ -57,6 +60,8 @@ fun AttachmentPreview(
currentUserId: Int?, currentUserId: Int?,
pendingFileUri: String?, pendingFileUri: String?,
isUploading: Boolean, isUploading: Boolean,
fileThumbnail: String? = null,
fileAspectRatio: Float? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val isImage = file?.let { isImageFilename(it.name) } ?: pendingFileUri?.let { val isImage = file?.let { isImageFilename(it.name) } ?: pendingFileUri?.let {
@@ -65,32 +70,45 @@ fun AttachmentPreview(
it.endsWith(".gif", true) || it.endsWith(".webp", true) it.endsWith(".gif", true) || it.endsWith(".webp", true)
} ?: false } ?: false
Box( val baseModifier = modifier
modifier = modifier .then(
.size(IMAGE_SIZE) if (fileAspectRatio != null && fileAspectRatio > 0f) {
Modifier.aspectRatio(fileAspectRatio).sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_SIZE)
} else {
Modifier.size(IMAGE_SIZE)
}
)
.clip(RoundedCornerShape(IMAGE_RADIUS)) .clip(RoundedCornerShape(IMAGE_RADIUS))
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant)
Box(
modifier = baseModifier,
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
when { when {
pendingFileUri != null -> { pendingFileUri != null -> {
Logger.d("AttachmentPreview", "Rendering: pendingFileUri, isUploading=$isUploading")
PendingImageContent( PendingImageContent(
uri = pendingFileUri, uri = pendingFileUri,
isUploading = isUploading, isUploading = isUploading,
isImage = isImage isImage = isImage
) )
} }
file != null && isImage && dmEnvelope != null -> { file != null && isImage && dmEnvelope != null && !fileThumbnail.isNullOrBlank() -> {
Logger.d("AttachmentPreview", "Rendering: DecryptedImageContent file=${file.name} thumbLen=${fileThumbnail.length} aspectRatio=$fileAspectRatio")
DecryptedImageContent( DecryptedImageContent(
file = file, file = file,
envelope = dmEnvelope, envelope = dmEnvelope,
currentUserId = currentUserId currentUserId = currentUserId,
thumbnailBase64 = fileThumbnail,
aspectRatio = fileAspectRatio
) )
} }
file != null && !isImage -> { file != null && !isImage -> {
Logger.d("AttachmentPreview", "Rendering: FileIconContent file=${file.name}")
FileIconContent(filename = file.name) FileIconContent(filename = file.name)
} }
else -> { else -> {
Logger.d("AttachmentPreview", "Rendering: fallback Icon (file=$file, isImage=$isImage, hasEnvelope=${dmEnvelope != null}, thumbBlank=${fileThumbnail.isNullOrBlank()})")
Icon( Icon(
imageVector = Icons.Default.Image, imageVector = Icons.Default.Image,
contentDescription = null, contentDescription = null,
@@ -165,65 +183,95 @@ private fun InfiniteCircularProgress() {
private fun DecryptedImageContent( private fun DecryptedImageContent(
file: DmFile, file: DmFile,
envelope: DmEnvelope, envelope: DmEnvelope,
currentUserId: Int? currentUserId: Int?,
thumbnailBase64: String,
aspectRatio: Float?
) { ) {
var thumbnailBytes by remember(file.path) { mutableStateOf<ByteArray?>(null) }
var fullBytes by remember(file.path) { mutableStateOf<ByteArray?>(null) } var fullBytes by remember(file.path) { mutableStateOf<ByteArray?>(null) }
var imageReadyToUnblur by remember(file.path) { mutableStateOf(false) } val thumbnailBytes = remember(thumbnailBase64) {
runCatching { Base64.decode(thumbnailBase64) }.getOrNull()
}
LaunchedEffect(file.path) { LaunchedEffect(file.path) {
Logger.d("AttachmentPreview", "DecryptedImageContent: fetching full image path=${file.path}")
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
coroutineScope { fullBytes = runCatching { decryptFile(file, envelope, currentUserId) }.getOrNull()
val thumbDeferred = async { Logger.d("AttachmentPreview", "DecryptedImageContent: full image fetch done path=${file.path} success=${fullBytes != null} size=${fullBytes?.size ?: 0}")
ApiClient.fetchThumbnail(file.path)
}
val fullDeferred = async {
runCatching { decryptFile(file, envelope, currentUserId) }.getOrNull()
}
thumbnailBytes = thumbDeferred.await()
fullBytes = fullDeferred.await()
}
} }
} }
val hasThumb = thumbnailBytes != null Box(modifier = Modifier.fillMaxSize()) {
val hasFull = fullBytes != null when {
val showContent = hasThumb || hasFull thumbnailBytes == null -> {
if (!showContent) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator(modifier = Modifier.size(24.dp)) InfiniteCircularProgress()
}
return
}
LaunchedEffect(hasFull) {
if (hasFull) {
delay(80)
imageReadyToUnblur = true
} }
} }
val blurProgress by animateFloatAsState( else -> {
targetValue = if (imageReadyToUnblur) 0f else 1f, val thumbPainter = rememberAsyncImagePainter(
animationSpec = tween(300), model = thumbnailBytes,
label = "blur" contentScale = ContentScale.Crop
) )
val blurRadius = with(LocalDensity.current) { (blurProgress * 8.dp.toPx()).toDp() } val thumbState by thumbPainter.state.collectAsState()
val displayBytes = fullBytes ?: thumbnailBytes!! when (thumbState) {
is coil3.compose.AsyncImagePainter.State.Loading -> {
Box(modifier = Modifier.fillMaxSize()) { Box(
AsyncImage( modifier = Modifier.fillMaxSize(),
model = displayBytes, contentAlignment = Alignment.Center
) {
InfiniteCircularProgress()
}
}
is coil3.compose.AsyncImagePainter.State.Success -> {
Image(
painter = thumbPainter,
contentDescription = file.name, contentDescription = file.name,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(RoundedCornerShape(IMAGE_RADIUS)) .clip(RoundedCornerShape(IMAGE_RADIUS))
.then(if (blurProgress > 0.01f) Modifier.blur(blurRadius) else Modifier), .blur(8.dp),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
if (fullBytes != null) {
val fullPainter = rememberAsyncImagePainter(
model = fullBytes,
contentScale = ContentScale.Crop
)
val fullState by fullPainter.state.collectAsState()
when (fullState) {
is coil3.compose.AsyncImagePainter.State.Success -> {
val alpha = remember { Animatable(0f) }
LaunchedEffect(Unit) {
alpha.animateTo(1f, animationSpec = tween(300))
}
Image(
painter = fullPainter,
contentDescription = file.name,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(IMAGE_RADIUS))
.alpha(alpha.value),
contentScale = ContentScale.Crop
)
}
else -> { }
}
}
}
else -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
InfiniteCircularProgress()
}
}
}
}
}
} }
} }
@@ -103,6 +103,24 @@ abstract class ChatPanel(
} }
} }
/**
* Add multiple messages at once. Use when loading history so the list is not shown
* until all messages (including thumbnails) are ready.
*/
suspend fun addMessages(messages: List<Message>) {
if (messages.isEmpty()) return
addMessageMutex.withLock {
val existingIds = _state.messages.mapTo(mutableSetOf()) { it.id }
val newOnes = messages.filter { it.id !in existingIds }
if (newOnes.isNotEmpty()) {
updateState { currentState ->
val newMessages = (currentState.messages + newOnes).sortedBy { it.timestamp }
currentState.copy(messages = newMessages)
}
}
}
}
/** /**
* Update existing message (public for ChatScreen optimistic UI) * Update existing message (public for ChatScreen optimistic UI)
*/ */
@@ -211,13 +211,15 @@ fun MessageItem(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
) )
} }
message.files?.forEach { file -> message.files?.forEachIndexed { index, file ->
AttachmentPreview( AttachmentPreview(
file = file, file = file,
dmEnvelope = message.dmEnvelope, dmEnvelope = message.dmEnvelope,
currentUserId = currentUserId, currentUserId = currentUserId,
pendingFileUri = null, pendingFileUri = null,
isUploading = false, isUploading = false,
fileThumbnail = message.fileThumbnails?.getOrNull(index)?.takeIf { it.isNotBlank() },
fileAspectRatio = message.fileAspectRatios?.getOrNull(index)?.takeIf { it > 0f },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
) )
} }
@@ -5,6 +5,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
@@ -75,17 +77,24 @@ class DmPanel(
ApiClient.getDmHistory(otherUserId) ApiClient.getDmHistory(otherUserId)
}.onSuccess { response -> }.onSuccess { response ->
clearMessages() clearMessages()
response.messages.mapNotNull { envelope -> val decryptedForLog = mutableListOf<Pair<Int, String>>()
val messages = response.messages.mapNotNull { envelope ->
decryptEnvelope(envelope, currentUserId)?.let { plaintext -> decryptEnvelope(envelope, currentUserId)?.let { plaintext ->
decryptedForLog.add(envelope.id to plaintext)
createMessage(envelope, plaintext) createMessage(envelope, plaintext)
} }
}.forEach { addMessage(it) }
response.messages.forEach { envelope ->
if (envelope.replyToId != null) {
val replyTo = _state.messages.find { it.id == envelope.replyToId }
updateMessage(envelope.id) { it.copy(reply_to = replyTo) }
} }
decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) ->
Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json")
} }
val replyToMap = messages.associateBy { it.id }
val messagesWithReplies = messages.map { msg ->
val envelope = response.messages.find { it.id == msg.id }
if (envelope?.replyToId != null) {
msg.copy(reply_to = replyToMap[envelope.replyToId])
} else msg
}
addMessages(messagesWithReplies)
setHasMoreMessages(false) setHasMoreMessages(false)
}.onFailure { error -> }.onFailure { error ->
Logger.e("DmPanel", "Failed to load DM history: ${error.message}", error) Logger.e("DmPanel", "Failed to load DM history: ${error.message}", error)
@@ -167,14 +176,46 @@ class DmPanel(
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
if (plaintext != null) { if (plaintext != null) {
updateMessage(envelope.id) { it.copy(content = plaintext, is_edited = true) } val (content, fileThumbnails, fileAspectRatios) = parseDecryptedContent(plaintext)
updateMessage(envelope.id) {
it.copy(
content = content,
is_edited = true,
fileThumbnails = fileThumbnails ?: it.fileThumbnails,
fileAspectRatios = fileAspectRatios ?: it.fileAspectRatios
)
}
} else { } else {
updateMessage(envelope.id) { it.copy(is_edited = true) } updateMessage(envelope.id) { it.copy(is_edited = true) }
} }
} }
} }
private fun parseDecryptedContent(plaintext: String): Triple<String, List<String>?, List<Float>?> {
return runCatching {
val obj = json.parseToJsonElement(plaintext).jsonObject
val text = obj["text"]?.jsonPrimitive?.content ?: return@runCatching Triple(plaintext, null, null)
val thumbArr = obj["fileThumbnails"]?.jsonArray ?: return@runCatching Triple(text, 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
} else null
}?.takeIf { it.size == thumbnails.size }
Logger.d("DmPanel", "parseDecryptedContent: thumbnails=${thumbnails.size} [${thumbnails.map { "len=${it.length}" }.joinToString()}], aspectRatios=${aspectRatios?.joinToString() ?: "null"}")
Triple(text, thumbnails.ifEmpty { null }, aspectRatios)
}.getOrElse {
Logger.d("DmPanel", "parseDecryptedContent: parse failed, using plaintext fallback")
Triple(plaintext, null, null)
}
}
private fun createMessage(envelope: DmEnvelope, plaintext: String): Message { private fun createMessage(envelope: DmEnvelope, plaintext: String): Message {
val (content, fileThumbnails, fileAspectRatios) = parseDecryptedContent(plaintext)
val username = if (envelope.senderId == currentUserId) { val username = if (envelope.senderId == currentUserId) {
"You" "You"
} else { } else {
@@ -183,7 +224,7 @@ class DmPanel(
return Message( return Message(
id = envelope.id, id = envelope.id,
user_id = envelope.senderId, user_id = envelope.senderId,
content = plaintext, content = content,
timestamp = envelope.timestamp, timestamp = envelope.timestamp,
is_read = envelope.recipientId == currentUserId, is_read = envelope.recipientId == currentUserId,
is_edited = false, is_edited = false,
@@ -194,7 +235,9 @@ class DmPanel(
client_message_id = null, client_message_id = null,
reactions = null, reactions = null,
files = envelope.files, files = envelope.files,
dmEnvelope = envelope dmEnvelope = envelope,
fileThumbnails = fileThumbnails,
fileAspectRatios = fileAspectRatios
) )
} }
+1 -1
View File
@@ -1,5 +1,5 @@
[versions] [versions]
agp = "9.0.0" agp = "9.0.1"
androidx-activityCompose = "1.12.3" androidx-activityCompose = "1.12.3"
androidx-appcompat = "1.7.1" androidx-appcompat = "1.7.1"
androidx-core-ktx = "1.17.0" androidx-core-ktx = "1.17.0"