Implement image loading

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-13 23:36:28 +03:00
Unverified
parent 5ea5e43c23
commit 7da7cf6018
8 changed files with 392 additions and 18 deletions
@@ -331,6 +331,31 @@ object ApiClient {
}
}
/**
* 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.
* @param thumb if true, appends ?thumb=1 for thumbnail (JPEG, ~5 KB)
*/
suspend fun fetchEncryptedFile(path: String, thumb: Boolean = false): ByteArray {
val baseUrl = when {
path.startsWith("http") -> path
path.startsWith("/api") -> {
val serverBase = Config.apiBaseUrl.removeSuffix("/api")
"$serverBase$path"
}
else -> "${Config.apiBaseUrl}$path"
}
val url = if (thumb) "$baseUrl?thumb=1" else baseUrl
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.
*/
@@ -92,7 +92,16 @@ data class Message(
val verified: Boolean? = null,
val reply_to: Message? = null,
val client_message_id: String? = null,
val reactions: List<ReactionData>? = null
val reactions: List<ReactionData>? = null,
val files: List<DmFile>? = null,
/** For optimistic UI: local URI when sending, null when confirmed. */
val pendingFileUri: String? = null,
/** For optimistic UI: jobId to track upload progress. */
val uploadJobId: String? = null,
/** For optimistic UI: 0-100 upload progress, null when complete. */
val uploadProgress: Int? = null,
/** For DM file decryption; not serialized over network. */
@kotlinx.serialization.Transient val dmEnvelope: DmEnvelope? = null
)
@Serializable
@@ -46,3 +46,25 @@ suspend fun decryptEnvelope(envelope: DmEnvelope, currentUserId: Int?): String {
val plaintext = DmCrypto.decryptEnvelope(envelope.ivB64, envelope.ciphertextB64, mek)
return plaintext.decodeToString()
}
/**
* Decrypt a DM file attachment. Fetches encrypted bytes, unwraps file MEK, decrypts.
*/
suspend fun decryptFile(
file: ru.fromchat.api.DmFile,
envelope: DmEnvelope,
currentUserId: Int?
): ByteArray {
val wrappedMekB64 = file.wrappedMekB64
?: envelope.files?.find { it.path == file.path }?.wrappedMekB64
?: envelope.wrappedMekB64
?: throw IllegalArgumentException("No MEK available for file decryption: ${file.path}")
val nonceB64 = file.nonceB64
?: envelope.files?.find { it.path == file.path }?.nonceB64
?: throw IllegalArgumentException("No nonce available for file decryption: ${file.path}")
val mek = unwrapMek(wrappedMekB64, envelope, currentUserId)
val encryptedBytes = ru.fromchat.api.ApiClient.fetchEncryptedFile(file.path)
val ciphertextB64 = com.pr0gramm3r101.utils.crypto.Base64.encode(encryptedBytes)
return DmCrypto.decryptEnvelope(nonceB64, ciphertextB64, mek)
}
@@ -0,0 +1,249 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile
import ru.fromchat.crypto.decryptFile
private val IMAGE_SIZE = 160.dp
private val IMAGE_RADIUS = 8.dp
private fun isImageFilename(name: String): Boolean =
name.endsWith(".png", true) || name.endsWith(".jpg", true) ||
name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true)
@Composable
fun AttachmentPreview(
file: DmFile?,
dmEnvelope: DmEnvelope?,
currentUserId: Int?,
pendingFileUri: String?,
isUploading: Boolean,
modifier: Modifier = Modifier
) {
val isImage = file?.let { isImageFilename(it.name) } ?: pendingFileUri?.let {
it.contains("image", ignoreCase = true) || it.endsWith(".jpg", true) ||
it.endsWith(".png", true) || it.endsWith(".jpeg", true) ||
it.endsWith(".gif", true) || it.endsWith(".webp", true)
} ?: false
Box(
modifier = modifier
.size(IMAGE_SIZE)
.clip(RoundedCornerShape(IMAGE_RADIUS))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
when {
pendingFileUri != null -> {
PendingImageContent(
uri = pendingFileUri,
isUploading = isUploading,
isImage = isImage
)
}
file != null && isImage && dmEnvelope != null -> {
DecryptedImageContent(
file = file,
envelope = dmEnvelope,
currentUserId = currentUserId
)
}
file != null && !isImage -> {
FileIconContent(filename = file.name)
}
else -> {
Icon(
imageVector = Icons.Default.Image,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun PendingImageContent(
uri: String,
isUploading: Boolean,
isImage: Boolean
) {
if (isImage) {
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = uri,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(IMAGE_RADIUS))
.then(if (isUploading) Modifier.blur(8.dp) else Modifier),
contentScale = ContentScale.Crop
)
AnimatedVisibility(
visible = isUploading,
enter = fadeIn(),
exit = fadeOut(animationSpec = tween(300))
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
InfiniteCircularProgress()
}
}
}
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (isUploading) {
InfiniteCircularProgress()
} else {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun InfiniteCircularProgress() {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
strokeWidth = 3.dp
)
}
@Composable
private fun DecryptedImageContent(
file: DmFile,
envelope: DmEnvelope,
currentUserId: Int?
) {
var thumbnailBytes by remember(file.path) { mutableStateOf<ByteArray?>(null) }
var fullBytes by remember(file.path) { mutableStateOf<ByteArray?>(null) }
var imageReadyToUnblur by remember(file.path) { mutableStateOf(false) }
LaunchedEffect(file.path) {
withContext(Dispatchers.Default) {
coroutineScope {
val thumbDeferred = async {
ApiClient.fetchThumbnail(file.path)
}
val fullDeferred = async {
runCatching { decryptFile(file, envelope, currentUserId) }.getOrNull()
}
thumbnailBytes = thumbDeferred.await()
fullBytes = fullDeferred.await()
}
}
}
val hasThumb = thumbnailBytes != null
val hasFull = fullBytes != null
val showContent = hasThumb || hasFull
if (!showContent) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
return
}
LaunchedEffect(hasFull) {
if (hasFull) {
delay(80)
imageReadyToUnblur = true
}
}
val blurProgress by animateFloatAsState(
targetValue = if (imageReadyToUnblur) 0f else 1f,
animationSpec = tween(300),
label = "blur"
)
val blurRadius = with(LocalDensity.current) { (blurProgress * 8.dp.toPx()).toDp() }
val displayBytes = fullBytes ?: thumbnailBytes!!
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = displayBytes,
contentDescription = file.name,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(IMAGE_RADIUS))
.then(if (blurProgress > 0.01f) Modifier.blur(blurRadius) else Modifier),
contentScale = ContentScale.Crop
)
}
}
@Composable
private fun FileIconContent(filename: String) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = filename.take(20) + if (filename.length > 20) "" else "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
}
@@ -87,7 +87,7 @@ abstract class ChatPanel(
* Add message to list. Mutex prevents duplicate adds when same update
* is processed concurrently from multiple WebSocket connections.
*/
protected suspend fun addMessage(message: Message) {
suspend fun addMessage(message: Message) {
addMessageMutex.withLock {
val messageExists = _state.messages.any { it.id == message.id }
if (!messageExists) {
@@ -104,9 +104,9 @@ abstract class ChatPanel(
}
/**
* Update existing message
* Update existing message (public for ChatScreen optimistic UI)
*/
protected fun updateMessage(messageId: Int, updates: (Message) -> Message) {
fun updateMessage(messageId: Int, updates: (Message) -> Message) {
updateState { currentState ->
currentState.copy(
messages = currentState.messages.map { msg ->
@@ -273,6 +273,24 @@ fun ChatScreen(
}
}
LaunchedEffect(panel) {
if (panel.getRecipientId() != null) {
AttachmentUploadQueue.progressFlow.collect { progress ->
when (progress) {
is ru.fromchat.api.AttachmentUploadProgress.InProgress ->
panel.updateMessage(-progress.jobId.hashCode().let { if (it == 0) -1 else it }) {
if (it.uploadJobId == progress.jobId) it.copy(uploadProgress = progress.percent) else it
}
is ru.fromchat.api.AttachmentUploadProgress.Success ->
panel.updateMessage(-progress.jobId.hashCode().let { if (it == 0) -1 else it }) {
if (it.uploadJobId == progress.jobId) it.copy(uploadProgress = null) else it
}
else -> {}
}
}
}
}
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -442,9 +460,30 @@ fun ChatScreen(
if (attachments.isNotEmpty() && recipientId != null) {
val plaintext = text.ifBlank { "" }
attachments.forEach { att ->
val jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}"
val tempId = -jobId.hashCode().let { if (it == 0) -1 else it }
val optimisticMessage = Message(
id = tempId,
user_id = currentUserId ?: -1,
content = plaintext.ifBlank { att.filename },
timestamp = Clock.System.now().toString(),
is_read = false,
is_edited = false,
username = "You",
profile_picture = null,
verified = null,
reply_to = replyTo,
client_message_id = null,
reactions = null,
files = null,
pendingFileUri = att.uri,
uploadJobId = jobId,
uploadProgress = 0
)
panel.addMessage(optimisticMessage)
AttachmentUploadQueue.enqueue(
AttachmentUploadJob(
jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}",
jobId = jobId,
fileUri = att.uri,
filename = att.filename,
recipientId = recipientId,
@@ -523,6 +562,7 @@ fun ChatScreen(
MessageItem(
message = message,
isAuthor = message.user_id == currentUserId,
currentUserId = currentUserId,
onLongPress = {
contextMenuState = ContextMenuState(
isOpen = true,
@@ -52,7 +52,8 @@ fun MessageItem(
onLongPress: () -> Unit,
onTapPosition: (Offset) -> Unit = {},
modifier: Modifier = Modifier,
showUsername: Boolean = true
showUsername: Boolean = true,
currentUserId: Int? = null
) {
AnimatedVisibility(
visible = true,
@@ -199,17 +200,39 @@ fun MessageItem(
}
}
// Message content
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = if (isAuthor) {
Color.White
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.padding(horizontal = 12.dp)
)
// Attachments (images/files)
if (message.pendingFileUri != null) {
AttachmentPreview(
file = null,
dmEnvelope = null,
currentUserId = null,
pendingFileUri = message.pendingFileUri,
isUploading = message.uploadProgress != null,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
message.files?.forEach { file ->
AttachmentPreview(
file = file,
dmEnvelope = message.dmEnvelope,
currentUserId = currentUserId,
pendingFileUri = null,
isUploading = false,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
if (message.content.isNotBlank()) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = if (isAuthor) {
Color.White
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.padding(horizontal = 12.dp)
)
}
// Timestamp and edited indicator
Row(
@@ -146,6 +146,10 @@ class DmPanel(
scope.launch(Dispatchers.Default) {
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
if (plaintext != null) {
if (envelope.senderId == currentUserId) {
val oldestOptimistic = _state.messages.filter { it.id < 0 }.minByOrNull { it.timestamp }
oldestOptimistic?.let { removeMessage(it.id) }
}
addMessage(createMessage(envelope, plaintext))
if (envelope.replyToId != null) {
val replyTo = _state.messages.find { it.id == envelope.replyToId }
@@ -188,7 +192,9 @@ class DmPanel(
verified = null,
reply_to = null,
client_message_id = null,
reactions = null
reactions = null,
files = envelope.files,
dmEnvelope = envelope
)
}