Implement attachements in public chat

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-11 23:49:47 +03:00
Unverified
parent e4aba8d6e5
commit f90e2cafef
45 changed files with 2859 additions and 470 deletions
@@ -0,0 +1,21 @@
package ru.fromchat.api.local.cache
import java.io.FileOutputStream
actual fun generateAttachmentDiskThumbnail(
sourceAbsolutePath: String,
destAbsolutePath: String,
maxEdgePx: Int,
): Boolean {
val bitmap = ru.fromchat.api.local.download.decodeSampledImageFile(
sourceAbsolutePath,
maxEdgePx,
maxEdgePx,
) ?: return false
return runCatching {
FileOutputStream(destAbsolutePath).use { stream ->
bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, stream)
}
true
}.getOrDefault(false)
}
@@ -39,7 +39,11 @@ actual object PlatformDecodedBitmapCache {
}
actual fun decodeLocalImageFile(absolutePath: String, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? =
decodeSampledFromFile(absolutePath, reqWidthPx, reqHeightPx)?.asImageBitmap()
decodeSampledImageFile(absolutePath, reqWidthPx, reqHeightPx)?.asImageBitmap()
/** Sampled decode for disk thumbnail generation and tile loads. */
internal fun decodeSampledImageFile(path: String, reqWidthPx: Int, reqHeightPx: Int): Bitmap? =
decodeSampledFromFile(path, reqWidthPx, reqHeightPx)
actual fun decodeImageBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? =
decodeSampledFromBytes(bytes, reqWidthPx, reqHeightPx)?.asImageBitmap()
@@ -0,0 +1,23 @@
package ru.fromchat.api.local.download
import android.graphics.BitmapFactory
import androidx.exifinterface.media.ExifInterface
internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(absolutePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val orientation = runCatching {
ExifInterface(absolutePath).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
return when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90,
ExifInterface.ORIENTATION_ROTATE_270,
ExifInterface.ORIENTATION_TRANSPOSE,
ExifInterface.ORIENTATION_TRANSVERSE -> bounds.outHeight to bounds.outWidth
else -> bounds.outWidth to bounds.outHeight
}
}
@@ -33,6 +33,8 @@ import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.MainScope
@@ -81,6 +83,12 @@ import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitResponse
import ru.fromchat.api.schema.messages.dm.upload.DmUploadStatusResponse
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
import ru.fromchat.api.schema.messages.publicchat.SendMessageRequest
import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadChunkRequest
import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadChunkResponse
import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadCompleteResponse
import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadInitRequest
import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadInitResponse
import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadStatusResponse
import ru.fromchat.api.schema.server.RegisteredUserCountResponse
import ru.fromchat.api.schema.server.ServerInstanceIdResponse
import ru.fromchat.api.schema.server.TransportKeyResponse
@@ -796,6 +804,54 @@ object ApiClient {
}
}
suspend fun initPublicUpload(
filename: String,
totalSize: Long,
chunkSize: Int? = null,
): PublicUploadInitResponse =
http.post("${ServerConfig.apiBaseUrl}/public/upload/init") {
contentType(ContentType.Application.Json)
setBody(
PublicUploadInitRequest(
filename = filename,
totalSize = totalSize,
chunkSize = chunkSize,
),
)
}.body()
suspend fun getPublicUploadStatus(uploadId: String): PublicUploadStatusResponse =
http.get("${ServerConfig.apiBaseUrl}/public/upload/$uploadId") {
contentType(ContentType.Application.Json)
}.body()
suspend fun uploadPublicChunk(
uploadId: String,
offset: Long,
dataB64: String,
): PublicUploadChunkResponse =
http.patch("${ServerConfig.apiBaseUrl}/public/upload/$uploadId") {
contentType(ContentType.Application.Json)
setBody(
PublicUploadChunkRequest(
offset = offset,
dataB64 = dataB64,
),
)
}.body()
suspend fun completePublicUpload(uploadId: String): PublicUploadCompleteResponse =
http.post("${ServerConfig.apiBaseUrl}/public/upload/$uploadId/complete") {
contentType(ContentType.Application.Json)
setBody(mapOf("upload_id" to uploadId))
}.body()
suspend fun abortPublicUpload(uploadId: String) {
http.delete("${ServerConfig.apiBaseUrl}/public/upload/$uploadId") {
contentType(ContentType.Application.Json)
}
}
/**
* 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.
@@ -809,6 +865,9 @@ object ApiClient {
else -> "${ServerConfig.apiBaseUrl}$path"
}
/** Plain public-chat attachment URL (same path resolution as [encryptedFileUrl]). */
fun normalFileUrl(path: String): String = encryptedFileUrl(path)
/** Encrypted ciphertext stored on disk after a resumable download. */
data class EncryptedFileOnDisk(
val path: String,
@@ -1448,6 +1507,9 @@ object ApiClient {
content: String,
replyToId: Int? = null,
clientMessageId: String? = null,
uploadedFileIds: List<String> = emptyList(),
fileBytes: ByteArray? = null,
filename: String? = null,
): ru.fromchat.api.schema.messages.Message {
if (_suspensionState.value.isSuspended) {
throw IllegalStateException("Account suspended")
@@ -1457,12 +1519,23 @@ object ApiClient {
content = content.trim(),
reply_to_id = replyToId,
client_message_id = clientMessageId?.trim()?.takeIf { it.isNotEmpty() },
uploaded_file_ids = uploadedFileIds.takeIf { it.isNotEmpty() },
),
)
val response = http.submitFormWithBinaryData(
url = "${ServerConfig.apiBaseUrl}/send_message",
formData = formData {
append("payload", payloadJson)
if (fileBytes != null && filename != null) {
append(
"files",
fileBytes,
Headers.build {
append(HttpHeaders.ContentType, "application/octet-stream")
append(HttpHeaders.ContentDisposition, "filename=\"$filename\"")
},
)
}
},
).body<ru.fromchat.api.schema.messages.publicchat.SendMessageResponse>()
if (!response.status.equals("success", ignoreCase = true)) {
@@ -22,6 +22,12 @@ object AttachmentMediaLog {
fun persist(message: String, vararg fields: Pair<String, Any?>) = log("PERSIST", message, fields)
/** Image tile layout aspect ratio (upload → confirm). Filter: `adb logcat -s AttachmentMedia`. */
fun aspect(message: String, vararg fields: Pair<String, Any?>) = log("ASPECT", message, fields)
/** End-to-end outbound send timeline (tap → confirmed). Filter logcat: `AttachmentMedia`. */
fun send(message: String, vararg fields: Pair<String, Any?>) = log("SEND", message, fields)
fun nowMs(): Long = Clock.System.now().toEpochMilliseconds()
/** Short message text for download/upload log lines (not for UI). */
@@ -0,0 +1,14 @@
package ru.fromchat.api.local.cache
/** Matches server public-chat thumbnail long edge ([_THUMB_SIZE] in messaging.py). */
const val ATTACHMENT_DISK_THUMB_MAX_EDGE_PX = 80
/**
* Writes a tiny JPEG next to a cached full image for instant cold-start tiles.
* Returns true when [destAbsolutePath] exists after the call.
*/
expect fun generateAttachmentDiskThumbnail(
sourceAbsolutePath: String,
destAbsolutePath: String,
maxEdgePx: Int = ATTACHMENT_DISK_THUMB_MAX_EDGE_PX,
): Boolean
@@ -46,7 +46,17 @@ object DecryptedFileCache {
fileIndex: Int,
clientMessageId: String? = null,
): String? {
val key = storageKey(messageId, fileIndex, clientMessageId)
val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
if (cid != null) {
val clientKey = storageKey(-1, fileIndex, cid)
memoryCache[clientKey]?.takeIf { uriFileExists(it) }?.let { return it }
readDisk(clientKey)?.let { return it }
}
val key = if (messageId > 0) {
storageKey(messageId, fileIndex, null)
} else {
storageKey(messageId, fileIndex, cid)
}
memoryCache[key]?.takeIf { uriFileExists(it) }?.let { return it }
return readDisk(key)
}
@@ -116,7 +126,7 @@ object DecryptedFileCache {
val idKey = storageKey(messageId, fileIndex, null)
if (getCached(messageId, fileIndex, null) != null) return
val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } ?: return
val cidKey = storageKey(messageId, fileIndex, cid)
val cidKey = storageKey(-1, fileIndex, cid)
val sourceUri = cacheMutex.withLock { resolveUriLocked(cidKey) }
?: readDisk(cidKey)
?: return
@@ -223,6 +233,170 @@ object DecryptedFileCache {
}
}
/**
* Downloads a plain (non-encrypted) public attachment into the file cache.
*/
suspend fun getOrDownloadPlain(
messageId: Int,
fileIndex: Int,
file: DmFile,
clientMessageId: String? = null,
messageLabel: String? = null,
): String? {
val key = storageKey(messageId, fileIndex, clientMessageId)
getCached(messageId, fileIndex, clientMessageId)?.let { return it }
cacheMutex.withLock { resolveUriLocked(key) }?.let { return it }
val label = AttachmentMediaLog.messageLabel(messageLabel)
AttachmentDownloadNotifier.beginDownload(
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
return withContext(Dispatchers.Default) {
runCatching {
AttachmentDownloadScheduler.run(
storageKey = key,
messageId = messageId,
keepAliveInBackground = true,
work = {
downloadPlainAndPersist(
key = key,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
file = file,
messageLabel = label,
)
},
)
}.onFailure { error ->
if (error !is CancellationException) {
ApiClient.clearPartialEncryptedDownload(key)
}
}.getOrNull()
}
}
private suspend fun downloadPlainAndPersist(
key: String,
messageId: Int,
fileIndex: Int,
clientMessageId: String?,
file: DmFile,
messageLabel: String?,
): String? {
ensureAttachmentDownloadActive(key)
cacheMutex.withLock { resolveUriLocked(key) }?.let { return it }
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.InProgress(key, 1),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
val outputPath = diskPath(key, file.name)
if (outputPath == null) {
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Failed(key, "cache_write_failed"),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
return null
}
val downloaded = try {
ApiClient.fetchEncryptedFileResumable(
path = file.path,
resumeKey = key,
onProgress = { percent ->
checkAttachmentDownloadActive(key)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.InProgress(key, percent.coerceIn(0, 100)),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
},
)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
if (PlatformFileSystem.exists(outputPath)) {
PlatformFileSystem.delete(outputPath)
}
ApiClient.clearPartialEncryptedDownload(key)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Failed(key, error.message ?: "download_failed"),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
return null
}
ensureAttachmentDownloadActive(key)
if (downloaded.path != outputPath) {
runCatching {
copyOutboundFileToPath("file://${downloaded.path}", outputPath)
}.onFailure {
ApiClient.clearPartialEncryptedDownload(key)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Failed(key, "cache_write_failed"),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
return null
}
}
ApiClient.clearPartialEncryptedDownload(key)
val uri = cacheMutex.withLock {
resolveUriLocked(key) ?: commitCachePathLocked(key, outputPath)
}
if (uri == null) {
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Failed(key, "cache_write_failed"),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
return null
}
DownloadedFileRegistry.setExportUri(
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
exportUri = uri,
)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Success(storageKey = key, messageId = messageId),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
PendingFileSaveRegistry.onCacheReady(key)
return uri
}
private suspend fun decryptAndPersist(
key: String,
messageId: Int,
@@ -19,6 +19,8 @@ import ru.fromchat.api.local.download.ensureAttachmentDownloadActive
import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility
import ru.fromchat.api.local.cache.DecryptedImageCache.storageKey
import ru.fromchat.api.local.download.LocalDecodedImageCache
import ru.fromchat.api.local.download.ChatPreviewDecodeSize
import ru.fromchat.api.local.download.decodeLocalImageFile
/**
* Disk + in-memory cache for decrypted DM images.
@@ -26,6 +28,7 @@ import ru.fromchat.api.local.download.LocalDecodedImageCache
*/
object DecryptedImageCache {
private const val SUBDIR = "decrypted_images"
private const val DISK_THUMB_SUFFIX = "_thumb.jpg"
/** True when [uri] points at a file under this cache (safe to persist for offline preview). */
fun isDecryptedImageCacheUri(uri: String?): Boolean {
@@ -93,14 +96,101 @@ object DecryptedImageCache {
clientMessageId: String? = null,
): String? {
val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
// Always try the client-id key first — outbound seeds live there until aliasing.
if (cid != null) {
readDisk(storageKey(-1, fileIndex, cid))?.let { return it }
}
if (messageId > 0) {
readDisk(storageKey(messageId, fileIndex, null))?.let { return it }
} else {
readDisk(storageKey(messageId, fileIndex, cid))?.let { return it }
}
return readDisk(storageKey(messageId, fileIndex, null))
return null
}
fun getUriForStorageKey(storageKey: String): String? = readDisk(storageKey)
/** Synchronous lookup for the tiny on-disk JPEG written with the full cache file. */
fun getCachedThumbUri(
messageId: Int,
fileIndex: Int,
clientMessageId: String? = null,
): String? {
for (lookupKey in progressLookupKeys(messageId, fileIndex, clientMessageId)) {
readThumbDisk(lookupKey)?.let { return it }
}
return null
}
suspend fun ensureDiskThumbIfNeeded(
messageId: Int,
fileIndex: Int,
clientMessageId: String? = null,
) {
if (getCachedThumbUri(messageId, fileIndex, clientMessageId) != null) return
withContext(Dispatchers.Default) {
cacheMutex.withLock {
for (lookupKey in progressLookupKeys(messageId, fileIndex, clientMessageId)) {
val thumbPath = thumbDiskPath(lookupKey) ?: continue
if (PlatformFileSystem.exists(thumbPath)) return@withContext
val fullPath = diskPath(lookupKey)?.takeIf { PlatformFileSystem.exists(it) }
?: continue
writeDiskThumbLocked(lookupKey, fullPath)
return@withContext
}
}
}
}
suspend fun decodePlaceholderThumb(
memoryKey: String,
messageId: Int,
fileIndex: Int,
clientMessageId: String?,
serverThumbBytes: ByteArray?,
): androidx.compose.ui.graphics.ImageBitmap? = withContext(Dispatchers.Default) {
peekPlaceholderThumb(memoryKey, messageId, fileIndex, clientMessageId)?.let {
return@withContext it
}
// Prefer server base64 before generating a disk thumb from a huge full file.
val thumbBytes = serverThumbBytes?.takeIf { it.isNotEmpty() }
if (thumbBytes != null) {
val target = ChatPreviewDecodeSize(
ATTACHMENT_DISK_THUMB_MAX_EDGE_PX,
ATTACHMENT_DISK_THUMB_MAX_EDGE_PX,
)
LocalDecodedImageCache.loadThumb(memoryKey, thumbBytes, target)?.let {
return@withContext it
}
}
ensureDiskThumbIfNeeded(messageId, fileIndex, clientMessageId)
peekPlaceholderThumb(memoryKey, messageId, fileIndex, clientMessageId)
}
/** Sync disk/memory thumb for first composition (avoids empty tile before produceState). */
fun peekPlaceholderThumb(
memoryKey: String,
messageId: Int,
fileIndex: Int,
clientMessageId: String?,
): androidx.compose.ui.graphics.ImageBitmap? {
LocalDecodedImageCache.peekThumb(memoryKey)?.let { return it }
getCachedThumbUri(messageId, fileIndex, clientMessageId)?.let { uri ->
val path = uri.removePrefix("file://")
if (path.isNotEmpty()) {
val target = ChatPreviewDecodeSize(
ATTACHMENT_DISK_THUMB_MAX_EDGE_PX,
ATTACHMENT_DISK_THUMB_MAX_EDGE_PX,
)
decodeLocalImageFile(path, target.widthPx, target.heightPx)?.let { bitmap ->
LocalDecodedImageCache.putThumb(memoryKey, bitmap)
return bitmap
}
}
}
return null
}
fun messageIdFromStorageKey(storageKey: String): Int? {
if (!storageKey.startsWith("img_") || storageKey.startsWith("img_c_")) return null
return storageKey.removePrefix("img_").substringBefore('_').toIntOrNull()
@@ -116,7 +206,7 @@ object DecryptedImageCache {
val idKey = storageKey(messageId, fileIndex, null)
if (readDisk(idKey) != null) return
val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } ?: return
val cidKey = storageKey(messageId, fileIndex, cid)
val cidKey = storageKey(-1, fileIndex, cid)
val sourceUri = readDisk(cidKey) ?: return
val bytes = runCatching {
readOutboundFileBytes(sourceUri)
@@ -126,13 +216,16 @@ object DecryptedImageCache {
cacheMutex.withLock {
if (readDisk(idKey) == null) {
writeCacheLocked(idKey, bytes)
AttachmentMediaLog.diskCache(
"alias_ok",
"from" to cidKey,
"to" to idKey,
"bytes" to bytes.size,
)
}
diskPath(idKey)?.let { fullPath ->
writeDiskThumbLocked(idKey, fullPath)
}
AttachmentMediaLog.diskCache(
"alias_ok",
"from" to cidKey,
"to" to idKey,
"bytes" to bytes.size,
)
}
}
}
@@ -234,6 +327,178 @@ object DecryptedImageCache {
return uri
}
/**
* Downloads a plain (non-encrypted) public attachment into the image cache.
* Reuses the resumable Range download path without decrypt.
*/
suspend fun getOrDownloadPlain(
messageId: Int,
fileIndex: Int,
file: DmFile,
clientMessageId: String? = null,
messageLabel: String? = null,
): String? {
val key = storageKey(messageId, fileIndex, clientMessageId)
getCached(messageId, fileIndex, clientMessageId)?.let { uri ->
AttachmentMediaLog.diskCache(
"getOrDownloadPlain_hit",
"key" to key,
"msgId" to messageId,
"uri" to uri,
)
return uri
}
cacheMutex.withLock { resolveUriLocked(key) }?.let { return it }
val label = AttachmentMediaLog.messageLabel(messageLabel)
AttachmentDownloadNotifier.beginDownload(
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
)
val uri = withContext(Dispatchers.Default) {
runCatching {
AttachmentDownloadScheduler.run(
storageKey = key,
messageId = messageId,
work = {
downloadPlainAndPersist(
key = key,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
file = file,
messageLabel = label,
)
},
)
}.onFailure { error ->
if (error !is CancellationException) {
ApiClient.clearPartialEncryptedDownload(key)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Failed(
storageKey = key,
error = error.message ?: "download_failed",
),
messageLabel = label,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
)
}
}.getOrNull()
}
if (uri != null && messageId > 0) {
ensureDiskAliasForMessageId(messageId, fileIndex, clientMessageId)
}
return uri
}
private suspend fun downloadPlainAndPersist(
key: String,
messageId: Int,
fileIndex: Int,
clientMessageId: String?,
file: DmFile,
messageLabel: String?,
): String? {
ensureAttachmentDownloadActive(key)
cacheMutex.withLock { resolveUriLocked(key) }?.let { return it }
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.InProgress(key, 1),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
)
val t0 = AttachmentMediaLog.nowMs()
val outputPath = diskPath(key)
if (outputPath == null) {
ApiClient.clearPartialEncryptedDownload(key)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Failed(key, "cache_write_failed"),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
)
return null
}
val downloaded = runCatching {
ApiClient.fetchEncryptedFileResumable(
path = file.path,
resumeKey = key,
onProgress = { percent ->
checkAttachmentDownloadActive(key)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.InProgress(key, percent.coerceIn(0, 100)),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
)
},
)
}.onFailure { error ->
if (error is CancellationException) throw error
ApiClient.clearPartialEncryptedDownload(key)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Failed(key, error.message ?: "download_failed"),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
)
}.getOrNull() ?: return null
ensureAttachmentDownloadActive(key)
if (downloaded.path != outputPath) {
runCatching {
copyOutboundFileToPath("file://${downloaded.path}", outputPath)
}.onFailure {
ApiClient.clearPartialEncryptedDownload(key)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Failed(key, "cache_write_failed"),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
)
return null
}
}
ApiClient.clearPartialEncryptedDownload(key)
val uri = cacheMutex.withLock {
resolveUriLocked(key) ?: commitCachePathLocked(key, outputPath)
}
if (uri == null) {
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Failed(key, "cache_write_failed"),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
)
return null
}
AttachmentMediaLog.download(
"plain_persist_ok",
"key" to key,
"bytes" to downloaded.sizeBytes,
"ms" to (AttachmentMediaLog.nowMs() - t0),
"uri" to uri,
"msg" to messageLabel,
)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Success(storageKey = key, messageId = messageId),
messageLabel = messageLabel,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
)
return uri
}
suspend fun invalidateForMessage(messageId: Int) {
val dir = ensureCacheDir() ?: return
withContext(Dispatchers.Default) {
@@ -272,6 +537,7 @@ object DecryptedImageCache {
withContext(Dispatchers.Default) {
cacheMutex.withLock { memoryCache.remove(key) }
diskPath(key)?.let { invalidatePath(it) }
thumbDiskPath(key)?.let { invalidatePath(it) }
LocalDecodedImageCache.evict(key)
}
}
@@ -470,6 +736,23 @@ object DecryptedImageCache {
return "$dir/$storageKey"
}
private fun thumbDiskPath(storageKey: String): String? {
val dir = ensureCacheDir() ?: return null
return "$dir/$storageKey$DISK_THUMB_SUFFIX"
}
private fun readThumbDisk(storageKey: String): String? {
val path = thumbDiskPath(storageKey) ?: return null
if (!PlatformFileSystem.exists(path)) return null
return "file://$path"
}
private fun writeDiskThumbLocked(storageKey: String, sourceAbsolutePath: String) {
val dest = thumbDiskPath(storageKey) ?: return
if (PlatformFileSystem.exists(dest)) return
generateAttachmentDiskThumbnail(sourceAbsolutePath, dest, ATTACHMENT_DISK_THUMB_MAX_EDGE_PX)
}
private fun readDisk(storageKey: String): String? {
val path = diskPath(storageKey) ?: return null
if (!PlatformFileSystem.exists(path)) return null
@@ -484,6 +767,7 @@ object DecryptedImageCache {
if (!PlatformFileSystem.exists(path)) return null
val uri = "file://$path"
memoryCache[storageKey] = uri
writeDiskThumbLocked(storageKey, path)
uri
}.getOrElse {
invalidatePath(path)
@@ -495,6 +779,7 @@ object DecryptedImageCache {
if (!PlatformFileSystem.exists(path)) return null
val uri = "file://$path"
memoryCache[storageKey] = uri
writeDiskThumbLocked(storageKey, path)
return uri
}
@@ -39,6 +39,36 @@ interface OutboundFileInputStream {
suspend fun close()
}
/** Reads [length] bytes from [fileUri] starting at [offset] (seek via skip/read). */
suspend fun readOutboundFileRange(fileUri: String, offset: Long, length: Int): ByteArray {
if (length <= 0) return ByteArray(0)
val input = openOutboundFileInputStream(fileUri)
?: throw OutboundFileUnavailableException("Cannot open outbound file")
try {
var remaining = offset.coerceAtLeast(0L)
val skipBuf = ByteArray(8192)
while (remaining > 0L) {
val toRead = minOf(skipBuf.size.toLong(), remaining).toInt()
val n = input.read(skipBuf, 0, toRead)
if (n <= 0) throw OutboundFileUnavailableException("Unexpected EOF while seeking")
remaining -= n.toLong()
}
val buffer = ByteArray(length)
var read = 0
while (read < length) {
val n = input.read(buffer, read, length - read)
if (n <= 0) break
read += n
}
if (read < length) {
throw OutboundFileUnavailableException("Outbound file truncated")
}
return buffer
} finally {
input.close()
}
}
expect suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray)
expect suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray?
@@ -10,9 +10,12 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.publicchat.resolvePublicAttachmentLayout
import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.messages.dm.DmFile
import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.download.readLocalImageDimensions
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@@ -50,15 +53,30 @@ private data class PersistedDmMessagePayload(
@SerialName("localPreviewUri") val localPreviewUri: String? = null,
)
@Serializable
private data class PersistedPublicMessagePayload(
@SerialName("text") val text: String,
@SerialName("files") val files: List<DmFile>,
@SerialName("fileThumbnails") val fileThumbnails: List<String>? = null,
@SerialName("fileAspectRatios") val fileAspectRatios: List<Float>? = null,
@SerialName("fileSizes") val fileSizes: List<Long>? = null,
@SerialName("fileDimensions") val fileDimensions: List<List<Int>>? = null,
/** Canonical API [width, height] pairs — survives reopen without network. */
@SerialName("fileAspectRatioPairs") val fileAspectRatioPairs: List<List<Int>>? = null,
@SerialName("localPreviewUri") val localPreviewUri: String? = null,
)
data class ParsedDmMessageContent(
val text: String,
/** Reply target from encrypted JSON payload (`reply_to_id`), when present. */
val replyToId: Int? = null,
val envelope: DmEnvelope? = null,
val files: List<DmFile>? = null,
val fileThumbnails: List<String>? = null,
val fileAspectRatios: List<Float>? = null,
val fileSizes: List<Long>? = null,
val fileDimensions: List<Pair<Int, Int>>? = null,
val fileAspectRatioPairs: List<List<Int>>? = null,
val isContentCorrupted: Boolean = false,
val localPreviewUri: String? = null,
val pendingFileUri: String? = null,
@@ -114,6 +132,65 @@ fun resolveLocalPreviewUri(message: Message): String? {
return null
}
/** Sync disk lookup for cold-start chat open (no suspend alias copy). */
fun hydrateAttachmentPreviewFromDiskSync(message: Message): Message {
val previewUri = resolveLocalPreviewUri(message) ?: return hydrateDiskAspectRatioSync(message)
val withPreview = if (message.pendingFileUri == previewUri) {
message
} else {
message.copy(pendingFileUri = previewUri)
}
return hydrateDiskAspectRatioSync(withPreview)
}
private fun hydrateDiskAspectRatioSync(message: Message): Message {
val file = message.files?.firstOrNull() ?: return message
if (!isImageAttachmentFilename(file.name)) return message
if (messageHasLayoutAspect(message)) return message
val aspect = readDiskAspectRatioForMessage(message) ?: return message
return message.copy(pendingFileAspectRatio = aspect)
}
private fun isImageAttachmentFilename(name: String): Boolean =
name.endsWith(".png", true) || name.endsWith(".jpg", true) ||
name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true)
private fun messageHasLayoutAspect(message: Message): Boolean {
message.fileAspectRatioPairs?.firstOrNull()?.takeIf { it.size >= 2 }?.let { pair ->
val w = pair[0]
val h = pair[1]
if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) return true
}
message.fileDimensions?.firstOrNull()?.let { (w, h) ->
if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) return true
}
message.fileAspectRatios?.firstOrNull()?.takeIf { !isPlaceholderAttachmentAspectRatio(it) }?.let {
return true
}
message.pendingFileAspectRatio?.takeIf { it > 0f && !isPlaceholderAttachmentAspectRatio(it) }?.let {
return true
}
return false
}
private fun readDiskAspectRatioForMessage(message: Message): Float? {
val paths = buildList {
DecryptedImageCache.getCachedThumbUri(message.id, 0, message.client_message_id)
?.removePrefix("file://")
?.takeIf { it.isNotEmpty() }
?.let(::add)
message.pendingFileUri?.removePrefix("file://")?.takeIf { it.isNotEmpty() }?.let(::add)
resolveLocalPreviewUri(message)?.removePrefix("file://")?.takeIf { it.isNotEmpty() }?.let(::add)
}.distinct()
for (path in paths) {
if (!localPreviewFileExists("file://$path")) continue
val (w, h) = readLocalImageDimensions(path) ?: continue
if (w <= 0 || h <= 0 || isPlaceholderAttachmentDimensions(w, h)) continue
return aspectRatioFromDimensionPair(w, h)
}
return null
}
/**
* Layout width/height. Pixel pairs keep [w,h] order (landscape vs portrait).
* Do not min/max swap — that forces every landscape 4K image into portrait.
@@ -128,6 +205,12 @@ internal fun aspectRatioFromDimensionPair(w: Int, h: Int): Float {
return dw.toFloat() / dh.toFloat()
}
/** Server fallback when thumb meta is missing (e.g. very large images). */
internal fun isPlaceholderAttachmentDimensions(w: Int, h: Int): Boolean = w == 1 && h == 1
internal fun isPlaceholderAttachmentAspectRatio(ratio: Float): Boolean =
ratio in 0.999f..1.001f
private fun localPreviewFileExists(uri: String): Boolean {
val path = uri.removePrefix("file://")
return path.isNotEmpty() && PlatformFileSystem.exists(path)
@@ -158,6 +241,33 @@ fun encodePersistedDmMessage(message: Message): String {
return json.encodeToString(payload)
}
fun encodePersistedPublicMessage(message: Message): String {
val laidOut = message.resolvePublicAttachmentLayout()
val files = laidOut.files?.takeIf { it.isNotEmpty() } ?: return laidOut.content
val dims = laidOut.fileDimensions?.map { listOf(it.first, it.second) }
val pairs = laidOut.fileAspectRatioPairs
val payload = PersistedPublicMessagePayload(
text = laidOut.content,
files = files,
fileThumbnails = laidOut.fileThumbnails,
fileAspectRatios = laidOut.fileAspectRatios,
fileSizes = laidOut.fileSizes,
fileDimensions = dims,
fileAspectRatioPairs = pairs,
localPreviewUri = resolveLocalPreviewUri(laidOut),
)
AttachmentMediaLog.persist(
"encode_public",
"msgId" to laidOut.id,
"clientId" to laidOut.client_message_id,
"files" to files.size,
"localPreview" to (payload.localPreviewUri?.take(64) ?: "null"),
"dims" to (dims?.firstOrNull()?.joinToString("x") ?: "null"),
"pairs" to (pairs?.firstOrNull()),
)
return json.encodeToString(payload)
}
fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent {
val trimmed = plaintext.trim()
if (trimmed.startsWith("{")) {
@@ -194,6 +304,7 @@ fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent {
ParsedDmMessageContent(
text = payload.text,
envelope = payload.envelope,
files = payload.envelope.files,
fileThumbnails = payload.fileThumbnails,
fileAspectRatios = payload.fileAspectRatios,
fileSizes = payload.fileSizes,
@@ -207,6 +318,26 @@ fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent {
ParsedDmMessageContent(text = plaintext)
}
}
val isPersistedPublic = root?.containsKey("files") == true
if (isPersistedPublic) {
return runCatching {
val payload = json.decodeFromString<PersistedPublicMessagePayload>(trimmed)
ParsedDmMessageContent(
text = payload.text,
files = payload.files,
fileThumbnails = payload.fileThumbnails,
fileAspectRatios = payload.fileAspectRatios,
fileSizes = payload.fileSizes,
fileDimensions = payload.fileDimensions?.mapNotNull { pair ->
if (pair.size >= 2) pair[0] to pair[1] else null
},
fileAspectRatioPairs = payload.fileAspectRatioPairs,
localPreviewUri = payload.localPreviewUri?.takeIf { localPreviewFileExists(it) },
)
}.getOrElse {
ParsedDmMessageContent(text = plaintext)
}
}
return parseLegacyDmContentJson(trimmed)
}
return ParsedDmMessageContent(text = trimmed)
@@ -24,15 +24,19 @@ import ru.fromchat.api.local.messages.conversationIdForGroup
import ru.fromchat.api.local.messages.dmOtherUserIdFromConversationId
import ru.fromchat.api.local.db.encodeOptimisticOutboundMessage
import ru.fromchat.api.local.db.encodePersistedDmMessage
import ru.fromchat.api.local.db.encodePersistedPublicMessage
import ru.fromchat.api.local.db.parseDmMessageContent
import ru.fromchat.api.local.db.hydrateAttachmentPreviewFromDiskSync
import ru.fromchat.api.local.db.resolveLocalPreviewUri
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
import ru.fromchat.api.local.send.DmAttachmentOutboxPayload
import ru.fromchat.api.local.send.PublicAttachmentOutboxPayload
import ru.fromchat.api.local.send.SEND_ERROR_FAILED
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmConversation
import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.messages.publicchat.resolvePublicAttachmentLayout
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.CacheValidator
import ru.fromchat.db.Conversation
@@ -42,6 +46,7 @@ import ru.fromchat.api.local.cache.DecryptedFileCache
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.download.DownloadedFileRegistry
import ru.fromchat.ui.chat.utils.attachPublicReplyReferences
import ru.fromchat.ui.chat.isImageFilename
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages
import kotlin.concurrent.Volatile
@@ -74,22 +79,29 @@ object MessageCacheStore {
return if (t.length > maxLen) t.take(maxLen) + "\u2026" else t
}
@OptIn(ExperimentalCoroutinesApi::class)
fun observeMessages(instanceId: String, conversationId: String): Flow<List<Message>> =
db.messageDatabaseQueries
.selectMessagesByConversation(instanceId, conversationId)
.asFlow()
.mapToList(Dispatchers.Default)
.map { rows ->
val raw = hydrateReplyReferencesFromRows(rows)
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
sortMessagesForChatDisplay(
validatedOrEmpty(
conversationId,
dedupeMessagesByClientId(
enrichQueuedOutboundUi(withoutSuperseded, conversationId),
.mapLatest { rows ->
withContext(Dispatchers.Default) {
val raw = hydrateReplyReferencesFromRows(rows)
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
val hydrated = hydrateAttachmentPreviewsFromDisk(withoutSuperseded)
sortMessagesForChatDisplay(
validatedOrEmpty(
conversationId,
dedupeMessagesByClientId(
enrichQueuedOutboundUi(
hydrated,
conversationId,
),
),
),
),
)
)
}
}
suspend fun loadPublicMessages(): List<Message> =
@@ -106,12 +118,13 @@ object MessageCacheStore {
.executeAsList()
val raw = hydrateReplyReferencesFromRows(rows).reversed()
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
val withDiskPreviews = withoutSuperseded.map { hydrateAttachmentPreviewFromDiskSync(it) }
return ProfileCache.enrichPublicMessagesForDisplay(
sortMessagesForChatDisplay(
validatedOrEmpty(
convId,
dedupeMessagesByClientId(
enrichQueuedOutboundUi(withoutSuperseded, convId),
enrichQueuedOutboundUi(withDiskPreviews, convId),
),
),
),
@@ -153,15 +166,16 @@ object MessageCacheStore {
}
suspend fun replacePublicMessages(messages: List<Message>) {
ProfileCache.mergePreviewFromPublicMessages(messages)
val resolved = messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(resolved)
conversationIdForPublic().let {
replaceMessages(
it,
sortMessagesForChatDisplay(
dedupeMessagesByClientId(
messages + loadPendingMessages(it).filter { p ->
resolved + loadPendingMessages(it).filter { p ->
val cid = p.client_message_id
cid == null || messages.none { it.client_message_id == cid }
cid == null || resolved.none { it.client_message_id == cid }
}
)
)
@@ -230,8 +244,9 @@ object MessageCacheStore {
}
suspend fun upsertPublicMessage(message: Message) {
ProfileCache.mergePreviewFromPublicMessage(message)
upsertSingle(conversationIdForPublic(), message)
val resolved = message.resolvePublicAttachmentLayout()
ProfileCache.mergePreviewFromPublicMessage(resolved)
upsertSingle(conversationIdForPublic(), resolved)
}
suspend fun markSendFailed(conversationId: String, clientMessageId: String) {
@@ -286,14 +301,50 @@ object MessageCacheStore {
}
suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) {
ProfileCache.mergePreviewFromPublicMessage(confirmed)
confirmMessage(conversationIdForPublic(), clientMessageId, confirmed)
withContext(Dispatchers.Default) {
var resolved = confirmed.resolvePublicAttachmentLayout()
resolved = hydrateAttachmentPreviewFromDisk(resolved)
ProfileCache.mergePreviewFromPublicMessage(resolved)
confirmMessage(conversationIdForPublic(), clientMessageId, resolved)
}
}
suspend fun confirmDmMessage(otherUserId: Int, clientMessageId: String, confirmed: Message) {
confirmMessage(conversationIdForDm(otherUserId), clientMessageId, confirmed)
}
/** After download/decrypt, persist [localPreviewUri] so reopen skips network. */
suspend fun patchPublicMessageLocalPreview(
messageId: Int,
localPreviewUri: String,
) {
if (messageId <= 0 || !DecryptedImageCache.isDecryptedImageCacheUri(localPreviewUri)) return
val convId = conversationIdForPublic()
val iid = instanceId()
withContext(Dispatchers.Default) {
val row = db.messageDatabaseQueries
.selectMessageById(iid, convId, messageId.toLong())
.executeAsOneOrNull() ?: return@withContext
val msg = row.toAppMessage().copy(pendingFileUri = localPreviewUri)
if (msg.files.isNullOrEmpty() || msg.dmEnvelope != null) return@withContext
val laidOut = msg.resolvePublicAttachmentLayout()
db.messageDatabaseQueries.upsertMessage(
instanceId = iid,
id = laidOut.id.toLong(),
conversationId = convId,
userId = laidOut.user_id.toLong(),
content = encodePersistedPublicMessage(laidOut),
timestamp = laidOut.timestamp,
isRead = if (laidOut.is_read) 1L else 0L,
isEdited = if (laidOut.is_edited) 1L else 0L,
replyToId = resolveReplyToIdForPersistence(laidOut, row.replyToId),
clientMessageId = laidOut.client_message_id,
deletedFlag = 0L,
sendStatus = "sent",
)
}
}
/** After decrypt, persist [localPreviewUri] so reopen skips network. */
suspend fun patchDmMessageLocalPreview(
otherUserId: Int,
@@ -856,7 +907,12 @@ object MessageCacheStore {
private suspend fun confirmMessage(conversationId: String, clientMessageId: String, confirmed: Message) {
val iid = instanceId()
val storedContent = encodePersistedDmMessage(confirmed)
val storedContent = when {
!confirmed.files.isNullOrEmpty() && confirmed.dmEnvelope != null ->
encodePersistedDmMessage(confirmed)
!confirmed.files.isNullOrEmpty() -> encodePersistedPublicMessage(confirmed)
else -> confirmed.content
}
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.transaction {
val existingReplyToId = db.messageDatabaseQueries
@@ -901,7 +957,10 @@ object MessageCacheStore {
validatedOrEmpty(
conversationId,
dedupeMessagesByClientId(
enrichQueuedOutboundUi(withoutSuperseded, conversationId),
enrichQueuedOutboundUi(
hydrateAttachmentPreviewsFromDisk(withoutSuperseded),
conversationId,
),
),
),
)
@@ -915,6 +974,7 @@ object MessageCacheStore {
.selectRecentMessagesByConversation(iid, conversationId, limit)
.executeAsList()
hydrateReplyReferencesFromRows(rows).reversed()
.let { hydrateAttachmentPreviewsFromDisk(it) }
}
}
@@ -951,7 +1011,9 @@ object MessageCacheStore {
it.conversationId == conversationId &&
(
it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT ||
it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK
it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK ||
it.kind == OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT ||
it.kind == OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK
)
}
if (attachmentOutbox.isEmpty()) return messages
@@ -961,11 +1023,33 @@ object MessageCacheStore {
.toSet()
val payloads = attachmentOutbox.associate { row ->
row.clientMessageId to runCatching {
Triple(
outboxJson.decodeFromString<DmAttachmentOutboxPayload>(row.payloadJson),
row.bytesUploaded,
row.kind,
)
when (row.kind) {
OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT,
OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK -> {
val payload = outboxJson.decodeFromString<PublicAttachmentOutboxPayload>(row.payloadJson)
AttachmentOutboxUi(
fileUri = payload.fileUri,
filename = payload.filename,
fileSizeBytes = payload.fileSizeBytes,
aspectRatio = payload.aspectRatio,
encryptedFileSizeBytes = 0L,
bytesUploaded = row.bytesUploaded,
kind = row.kind,
)
}
else -> {
val payload = outboxJson.decodeFromString<DmAttachmentOutboxPayload>(row.payloadJson)
AttachmentOutboxUi(
fileUri = payload.fileUri,
filename = payload.filename,
fileSizeBytes = payload.fileSizeBytes,
aspectRatio = payload.aspectRatio,
encryptedFileSizeBytes = payload.encryptedFileSizeBytes,
bytesUploaded = row.bytesUploaded,
kind = row.kind,
)
}
}
}.getOrNull()
}
return messages.mapNotNull { msg ->
@@ -974,32 +1058,43 @@ object MessageCacheStore {
if (cid.isNotEmpty() && cid in confirmedClientIds) return@mapNotNull null
if (cid.isEmpty()) return@mapNotNull msg
val entry = payloads[cid] ?: return@mapNotNull msg
val (payload, bytesUploaded, kind) = entry
val uploadFinished = kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK
val uploadFinished =
entry.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK ||
entry.kind == OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK
val totalBytes = when {
payload.encryptedFileSizeBytes > 0L -> payload.encryptedFileSizeBytes
else -> payload.fileSizeBytes
entry.encryptedFileSizeBytes > 0L -> entry.encryptedFileSizeBytes
else -> entry.fileSizeBytes
}
val percent = when {
uploadFinished -> null
totalBytes > 0L && bytesUploaded > 0L ->
((bytesUploaded.toDouble() / totalBytes.toDouble()) * 100.0).toInt().coerceIn(0, 99)
bytesUploaded > 0L -> 1
totalBytes > 0L && entry.bytesUploaded > 0L ->
((entry.bytesUploaded.toDouble() / totalBytes.toDouble()) * 100.0).toInt().coerceIn(0, 99)
entry.bytesUploaded > 0L -> 1
else -> msg.uploadProgress ?: 0
}
msg.copy(
pendingFileUri = payload.fileUri,
pendingFilename = payload.filename,
pendingFileAspectRatio = payload.aspectRatio?.takeIf { it > 0f }
pendingFileUri = entry.fileUri,
pendingFilename = entry.filename,
pendingFileAspectRatio = entry.aspectRatio?.takeIf { it > 0f }
?: msg.pendingFileAspectRatio,
uploadJobId = cid,
uploadProgress = percent,
fileSizes = msg.fileSizes
?: payload.fileSizeBytes.takeIf { it > 0L }?.let { listOf(it) },
?: entry.fileSizeBytes.takeIf { it > 0L }?.let { listOf(it) },
)
}
}
private data class AttachmentOutboxUi(
val fileUri: String,
val filename: String,
val fileSizeBytes: Long,
val aspectRatio: Float?,
val encryptedFileSizeBytes: Long,
val bytesUploaded: Long,
val kind: String,
)
private fun purgeSupersededPendingRows(
instanceId: String,
conversationId: String,
@@ -1088,15 +1183,16 @@ object MessageCacheStore {
reply_to = null,
client_message_id = clientMessageId,
reactions = null,
files = parsed.envelope?.files,
files = parsed.files ?: parsed.envelope?.files,
dmEnvelope = parsed.envelope,
fileThumbnails = parsed.fileThumbnails,
fileAspectRatios = parsed.fileAspectRatios,
fileSizes = parsed.fileSizes,
fileDimensions = parsed.fileDimensions,
fileAspectRatioPairs = parsed.fileAspectRatioPairs,
isContentCorrupted = parsed.isContentCorrupted,
)
return base.copy(
val hydrated = base.copy(
pendingFileUri = parsed.pendingFileUri
?: parsed.localPreviewUri
?: resolveLocalPreviewUri(base),
@@ -1109,11 +1205,36 @@ object MessageCacheStore {
},
uploadError = if (sendStatus == "failed") SEND_ERROR_FAILED else null,
)
return if (!hydrated.files.isNullOrEmpty() && hydrated.dmEnvelope == null) {
hydrated.resolvePublicAttachmentLayout()
} else {
hydrated
}
}
private suspend fun ensureAttachmentDiskAlias(message: Message) {
val cid = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() } ?: return
if (message.id <= 0) return
val file = message.files?.firstOrNull() ?: return
if (isImageFilename(file.name)) {
DecryptedImageCache.ensureDiskAliasForMessageId(message.id, 0, cid)
} else if (message.dmEnvelope != null) {
DecryptedFileCache.ensureDiskAliasForMessageId(message.id, 0, cid)
}
}
private suspend fun hydrateAttachmentPreviewFromDisk(message: Message): Message {
ensureAttachmentDiskAlias(message)
return hydrateAttachmentPreviewFromDiskSync(message)
}
private suspend fun hydrateAttachmentPreviewsFromDisk(messages: List<Message>): List<Message> =
messages.map { hydrateAttachmentPreviewFromDisk(it) }
private fun storedMessageContent(msg: Message): String = when {
msg.id < 0 -> encodeOptimisticOutboundMessage(msg)
!msg.files.isNullOrEmpty() && msg.dmEnvelope != null -> encodePersistedDmMessage(msg)
!msg.files.isNullOrEmpty() -> encodePersistedPublicMessage(msg)
else -> msg.content
}
@@ -121,6 +121,10 @@ object LocalDecodedImageCache {
return bitmap
}
fun putThumb(storageKey: String, bitmap: ImageBitmap) {
PlatformDecodedBitmapCache.put(storageKey + THUMB_SUFFIX, bitmap)
}
fun evict(storageKey: String) {
PlatformDecodedBitmapCache.remove(previewCacheKey(storageKey))
PlatformDecodedBitmapCache.remove(fullscreenCacheKey(storageKey))
@@ -0,0 +1,4 @@
package ru.fromchat.api.local.download
/** EXIF-oriented width/height from a local file (bounds read only; no full decode). */
internal expect fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>?
@@ -11,6 +11,8 @@ import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.local.cache.readOutboundFileBytes
import ru.fromchat.api.local.mimeTypeForFilename
import ru.fromchat.ui.chat.isImageFilename
import ru.fromchat.ui.chat.utils.attachmentDecodeCacheKeys
import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap
data class SavableMessageImage(
val fileIndex: Int,
@@ -25,13 +27,20 @@ fun mimeTypeForImageFilename(filename: String): String = mimeTypeForFilename(fil
fun isMessageImageFullyLoaded(message: Message, fileIndex: Int): Boolean {
val file = message.files?.getOrNull(fileIndex)
if (file != null && isImageFilename(file.name)) {
if (message.dmEnvelope != null) {
return DecryptedImageCache.getCached(
val cacheKeys = attachmentDecodeCacheKeys(
messageId = message.id,
fileIndex = fileIndex,
clientMessageId = message.client_message_id,
)
if (DecryptedImageCache.getCached(
messageId = message.id,
fileIndex = fileIndex,
clientMessageId = message.client_message_id,
) != null
) {
return true
}
if (peekDecodedAttachmentBitmap(cacheKeys) != null) return true
return false
}
if (fileIndex != 0) return false
@@ -557,6 +557,9 @@ object DmAttachmentOutboxHandler {
MessageDatabaseProvider.database.messageDatabaseQueries
.selectPendingOutboxForInstance(instanceId)
.executeAsList()
.any { it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT }
.any {
it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT ||
it.kind == OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT
}
}
}
@@ -37,5 +37,10 @@ suspend fun prepareOutboundFileForSend(
exportUri = cacheUri,
)
StagedOutboundPreview(stagedUri = staged.uri, aspectRatio = null, sizeBytes = staged.sizeBytes)
StagedOutboundPreview(
stagedUri = staged.uri,
previewUri = cacheUri,
aspectRatio = null,
sizeBytes = staged.sizeBytes,
)
}
@@ -10,6 +10,8 @@ import ru.fromchat.api.local.download.LocalDecodedImageCache
data class StagedOutboundPreview(
val stagedUri: String,
/** Decrypted-image cache URI for UI preview (preferred over [stagedUri]). */
val previewUri: String? = null,
val aspectRatio: Float?,
val sizeBytes: Long = 0L,
)
@@ -30,7 +32,7 @@ suspend fun prepareOutboundImageForSend(
}.getOrNull() ?: return@withContext null
if (staged.sizeBytes <= 0L) return@withContext null
DecryptedImageCache.seedFromLocalFile(
val previewUri = DecryptedImageCache.seedFromLocalFile(
messageId = optimisticMessageId,
fileIndex = 0,
localFileUri = staged.uri,
@@ -39,9 +41,18 @@ suspend fun prepareOutboundImageForSend(
val storageKey = DecryptedImageCache.storageKey(optimisticMessageId, 0, clientMessageId)
val decodeTarget = previewSeedDecodeSize(aspectRatio)
LocalDecodedImageCache.loadFull(storageKey, staged.uri, decodeTarget)
LocalDecodedImageCache.loadFull(
storageKey,
(previewUri ?: staged.uri).removePrefix("file://"),
decodeTarget,
)
StagedOutboundPreview(stagedUri = staged.uri, aspectRatio = aspectRatio, sizeBytes = staged.sizeBytes)
StagedOutboundPreview(
stagedUri = staged.uri,
previewUri = previewUri,
aspectRatio = aspectRatio,
sizeBytes = staged.sizeBytes,
)
}
/** High-quality seed decode before the tile is measured (refined when laid out). */
@@ -33,3 +33,15 @@ data class DmAttachmentOutboxPayload(
val encryptedFileSizeBytes: Long = 0L,
val uploadId: String = "",
)
@Serializable
data class PublicAttachmentOutboxPayload(
val content: String,
val clientMessageId: String,
val replyToId: Int? = null,
val fileUri: String,
val filename: String,
val fileSizeBytes: Long = 0L,
val aspectRatio: Float? = null,
val uploadId: String = "",
)
@@ -255,6 +255,53 @@ object OutgoingMessageCoordinator {
kickOutboxDrain(instanceId)
}
suspend fun enqueuePublicAttachment(
content: String,
clientMessageId: String,
replyToId: Int?,
fileUri: String,
filename: String,
optimisticMessage: Message,
aspectRatio: Float? = null,
fileSizeBytes: Long = 0L,
) {
val instanceId = CacheContext.requireActiveInstanceId()
val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
AttachmentUploadNotifier.emit(
AttachmentUploadProgress.Pending(clientMessageId, filename),
messageLabel = content,
)
AttachmentUploadNotifier.emit(
AttachmentUploadProgress.InProgress(clientMessageId, 1, filename),
messageLabel = content,
)
withContext(Dispatchers.Default) {
MessageRepository.upsertPublicMessage(optimisticMessage)
val payload = json.encodeToString(
PublicAttachmentOutboxPayload(
content = content,
clientMessageId = clientMessageId,
replyToId = replyToId,
fileUri = fileUri,
filename = filename,
fileSizeBytes = fileSizeBytes.coerceAtLeast(0L),
aspectRatio = aspectRatio?.takeIf { it > 0f },
),
)
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
instanceId = instanceId,
clientMessageId = clientMessageId,
conversationId = conversationId,
kind = KIND_SEND_PUBLIC_ATTACHMENT,
payloadJson = payload,
retryCount = 0L,
nextAttemptAt = null,
bytesUploaded = 0L,
)
}
kickOutboxDrain(instanceId)
}
suspend fun clearAttachmentOutboxAfterAck(clientMessageId: String) {
val cid = clientMessageId.trim()
if (cid.isEmpty()) return
@@ -281,6 +328,22 @@ object OutgoingMessageCoordinator {
}
}
suspend fun abortPublicServerUploadIfNeeded(uploadId: String) {
val id = uploadId.trim()
if (id.isEmpty()) return
runCatching { ApiClient.abortPublicUpload(id) }
.onSuccess {
AttachmentMediaLog.upload("public_server_abort_ok", "uploadId" to id)
}
.onFailure { error ->
AttachmentMediaLog.upload(
"public_server_abort_failed",
"uploadId" to id,
"error" to (error.message ?: "unknown"),
)
}
}
/** Re-queues a failed public / text outbound row (outbox row must still exist). */
fun retryOutboundMessage(clientMessageId: String, conversationId: String) {
val cid = clientMessageId.trim()
@@ -316,11 +379,15 @@ object OutgoingMessageCoordinator {
val row = MessageDatabaseProvider.database.messageDatabaseQueries
.selectOutboxItem(instanceId, cid)
.executeAsOneOrNull()
if (row?.kind == KIND_SEND_DM_ATTACHMENT) {
runCatching {
when (row?.kind) {
KIND_SEND_DM_ATTACHMENT -> runCatching {
val payload = json.decodeFromString<DmAttachmentOutboxPayload>(row.payloadJson)
abortDmServerUploadIfNeeded(payload.uploadId)
}
KIND_SEND_PUBLIC_ATTACHMENT -> runCatching {
val payload = json.decodeFromString<PublicAttachmentOutboxPayload>(row.payloadJson)
abortPublicServerUploadIfNeeded(payload.uploadId)
}
}
MessageDatabaseProvider.database.messageDatabaseQueries.deleteOutboxItem(instanceId, cid)
MessageCacheStore.deleteMessageByClientMessageId(conversationId, cid)
@@ -366,7 +433,14 @@ object OutgoingMessageCoordinator {
scheduleOutboxRetry(id)
}
}
KIND_SEND_DM_ATTACHMENT_AWAITING_ACK -> Unit
KIND_SEND_PUBLIC_ATTACHMENT -> {
if (!PublicAttachmentOutboxHandler.process(row)) {
allOk = false
scheduleOutboxRetry(id)
}
}
KIND_SEND_DM_ATTACHMENT_AWAITING_ACK,
KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK -> Unit
}
}
allOk
@@ -378,6 +452,9 @@ object OutgoingMessageCoordinator {
const val KIND_SEND_DM_ATTACHMENT = "send_dm_attachment"
/** Upload+send finished; row kept until DM ack so UI can still resolve local preview from outbox. */
const val KIND_SEND_DM_ATTACHMENT_AWAITING_ACK = "send_dm_attachment_awaiting_ack"
const val KIND_SEND_PUBLIC_ATTACHMENT = "send_public_attachment"
/** Upload+send finished; row kept briefly so UI can still resolve local preview from outbox. */
const val KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK = "send_public_attachment_awaiting_ack"
suspend fun pruneStaleAttachmentOutboxForInstance(instanceId: String) {
val id = instanceId.trim()
@@ -392,7 +469,9 @@ object OutgoingMessageCoordinator {
for (row in rows) {
when (row.kind) {
KIND_SEND_DM_ATTACHMENT,
KIND_SEND_DM_ATTACHMENT_AWAITING_ACK -> {
KIND_SEND_DM_ATTACHMENT_AWAITING_ACK,
KIND_SEND_PUBLIC_ATTACHMENT,
KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK -> {
if (!MessageCacheStore.hasSentMessageWithClientId(
row.conversationId,
row.clientMessageId,
@@ -0,0 +1,460 @@
package ru.fromchat.api.local.send
import com.pr0gramm3r101.utils.crypto.Base64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.cache.OutboundFileUnavailableException
import ru.fromchat.api.local.cache.UPLOAD_ERROR_FILE_TOO_LARGE
import ru.fromchat.api.local.cache.isFileTooLargeForUpload
import ru.fromchat.api.local.cache.isLikelyUploadMemoryError
import ru.fromchat.api.local.cache.isOutboundFileUnavailable
import ru.fromchat.api.local.cache.openOutboundFileInputStream
import ru.fromchat.api.local.cache.queryOutboundUriSizeBytes
import ru.fromchat.api.local.cache.repairInterruptedUploadArtifacts
import ru.fromchat.api.local.cache.stageOutboundFileForUpload
import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.api.local.messages.optimisticMessageIdForClientMessageId
import ru.fromchat.api.local.workers.AttachmentUploadNotifier
import ru.fromchat.api.local.workers.AttachmentUploadProgress
import ru.fromchat.api.local.db.isPlaceholderAttachmentAspectRatio
import ru.fromchat.api.local.db.isPlaceholderAttachmentDimensions
import ru.fromchat.api.schema.messages.publicchat.resolvePublicAttachmentLayout
import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadCompleteResponse
import ru.fromchat.db.Outbox
import ru.fromchat.ui.chat.isImageFilename
private const val DEFAULT_CHUNK_SIZE = 262_144
object PublicAttachmentOutboxHandler {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private fun outboxRowExists(instanceId: String, clientMessageId: String): Boolean =
MessageDatabaseProvider.database.messageDatabaseQueries
.selectOutboxItem(instanceId, clientMessageId)
.executeAsOneOrNull() != null
private fun ensureStillQueued(instanceId: String, clientMessageId: String) {
if (!outboxRowExists(instanceId, clientMessageId)) {
AttachmentMediaLog.upload("cancelled_in_flight", "job" to clientMessageId)
throw kotlinx.coroutines.CancellationException("Upload cancelled")
}
}
suspend fun process(row: Outbox): Boolean {
if (row.kind != OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT) return false
val instanceId = row.instanceId
val payload = json.decodeFromString<PublicAttachmentOutboxPayload>(row.payloadJson)
val clientMessageId = payload.clientMessageId.trim()
if (clientMessageId.isEmpty()) return true
if (!outboxRowExists(instanceId, clientMessageId)) return true
val conversationId = row.conversationId
if (MessageCacheStore.hasSentMessageWithClientId(conversationId, clientMessageId)) {
withContext(Dispatchers.Default) {
MessageDatabaseProvider.database.messageDatabaseQueries.deleteOutboxItem(
instanceId,
clientMessageId,
)
MessageDatabaseProvider.database.messageDatabaseQueries.deletePendingMessageByClientMessageId(
instanceId,
conversationId,
clientMessageId,
)
}
return true
}
val serverUploadId = arrayOf(payload.uploadId.trim())
return runCatching {
repairInterruptedUploadArtifacts(instanceId, clientMessageId)
ensureStillQueued(instanceId, clientMessageId)
AttachmentMediaLog.send(
"outbox_start",
"job" to clientMessageId.take(12),
"file" to payload.filename,
"bytes" to payload.fileSizeBytes,
"uploadId" to payload.uploadId.take(12),
)
AttachmentUploadNotifier.emit(
AttachmentUploadProgress.Pending(clientMessageId, payload.filename),
messageLabel = payload.content,
)
val stagedPayload = ensureStagedPayload(instanceId, row, payload)
if (isFileTooLargeForUpload(stagedPayload.fileSizeBytes)) {
throw IllegalStateException(UPLOAD_ERROR_FILE_TOO_LARGE)
}
if (!isImageFilename(stagedPayload.filename)) {
seedOutboundFileAsDownloaded(
messageId = optimisticMessageIdForClientMessageId(clientMessageId),
fileIndex = 0,
localFileUri = stagedPayload.fileUri,
displayFilename = stagedPayload.filename,
clientMessageId = clientMessageId,
)
}
ensureStillQueued(instanceId, clientMessageId)
val restoredPercent = uploadPercent(row.bytesUploaded, stagedPayload)
AttachmentMediaLog.send(
"outbox_upload_begin",
"job" to clientMessageId.take(12),
"restoredPct" to restoredPercent,
"bytes" to stagedPayload.fileSizeBytes,
)
if (restoredPercent > 0) {
emitProgress(clientMessageId, restoredPercent, stagedPayload.filename, stagedPayload.content)
} else {
emitProgress(clientMessageId, 0, stagedPayload.filename, stagedPayload.content)
}
val completed = sendResumable(
instanceId = instanceId,
row = row,
payload = stagedPayload,
serverUploadId = serverUploadId,
)
ensureStillQueued(instanceId, clientMessageId)
AttachmentMediaLog.send(
"outbox_upload_complete",
"job" to clientMessageId.take(12),
"fileId" to completed.fileId,
)
val confirmed = ApiClient.sendMessageViaHttp(
content = stagedPayload.content,
replyToId = stagedPayload.replyToId,
clientMessageId = clientMessageId,
uploadedFileIds = listOf(completed.fileId),
)
val resolvedConfirmed = mergeConfirmedPublicAttachment(
confirmed = confirmed.copy(client_message_id = clientMessageId),
payload = stagedPayload,
)
AttachmentMediaLog.send(
"outbox_http_ack",
"job" to clientMessageId.take(12),
"realId" to resolvedConfirmed.id,
"files" to (resolvedConfirmed.files?.size ?: 0),
"aspect" to resolvedConfirmed.pendingFileAspectRatio,
"dims" to resolvedConfirmed.fileDimensions?.firstOrNull(),
)
withContext(Dispatchers.Default) {
if (resolvedConfirmed.id > 0 && isImageFilename(stagedPayload.filename)) {
DecryptedImageCache.seedFromLocalFile(
messageId = resolvedConfirmed.id,
fileIndex = 0,
localFileUri = stagedPayload.fileUri,
clientMessageId = clientMessageId,
)
DecryptedImageCache.ensureDiskAliasForMessageId(
messageId = resolvedConfirmed.id,
fileIndex = 0,
clientMessageId = clientMessageId,
)
}
MessageCacheStore.confirmPublicMessage(
clientMessageId,
resolvedConfirmed,
)
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
instanceId = instanceId,
clientMessageId = clientMessageId,
conversationId = row.conversationId,
kind = OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK,
payloadJson = row.payloadJson,
retryCount = row.retryCount,
nextAttemptAt = null,
bytesUploaded = row.bytesUploaded,
)
}
AttachmentUploadNotifier.emit(
AttachmentUploadProgress.Success(clientMessageId),
messageLabel = payload.content,
)
AttachmentMediaLog.send(
"outbox_success",
"job" to clientMessageId.take(12),
"realId" to confirmed.id,
)
true
}.getOrElse { error ->
if (error is kotlinx.coroutines.CancellationException) {
OutgoingMessageCoordinator.abortPublicServerUploadIfNeeded(serverUploadId[0])
AttachmentMediaLog.send("outbox_cancelled", "job" to clientMessageId.take(12))
return true
}
AttachmentMediaLog.send(
"outbox_error",
"job" to clientMessageId.take(12),
"err" to (error.message ?: error::class.simpleName),
)
if (error.isOutboundFileUnavailable()) {
AttachmentUploadNotifier.emit(
AttachmentUploadProgress.Failed(
jobId = clientMessageId,
error = error.message ?: "Attachment unavailable",
),
messageLabel = payload.content,
)
runCatching {
val optimisticId = optimisticMessageIdForClientMessageId(clientMessageId)
clearOutboundImageCaches(clientMessageId, optimisticId)
clearOutboundFileCaches(clientMessageId, optimisticId)
OutgoingMessageCoordinator.cancelOutboundMessage(clientMessageId, row.conversationId)
}
return true
}
val failureKey = when {
error.message == UPLOAD_ERROR_FILE_TOO_LARGE -> UPLOAD_ERROR_FILE_TOO_LARGE
isLikelyUploadMemoryError(error) -> UPLOAD_ERROR_FILE_TOO_LARGE
else -> null
}
if (failureKey != null) {
AttachmentUploadNotifier.emit(
AttachmentUploadProgress.Failed(
jobId = clientMessageId,
error = failureKey,
),
messageLabel = payload.content,
)
} else {
// Transient (network / parse / 5xx): keep uploading UI and retry — do not flash failed.
AttachmentMediaLog.send(
"outbox_retryable",
"job" to clientMessageId.take(12),
"err" to (error.message ?: error::class.simpleName),
)
}
false
}
}
private suspend fun sendResumable(
instanceId: String,
row: Outbox,
payload: PublicAttachmentOutboxPayload,
serverUploadId: Array<String>,
): PublicUploadCompleteResponse {
val totalSize = payload.fileSizeBytes
if (totalSize <= 0L) {
throw OutboundFileUnavailableException("Attachment file is empty or unavailable")
}
var activePayload = payload
var bytesUploaded = row.bytesUploaded.coerceAtLeast(0L)
if (bytesUploaded > totalSize) {
bytesUploaded = 0L
}
var uploadId = activePayload.uploadId.trim().ifBlank { serverUploadId[0] }
try {
if (uploadId.isEmpty()) {
val init = ApiClient.initPublicUpload(
filename = payload.filename,
totalSize = totalSize,
chunkSize = DEFAULT_CHUNK_SIZE,
)
uploadId = init.uploadId
serverUploadId[0] = uploadId
persistPayloadProgress(instanceId, row, activePayload.copy(uploadId = uploadId), bytesUploaded)
} else {
serverUploadId[0] = uploadId
}
val serverStatus = ApiClient.getPublicUploadStatus(uploadId)
val serverOffset = serverStatus.offset.coerceAtLeast(0L)
if (serverStatus.totalSize > 0L && serverStatus.totalSize != totalSize) {
OutgoingMessageCoordinator.abortPublicServerUploadIfNeeded(uploadId)
uploadId = ""
serverUploadId[0] = ""
bytesUploaded = 0L
val init = ApiClient.initPublicUpload(
filename = activePayload.filename,
totalSize = totalSize,
chunkSize = DEFAULT_CHUNK_SIZE,
)
uploadId = init.uploadId
serverUploadId[0] = uploadId
activePayload = activePayload.copy(uploadId = uploadId)
persistPayloadProgress(instanceId, row, activePayload, bytesUploaded)
}
var offset = maxOf(bytesUploaded, serverOffset)
val input = openOutboundFileInputStream(activePayload.fileUri)
?: throw OutboundFileUnavailableException("Failed to open staged attachment")
try {
skipBytes(input, offset)
while (offset < totalSize) {
ensureStillQueued(instanceId, activePayload.clientMessageId)
val chunkLen = minOf(DEFAULT_CHUNK_SIZE.toLong(), totalSize - offset).toInt()
val chunk = ByteArray(chunkLen)
var filled = 0
while (filled < chunkLen) {
val read = input.read(chunk, filled, chunkLen - filled)
if (read < 0) break
filled += read
}
if (filled <= 0) {
throw OutboundFileUnavailableException("Unexpected EOF while reading attachment")
}
val toSend = if (filled == chunkLen) chunk else chunk.copyOf(filled)
val chunkResp = ApiClient.uploadPublicChunk(
uploadId = uploadId,
offset = offset,
dataB64 = Base64.encode(toSend),
)
offset = chunkResp.offsetReceived.coerceAtLeast(offset + toSend.size.toLong())
val percent = ((offset.toDouble() / totalSize.toDouble()) * 100.0).toInt()
emitProgress(activePayload.clientMessageId, percent, activePayload.filename, activePayload.content)
persistPayloadProgress(
instanceId,
row,
activePayload.copy(uploadId = uploadId),
offset,
)
}
} finally {
input.close()
}
val completed = ApiClient.completePublicUpload(uploadId)
serverUploadId[0] = ""
return completed
} catch (e: kotlinx.coroutines.CancellationException) {
OutgoingMessageCoordinator.abortPublicServerUploadIfNeeded(uploadId)
serverUploadId[0] = ""
throw e
}
}
private suspend fun skipBytes(input: ru.fromchat.api.local.cache.OutboundFileInputStream, count: Long) {
if (count <= 0L) return
val buffer = ByteArray(DEFAULT_CHUNK_SIZE)
var remaining = count
while (remaining > 0L) {
val toRead = minOf(buffer.size.toLong(), remaining).toInt()
val read = input.read(buffer, 0, toRead)
if (read < 0) {
throw OutboundFileUnavailableException("Unexpected EOF while seeking attachment")
}
remaining -= read.toLong()
}
}
private suspend fun ensureStagedPayload(
instanceId: String,
row: Outbox,
payload: PublicAttachmentOutboxPayload,
): PublicAttachmentOutboxPayload {
val expectedSize = payload.fileSizeBytes.takeIf { it > 0L }
?: queryOutboundUriSizeBytes(payload.fileUri)
?: 0L
val staged = stageOutboundFileForUpload(
instanceId = instanceId,
clientMessageId = payload.clientMessageId,
sourceUri = payload.fileUri,
expectedSizeBytes = expectedSize,
)
if (staged.sizeBytes <= 0L) {
throw OutboundFileUnavailableException("Attachment file is empty or unavailable")
}
val updated = payload.copy(fileUri = staged.uri, fileSizeBytes = staged.sizeBytes)
if (updated.fileUri == payload.fileUri && updated.fileSizeBytes == payload.fileSizeBytes) {
return updated
}
persistPayloadProgress(instanceId, row, updated, row.bytesUploaded)
return updated
}
private suspend fun persistPayloadProgress(
instanceId: String,
row: Outbox,
payload: PublicAttachmentOutboxPayload,
bytesUploaded: Long,
) {
if (!outboxRowExists(instanceId, row.clientMessageId)) return
withContext(Dispatchers.Default) {
if (!outboxRowExists(instanceId, row.clientMessageId)) return@withContext
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
instanceId = instanceId,
clientMessageId = row.clientMessageId,
conversationId = row.conversationId,
kind = row.kind,
payloadJson = json.encodeToString(payload),
retryCount = row.retryCount,
nextAttemptAt = row.nextAttemptAt,
bytesUploaded = bytesUploaded,
)
}
}
private fun uploadPercent(bytesUploaded: Long, payload: PublicAttachmentOutboxPayload): Int {
val total = payload.fileSizeBytes
if (total <= 0L || bytesUploaded <= 0L) return 0
return ((bytesUploaded.toDouble() / total.toDouble()) * 100.0).toInt().coerceIn(0, 99)
}
private fun emitProgress(
jobId: String,
percent: Int,
filename: String? = null,
messageLabel: String? = null,
) {
AttachmentUploadNotifier.emit(
AttachmentUploadProgress.InProgress(
jobId = jobId,
percent = percent.coerceIn(0, 100),
filename = filename,
),
messageLabel = messageLabel,
)
}
private fun mergeConfirmedPublicAttachment(
confirmed: ru.fromchat.api.schema.messages.Message,
payload: PublicAttachmentOutboxPayload,
): ru.fromchat.api.schema.messages.Message {
val laidOut = confirmed.resolvePublicAttachmentLayout()
AttachmentMediaLog.aspect(
"outbox_http_raw",
"job" to payload.clientMessageId.take(12),
"realId" to laidOut.id,
"pairs" to confirmed.fileAspectRatioPairs?.firstOrNull(),
"resolvedPairs" to laidOut.fileAspectRatioPairs?.firstOrNull(),
"resolvedDims" to laidOut.fileDimensions?.firstOrNull(),
"resolvedRatio" to laidOut.fileAspectRatios?.firstOrNull(),
"payloadAspect" to payload.aspectRatio,
)
val serverPair = laidOut.fileAspectRatioPairs?.firstOrNull()
?: confirmed.fileAspectRatioPairs?.firstOrNull()
val serverDim = laidOut.fileDimensions?.firstOrNull()
val payloadAspect = payload.aspectRatio?.takeIf { it > 0f }
val serverHasReal = serverPair?.let { pair ->
pair.size >= 2 && !isPlaceholderAttachmentDimensions(pair[0], pair[1])
} == true || serverDim?.let { (w, h) ->
!isPlaceholderAttachmentDimensions(w, h)
} == true
if (!serverHasReal) {
val aspect = payloadAspect
val stagedUri = payload.fileUri.trim().takeIf { it.isNotEmpty() }
AttachmentMediaLog.aspect(
"outbox_apply_payload_aspect",
"job" to payload.clientMessageId.take(12),
"aspect" to aspect,
"stagedUri" to stagedUri?.take(48),
)
return laidOut.copy(
pendingFileUri = stagedUri?.takeIf { path ->
com.pr0gramm3r101.utils.files.PlatformFileSystem.exists(
path.removePrefix("file://"),
)
},
pendingFileAspectRatio = aspect,
fileAspectRatios = aspect?.let { listOf(it) },
)
}
AttachmentMediaLog.aspect("outbox_keep_server", "job" to payload.clientMessageId.take(12))
return laidOut
}
}
@@ -10,6 +10,7 @@ import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.api.local.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.send.DmAttachmentOutboxPayload
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.local.send.PublicAttachmentOutboxPayload
import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.repairInterruptedUploadArtifacts
@@ -53,9 +54,19 @@ object AttachmentTransferBootstrap {
.selectPendingOutboxForInstance(instanceId)
.executeAsList()
for (row in rows) {
if (row.kind != OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT) continue
if (
row.kind != OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT &&
row.kind != OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT
) {
continue
}
val clientMessageId = runCatching {
json.decodeFromString<DmAttachmentOutboxPayload>(row.payloadJson).clientMessageId.trim()
when (row.kind) {
OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT ->
json.decodeFromString<DmAttachmentOutboxPayload>(row.payloadJson).clientMessageId.trim()
else ->
json.decodeFromString<PublicAttachmentOutboxPayload>(row.payloadJson).clientMessageId.trim()
}
}.getOrNull().orEmpty()
if (clientMessageId.isEmpty()) continue
repairInterruptedUploadArtifacts(instanceId, clientMessageId)
@@ -25,7 +25,7 @@ object AttachmentUploadNotifier {
"msg" to msg,
)
is AttachmentUploadProgress.InProgress -> {
if (progress.percent == 1 || progress.percent % 10 == 0 || progress.percent >= 95) {
if (progress.percent == 1 || progress.percent % 5 == 0 || progress.percent >= 95) {
AttachmentMediaLog.upload(
"progress",
"job" to progress.jobId,
@@ -33,20 +33,36 @@ object AttachmentUploadNotifier {
"file" to progress.filename,
"msg" to msg,
)
AttachmentMediaLog.send(
"upload_progress",
"job" to progress.jobId.take(12),
"pct" to progress.percent,
)
}
}
is AttachmentUploadProgress.Success ->
is AttachmentUploadProgress.Success -> {
AttachmentMediaLog.upload("success", "job" to progress.jobId, "msg" to msg)
is AttachmentUploadProgress.Failed ->
AttachmentMediaLog.send("upload_success", "job" to progress.jobId.take(12))
}
is AttachmentUploadProgress.Failed -> {
AttachmentMediaLog.upload(
"failed",
"job" to progress.jobId,
"err" to progress.error,
"msg" to msg,
)
AttachmentMediaLog.send(
"upload_failed",
"job" to progress.jobId.take(12),
"err" to progress.error,
)
}
}
mainScope.launch {
_progressFlow.emit(progress)
// Prefer immediate delivery so the determinate indicator tracks chunks.
if (!_progressFlow.tryEmit(progress)) {
mainScope.launch {
_progressFlow.emit(progress)
}
}
}
}
@@ -38,16 +38,21 @@ data class Message(
@Transient val uploadError: String? = null,
/** For DM file decryption; not serialized over network. */
@Transient val dmEnvelope: DmEnvelope? = null,
/** Blurhashes for image files (by index); from decrypted message JSON. */
@Transient val fileThumbnails: List<String>? = null,
/** Aspect ratios (width/height) for image files (by index); from decrypted message JSON. */
/** Base64 JPEG thumbnails for image files (by index); public API or decrypted DM JSON. */
@SerialName("fileThumbnails") val fileThumbnails: List<String>? = null,
/**
* Pixel [width, height] pairs from public API / DM plaintext.
* Prefer [fileDimensions] / [fileAspectRatios] for layout after [resolvePublicAttachmentLayout].
*/
@SerialName("fileAspectRatios") val fileAspectRatioPairs: List<List<Int>>? = null,
/** File sizes in bytes (by index). */
@SerialName("fileSizes") val fileSizes: List<Long>? = null,
/** Aspect ratios (width/height) for image files (by index); derived client-side. */
@Transient val fileAspectRatios: List<Float>? = null,
/** File sizes in bytes (by index); from decrypted message JSON. */
@Transient val fileSizes: List<Long>? = null,
/** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */
/** Image dimensions (width, height) for image files (by index). */
@Transient val fileDimensions: List<Pair<Int, Int>>? = null,
/** True when DM plaintext could not be decrypted and [content] shows the corrupted placeholder. */
@Transient val isContentCorrupted: Boolean = false,
/** Reply target id from local DB when nested [reply_to] is not hydrated yet. */
@Transient val replyToId: Int? = null,
)
)
@@ -0,0 +1,40 @@
package ru.fromchat.api.schema.messages.publicchat
import ru.fromchat.api.local.db.aspectRatioFromDimensionPair
import ru.fromchat.api.local.db.isPlaceholderAttachmentDimensions
import ru.fromchat.api.schema.messages.Message
/**
* Maps public-API `[width, height]` pairs into layout fields used by attachment tiles.
*/
fun Message.resolvePublicAttachmentLayout(): Message {
val dims = fileAspectRatioPairs
?.mapNotNull { pair ->
if (pair.size >= 2) {
val w = pair[0]
val h = pair[1]
if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) w to h else null
} else {
null
}
}
?.takeIf { it.isNotEmpty() }
?: fileDimensions?.filterNot { (w, h) -> isPlaceholderAttachmentDimensions(w, h) }
?.takeIf { it.isNotEmpty() }
val ratios = dims
?.map { (w, h) -> aspectRatioFromDimensionPair(w, h) }
?.takeIf { it.isNotEmpty() }
?: fileAspectRatios
if (dims == fileDimensions && ratios == fileAspectRatios) return this
val resolved = copy(fileDimensions = dims, fileAspectRatios = ratios)
if (fileAspectRatioPairs != null) {
ru.fromchat.api.local.AttachmentMediaLog.aspect(
"resolve_public_layout",
"msgId" to id,
"pairsIn" to fileAspectRatioPairs.firstOrNull(),
"dimsOut" to resolved.fileDimensions?.firstOrNull(),
"ratioOut" to resolved.fileAspectRatios?.firstOrNull(),
)
}
return resolved
}
@@ -7,4 +7,5 @@ data class SendMessageRequest(
val content: String,
val reply_to_id: Int? = null,
val client_message_id: String? = null,
)
val uploaded_file_ids: List<String>? = null,
)
@@ -0,0 +1,10 @@
package ru.fromchat.api.schema.messages.publicchat.upload
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PublicUploadChunkRequest(
val offset: Long,
@SerialName("data_b64") val dataB64: String,
)
@@ -0,0 +1,10 @@
package ru.fromchat.api.schema.messages.publicchat.upload
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Matches file_storage resumable chunk response: `{"offset_received": <long>}`. */
@Serializable
data class PublicUploadChunkResponse(
@SerialName("offset_received") val offsetReceived: Long = 0L,
)
@@ -0,0 +1,10 @@
package ru.fromchat.api.schema.messages.publicchat.upload
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PublicUploadCompleteResponse(
@SerialName("file_id") val fileId: String,
@SerialName("upload_id") val uploadId: String,
)
@@ -0,0 +1,11 @@
package ru.fromchat.api.schema.messages.publicchat.upload
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PublicUploadInitRequest(
val filename: String,
@SerialName("total_size") val totalSize: Long,
@SerialName("chunk_size") val chunkSize: Int? = null,
)
@@ -0,0 +1,11 @@
package ru.fromchat.api.schema.messages.publicchat.upload
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PublicUploadInitResponse(
@SerialName("upload_id") val uploadId: String,
@SerialName("chunk_size") val chunkSize: Int,
val offset: Long = 0L,
)
@@ -0,0 +1,12 @@
package ru.fromchat.api.schema.messages.publicchat.upload
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PublicUploadStatusResponse(
@SerialName("upload_id") val uploadId: String,
val offset: Long = 0L,
@SerialName("total_size") val totalSize: Long = 0L,
val complete: Boolean = false,
)
@@ -22,18 +22,16 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AttachFile
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
@@ -69,13 +67,11 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.pr0gramm3r101.utils.conditional
import com.pr0gramm3r101.utils.crypto.Base64
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.stringResource
@@ -102,17 +98,25 @@ import ru.fromchat.ui.chat.components.AttachmentLeadingTransitionMs
import ru.fromchat.ui.chat.components.CancellableAttachmentProgressIndicator
import ru.fromchat.ui.chat.components.ChatFileAttachmentTile
import ru.fromchat.ui.chat.components.FileAttachmentLeadingSlot
import ru.fromchat.ui.chat.utils.ATTACHMENT_TILE_MAX_WIDTH
import ru.fromchat.ui.chat.utils.attachmentDecodeCacheKeys
import ru.fromchat.ui.chat.utils.attachmentImageCornerShape
import ru.fromchat.ui.chat.utils.attachmentTileLayout
import ru.fromchat.ui.chat.utils.coalesceDecodeTarget
import ru.fromchat.ui.chat.utils.decodeSizeChangedMeaningfully
import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap
import ru.fromchat.ui.components.Text
import com.pr0gramm3r101.utils.scaleOnPress
import ru.fromchat.ui.chat.MessageGroupInfo
private val IMAGE_SIZE = 160.dp
private val IMAGE_MAX_HEIGHT = 240.dp
private const val BLUR_FADE_MS = 450
private enum class AttachmentTileVisual {
Empty,
Thumb,
Full,
}
internal 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)
@@ -163,7 +167,7 @@ fun AttachmentPreview(
}
val isPendingImage = pendingFileUri != null && isImage && file == null && dmEnvelope == null
val isConfirmedImage = file != null && isImage && dmEnvelope != null && !isPendingImage
val isConfirmedImage = file != null && isImage && !isPendingImage
val showImageTile = isPendingImage || isConfirmedImage
val isPendingFile = pendingFileUri != null && !isImage
@@ -213,18 +217,9 @@ fun AttachmentPreview(
Modifier
}
)
.conditional(
fileAspectRatio != null && fileAspectRatio > 0f,
`if` = {
Modifier
.heightIn(max = IMAGE_MAX_HEIGHT)
.widthIn(max = IMAGE_SIZE)
.aspectRatio(fileAspectRatio!!, matchHeightConstraintsFirst = true)
},
`else` = {
Modifier.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_MAX_HEIGHT)
}
)
// Explicit px size from dp max + aspect — do not wrap to thumb intrinsics
// (IntrinsicSize.Max bubbles otherwise shrink to ~80px ≈ 3× too small).
.attachmentTileLayout(aspectRatio = fileAspectRatio)
.clip(attachmentImageCornerShape(isAuthor, messageGroup))
.then(
if (onImageBounds != null && showImageTile) {
@@ -251,8 +246,11 @@ fun AttachmentPreview(
.fillMaxSize()
.graphicsLayer { alpha = if (isExpanded) 0f else 1f }
) {
val imageStableKey = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
?: "img:${messageId ?: 0}:${fileIndex ?: 0}"
val imageStableKey = when {
(messageId ?: 0) > 0 -> "img:$messageId:${fileIndex ?: 0}"
else -> clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
?: "img:${messageId ?: 0}:${fileIndex ?: 0}"
}
key(imageStableKey) {
ChatImageTileContent(
messageId = messageId ?: -1,
@@ -314,45 +312,74 @@ private fun ChatImageTileContent(
val clipShape = attachmentImageCornerShape(isAuthor, messageGroup)
val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f }
val fallbackDecodeSize = rememberChatPreviewDecodeSize(IMAGE_SIZE, layoutAspect)
val fallbackDecodeSize = rememberChatPreviewDecodeSize(ATTACHMENT_TILE_MAX_WIDTH, layoutAspect)
val seedDecodeSize = remember(layoutAspect) { previewSeedDecodeSize(layoutAspect) }
val decryptCacheKey = remember(messageId, fileIndex, cacheClientId) {
DecryptedImageCache.storageKey(messageId, fileIndex, cacheClientId)
val decodeCacheKeys = remember(cacheClientId, messageId, fileIndex) {
attachmentDecodeCacheKeys(messageId, fileIndex, cacheClientId)
}
var tileDecodeSize by remember(decryptCacheKey) { mutableStateOf<ChatPreviewDecodeSize?>(null) }
val bitmapStateKey = remember(decodeCacheKeys) { decodeCacheKeys.joinToString("|") }
val decryptCacheKey = decodeCacheKeys.first()
var tileDecodeSize by remember(bitmapStateKey) { mutableStateOf<ChatPreviewDecodeSize?>(null) }
val decodeSize = remember(tileDecodeSize, fallbackDecodeSize, seedDecodeSize) {
coalesceDecodeTarget(tileDecodeSize, fallbackDecodeSize, seedDecodeSize)
}
val initialFull = remember(decryptCacheKey) { LocalDecodedImageCache.peekFull(decryptCacheKey) }
val hadInstantFull = initialFull != null
var cachedPath by remember(decryptCacheKey) {
var cachedPath by remember(bitmapStateKey) {
mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId))
}
val diskCacheUri = cachedPath
val hasDiskCache = diskCacheUri != null
val displayLocalUri = localUri?.trim()?.takeIf { it.isNotEmpty() } ?: diskCacheUri
val hasLocalSource = displayLocalUri != null
val hasServerThumb = thumbnailBase64?.isNotBlank() == true
val useBlurredThumbPlaceholder = hasServerThumb && !hasDiskCache
var fullBitmap by remember(decryptCacheKey) {
val initialFull = remember(bitmapStateKey) { peekDecodedAttachmentBitmap(decodeCacheKeys) }
var fullBitmap by remember(bitmapStateKey) {
mutableStateOf(initialFull)
}
var didRevealFull by remember(decryptCacheKey) { mutableStateOf(hadInstantFull || hasDiskCache) }
val thumbBlurAlpha = remember(decryptCacheKey, useBlurredThumbPlaceholder, hasLocalSource) {
Animatable(
when {
hadInstantFull || hasDiskCache -> 0f
useBlurredThumbPlaceholder || hasLocalSource -> 1f
else -> 0f
},
val placeholderMemoryKey = remember(decryptCacheKey) { "${decryptCacheKey}#placeholder" }
val thumbnailBytes = remember(thumbnailBase64) {
thumbnailBase64?.let { decodeAttachmentThumbnailBase64(it) }
}
val initialPlaceholder = remember(bitmapStateKey) {
DecryptedImageCache.peekPlaceholderThumb(
memoryKey = placeholderMemoryKey,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = cacheClientId,
)
}
val fullRevealAlpha = remember(decryptCacheKey) {
Animatable(if (hadInstantFull) 1f else 0f)
val placeholderBitmap by produceState(
initialValue = initialPlaceholder,
placeholderMemoryKey,
decryptCacheKey,
thumbnailBytes,
messageId,
fileIndex,
cacheClientId,
diskCacheUri,
displayLocalUri,
) {
value = DecryptedImageCache.decodePlaceholderThumb(
memoryKey = placeholderMemoryKey,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = cacheClientId,
serverThumbBytes = thumbnailBytes,
) ?: initialPlaceholder
}
val showOutboundBlurOverlay = isOutboundPending && hasLocalSource
val hasFullPreview = fullBitmap != null
val hasPlaceholder = placeholderBitmap != null
var decryptFinished by remember(bitmapStateKey) {
mutableStateOf(fullBitmap != null || placeholderBitmap != null)
}
val tileVisual = when {
fullBitmap != null -> AttachmentTileVisual.Full
placeholderBitmap != null -> AttachmentTileVisual.Thumb
else -> AttachmentTileVisual.Empty
}
val showOutboundBlurOverlay = (isOutboundPending || isUploading || awaitingServerAck) &&
(hasLocalSource || hasFullPreview || hasPlaceholder)
val treatAsOutbound = isOutboundPending || isUploading || awaitingServerAck
val outboundOverlayAlpha = remember(showOutboundBlurOverlay) {
Animatable(if (showOutboundBlurOverlay) 1f else 0f)
}
@@ -372,50 +399,27 @@ private fun ChatImageTileContent(
AttachmentDownloadNotifier.isCancelled(messageId, fileIndex, cacheClientId) ||
AttachmentDownloadNotifier.hasResumablePartial(messageId, fileIndex, cacheClientId)
}
LaunchedEffect(messageId, fileIndex, cacheClientId) {
LaunchedEffect(messageId, fileIndex, cacheClientId, isOutboundPending, isUploading, awaitingServerAck) {
if (isOutboundPending || isUploading || awaitingServerAck) {
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
return@LaunchedEffect
}
AttachmentDownloadNotifier.restorePausedForAttachment(
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = cacheClientId,
)
}
var isAwaitingNetworkFull by remember(decryptCacheKey) { mutableStateOf(false) }
var loadAttempt by remember(decryptCacheKey) { mutableIntStateOf(0) }
var isAwaitingNetworkFull by remember(bitmapStateKey) { mutableStateOf(false) }
var loadAttempt by remember(bitmapStateKey) { mutableIntStateOf(0) }
LaunchedEffect(showOutboundBlurOverlay) {
if (showOutboundBlurOverlay) {
outboundOverlayAlpha.snapTo(1f)
} else {
if (fullBitmap != null) {
didRevealFull = true
fullRevealAlpha.snapTo(1f)
thumbBlurAlpha.snapTo(0f)
}
outboundOverlayAlpha.animateTo(0f, tween(BLUR_FADE_MS, easing = FastOutSlowInEasing))
}
}
val thumbnailBytes = remember(thumbnailBase64, hasDiskCache) {
if (hasDiskCache) null else thumbnailBase64?.let { decodeAttachmentThumbnailBase64(it) }
}
val thumbBitmap by produceState(
initialValue = if (hasDiskCache) null else LocalDecodedImageCache.peekThumb(decryptCacheKey),
decryptCacheKey,
thumbnailBytes,
decodeSize,
hasDiskCache,
) {
if (hasDiskCache || thumbnailBytes == null) {
value = null
return@produceState
}
value = LocalDecodedImageCache.peekThumb(decryptCacheKey)
?: withContext(Dispatchers.Default) {
LocalDecodedImageCache.loadThumb(decryptCacheKey, thumbnailBytes, decodeSize)
}
}
var decryptFinished by remember(decryptCacheKey) {
mutableStateOf(fullBitmap != null)
}
val imageContentScale = ContentScale.Fit
val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f)
@@ -445,6 +449,26 @@ private fun ChatImageTileContent(
"diskCache" to (diskCacheUri?.take(48) ?: "null"),
"target" to "${decodeSize.widthPx}x${decodeSize.heightPx}",
)
// Already showing a decoded preview — never flash failed/loading by re-fetching.
val cachedBitmap = fullBitmap ?: peekDecodedAttachmentBitmap(decodeCacheKeys)
if (cachedBitmap != null && (isOutboundPending || isUploading || awaitingServerAck ||
isAuthor || hasLocalSource)
) {
if (fullBitmap == null) {
fullBitmap = cachedBitmap
}
decryptFinished = true
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
onFullyLoaded(true)
AttachmentMediaLog.send(
"tile_keep_local",
"key" to decryptCacheKey,
"msgId" to messageId,
"pending" to isOutboundPending,
"uploading" to isUploading,
)
return@LaunchedEffect
}
val diskUri = diskCacheUri
?: DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.also { cachedPath = it }
val localPaths = buildList {
@@ -452,13 +476,14 @@ private fun ChatImageTileContent(
diskUri?.let { cached -> if (none { it == cached }) add(cached) }
}
if (localPaths.isNotEmpty()) {
val quickDecode = previewSeedDecodeSize(aspectRatio)
val loaded = withContext(Dispatchers.Default) {
LocalDecodedImageCache.peekFull(decryptCacheKey)
peekDecodedAttachmentBitmap(decodeCacheKeys)
?: localPaths.firstNotNullOfOrNull { path ->
LocalDecodedImageCache.loadFull(
decryptCacheKey,
path.removePrefix("file://"),
decodeSize,
quickDecode,
)
}
}
@@ -472,26 +497,67 @@ private fun ChatImageTileContent(
fullBitmap = loaded
cachedPath = diskUri ?: localPaths.firstOrNull()
decryptFinished = true
didRevealFull = true
fullRevealAlpha.snapTo(1f)
thumbBlurAlpha.snapTo(0f)
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
onFullyLoaded(true)
// Own sends with a local preview never need a network round-trip for display.
if (isOutboundPending || isAuthor) return@LaunchedEffect
} else {
AttachmentMediaLog.tileLoad(
"load_local_miss",
"key" to decryptCacheKey,
"paths" to localPaths.size,
)
}
}
// Own sends: never fall through to network while a local/disk preview exists (or is expected).
if (isOutboundPending || isUploading || awaitingServerAck || isAuthor) {
if (fullBitmap == null && localPaths.isEmpty()) {
DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri ->
cachedPath = uri
val loaded = withContext(Dispatchers.Default) {
LocalDecodedImageCache.loadFull(
decryptCacheKey,
uri.removePrefix("file://"),
decodeSize,
)
}
if (loaded != null) {
fullBitmap = loaded
decryptFinished = true
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
onFullyLoaded(true)
AttachmentMediaLog.send(
"tile_cache_hit",
"key" to decryptCacheKey,
"msgId" to messageId,
)
return@LaunchedEffect
}
}
}
if (fullBitmap != null || placeholderBitmap != null || localPaths.isNotEmpty() ||
cachedPath != null
) {
decryptFinished = true
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
onFullyLoaded(true)
return@LaunchedEffect
}
AttachmentMediaLog.tileLoad(
"load_local_miss",
"key" to decryptCacheKey,
"paths" to localPaths.size,
)
if (isOutboundPending || isUploading || awaitingServerAck || localUri != null) {
decryptFinished = placeholderBitmap != null || thumbnailBytes != null
AttachmentMediaLog.send(
"tile_outbound_wait",
"key" to decryptCacheKey,
"msgId" to messageId,
"local" to (localUri?.take(32) ?: "null"),
"failed" to decryptFailed,
)
return@LaunchedEffect
}
}
if (isOutboundPending) {
decryptFinished = localPaths.isEmpty() && thumbBitmap == null && thumbnailBytes == null
if (fullBitmap != null || thumbBitmap != null) onFullyLoaded(true)
return@LaunchedEffect
}
if (serverFile == null || envelope == null) {
if (serverFile == null) {
decryptFinished = true
AttachmentMediaLog.tileLoad("load_skip_no_envelope", "key" to decryptCacheKey)
AttachmentMediaLog.tileLoad("load_skip_no_file", "key" to decryptCacheKey)
return@LaunchedEffect
}
if (diskUri != null) {
@@ -510,9 +576,6 @@ private fun ChatImageTileContent(
)
fullBitmap = loaded
decryptFinished = true
didRevealFull = true
fullRevealAlpha.snapTo(1f)
thumbBlurAlpha.snapTo(0f)
onFullyLoaded(true)
return@LaunchedEffect
}
@@ -522,22 +585,33 @@ private fun ChatImageTileContent(
"uri" to diskUri,
)
}
val usePlainDownload = envelope == null
AttachmentMediaLog.tileLoad(
"load_network_decrypt",
if (usePlainDownload) "load_network_plain" else "load_network_decrypt",
"key" to decryptCacheKey,
"file" to serverFile.path,
)
isAwaitingNetworkFull = true
val uri = try {
DecryptedImageCache.getOrDecrypt(
messageId = messageId,
fileIndex = fileIndex,
file = serverFile,
envelope = envelope,
currentUserId = currentUserId,
clientMessageId = cacheClientId,
messageLabel = messageLabel,
)
if (usePlainDownload) {
DecryptedImageCache.getOrDownloadPlain(
messageId = messageId,
fileIndex = fileIndex,
file = serverFile,
clientMessageId = cacheClientId,
messageLabel = messageLabel,
)
} else {
DecryptedImageCache.getOrDecrypt(
messageId = messageId,
fileIndex = fileIndex,
file = serverFile,
envelope = envelope,
currentUserId = currentUserId,
clientMessageId = cacheClientId,
messageLabel = messageLabel,
)
}
} catch (error: Throwable) {
AttachmentMediaLog.tileLoad(
"load_exception",
@@ -566,9 +640,6 @@ private fun ChatImageTileContent(
)
if (fullBitmap != null) {
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
didRevealFull = true
fullRevealAlpha.snapTo(1f)
thumbBlurAlpha.snapTo(0f)
onFullyLoaded(true)
}
}
@@ -596,53 +667,39 @@ private fun ChatImageTileContent(
}
}
LaunchedEffect(fullBitmap, thumbBitmap, hadInstantFull, hasDiskCache) {
when {
fullBitmap == null -> {
if (hasDiskCache) {
thumbBlurAlpha.snapTo(0f)
return@LaunchedEffect
}
didRevealFull = false
fullRevealAlpha.snapTo(0f)
if (thumbBitmap != null) thumbBlurAlpha.snapTo(1f)
}
didRevealFull -> return@LaunchedEffect
hadInstantFull || hasDiskCache -> {
didRevealFull = true
fullRevealAlpha.snapTo(1f)
thumbBlurAlpha.snapTo(0f)
}
thumbBitmap != null -> {
didRevealFull = true
thumbBlurAlpha.snapTo(1f)
fullRevealAlpha.snapTo(0f)
coroutineScope {
launch {
thumbBlurAlpha.animateTo(0f, tween(BLUR_FADE_MS, easing = FastOutSlowInEasing))
}
launch {
fullRevealAlpha.animateTo(1f, tween(BLUR_FADE_MS, easing = FastOutSlowInEasing))
}
}
}
else -> {
didRevealFull = true
fullRevealAlpha.snapTo(1f)
thumbBlurAlpha.snapTo(0f)
}
LaunchedEffect(placeholderBitmap) {
if (placeholderBitmap != null) decryptFinished = true
}
LaunchedEffect(treatAsOutbound, messageId, fileIndex, cacheClientId) {
if (treatAsOutbound) {
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
}
}
val isDownloadingFullImage = !isOutboundPending && fullBitmap == null &&
(downloadProgress != null || isAwaitingNetworkFull)
val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay && !downloadCancelled
val showDownloadCancelledOverlay = downloadCancelled && fullBitmap == null && !isOutboundPending
val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !isOutboundPending && !downloadCancelled
val showSpinnerOnly = fullBitmap == null && thumbBitmap == null && !hasLocalSource &&
LaunchedEffect(hasLocalSource, hasDiskCache, fullBitmap, placeholderBitmap, isAuthor, messageId, fileIndex, cacheClientId) {
if (isAuthor && (hasLocalSource || hasDiskCache || fullBitmap != null || placeholderBitmap != null)) {
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
}
}
val suppressNetworkChrome = treatAsOutbound || hasLocalSource || hasDiskCache ||
fullBitmap != null || placeholderBitmap != null || (isAuthor && localUri != null)
val isDownloadingFullImage = !treatAsOutbound && fullBitmap == null &&
(downloadProgress != null || isAwaitingNetworkFull) && !suppressNetworkChrome
val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay &&
!downloadCancelled && !hasLocalSource
val showDownloadCancelledOverlay = downloadCancelled && fullBitmap == null && !treatAsOutbound &&
!suppressNetworkChrome
val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !treatAsOutbound &&
!downloadCancelled && !suppressNetworkChrome
val showSpinnerOnly = fullBitmap == null && placeholderBitmap == null && !hasLocalSource &&
!suppressNetworkChrome &&
!showLoadFailedOverlay &&
!showOutboundBlurOverlay &&
(isDownloadingFullImage || (!decryptFinished && !isOutboundPending))
!showDownloadProgressOverlay &&
!showDownloadCancelledOverlay &&
(!decryptFinished && !treatAsOutbound)
Box(
modifier = Modifier
@@ -660,52 +717,52 @@ private fun ChatImageTileContent(
) {
when {
else -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(tilePlaceholderColor),
)
if (fullBitmap == null && hasLocalSource && !showOutboundBlurOverlay) {
AsyncImage(
model = displayLocalUri,
contentDescription = null,
contentScale = imageContentScale,
modifier = Modifier.fillMaxSize(),
)
}
thumbBitmap?.let { thumb ->
Box(
modifier = Modifier
.fillMaxSize()
.then(
if (thumbBlurAlpha.value > 0.01f) {
Modifier.hazeEffect(style = HazeMaterials.thin())
} else {
Modifier
},
),
) {
CachedAttachmentImage(
bitmap = thumb,
contentDescription = serverFile?.name,
contentScale = imageContentScale,
modifier = Modifier
.fillMaxSize()
.alpha(thumbBlurAlpha.value.coerceIn(0f, 1f)),
)
AnimatedContent(
targetState = tileVisual,
modifier = Modifier.fillMaxSize(),
transitionSpec = {
fadeIn(tween(BLUR_FADE_MS, easing = FastOutSlowInEasing))
.togetherWith(fadeOut(tween(BLUR_FADE_MS, easing = FastOutSlowInEasing)))
},
label = "attachment_tile_image",
) { visual ->
when (visual) {
AttachmentTileVisual.Empty -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(tilePlaceholderColor),
)
}
AttachmentTileVisual.Thumb -> {
placeholderBitmap?.let { thumb ->
Box(
modifier = Modifier
.fillMaxSize()
.hazeEffect(style = HazeMaterials.thin()),
) {
CachedAttachmentImage(
bitmap = thumb,
contentDescription = serverFile?.name,
contentScale = imageContentScale,
modifier = Modifier.fillMaxSize(),
)
}
}
}
AttachmentTileVisual.Full -> {
fullBitmap?.let { full ->
CachedAttachmentImage(
bitmap = full,
contentDescription = serverFile?.name,
contentScale = imageContentScale,
modifier = Modifier.fillMaxSize(),
)
LaunchedEffect(full) { onFullyLoaded(true) }
}
}
}
}
fullBitmap?.let { full ->
CachedAttachmentImage(
bitmap = full,
contentDescription = serverFile?.name,
contentScale = imageContentScale,
modifier = Modifier
.fillMaxSize()
.alpha(fullRevealAlpha.value.coerceIn(0f, 1f)),
)
LaunchedEffect(full) { onFullyLoaded(true) }
}
AnimatedVisibility(
visible = showDownloadProgressOverlay,
enter = scaleIn(
@@ -766,7 +823,7 @@ private fun ChatImageTileContent(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
IndefiniteCircularProgress(modifier = Modifier.size(28.dp))
ExpressiveUploadIndicator(uploadProgress = null, modifier = Modifier.size(28.dp))
}
}
if (showLoadFailedOverlay) {
@@ -785,8 +842,12 @@ private fun ChatImageTileContent(
}
if (showOutboundBlurOverlay && outboundOverlayAlpha.value > 0.01f) {
UploadingImageOverlay(
model = localUri ?: displayLocalUri!!,
uploadProgress = if (isUploading || awaitingServerAck) uploadProgress else null,
model = localUri ?: displayLocalUri,
previewBitmap = fullBitmap,
uploadProgress = when {
isUploading || awaitingServerAck -> uploadProgress ?: 0
else -> null
},
clipShape = clipShape,
contentScale = imageContentScale,
onCancelUpload = if (isUploading && !awaitingServerAck) onCancelUpload else null,
@@ -795,7 +856,7 @@ private fun ChatImageTileContent(
.alpha(outboundOverlayAlpha.value),
)
}
if (isOutboundPending && !uploadError.isNullOrBlank() && onRetryUpload != null) {
if (treatAsOutbound && !uploadError.isNullOrBlank() && onRetryUpload != null) {
AttachmentUploadFailedOverlay(
isAuthor = isAuthor,
errorKey = uploadError,
@@ -849,7 +910,8 @@ private fun DownloadCancelledImageOverlay(
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun UploadingImageOverlay(
model: String,
model: String?,
previewBitmap: ImageBitmap?,
uploadProgress: Int?,
clipShape: RoundedCornerShape,
contentScale: ContentScale = ContentScale.Fit,
@@ -863,18 +925,35 @@ private fun UploadingImageOverlay(
.clip(clipShape)
.hazeEffect(style = HazeMaterials.thin())
) {
AsyncImage(
model = model,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = contentScale,
)
when {
previewBitmap != null -> {
CachedAttachmentImage(
bitmap = previewBitmap,
contentDescription = null,
contentScale = contentScale,
modifier = Modifier.fillMaxSize(),
)
}
!model.isNullOrBlank() -> {
AsyncImage(
model = model,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = contentScale,
)
}
else -> {
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
)
}
}
}
Box(
modifier = Modifier
.matchParentSize()
.padding(16.dp),
contentAlignment = Alignment.Center
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (onCancelUpload != null) {
CancellableAttachmentProgressIndicator(
@@ -903,8 +982,10 @@ internal fun ExpressiveUploadIndicator(
) {
// Latch first percent so we do not swap indeterminate ↔ determinate indicators (that resets animation).
var latchedPercent by remember { mutableStateOf<Int?>(null) }
if (uploadProgress != null) {
latchedPercent = uploadProgress.coerceIn(0, 100)
LaunchedEffect(uploadProgress) {
if (uploadProgress != null) {
latchedPercent = uploadProgress.coerceIn(0, 100)
}
}
val clampedProgress = latchedPercent
val indeterminate = clampedProgress == null
@@ -930,9 +1011,12 @@ internal fun ExpressiveUploadIndicator(
val defaultIndicatorAmplitude = WavyProgressIndicatorDefaults.indicatorAmplitude
Box(modifier = modifier, contentAlignment = Alignment.Center) {
val indicatorModifier = Modifier
.fillMaxHeight()
.aspectRatio(1f)
if (indeterminate) {
CircularWavyProgressIndicator(
modifier = Modifier.fillMaxSize(),
modifier = indicatorModifier,
color = primary,
trackColor = trackColor,
amplitude = animatedWaveStrength
@@ -940,7 +1024,7 @@ internal fun ExpressiveUploadIndicator(
} else {
CircularWavyProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.fillMaxSize(),
modifier = indicatorModifier,
color = primary,
trackColor = trackColor,
amplitude = { p -> animatedWaveStrength * defaultIndicatorAmplitude(p) }
@@ -984,6 +1068,7 @@ private fun PendingImageContent(
if (uploading) {
UploadingImageOverlay(
model = uri,
previewBitmap = null,
uploadProgress = uploadProgress,
clipShape = attachmentImageCornerShape(isAuthor = false),
modifier = Modifier.matchParentSize(),
@@ -999,14 +1084,10 @@ private fun PendingImageContent(
contentAlignment = Alignment.Center
) {
if (isUploading) {
if (uploadProgress != null) {
DeterminateCircularProgress(
progress = uploadProgress,
modifier = Modifier.size(40.dp)
)
} else {
IndefiniteCircularProgress(modifier = Modifier.size(40.dp))
}
ExpressiveUploadIndicator(
uploadProgress = uploadProgress,
modifier = Modifier.size(40.dp),
)
} else {
Icon(
imageVector = Icons.Rounded.AttachFile,
@@ -1019,34 +1100,6 @@ private fun PendingImageContent(
}
}
@Composable
private fun IndefiniteCircularProgress(modifier: Modifier = Modifier) {
CircularProgressIndicator(
modifier = modifier,
strokeWidth = 3.dp
)
}
@Composable
private fun DeterminateCircularProgress(
progress: Int,
modifier: Modifier = Modifier
) {
val animatedProgress by animateFloatAsState(
targetValue = (progress.coerceIn(0, 100) / 100f),
animationSpec = tween(
durationMillis = 250,
easing = FastOutSlowInEasing
),
label = "uploadProgress"
)
CircularProgressIndicator(
progress = { animatedProgress },
modifier = modifier,
strokeWidth = 3.dp
)
}
@Composable
internal fun CachedAttachmentImage(
bitmap: ImageBitmap,
@@ -239,7 +239,7 @@ fun ChatInput(
onClearReply: () -> Unit,
onClearEdit: () -> Unit,
hazeState: HazeState,
recipientId: Int? = null,
supportsAttachments: Boolean = false,
currentUserId: Int? = null,
isReadOnly: Boolean = false,
onReadOnlyMessageClick: () -> Unit = {},
@@ -477,7 +477,7 @@ fun ChatInput(
},
)
if (recipientId != null) {
if (supportsAttachments) {
Box(
modifier = Modifier
.padding(vertical = ChatInputIconSlotVerticalInset)
@@ -9,15 +9,19 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.api.local.messages.generateClientMessageId
import ru.fromchat.api.local.messages.nowMessageTimestampIso
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.publicchat.resolvePublicAttachmentLayout
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.Logger
import ru.fromchat.api.local.send.clearOutboundFileCaches
import ru.fromchat.api.local.send.clearOutboundImageCaches
import ru.fromchat.api.local.db.isPlaceholderAttachmentAspectRatio
import ru.fromchat.api.local.db.isPlaceholderAttachmentDimensions
import ru.fromchat.ui.chat.utils.TypingHandler
import ru.fromchat.ui.chat.utils.TypingUser
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
@@ -43,7 +47,9 @@ data class ChatPanelState(
/** Public chat: registered user count; null until first successful load or WS update. */
val publicGroupMemberCount: Int? = null,
/** Public chat: true until first count response (HTTP or WebSocket). */
val publicGroupMetaLoading: Boolean = false
val publicGroupMetaLoading: Boolean = false,
/** Dissolve keys ([messageDissolveKey]) currently playing the Thanos delete/cancel animation. */
val dissolvingMessageKeys: Set<String> = emptySet(),
)
@Serializable
@@ -242,11 +248,7 @@ abstract class ChatPanel(
open suspend fun cancelQueuedMessage(message: Message) {
val cid = message.client_message_id?.trim().orEmpty()
if (cid.isEmpty()) return
if (message.pendingFileUri != null) {
clearOutboundImageCaches(cid, message.id)
clearOutboundFileCaches(cid, message.id)
}
removeMessage(message.id)
if (!beginMessageDissolve(message)) return
OutgoingMessageCoordinator.cancelOutboundMessage(cid, outboxConversationId())
}
@@ -259,6 +261,59 @@ abstract class ChatPanel(
cancelQueuedMessage(message)
}
/**
* Starts fade+collapse exit animation. Returns false if already exiting.
* [finishDissolveAnimation] removes the row when the animation completes (or on timeout).
*/
fun beginMessageDissolve(message: Message): Boolean {
val key = messageDissolveKey(message)
if (key in _state.dissolvingMessageKeys) return false
if (_state.messages.none { messageDissolveKey(it) == key }) return false
updateState { state ->
state.copy(dissolvingMessageKeys = state.dissolvingMessageKeys + key)
}
// Off-screen / missed capture: still remove after the dissolve window.
scope.launch {
delay(messageExitDurationMs().toLong())
if (key in _state.dissolvingMessageKeys) {
finishDissolveAnimation(key)
}
}
return true
}
/** Called when the exit animation completes (or as a timeout fallback). */
fun finishDissolveAnimation(dissolveKey: String) {
val key = dissolveKey.trim()
if (key.isEmpty()) return
val message = _state.messages.find { messageDissolveKey(it) == key }
updateState { state ->
state.copy(
messages = state.messages.filter { messageDissolveKey(it) != key },
dissolvingMessageKeys = state.dissolvingMessageKeys - key,
)
}
if (message == null) return
if (message.id > 0) clearReplyReferencesTo(message.id)
scope.launch(Dispatchers.Default) {
val cid = message.client_message_id?.trim().orEmpty()
if (message.pendingFileUri != null && cid.isNotEmpty()) {
clearOutboundImageCaches(cid, message.id)
clearOutboundFileCaches(cid, message.id)
}
if (message.id < 0 || !message.pendingFileUri.isNullOrBlank()) {
runCatching { removeOptimisticFromCache(message) }
}
}
}
/** @deprecated Use [finishDissolveAnimation]. */
fun finishExitAnimation(clientMessageId: String) {
val cid = clientMessageId.trim()
if (cid.isEmpty()) return
finishDissolveAnimation("c:$cid")
}
/**
* Remove message from list
*/
@@ -329,11 +384,20 @@ abstract class ChatPanel(
}
/**
* Handle message confirmation (replace temp message with confirmed)
* Handle message confirmation (replace temp message with confirmed).
* Preserves local attachment preview fields so the tile does not flash failed/loading.
*/
fun handleMessageConfirmed(tempId: String, confirmedMessage: Message) {
val pending = pendingMessages.remove(tempId)
pending?.first?.cancel()
AttachmentMediaLog.send(
"confirm_start",
"job" to tempId.take(12),
"realId" to confirmedMessage.id,
"files" to (confirmedMessage.files?.size ?: 0),
"pendingUri" to (_state.messages.find { it.client_message_id == tempId }
?.pendingFileUri?.take(48) ?: "null"),
)
updateState { currentState ->
val optimistic = currentState.messages.find { it.client_message_id == tempId }
@@ -343,12 +407,32 @@ abstract class ChatPanel(
} else {
confirmedMessage
}
val resolvedConfirmed = if (withClientId.reply_to == null) {
val withReply = if (withClientId.reply_to == null) {
val reply = optimistic?.reply_to
if (reply != null) withClientId.copy(reply_to = reply) else withClientId
} else {
withClientId
}
val resolvedConfirmed = mergeConfirmedAttachmentUi(optimistic, withReply)
AttachmentMediaLog.send(
"confirm_merged",
"job" to tempId.take(12),
"realId" to resolvedConfirmed.id,
"keepPreview" to (resolvedConfirmed.pendingFileUri?.take(48) ?: "null"),
"thumbs" to (resolvedConfirmed.fileThumbnails?.size ?: 0),
)
AttachmentMediaLog.aspect(
"confirm_merged",
"job" to tempId.take(12),
"realId" to resolvedConfirmed.id,
"pairs" to resolvedConfirmed.fileAspectRatioPairs?.firstOrNull(),
"dims" to resolvedConfirmed.fileDimensions?.firstOrNull(),
"ratios" to resolvedConfirmed.fileAspectRatios?.firstOrNull(),
"pendingAspect" to resolvedConfirmed.pendingFileAspectRatio,
"optAspect" to optimistic?.pendingFileAspectRatio,
"optDims" to optimistic?.fileDimensions?.firstOrNull(),
"serverPairs" to withReply.fileAspectRatioPairs?.firstOrNull(),
)
val withoutDupReal = if (resolvedConfirmed.id > 0) {
currentState.messages.filter { it.id != resolvedConfirmed.id }
} else {
@@ -369,10 +453,122 @@ abstract class ChatPanel(
)
}
scope.launch(Dispatchers.Default) {
val resolved = _state.messages.find { it.client_message_id == tempId }
?: _state.messages.find { it.id == confirmedMessage.id }
val toPersist = resolved ?: confirmedMessage
val optimistic = pending?.second
?: _state.messages.find { it.client_message_id == tempId }
val resolvedForSeed = _state.messages.find { it.client_message_id == tempId }
?: confirmedMessage
seedConfirmedAttachmentCaches(tempId, optimistic, resolvedForSeed)
AttachmentMediaLog.send(
"confirm_seeded",
"job" to tempId.take(12),
"realId" to resolvedForSeed.id,
)
val toPersist = resolvedForSeed
runCatching { onOptimisticMessageConfirmed(tempId, toPersist) }
AttachmentMediaLog.send(
"confirm_done",
"job" to tempId.take(12),
"realId" to confirmedMessage.id,
)
}
}
/**
* Keep the outbound local preview on the confirmed row (DM + public) so confirm is a
* blur-fade, not a failed/loading flash.
*/
protected fun mergeConfirmedAttachmentUi(optimistic: Message?, confirmed: Message): Message {
if (optimistic == null) return confirmed
val hasFiles = !confirmed.files.isNullOrEmpty()
if (!hasFiles && optimistic.pendingFileUri == null) return confirmed
val localPreview = optimistic.pendingFileUri
val primaryName = confirmed.files?.firstOrNull()?.name
?: optimistic.pendingFilename
?: localPreview?.substringAfterLast('/')?.substringBefore('?')
val isImage = primaryName != null && isImageFilename(primaryName)
val serverDim = confirmed.fileDimensions?.firstOrNull()
?: confirmed.fileAspectRatioPairs?.firstOrNull()?.takeIf { it.size >= 2 }?.let { (w, h) -> w to h }
val serverRatio = confirmed.fileAspectRatios?.firstOrNull()
val serverAspect = serverRatio
?: serverDim?.let { (w, h) -> if (h > 0) w.toFloat() / h.toFloat() else null }
val serverPairList = confirmed.fileAspectRatioPairs?.firstOrNull()
val serverHasRealDims = serverPairList?.let { pair ->
pair.size >= 2 && !isPlaceholderAttachmentDimensions(pair[0], pair[1])
} == true || serverDim?.let { (w, h) ->
!isPlaceholderAttachmentDimensions(w, h)
} == true
val keepLocalAspect = isImage && !serverHasRealDims && (
optimistic.pendingFileAspectRatio != null ||
optimistic.fileDimensions?.firstOrNull()?.let { (w, h) ->
!isPlaceholderAttachmentDimensions(w, h)
} == true
)
val merged = confirmed.copy(
pendingFileUri = when {
isImage -> localPreview
else -> null
},
pendingFilename = null,
pendingFileAspectRatio = when {
keepLocalAspect -> optimistic.pendingFileAspectRatio ?: serverAspect
isImage -> null
else -> null
},
fileAspectRatios = when {
keepLocalAspect -> optimistic.pendingFileAspectRatio?.let { listOf(it) }
?: optimistic.fileAspectRatios
?: confirmed.fileAspectRatios
else -> confirmed.fileAspectRatios
?: optimistic.pendingFileAspectRatio?.takeIf { !serverHasRealDims }?.let { listOf(it) }
?: optimistic.fileAspectRatios
},
fileDimensions = when {
keepLocalAspect -> optimistic.fileDimensions ?: confirmed.fileDimensions
else -> confirmed.fileDimensions ?: optimistic.fileDimensions
},
fileSizes = confirmed.fileSizes ?: optimistic.fileSizes,
fileThumbnails = confirmed.fileThumbnails ?: optimistic.fileThumbnails,
uploadJobId = null,
uploadProgress = null,
uploadError = null,
)
return if (hasFiles) merged.resolvePublicAttachmentLayout() else merged
}
protected open suspend fun seedConfirmedAttachmentCaches(
clientMessageId: String,
optimistic: Message?,
confirmed: Message,
) {
val cid = clientMessageId.trim()
if (cid.isEmpty() || confirmed.id <= 0) return
val localUri = optimistic?.pendingFileUri?.trim()?.takeIf { it.isNotEmpty() } ?: return
val file = confirmed.files?.firstOrNull() ?: return
if (isImageFilename(file.name)) {
ru.fromchat.api.local.cache.DecryptedImageCache.seedFromLocalFile(
messageId = confirmed.id,
fileIndex = 0,
localFileUri = localUri,
clientMessageId = cid,
)
ru.fromchat.api.local.cache.DecryptedImageCache.ensureDiskAliasForMessageId(
messageId = confirmed.id,
fileIndex = 0,
clientMessageId = cid,
)
} else {
ru.fromchat.api.local.send.seedOutboundFileAsDownloaded(
messageId = confirmed.id,
fileIndex = 0,
localFileUri = localUri,
displayFilename = file.name,
clientMessageId = cid,
)
ru.fromchat.api.local.cache.DecryptedFileCache.ensureDiskAliasForMessageId(
messageId = confirmed.id,
fileIndex = 0,
clientMessageId = cid,
)
}
}
@@ -560,6 +756,10 @@ abstract class ChatPanel(
/** DM recipient ID for attachment uploads; null for non-DM panels. */
open fun getRecipientId(): Int? = null
/** Whether the composer may attach images/files (DM when recipient is known, or public chat). */
open val supportsAttachments: Boolean
get() = getRecipientId() != null
open val showUsernamesInMessages: Boolean
get() = true
@@ -36,6 +36,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
@@ -82,6 +83,7 @@ import ru.fromchat.Logger
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.db.store.ConnectionStatus
@@ -212,15 +214,44 @@ fun ChatScreen(
mutableStateOf(setOf<String>())
}
// Visit-scoped (not saveable): reopen must re-seed history, never replay enter.
var lastAnimatedMessageKeys by remember(panelId) { mutableStateOf(setOf<String>()) }
var knownEnterIdentities by remember(panelId) { mutableStateOf(setOf<String>()) }
val openingMessages = remember(panelId) { panel.getState().messages }
val mountSnapshotKeys = remember(panelId) {
openingMessages.mapTo(mutableSetOf()) { messageListKey(it) }.toSet()
}
val mountSnapshotIdentities = remember(panelId) {
openingMessages.mapTo(mutableSetOf()) { messageEnterIdentity(it) }.toSet()
}
var lastAnimatedMessageKeys by remember(panelId) { mutableStateOf(mountSnapshotKeys) }
var knownEnterIdentities by remember(panelId) { mutableStateOf(mountSnapshotIdentities) }
var enterAnimationsSeeded by remember(panelId) {
mutableStateOf(mountSnapshotKeys.isNotEmpty())
}
// Blocks composition-only enter before the first LaunchedEffect seed on this visit.
var visitEnterSyncComplete by remember(panelId) {
mutableStateOf(mountSnapshotKeys.isNotEmpty())
}
val enterCoordinator = remember(panelId) { MessageEnterCoordinator(scope) }
val activeEnterAnimation by enterCoordinator.currentItem.collectAsState()
val pendingNewMessageKeys by enterCoordinator.pendingNewMessageKeys.collectAsState()
val queuedEnter by enterCoordinator.queuedEnter.collectAsState()
var previousNewestFingerprint by remember(panelId) { mutableStateOf("") }
var previousEnterMessageCount by remember(panelId) { mutableIntStateOf(0) }
var enterAnimationsSeeded by remember(panelId) { mutableStateOf(false) }
LaunchedEffect(panelId) {
val initial = panel.getState().messages
if (initial.isEmpty()) return@LaunchedEffect
val presentKeys = initial.mapTo(mutableSetOf()) { messageListKey(it) }
val presentIdentities = initial.mapTo(mutableSetOf()) { messageEnterIdentity(it) }
lastAnimatedMessageKeys = presentKeys
knownEnterIdentities = presentIdentities
enterAnimationsSeeded = true
visitEnterSyncComplete = true
previousEnterMessageCount = initial.size
val newest = initial.lastOrNull()
if (newest != null) {
previousNewestFingerprint = "${messageListKey(newest)}|${initial.size}"
}
}
LaunchedEffect(panelState.messages) {
val messages = panelState.messages
@@ -255,6 +286,7 @@ fun ChatScreen(
previousNewestFingerprint = fingerprint
previousEnterMessageCount = messages.size
enterAnimationsSeeded = true
visitEnterSyncComplete = true
}
// First non-empty load for this visit: never animate existing history.
@@ -299,6 +331,10 @@ fun ChatScreen(
seedWithoutAnimating("skip_new_not_newest")
return@LaunchedEffect
}
if (newestKey in mountSnapshotKeys || newestIdentity in mountSnapshotIdentities) {
seedWithoutAnimating("skip_opening_snapshot")
return@LaunchedEffect
}
if (newestKey in lastAnimatedMessageKeys || newestIdentity in knownEnterIdentities) {
seedWithoutAnimating("skip_already_known")
return@LaunchedEffect
@@ -658,26 +694,47 @@ fun ChatScreen(
}
LaunchedEffect(panel) {
if (panel.getRecipientId() != null) {
if (panel.supportsAttachments) {
AttachmentUploadNotifier.progressFlow.collect { progress ->
when (progress) {
is AttachmentUploadProgress.Pending ->
is AttachmentUploadProgress.Pending -> {
AttachmentMediaLog.send(
"ui_progress_pending",
"job" to progress.jobId.take(12),
)
panel.updateMessageByClientMessageId(progress.jobId) {
it.copy(uploadProgress = 0, uploadError = null)
}
}
is AttachmentUploadProgress.InProgress ->
is AttachmentUploadProgress.InProgress -> {
AttachmentMediaLog.send(
"ui_progress_apply",
"job" to progress.jobId.take(12),
"pct" to progress.percent,
)
panel.updateMessageByClientMessageId(progress.jobId) {
it.copy(uploadProgress = progress.percent, uploadError = null)
}
}
is AttachmentUploadProgress.Success ->
is AttachmentUploadProgress.Success -> {
AttachmentMediaLog.send(
"ui_progress_success",
"job" to progress.jobId.take(12),
)
panel.updateMessageByClientMessageId(progress.jobId) {
it.copy(uploadProgress = null)
}
}
is AttachmentUploadProgress.Failed -> {
if (progress.error == "Cancelled") return@collect
AttachmentMediaLog.send(
"ui_progress_failed",
"job" to progress.jobId.take(12),
"err" to progress.error,
)
panel.updateMessageByClientMessageId(progress.jobId) {
it.copy(uploadProgress = null, uploadError = progress.error)
}
@@ -756,12 +813,21 @@ fun ChatScreen(
scope.launch {
val replyToId = replyTo?.id?.takeIf { it > 0 }
val recipientId = panel.getRecipientId()
if (attachments.isNotEmpty() && recipientId != null) {
if (attachments.isNotEmpty() && panel.supportsAttachments) {
val plaintext = text.ifBlank { "" }
attachments.forEach { att ->
val jobId = generateClientMessageId()
val tempId = optimisticMessageIdForClientMessageId(jobId)
val isImage = att.isImage
val sendT0 = AttachmentMediaLog.nowMs()
AttachmentMediaLog.send(
"tap_send",
"job" to jobId.take(12),
"image" to isImage,
"file" to att.filename,
"uri" to att.uri.take(64),
"public" to (recipientId == null),
)
scope.launch(Dispatchers.Default) {
val imageDimensions = if (isImage) {
getImageDimensions(att.uri)
@@ -776,6 +842,13 @@ fun ChatScreen(
} else {
null
}
AttachmentMediaLog.send(
"staging_start",
"job" to jobId.take(12),
"dims" to imageDimensions,
"aspect" to aspectRatio,
"elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0),
)
val staged = if (isImage) {
prepareOutboundImageForSend(
clientMessageId = jobId,
@@ -792,9 +865,24 @@ fun ChatScreen(
)
}
if (staged == null) {
AttachmentMediaLog.send(
"staging_failed",
"job" to jobId.take(12),
"elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0),
)
return@launch
}
val fileUri = staged.stagedUri
AttachmentMediaLog.send(
"staging_ok",
"job" to jobId.take(12),
"bytes" to staged.sizeBytes,
"preview" to (staged.previewUri?.take(48) ?: "null"),
"staged" to staged.stagedUri.take(48),
"elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0),
)
val uploadUri = staged.stagedUri
val previewUri = staged.previewUri?.takeIf { it.isNotBlank() }
?: staged.stagedUri
val optimisticMessage = Message(
id = tempId,
user_id = currentUserId ?: -1,
@@ -809,7 +897,7 @@ fun ChatScreen(
client_message_id = jobId,
reactions = null,
files = null,
pendingFileUri = fileUri,
pendingFileUri = previewUri,
pendingFilename = att.filename,
uploadJobId = jobId,
uploadProgress = 0,
@@ -819,6 +907,13 @@ fun ChatScreen(
)
withContext(Dispatchers.Main) {
panel.addMessage(optimisticMessage)
AttachmentMediaLog.send(
"optimistic_added",
"job" to jobId.take(12),
"tempId" to tempId,
"pendingUri" to previewUri.take(48),
"elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0),
)
}
AttachmentUploadNotifier.emit(
AttachmentUploadProgress.InProgress(
@@ -828,16 +923,45 @@ fun ChatScreen(
),
messageLabel = plaintext,
)
OutgoingMessageCoordinator.enqueueDmAttachment(
recipientId = recipientId,
plaintext = plaintext,
clientMessageId = jobId,
replyToId = replyToId,
fileUri = fileUri,
filename = att.filename,
optimisticMessage = optimisticMessage,
aspectRatio = staged.aspectRatio ?: aspectRatio,
fileSizeBytes = staged.sizeBytes,
if (recipientId != null) {
AttachmentMediaLog.send(
"enqueue_dm",
"job" to jobId.take(12),
"peer" to recipientId,
"elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0),
)
OutgoingMessageCoordinator.enqueueDmAttachment(
recipientId = recipientId,
plaintext = plaintext,
clientMessageId = jobId,
replyToId = replyToId,
fileUri = uploadUri,
filename = att.filename,
optimisticMessage = optimisticMessage,
aspectRatio = staged.aspectRatio ?: aspectRatio,
fileSizeBytes = staged.sizeBytes,
)
} else {
AttachmentMediaLog.send(
"enqueue_public",
"job" to jobId.take(12),
"elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0),
)
OutgoingMessageCoordinator.enqueuePublicAttachment(
content = plaintext,
clientMessageId = jobId,
replyToId = replyToId,
fileUri = uploadUri,
filename = att.filename,
optimisticMessage = optimisticMessage,
aspectRatio = staged.aspectRatio ?: aspectRatio,
fileSizeBytes = staged.sizeBytes,
)
}
AttachmentMediaLog.send(
"enqueue_done",
"job" to jobId.take(12),
"elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0),
)
}
}
@@ -859,7 +983,7 @@ fun ChatScreen(
inputText = ""
},
hazeState = hazeState,
recipientId = panel.getRecipientId(),
supportsAttachments = panel.supportsAttachments,
isReadOnly = isReadOnly,
onReadOnlyMessageClick = {
if (isReadOnly) {
@@ -948,10 +1072,16 @@ fun ChatScreen(
// Keep the newest bubble as NewMessage before enqueue runs.
val newest = panelState.messages.lastOrNull()
val newestListKey = newest?.let { messageListKey(it) }
val newestEnterIdentity = newest?.let { messageEnterIdentity(it) }
val compositionPendingNewest =
enterAnimationsSeeded &&
visitEnterSyncComplete &&
enterAnimationsSeeded &&
newestListKey != null &&
newestEnterIdentity != null &&
newestListKey !in mountSnapshotKeys &&
newestEnterIdentity !in mountSnapshotIdentities &&
newestListKey !in lastAnimatedMessageKeys &&
newestEnterIdentity !in knownEnterIdentities &&
newestListKey !in pendingNewMessageKeys &&
activeEnterAnimation?.newMessageKey != newestListKey
val pendingKeysForRole =
@@ -1041,7 +1171,16 @@ fun ChatScreen(
}
}
},
enterAnimationRole = enterRole,
enterAnimationRole = if (!visitEnterSyncComplete) {
EnterAnimationRole.None
} else {
enterRole
},
isExiting = messageDissolveKey(message) in
panelState.dissolvingMessageKeys,
onExitAnimationFinished = {
panel.finishDissolveAnimation(messageDissolveKey(message))
},
modifier = Modifier.padding(top = item.spacingAbove),
isContextMenuOpen = contextMenuState.isOpen,
isContextMenuForThisMessage =
@@ -80,7 +80,10 @@ import ru.fromchat.api.local.messages.formatMessageDateTimeLocal
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.back
import ru.fromchat.more
import ru.fromchat.api.local.send.previewSeedDecodeSize
import ru.fromchat.ui.chat.utils.attachmentDecodeCacheKeys
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap
import ru.fromchat.ui.components.BackHandler
import ru.fromchat.ui.components.Text
import kotlin.math.abs
@@ -124,29 +127,39 @@ fun ImageFullscreenPreview(
val envelope = message.dmEnvelope
val cacheClientId = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
val decryptCacheKey = remember(message.id, fileIndex, cacheClientId) {
DecryptedImageCache.storageKey(message.id, fileIndex, cacheClientId)
val decodeCacheKeys = remember(message.id, fileIndex, cacheClientId) {
attachmentDecodeCacheKeys(message.id, fileIndex, cacheClientId)
}
val fsCacheKey = remember(decryptCacheKey) {
val bitmapStateKey = remember(decodeCacheKeys) { decodeCacheKeys.joinToString("|") }
val decryptCacheKey = decodeCacheKeys.first()
val fsCacheKey = remember(bitmapStateKey) {
LocalDecodedImageCache.fullscreenCacheKey(decryptCacheKey)
}
var cachedPath by remember(decryptCacheKey) {
var cachedPath by remember(bitmapStateKey) {
mutableStateOf(DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId))
}
var previewBitmap by remember(decryptCacheKey) {
mutableStateOf(LocalDecodedImageCache.peekFull(decryptCacheKey))
var previewBitmap by remember(bitmapStateKey) {
mutableStateOf(peekDecodedAttachmentBitmap(decodeCacheKeys))
}
var fullscreenBitmap by remember(fsCacheKey) {
mutableStateOf(LocalDecodedImageCache.peekFullscreen(decryptCacheKey))
var fullscreenBitmap by remember(bitmapStateKey) {
mutableStateOf(
decodeCacheKeys.firstNotNullOfOrNull { key ->
LocalDecodedImageCache.peekFullscreen(key)
},
)
}
val hasInstantBitmap = previewBitmap != null || fullscreenBitmap != null
val openAspectFromBitmap = previewBitmap?.let { it.width.toFloat() / it.height.toFloat() }
val openAspectFromBitmap = (fullscreenBitmap ?: previewBitmap)?.let {
it.width.toFloat() / it.height.toFloat()
}
val layoutAspectHint = imageAspectRatioForMessage(
fileAspectRatios = message.fileAspectRatios,
fileDimensions = message.fileDimensions,
pendingFileAspectRatio = message.pendingFileAspectRatio,
fileAspectRatioPairs = message.fileAspectRatioPairs,
fileIndex = fileIndex,
confirmed = message.id > 0,
hasLocalPreview = !message.pendingFileUri.isNullOrBlank() || cachedPath != null,
)
var animationBounds by remember(decryptCacheKey) { mutableStateOf<Rect?>(null) }
if (animationBounds == null && thumbnailBounds != null) {
@@ -158,7 +171,7 @@ fun ImageFullscreenPreview(
var menusVisible by remember { mutableStateOf(true) }
var dismissRequested by remember { mutableStateOf(false) }
val backgroundAlpha = remember { Animatable(1f) }
var hasPlayedOpenAnimation by remember(decryptCacheKey) { mutableStateOf(false) }
var hasPlayedOpenAnimation by remember(bitmapStateKey) { mutableStateOf(false) }
var isOpenAnimationPlaying by remember { mutableStateOf(false) }
var dismissProgress by remember { mutableStateOf(0f) }
val isInitialOpenState = animationBounds != null && !hasPlayedOpenAnimation
@@ -205,18 +218,56 @@ fun ImageFullscreenPreview(
)
}
LaunchedEffect(decryptCacheKey, fsCacheKey, fullscreenDecodeSize) {
val uri = cachedPath ?: DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId)
?: DecryptedImageCache.getOrDecrypt(
messageId = message.id,
fileIndex = fileIndex,
file = file,
envelope = envelope,
currentUserId = currentUserId,
clientMessageId = cacheClientId,
messageLabel = message.content,
).also { cachedPath = it }
LaunchedEffect(bitmapStateKey, fsCacheKey, fullscreenDecodeSize, layoutAspectHint) {
if (previewBitmap == null) {
peekDecodedAttachmentBitmap(decodeCacheKeys)?.let { previewBitmap = it }
}
val existingUri = cachedPath
?: DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId)
if (previewBitmap == null && existingUri != null) {
cachedPath = existingUri
val quick = withContext(Dispatchers.Default) {
LocalDecodedImageCache.loadFull(
decryptCacheKey,
existingUri.removePrefix("file://"),
previewSeedDecodeSize(layoutAspectHint),
)
}
if (quick != null) previewBitmap = quick
}
val uri = cachedPath ?: (
if (envelope != null) {
DecryptedImageCache.getOrDecrypt(
messageId = message.id,
fileIndex = fileIndex,
file = file,
envelope = envelope,
currentUserId = currentUserId,
clientMessageId = cacheClientId,
messageLabel = message.content,
)
} else {
DecryptedImageCache.getOrDownloadPlain(
messageId = message.id,
fileIndex = fileIndex,
file = file,
clientMessageId = cacheClientId,
messageLabel = message.content,
)
}
)?.also { cachedPath = it }
if (uri == null) return@LaunchedEffect
if (previewBitmap == null) {
val tilePreview = withContext(Dispatchers.Default) {
LocalDecodedImageCache.loadFull(
decryptCacheKey,
uri.removePrefix("file://"),
previewSeedDecodeSize(layoutAspectHint),
)
}
if (tilePreview != null) previewBitmap = tilePreview
}
if (fullscreenBitmap != null) return@LaunchedEffect
val hiRes = withContext(Dispatchers.Default) {
LocalDecodedImageCache.loadFullscreen(decryptCacheKey, uri, fullscreenDecodeSize)
}
@@ -226,17 +277,12 @@ fun ImageFullscreenPreview(
}
val displayBitmap = fullscreenBitmap ?: previewBitmap
val displayAspect = displayBitmap?.let { bmp ->
bmp.width.toFloat() / bmp.height.toFloat()
} ?: message.fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }
val contentHeightAtScale1 = if (displayAspect != null) {
containerWidth / displayAspect
} else {
containerHeight
}
val hasLocalFile = cachedPath != null ||
!message.pendingFileUri.isNullOrBlank() ||
DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId) != null
when {
displayBitmap == null -> {
displayBitmap == null && !hasLocalFile -> {
Box(
modifier = Modifier
.fillMaxSize()
@@ -253,6 +299,9 @@ fun ImageFullscreenPreview(
)
}
else -> {
val displayAspect = displayBitmap?.let { bmp ->
bmp.width.toFloat() / bmp.height.toFloat()
}
val layoutAspect = openAspectFromBitmap
?: thumbLayoutAspect
?: layoutAspectHint
@@ -260,7 +309,7 @@ fun ImageFullscreenPreview(
val layoutContentHeight = if (layoutAspect != null) {
containerWidth / layoutAspect
} else {
contentHeightAtScale1
containerHeight
}
val initial = remember(
animationBounds, containerWidth, containerHeight, layoutContentHeight,
@@ -635,22 +684,26 @@ fun ImageFullscreenPreview(
.then(sharedElementModifier)
.offset { IntOffset(offsetXAnim.value.roundToInt(), offsetYAnim.value.roundToInt()) }
) {
FullscreenBitmapImage(
bitmap = displayBitmap,
contentDescription = file.name,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scaleAnim.value
scaleY = scaleAnim.value
translationX = offsetXAnim.value - offsetXAnim.value.roundToInt()
translationY = offsetYAnim.value - offsetYAnim.value.roundToInt()
val scale = maxOf(scaleAnim.value, 0.01f)
shape = androidx.compose.foundation.shape.RoundedCornerShape((cornerRadiusAnim.value / scale).dp)
clip = true
},
contentScale = ContentScale.Fit
)
displayBitmap?.let { bmp ->
FullscreenBitmapImage(
bitmap = bmp,
contentDescription = file.name,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scaleAnim.value
scaleY = scaleAnim.value
translationX = offsetXAnim.value - offsetXAnim.value.roundToInt()
translationY = offsetYAnim.value - offsetYAnim.value.roundToInt()
val scale = maxOf(scaleAnim.value, 0.01f)
shape = androidx.compose.foundation.shape.RoundedCornerShape(
(cornerRadiusAnim.value / scale).dp,
)
clip = true
},
contentScale = ContentScale.Fit,
)
}
}
}
}
@@ -0,0 +1,17 @@
package ru.fromchat.ui.chat
import ru.fromchat.api.schema.messages.Message
/** Fade-out duration when deleting/cancelling a message row. */
internal const val MessageExitFadeMs = 220
/** Layout height collapse while fading (runs in parallel). */
internal const val MessageExitCollapseMs = 280
internal fun messageExitDurationMs(): Int =
maxOf(MessageExitFadeMs, MessageExitCollapseMs) + 80
internal fun messageDissolveKey(message: Message): String {
val cid = message.client_message_id?.trim().orEmpty()
return if (cid.isNotEmpty()) "c:$cid" else "i:${message.id}"
}
@@ -48,6 +48,7 @@ import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
@@ -59,9 +60,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.roundToInt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Logger
import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.Res
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.store.ProfileCache
@@ -95,10 +98,13 @@ internal fun isFilenameOnlyMessageCaption(message: Message): Boolean {
private fun isMessageCorrupted(message: Message): Boolean {
val files = message.files ?: return false
return files.withIndex().any { (index, file) ->
isImageFilename(file.name) && (
message.dmEnvelope == null ||
if (!isImageFilename(file.name)) return@any false
val isPlainPublic = message.dmEnvelope == null &&
(file.path.contains("/normal/") ||
(file.nonceB64.isNullOrBlank() && file.wrappedMekB64.isNullOrBlank()))
if (isPlainPublic) return@any false
message.dmEnvelope == null ||
message.fileThumbnails?.getOrNull(index)?.isBlank() != false
)
}
}
@@ -154,6 +160,9 @@ fun MessageItem(
showTimestamp: Boolean = true,
onBubbleTap: (() -> Unit)? = null,
enterAnimationRole: EnterAnimationRole = EnterAnimationRole.None,
/** When true, fade out and collapse layout space before removal. */
isExiting: Boolean = false,
onExitAnimationFinished: (() -> Unit)? = null,
) {
val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) {
isMessageCorrupted(message)
@@ -262,7 +271,8 @@ fun MessageItem(
enterScale.snapTo(0f)
}
}
val runEnterAnimation = enterStarted && !enterFinished
val runEnterAnimation = enterStarted && !enterFinished && !isExiting
val shrinkLayoutForEnter = isNewEnterRole && runEnterAnimation
// Single effect: start the spring as soon as this bubble is marked for enter.
LaunchedEffect(enterIdentity, runEnterAnimation) {
if (!runEnterAnimation) return@LaunchedEffect
@@ -285,6 +295,17 @@ fun MessageItem(
"spring_end identity=${enterIdentity.take(12)} role=${enterAnimationRole.name}",
)
}
val exitAlpha = remember(enterIdentity) { Animatable(1f) }
val layoutCollapse = remember(enterIdentity) { Animatable(1f) }
LaunchedEffect(enterIdentity, isExiting) {
if (!isExiting) return@LaunchedEffect
enterFinished = true
coroutineScope {
launch { exitAlpha.animateTo(0f, tween(MessageExitFadeMs)) }
launch { layoutCollapse.animateTo(0f, tween(MessageExitCollapseMs)) }
}
onExitAnimationFinished?.invoke()
}
LaunchedEffect(enterIdentity, enterAnimationRole, showSendingIndicator, sendFailed) {
when (enterAnimationRole) {
EnterAnimationRole.PreviousLast -> {
@@ -295,7 +316,7 @@ fun MessageItem(
}
EnterAnimationRole.NewMessage -> Unit
else -> {
if (!runEnterAnimation && enterScale.value != 1f) {
if (!isExiting && !runEnterAnimation && enterScale.value != 1f) {
enterScale.snapTo(1f)
}
timestampForceAlpha.snapTo(1f)
@@ -398,12 +419,15 @@ fun MessageItem(
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { rowLayoutCoords = it }
.graphicsLayer {
alpha = if (isExiting) exitAlpha.value else 1f
}
.then(rowLongPress)
.padding(horizontal = 8.dp)
.enterLayoutHeight(
scale = enterScale.value,
scale = if (isExiting) layoutCollapse.value else enterScale.value,
minHeightPx = minEnterHeightPx,
active = runEnterAnimation,
active = shrinkLayoutForEnter || isExiting,
),
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom,
@@ -701,6 +725,37 @@ fun MessageItem(
(primaryFile != null && !primaryIsImage)
if (showPrimaryImageSlot) {
val imageKey = imageAttachmentKey(message, 0)
val layoutAspect = imageAspectRatioForMessage(
fileAspectRatios = message.fileAspectRatios,
fileDimensions = message.fileDimensions,
pendingFileAspectRatio = message.pendingFileAspectRatio,
fileAspectRatioPairs = message.fileAspectRatioPairs,
fileIndex = 0,
confirmed = message.id > 0,
hasLocalPreview = !message.pendingFileUri.isNullOrBlank(),
)
LaunchedEffect(
message.id,
message.client_message_id,
layoutAspect,
message.fileAspectRatioPairs,
message.fileDimensions,
message.pendingFileAspectRatio,
message.pendingFileUri,
message.files?.firstOrNull()?.path,
) {
AttachmentMediaLog.aspect(
"tile_state",
"msgId" to message.id,
"clientId" to message.client_message_id?.take(12),
"layoutAspect" to layoutAspect,
"pairs" to message.fileAspectRatioPairs?.firstOrNull(),
"dims" to message.fileDimensions?.firstOrNull(),
"pendingAspect" to message.pendingFileAspectRatio,
"pendingUri" to message.pendingFileUri?.take(48),
"file" to message.files?.firstOrNull()?.path?.takeLast(32),
)
}
val awaitingServer =
message.id < 0 && message.files.isNullOrEmpty()
val isOutboundPendingImage =
@@ -723,19 +778,7 @@ fun MessageItem(
fileThumbnail = message.fileThumbnails
?.firstOrNull()
?.takeIf { it.isNotBlank() },
fileAspectRatio = imageAspectRatioForMessage(
fileAspectRatios = message.fileAspectRatios,
fileDimensions = message.fileDimensions,
pendingFileAspectRatio =
message.pendingFileAspectRatio,
fileIndex = 0,
confirmed = message.id > 0,
hasLocalPreview =
DecryptedImageCache
.isDecryptedImageCacheUri(
message.pendingFileUri,
),
),
fileAspectRatio = layoutAspect,
fileSizeBytes =
message.fileSizes?.firstOrNull(),
messageId = message.id,
@@ -845,6 +888,7 @@ fun MessageItem(
fileDimensions = message.fileDimensions,
pendingFileAspectRatio =
message.pendingFileAspectRatio,
fileAspectRatioPairs = message.fileAspectRatioPairs,
fileIndex = index,
confirmed = message.id > 0,
hasLocalPreview = index == 0 &&
@@ -120,6 +120,10 @@ fun ChatFileAttachmentTile(
val displayUploadProgress = if (isUploading) uploadProgress ?: 0 else uploadProgress
val openableLocalUri = resolvedCacheUri
?: pendingFileUri?.takeIf { isPendingLocal }
val canPlainDownload = file != null && dmEnvelope == null &&
(file.path.contains("/normal/") || (file.nonceB64.isNullOrBlank() && file.wrappedMekB64.isNullOrBlank()))
val canEncryptedDownload = file != null && dmEnvelope != null
val canDownload = canPlainDownload || canEncryptedDownload
val onRowClick: (() -> Unit)? = when {
openableLocalUri != null -> {
@@ -132,7 +136,7 @@ fun ChatFileAttachmentTile(
}
}
}
downloadPaused && file != null && dmEnvelope != null -> {
downloadPaused && canDownload -> file?.let { downloadFile ->
{
AttachmentDownloadNotifier.beginDownload(
messageId = messageId,
@@ -141,11 +145,11 @@ fun ChatFileAttachmentTile(
mirrorAsFileAttachment = true,
)
scope.launch {
val ok = DmFileDownloader.downloadToCache(
val ok = downloadAttachmentToCache(
messageId = messageId,
fileIndex = fileIndex,
file = file,
envelope = dmEnvelope,
file = downloadFile,
dmEnvelope = dmEnvelope,
currentUserId = currentUserId,
clientMessageId = clientMessageId,
messageLabel = messageLabel,
@@ -162,7 +166,7 @@ fun ChatFileAttachmentTile(
}
}
}
file != null && dmEnvelope != null && !isDownloading && !downloadPaused -> {
canDownload && !isDownloading && !downloadPaused -> file?.let { downloadFile ->
{
AttachmentDownloadNotifier.beginDownload(
messageId = messageId,
@@ -171,11 +175,11 @@ fun ChatFileAttachmentTile(
mirrorAsFileAttachment = true,
)
scope.launch {
val ok = DmFileDownloader.downloadToCache(
val ok = downloadAttachmentToCache(
messageId = messageId,
fileIndex = fileIndex,
file = file,
envelope = dmEnvelope,
file = downloadFile,
dmEnvelope = dmEnvelope,
currentUserId = currentUserId,
clientMessageId = clientMessageId,
messageLabel = messageLabel,
@@ -273,3 +277,33 @@ fun ChatFileAttachmentTile(
}
}
}
private suspend fun downloadAttachmentToCache(
messageId: Int,
fileIndex: Int,
file: DmFile,
dmEnvelope: DmEnvelope?,
currentUserId: Int?,
clientMessageId: String?,
messageLabel: String?,
): Boolean =
if (dmEnvelope != null) {
DmFileDownloader.downloadToCache(
messageId = messageId,
fileIndex = fileIndex,
file = file,
envelope = dmEnvelope,
currentUserId = currentUserId,
clientMessageId = clientMessageId,
messageLabel = messageLabel,
)
} else {
DecryptedFileCache.getOrDownloadPlain(
messageId = messageId,
fileIndex = fileIndex,
file = file,
clientMessageId = clientMessageId,
messageLabel = messageLabel,
) != null
}
@@ -588,8 +588,9 @@ class DmPanel(
cancelQueuedMessage(queued)
return
}
val clientId = _state.messages.find { it.id == messageId }?.client_message_id
deleteMessageImmediately(messageId)
val message = _state.messages.find { it.id == messageId } ?: return
val clientId = message.client_message_id
beginMessageDissolve(message)
DownloadedFileRegistry.invalidateForMessage(messageId)
DecryptedImageCache.invalidateForMessage(messageId)
DecryptedFileCache.invalidateForMessage(messageId)
@@ -18,10 +18,13 @@ import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.local.messages.conversationIdForGroup
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
import ru.fromchat.api.local.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.download.AttachmentDownloadProgress
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.publicchat.SendMessageResponse
import ru.fromchat.api.schema.messages.publicchat.resolvePublicAttachmentLayout
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.MessageDeletedData
import ru.fromchat.api.schema.websocket.types.ReactionUpdateData
@@ -33,6 +36,7 @@ import ru.fromchat.ui.chat.utils.PublicChatTypingHandler
import ru.fromchat.ui.chat.utils.TypingHandler
import ru.fromchat.ui.chat.utils.attachPublicReplyReferences
import ru.fromchat.ui.chat.utils.mergeDatabaseMessagesWithPanelState
import ru.fromchat.ui.chat.utils.mergeMessageUiFields
import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting
class PublicChatPanel(
@@ -76,7 +80,7 @@ class PublicChatPanel(
val fresh = byId[message.id] ?: return@map message
ProfileCache.mergePreviewFromPublicMessage(fresh)
ProfileCache.enrichPublicMessageForDisplay(
message.copy(
mergeMessageUiFields(fresh, message).copy(
username = fresh.username,
profile_picture = fresh.profile_picture,
verified = fresh.verified,
@@ -93,6 +97,9 @@ class PublicChatPanel(
override val usesPublicGroupSubtitle: Boolean
get() = true
override val supportsAttachments: Boolean
get() = true
init {
val instanceId = CacheContext.activeInstanceId.value.trim()
if (instanceId.isNotEmpty()) {
@@ -120,6 +127,21 @@ class PublicChatPanel(
}
}
}
scope.launch(Dispatchers.Default) {
AttachmentDownloadNotifier.progressFlow.collect { event ->
if (event !is AttachmentDownloadProgress.Success || event.messageId <= 0) return@collect
val uri = DecryptedImageCache.getUriForStorageKey(event.storageKey) ?: return@collect
withContext(Dispatchers.Main.immediate) {
updateMessage(event.messageId) { msg ->
msg.copy(pendingFileUri = uri)
}
}
MessageCacheStore.patchPublicMessageLocalPreview(
messageId = event.messageId,
localPreviewUri = uri,
)
}
}
scope.launch {
typingHandler.typingUsers.collect { users ->
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}")
@@ -213,18 +235,19 @@ class PublicChatPanel(
* Match optimistic rows via [Message.client_message_id] from the server ack (never by text).
*/
private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) {
val laidOut = newMsg.resolvePublicAttachmentLayout()
val uid = currentUserId
if (uid != null && newMsg.user_id == uid) {
val cid = newMsg.client_message_id?.trim().orEmpty()
if (uid != null && laidOut.user_id == uid) {
val cid = laidOut.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty()) {
handleMessageConfirmed(cid, newMsg)
handleMessageConfirmed(cid, laidOut)
return
}
if (newMsg.id > 0 && _state.messages.any { it.id == newMsg.id }) {
if (laidOut.id > 0 && _state.messages.any { it.id == laidOut.id }) {
return
}
}
ingestIncomingPublicMessage(newMsg)
ingestIncomingPublicMessage(laidOut)
}
private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
@@ -238,11 +261,20 @@ class PublicChatPanel(
}
private fun mergeNetworkHistoryWithShown(shown: List<Message>, fromNetwork: List<Message>): List<Message> {
val shownById = shown.associateBy { it.id }
val networkIds = fromNetwork.map { it.id }.toSet()
val merged = fromNetwork.map { net ->
val local = shownById[net.id]
if (local == null) {
net.resolvePublicAttachmentLayout()
} else {
mergeMessageUiFields(net, local)
}
}
val ahead = shown.filter { it.id > 0 && it.id !in networkIds }
if (ahead.isEmpty()) return fromNetwork
if (ahead.isEmpty()) return merged
return ru.fromchat.api.local.messages.sortMessagesForChatDisplay(
ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(fromNetwork + ahead),
ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(merged + ahead),
)
}
@@ -273,7 +305,11 @@ class PublicChatPanel(
override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) {
withContext(Dispatchers.Default) {
MessageCacheStore.confirmPublicMessage(clientMessageId, confirmed)
MessageCacheStore.confirmPublicMessage(
clientMessageId,
confirmed.resolvePublicAttachmentLayout(),
)
OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(clientMessageId)
}
}
@@ -296,12 +332,13 @@ class PublicChatPanel(
val response = responseResult.getOrNull()
if (response != null && response.messages.isNotEmpty()) {
ProfileCache.mergePreviewFromPublicMessages(response.messages)
val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
withContext(Dispatchers.Main) {
val shown = _state.messages
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, response.messages)) {
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
val withSenders = mergePublicSenderFieldsFromNetwork(shown, response.messages)
val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
if (withSenders != shown) {
updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) }
}
@@ -311,7 +348,7 @@ class PublicChatPanel(
batchStateUpdates {
val merged = preserveReplyToFromExisting(
shown,
mergeNetworkHistoryWithShown(shown, response.messages),
mergeNetworkHistoryWithShown(shown, networkMessages),
)
clearMessages()
addMessages(
@@ -323,7 +360,7 @@ class PublicChatPanel(
}
}
withContext(Dispatchers.Default) {
val mergedForCache = mergeNetworkHistoryWithShown(_state.messages, response.messages)
val mergedForCache = mergeNetworkHistoryWithShown(_state.messages, networkMessages)
MessageCacheStore.replacePublicMessages(mergedForCache)
}
} else if (responseResult.isFailure) {
@@ -369,8 +406,9 @@ class PublicChatPanel(
ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id)
}
if (response.messages.isNotEmpty()) {
ProfileCache.mergePreviewFromPublicMessages(response.messages)
val older = ProfileCache.enrichPublicMessagesForDisplay(response.messages.reversed())
val olderRaw = response.messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(olderRaw)
val older = ProfileCache.enrichPublicMessagesForDisplay(olderRaw.reversed())
updateState { currentState ->
currentState.copy(
messages = older + currentState.messages
@@ -499,9 +537,12 @@ class PublicChatPanel(
}
override suspend fun handleDeleteMessage(messageId: Int) {
deleteMessageImmediately(messageId)
clearReplyReferencesTo(messageId)
val message = _state.messages.find { it.id == messageId } ?: return
if (messageId < 0) {
cancelQueuedMessage(message)
return
}
beginMessageDissolve(message)
ApiClient.deleteMessage(messageId)
}
@@ -1,18 +1,87 @@
package ru.fromchat.ui.chat.utils
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.aspectRatioFromDimensionPair
import ru.fromchat.api.local.db.isPlaceholderAttachmentAspectRatio
import ru.fromchat.api.local.db.isPlaceholderAttachmentDimensions
import ru.fromchat.api.local.download.ChatPreviewDecodeSize
import ru.fromchat.api.local.download.LocalDecodedImageCache
import ru.fromchat.ui.chat.MessageGroupInfo
import ru.fromchat.ui.chat.bubbleTopRadii
/** Max attachment preview width in chat bubbles (160dp × 1.3). */
internal val ATTACHMENT_TILE_MAX_WIDTH = 208.dp
/** Max attachment preview height (240dp × 1.3). */
internal val ATTACHMENT_TILE_MAX_HEIGHT = 312.dp
/** Keeps wide panoramas tall enough for upload/download chrome (80dp × 1.3). */
internal val ATTACHMENT_TILE_MIN_SHORT_EDGE = 104.dp
/** Padding between bubble edge and attachment image (must match MessageItem image padding). */
internal val ATTACHMENT_IMAGE_INSET = 2.dp
/** Slight rounding on image preview bottom corners (less than bubble). */
private val IMAGE_BOTTOM_CORNER = 4.dp
/**
* Fixed dp tile size from max bounds + aspect ratio.
*
* Uses [requiredSize] so [androidx.compose.foundation.layout.IntrinsicSize.Max] bubbles do not
* adopt the child Image's intrinsic width (tiny ~80px disk/server thumbs ~3× too small on
* xxhdpi vs [maxWidth] in dp).
*/
internal fun computeAttachmentTileSize(
aspectRatio: Float,
maxWidth: Dp = ATTACHMENT_TILE_MAX_WIDTH,
maxHeight: Dp = ATTACHMENT_TILE_MAX_HEIGHT,
minShortEdge: Dp = ATTACHMENT_TILE_MIN_SHORT_EDGE,
): Pair<Dp, Dp> {
var width = maxWidth
var height = maxWidth / aspectRatio
if (height > maxHeight) {
height = maxHeight
width = maxHeight * aspectRatio
}
val shortEdge = minOf(width, height)
if (shortEdge < minShortEdge) {
val scale = minShortEdge / shortEdge
width *= scale
height *= scale
if (width > maxWidth) {
val shrink = maxWidth / width
width = maxWidth
height *= shrink
}
if (height > maxHeight) {
val shrink = maxHeight / height
height = maxHeight
width *= shrink
}
}
return width to height
}
@Composable
internal fun Modifier.attachmentTileLayout(
aspectRatio: Float?,
maxWidth: Dp = ATTACHMENT_TILE_MAX_WIDTH,
maxHeight: Dp = ATTACHMENT_TILE_MAX_HEIGHT,
): Modifier {
val ratio = aspectRatio?.takeIf { it.isFinite() && it > 0f } ?: 1f
val (width, height) = remember(ratio, maxWidth, maxHeight) {
computeAttachmentTileSize(ratio, maxWidth, maxHeight)
}
return this.requiredSize(width, height)
}
/** Inner clip: top corners follow bubble minus inset; bottom corners lightly rounded. */
internal fun attachmentImageCornerShape(
isAuthor: Boolean,
@@ -31,22 +100,62 @@ internal fun attachmentImageCornerShape(
)
}
/** Width / height for layout and decode. Pixel dimensions and server ratios beat stale pending. */
/** Decode-cache keys for a message attachment (client-id seed + confirmed message id). */
internal fun attachmentDecodeCacheKeys(
messageId: Int,
fileIndex: Int,
clientMessageId: String?,
): List<String> {
val keys = ArrayList<String>(2)
clientMessageId?.trim()?.takeIf { it.isNotEmpty() }?.let { cid ->
keys.add(DecryptedImageCache.storageKey(-1, fileIndex, cid))
}
if (messageId > 0) {
val idKey = DecryptedImageCache.storageKey(messageId, fileIndex, null)
if (idKey !in keys) keys.add(idKey)
}
if (keys.isEmpty()) {
keys.add(DecryptedImageCache.storageKey(messageId, fileIndex, clientMessageId))
}
return keys
}
internal fun peekDecodedAttachmentBitmap(cacheKeys: List<String>) =
cacheKeys.firstNotNullOfOrNull { LocalDecodedImageCache.peekFull(it) }
/**
* Width / height for layout and decode.
* When a local preview exists, keep the outbound aspect if the server only sent the [1,1] placeholder.
*/
internal fun imageAspectRatioForMessage(
fileAspectRatios: List<Float>?,
fileDimensions: List<Pair<Int, Int>>?,
pendingFileAspectRatio: Float?,
fileAspectRatioPairs: List<List<Int>>? = null,
fileIndex: Int = 0,
@Suppress("UNUSED_PARAMETER") confirmed: Boolean = true,
@Suppress("UNUSED_PARAMETER") hasLocalPreview: Boolean = false,
): Float? {
fileDimensions?.getOrNull(fileIndex)?.let { (w, h) ->
if (w > 0 && h > 0) {
val localRatio = pendingFileAspectRatio?.takeIf { fileIndex == 0 && it > 0f }
val pair = fileAspectRatioPairs?.getOrNull(fileIndex)
val pairRatio = pair?.takeIf { it.size >= 2 }?.let { (w, h) ->
if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) {
aspectRatioFromDimensionPair(w, h)
} else {
null
}
}
val serverDim = fileDimensions?.getOrNull(fileIndex)
val serverRatio = fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }
pairRatio?.let { return it }
serverDim?.let { (w, h) ->
if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) {
return aspectRatioFromDimensionPair(w, h)
}
}
fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }?.let { return it }
return pendingFileAspectRatio?.takeIf { fileIndex == 0 && it > 0f }
serverRatio?.takeIf { !isPlaceholderAttachmentAspectRatio(it) }?.let { return it }
return localRatio
}
internal fun coalesceDecodeTarget(vararg sizes: ChatPreviewDecodeSize?): ChatPreviewDecodeSize {
@@ -3,9 +3,11 @@ package ru.fromchat.ui.chat.utils
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.aspectRatioFromDimensionPair
import ru.fromchat.api.local.db.isPlaceholderAttachmentDimensions
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.messages.publicchat.resolvePublicAttachmentLayout
internal fun resolveDmReplyToId(
envelope: DmEnvelope?,
@@ -80,37 +82,82 @@ internal fun mergeDatabaseMessagesWithPanelState(
}
internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
if (panel == null) return db
if (panel == null) {
return if (!db.files.isNullOrEmpty() && db.dmEnvelope == null) {
db.resolvePublicAttachmentLayout()
} else {
db
}
}
val confirmed = db.id > 0
val dbHasRealLayout = db.fileAspectRatioPairs?.firstOrNull()?.let { pair ->
pair.size >= 2 && !isPlaceholderAttachmentDimensions(pair[0], pair[1])
} == true || db.fileDimensions?.firstOrNull()?.let { (w, h) ->
!isPlaceholderAttachmentDimensions(w, h)
} == true
// Keep any local preview across confirm so own image sends never flash network error UI.
val localPreview = db.pendingFileUri?.takeIf { DecryptedImageCache.isDecryptedImageCacheUri(it) }
?: panel.pendingFileUri?.takeIf { DecryptedImageCache.isDecryptedImageCacheUri(it) }
return db.copy(
?: panel.pendingFileUri?.takeIf { it.isNotBlank() }
?: db.pendingFileUri?.takeIf { it.isNotBlank() }
val serverDim = db.fileDimensions?.firstOrNull()
?: db.fileAspectRatioPairs?.firstOrNull()?.takeIf { it.size >= 2 }?.let { (w, h) -> w to h }
?: panel.fileDimensions?.firstOrNull()
?: panel.fileAspectRatioPairs?.firstOrNull()?.takeIf { it.size >= 2 }?.let { (w, h) -> w to h }
val serverRatio = db.fileAspectRatios?.firstOrNull() ?: panel.fileAspectRatios?.firstOrNull()
val localAspect = panel.pendingFileAspectRatio?.takeIf { it > 0f }
?: db.pendingFileAspectRatio?.takeIf { it > 0f }
?: panel.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
?: db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
val merged = db.copy(
pendingFileUri = when {
confirmed -> localPreview ?: db.pendingFileUri
confirmed -> localPreview
else -> panel.pendingFileUri ?: db.pendingFileUri
},
pendingFilename = if (confirmed) null else panel.pendingFilename ?: db.pendingFilename,
uploadJobId = if (confirmed) null else panel.uploadJobId ?: db.uploadJobId,
pendingFileAspectRatio = if (confirmed) {
db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
?: db.fileAspectRatios?.firstOrNull()
pendingFileAspectRatio = when {
confirmed && dbHasRealLayout -> null
confirmed -> localAspect
?: serverDim?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
?: serverRatio
?: db.pendingFileAspectRatio
} else {
panel.pendingFileAspectRatio ?: db.pendingFileAspectRatio
else -> panel.pendingFileAspectRatio ?: db.pendingFileAspectRatio
},
uploadProgress = if (confirmed) null else panel.uploadProgress ?: db.uploadProgress,
uploadError = if (confirmed) null else panel.uploadError ?: db.uploadError,
files = db.files ?: panel.files,
dmEnvelope = db.dmEnvelope ?: panel.dmEnvelope,
fileThumbnails = db.fileThumbnails ?: panel.fileThumbnails,
fileAspectRatios = db.fileAspectRatios ?: panel.fileAspectRatios,
fileAspectRatioPairs = db.fileAspectRatioPairs ?: panel.fileAspectRatioPairs,
fileAspectRatios = when {
confirmed && dbHasRealLayout -> db.fileAspectRatios ?: panel.fileAspectRatios
confirmed && localAspect != null -> listOf(localAspect)
else -> db.fileAspectRatios ?: panel.fileAspectRatios
},
fileSizes = db.fileSizes ?: panel.fileSizes,
fileDimensions = db.fileDimensions ?: panel.fileDimensions,
fileDimensions = when {
confirmed && dbHasRealLayout -> db.fileDimensions ?: panel.fileDimensions
confirmed && panel.fileDimensions?.any { (w, h) ->
!isPlaceholderAttachmentDimensions(w, h)
} == true && !dbHasRealLayout -> panel.fileDimensions
confirmed && db.fileDimensions?.any { (w, h) ->
!isPlaceholderAttachmentDimensions(w, h)
} == true -> db.fileDimensions
else -> db.fileDimensions ?: panel.fileDimensions
},
content = db.content.ifBlank { panel.content },
isContentCorrupted = panel.isContentCorrupted || db.isContentCorrupted,
replyToId = db.replyToId ?: panel.replyToId,
reply_to = db.reply_to ?: panel.reply_to,
client_message_id = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
?: db.client_message_id,
)
return if (!merged.files.isNullOrEmpty() && merged.dmEnvelope == null) {
merged.resolvePublicAttachmentLayout()
} else {
merged
}
}
/** Keeps hydrated [Message.reply_to] when a network/DB refresh omits nested reply payloads. */
@@ -0,0 +1,42 @@
@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
package ru.fromchat.api.local.cache
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.useContents
import platform.CoreGraphics.CGRectMake
import platform.Foundation.NSData
import platform.Foundation.create
import platform.Foundation.writeToFile
import platform.UIKit.UIGraphicsBeginImageContextWithOptions
import platform.UIKit.UIGraphicsEndImageContext
import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext
import platform.UIKit.UIImage
import platform.UIKit.UIImageJPEGRepresentation
actual fun generateAttachmentDiskThumbnail(
sourceAbsolutePath: String,
destAbsolutePath: String,
maxEdgePx: Int,
): Boolean {
val data = NSData.create(contentsOfFile = sourceAbsolutePath) ?: return false
val image = UIImage.imageWithData(data) ?: return false
val width = image.size.useContents { width }
val height = image.size.useContents { height }
if (width <= 0.0 || height <= 0.0) return false
val longEdge = maxOf(width, height)
val scale = if (longEdge > maxEdgePx) maxEdgePx.toDouble() / longEdge else 1.0
val dstW = (width * scale).coerceAtLeast(1.0)
val dstH = (height * scale).coerceAtLeast(1.0)
UIGraphicsBeginImageContextWithOptions(
platform.CoreGraphics.CGSizeMake(dstW, dstH),
false,
1.0,
)
image.drawInRect(CGRectMake(0.0, 0.0, dstW, dstH))
val scaled = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
val jpeg = scaled?.let { UIImageJPEGRepresentation(it, 0.85) } ?: return false
return jpeg.writeToFile(destAbsolutePath, atomically = true)
}
@@ -0,0 +1,3 @@
package ru.fromchat.api.local.download
internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? = null