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? = 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? = actual fun decodeImageBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? =
decodeSampledFromBytes(bytes, reqWidthPx, reqHeightPx)?.asImageBitmap() 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.request.setBody
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.MainScope 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.messages.dm.upload.DmUploadStatusResponse
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
import ru.fromchat.api.schema.messages.publicchat.SendMessageRequest 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.RegisteredUserCountResponse
import ru.fromchat.api.schema.server.ServerInstanceIdResponse import ru.fromchat.api.schema.server.ServerInstanceIdResponse
import ru.fromchat.api.schema.server.TransportKeyResponse 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". * Fetch encrypted file bytes. Path is e.g. "/uploads/files/encrypted/xxx.jpg" or "/api/uploads/files/encrypted/xxx.jpg".
* Backend may return path with /api prefix; apiBaseUrl already includes /api, so we avoid double /api. * Backend may return path with /api prefix; apiBaseUrl already includes /api, so we avoid double /api.
@@ -809,6 +865,9 @@ object ApiClient {
else -> "${ServerConfig.apiBaseUrl}$path" 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. */ /** Encrypted ciphertext stored on disk after a resumable download. */
data class EncryptedFileOnDisk( data class EncryptedFileOnDisk(
val path: String, val path: String,
@@ -1448,6 +1507,9 @@ object ApiClient {
content: String, content: String,
replyToId: Int? = null, replyToId: Int? = null,
clientMessageId: String? = null, clientMessageId: String? = null,
uploadedFileIds: List<String> = emptyList(),
fileBytes: ByteArray? = null,
filename: String? = null,
): ru.fromchat.api.schema.messages.Message { ): ru.fromchat.api.schema.messages.Message {
if (_suspensionState.value.isSuspended) { if (_suspensionState.value.isSuspended) {
throw IllegalStateException("Account suspended") throw IllegalStateException("Account suspended")
@@ -1457,12 +1519,23 @@ object ApiClient {
content = content.trim(), content = content.trim(),
reply_to_id = replyToId, reply_to_id = replyToId,
client_message_id = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }, client_message_id = clientMessageId?.trim()?.takeIf { it.isNotEmpty() },
uploaded_file_ids = uploadedFileIds.takeIf { it.isNotEmpty() },
), ),
) )
val response = http.submitFormWithBinaryData( val response = http.submitFormWithBinaryData(
url = "${ServerConfig.apiBaseUrl}/send_message", url = "${ServerConfig.apiBaseUrl}/send_message",
formData = formData { formData = formData {
append("payload", payloadJson) 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>() ).body<ru.fromchat.api.schema.messages.publicchat.SendMessageResponse>()
if (!response.status.equals("success", ignoreCase = true)) { 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) 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() fun nowMs(): Long = Clock.System.now().toEpochMilliseconds()
/** Short message text for download/upload log lines (not for UI). */ /** 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, fileIndex: Int,
clientMessageId: String? = null, clientMessageId: String? = null,
): String? { ): 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 } memoryCache[key]?.takeIf { uriFileExists(it) }?.let { return it }
return readDisk(key) return readDisk(key)
} }
@@ -116,7 +126,7 @@ object DecryptedFileCache {
val idKey = storageKey(messageId, fileIndex, null) val idKey = storageKey(messageId, fileIndex, null)
if (getCached(messageId, fileIndex, null) != null) return if (getCached(messageId, fileIndex, null) != null) return
val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } ?: 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) } val sourceUri = cacheMutex.withLock { resolveUriLocked(cidKey) }
?: readDisk(cidKey) ?: readDisk(cidKey)
?: return ?: 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( private suspend fun decryptAndPersist(
key: String, key: String,
messageId: Int, messageId: Int,
@@ -19,6 +19,8 @@ import ru.fromchat.api.local.download.ensureAttachmentDownloadActive
import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility
import ru.fromchat.api.local.cache.DecryptedImageCache.storageKey import ru.fromchat.api.local.cache.DecryptedImageCache.storageKey
import ru.fromchat.api.local.download.LocalDecodedImageCache 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. * Disk + in-memory cache for decrypted DM images.
@@ -26,6 +28,7 @@ import ru.fromchat.api.local.download.LocalDecodedImageCache
*/ */
object DecryptedImageCache { object DecryptedImageCache {
private const val SUBDIR = "decrypted_images" 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). */ /** True when [uri] points at a file under this cache (safe to persist for offline preview). */
fun isDecryptedImageCacheUri(uri: String?): Boolean { fun isDecryptedImageCacheUri(uri: String?): Boolean {
@@ -93,14 +96,101 @@ object DecryptedImageCache {
clientMessageId: String? = null, clientMessageId: String? = null,
): String? { ): String? {
val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
// Always try the client-id key first — outbound seeds live there until aliasing.
if (cid != null) { 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 } readDisk(storageKey(messageId, fileIndex, cid))?.let { return it }
} }
return readDisk(storageKey(messageId, fileIndex, null)) return null
} }
fun getUriForStorageKey(storageKey: String): String? = readDisk(storageKey) 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? { fun messageIdFromStorageKey(storageKey: String): Int? {
if (!storageKey.startsWith("img_") || storageKey.startsWith("img_c_")) return null if (!storageKey.startsWith("img_") || storageKey.startsWith("img_c_")) return null
return storageKey.removePrefix("img_").substringBefore('_').toIntOrNull() return storageKey.removePrefix("img_").substringBefore('_').toIntOrNull()
@@ -116,7 +206,7 @@ object DecryptedImageCache {
val idKey = storageKey(messageId, fileIndex, null) val idKey = storageKey(messageId, fileIndex, null)
if (readDisk(idKey) != null) return if (readDisk(idKey) != null) return
val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } ?: 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 sourceUri = readDisk(cidKey) ?: return
val bytes = runCatching { val bytes = runCatching {
readOutboundFileBytes(sourceUri) readOutboundFileBytes(sourceUri)
@@ -126,6 +216,10 @@ object DecryptedImageCache {
cacheMutex.withLock { cacheMutex.withLock {
if (readDisk(idKey) == null) { if (readDisk(idKey) == null) {
writeCacheLocked(idKey, bytes) writeCacheLocked(idKey, bytes)
}
diskPath(idKey)?.let { fullPath ->
writeDiskThumbLocked(idKey, fullPath)
}
AttachmentMediaLog.diskCache( AttachmentMediaLog.diskCache(
"alias_ok", "alias_ok",
"from" to cidKey, "from" to cidKey,
@@ -135,7 +229,6 @@ object DecryptedImageCache {
} }
} }
} }
}
/** /**
* Returns a cached file URI, decrypting and persisting once per [storageKey]. * Returns a cached file URI, decrypting and persisting once per [storageKey].
@@ -234,6 +327,178 @@ object DecryptedImageCache {
return uri 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) { suspend fun invalidateForMessage(messageId: Int) {
val dir = ensureCacheDir() ?: return val dir = ensureCacheDir() ?: return
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@@ -272,6 +537,7 @@ object DecryptedImageCache {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
cacheMutex.withLock { memoryCache.remove(key) } cacheMutex.withLock { memoryCache.remove(key) }
diskPath(key)?.let { invalidatePath(it) } diskPath(key)?.let { invalidatePath(it) }
thumbDiskPath(key)?.let { invalidatePath(it) }
LocalDecodedImageCache.evict(key) LocalDecodedImageCache.evict(key)
} }
} }
@@ -470,6 +736,23 @@ object DecryptedImageCache {
return "$dir/$storageKey" 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? { private fun readDisk(storageKey: String): String? {
val path = diskPath(storageKey) ?: return null val path = diskPath(storageKey) ?: return null
if (!PlatformFileSystem.exists(path)) return null if (!PlatformFileSystem.exists(path)) return null
@@ -484,6 +767,7 @@ object DecryptedImageCache {
if (!PlatformFileSystem.exists(path)) return null if (!PlatformFileSystem.exists(path)) return null
val uri = "file://$path" val uri = "file://$path"
memoryCache[storageKey] = uri memoryCache[storageKey] = uri
writeDiskThumbLocked(storageKey, path)
uri uri
}.getOrElse { }.getOrElse {
invalidatePath(path) invalidatePath(path)
@@ -495,6 +779,7 @@ object DecryptedImageCache {
if (!PlatformFileSystem.exists(path)) return null if (!PlatformFileSystem.exists(path)) return null
val uri = "file://$path" val uri = "file://$path"
memoryCache[storageKey] = uri memoryCache[storageKey] = uri
writeDiskThumbLocked(storageKey, path)
return uri return uri
} }
@@ -39,6 +39,36 @@ interface OutboundFileInputStream {
suspend fun close() 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 saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray)
expect suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): 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.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.schema.messages.Message 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.DmEnvelope
import ru.fromchat.api.schema.messages.dm.DmFile
import ru.fromchat.api.local.AttachmentMediaLog import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.download.readLocalImageDimensions
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@@ -50,15 +53,30 @@ private data class PersistedDmMessagePayload(
@SerialName("localPreviewUri") val localPreviewUri: String? = null, @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( data class ParsedDmMessageContent(
val text: String, val text: String,
/** Reply target from encrypted JSON payload (`reply_to_id`), when present. */ /** Reply target from encrypted JSON payload (`reply_to_id`), when present. */
val replyToId: Int? = null, val replyToId: Int? = null,
val envelope: DmEnvelope? = null, val envelope: DmEnvelope? = null,
val files: List<DmFile>? = null,
val fileThumbnails: List<String>? = null, val fileThumbnails: List<String>? = null,
val fileAspectRatios: List<Float>? = null, val fileAspectRatios: List<Float>? = null,
val fileSizes: List<Long>? = null, val fileSizes: List<Long>? = null,
val fileDimensions: List<Pair<Int, Int>>? = null, val fileDimensions: List<Pair<Int, Int>>? = null,
val fileAspectRatioPairs: List<List<Int>>? = null,
val isContentCorrupted: Boolean = false, val isContentCorrupted: Boolean = false,
val localPreviewUri: String? = null, val localPreviewUri: String? = null,
val pendingFileUri: String? = null, val pendingFileUri: String? = null,
@@ -114,6 +132,65 @@ fun resolveLocalPreviewUri(message: Message): String? {
return null 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). * 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. * 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() 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 { private fun localPreviewFileExists(uri: String): Boolean {
val path = uri.removePrefix("file://") val path = uri.removePrefix("file://")
return path.isNotEmpty() && PlatformFileSystem.exists(path) return path.isNotEmpty() && PlatformFileSystem.exists(path)
@@ -158,6 +241,33 @@ fun encodePersistedDmMessage(message: Message): String {
return json.encodeToString(payload) 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 { fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent {
val trimmed = plaintext.trim() val trimmed = plaintext.trim()
if (trimmed.startsWith("{")) { if (trimmed.startsWith("{")) {
@@ -194,6 +304,7 @@ fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent {
ParsedDmMessageContent( ParsedDmMessageContent(
text = payload.text, text = payload.text,
envelope = payload.envelope, envelope = payload.envelope,
files = payload.envelope.files,
fileThumbnails = payload.fileThumbnails, fileThumbnails = payload.fileThumbnails,
fileAspectRatios = payload.fileAspectRatios, fileAspectRatios = payload.fileAspectRatios,
fileSizes = payload.fileSizes, fileSizes = payload.fileSizes,
@@ -207,6 +318,26 @@ fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent {
ParsedDmMessageContent(text = plaintext) 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 parseLegacyDmContentJson(trimmed)
} }
return ParsedDmMessageContent(text = 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.messages.dmOtherUserIdFromConversationId
import ru.fromchat.api.local.db.encodeOptimisticOutboundMessage import ru.fromchat.api.local.db.encodeOptimisticOutboundMessage
import ru.fromchat.api.local.db.encodePersistedDmMessage 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.parseDmMessageContent
import ru.fromchat.api.local.db.hydrateAttachmentPreviewFromDiskSync
import ru.fromchat.api.local.db.resolveLocalPreviewUri import ru.fromchat.api.local.db.resolveLocalPreviewUri
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
import ru.fromchat.api.local.send.DmAttachmentOutboxPayload 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.SEND_ERROR_FAILED
import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmConversation import ru.fromchat.api.schema.messages.dm.DmConversation
import ru.fromchat.api.schema.messages.dm.DmEnvelope 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.CacheContext
import ru.fromchat.api.local.cache.CacheValidator import ru.fromchat.api.local.cache.CacheValidator
import ru.fromchat.db.Conversation 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.cache.DecryptedImageCache
import ru.fromchat.api.local.download.DownloadedFileRegistry import ru.fromchat.api.local.download.DownloadedFileRegistry
import ru.fromchat.ui.chat.utils.attachPublicReplyReferences 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.dedupeMessagesByClientId
import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
@@ -74,23 +79,30 @@ object MessageCacheStore {
return if (t.length > maxLen) t.take(maxLen) + "\u2026" else t return if (t.length > maxLen) t.take(maxLen) + "\u2026" else t
} }
@OptIn(ExperimentalCoroutinesApi::class)
fun observeMessages(instanceId: String, conversationId: String): Flow<List<Message>> = fun observeMessages(instanceId: String, conversationId: String): Flow<List<Message>> =
db.messageDatabaseQueries db.messageDatabaseQueries
.selectMessagesByConversation(instanceId, conversationId) .selectMessagesByConversation(instanceId, conversationId)
.asFlow() .asFlow()
.mapToList(Dispatchers.Default) .mapToList(Dispatchers.Default)
.map { rows -> .mapLatest { rows ->
withContext(Dispatchers.Default) {
val raw = hydrateReplyReferencesFromRows(rows) val raw = hydrateReplyReferencesFromRows(rows)
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
val hydrated = hydrateAttachmentPreviewsFromDisk(withoutSuperseded)
sortMessagesForChatDisplay( sortMessagesForChatDisplay(
validatedOrEmpty( validatedOrEmpty(
conversationId, conversationId,
dedupeMessagesByClientId( dedupeMessagesByClientId(
enrichQueuedOutboundUi(withoutSuperseded, conversationId), enrichQueuedOutboundUi(
hydrated,
conversationId,
),
), ),
), ),
) )
} }
}
suspend fun loadPublicMessages(): List<Message> = suspend fun loadPublicMessages(): List<Message> =
ProfileCache.enrichPublicMessagesForDisplay(loadMessages(conversationIdForPublic())) ProfileCache.enrichPublicMessagesForDisplay(loadMessages(conversationIdForPublic()))
@@ -106,12 +118,13 @@ object MessageCacheStore {
.executeAsList() .executeAsList()
val raw = hydrateReplyReferencesFromRows(rows).reversed() val raw = hydrateReplyReferencesFromRows(rows).reversed()
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
val withDiskPreviews = withoutSuperseded.map { hydrateAttachmentPreviewFromDiskSync(it) }
return ProfileCache.enrichPublicMessagesForDisplay( return ProfileCache.enrichPublicMessagesForDisplay(
sortMessagesForChatDisplay( sortMessagesForChatDisplay(
validatedOrEmpty( validatedOrEmpty(
convId, convId,
dedupeMessagesByClientId( dedupeMessagesByClientId(
enrichQueuedOutboundUi(withoutSuperseded, convId), enrichQueuedOutboundUi(withDiskPreviews, convId),
), ),
), ),
), ),
@@ -153,15 +166,16 @@ object MessageCacheStore {
} }
suspend fun replacePublicMessages(messages: List<Message>) { suspend fun replacePublicMessages(messages: List<Message>) {
ProfileCache.mergePreviewFromPublicMessages(messages) val resolved = messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(resolved)
conversationIdForPublic().let { conversationIdForPublic().let {
replaceMessages( replaceMessages(
it, it,
sortMessagesForChatDisplay( sortMessagesForChatDisplay(
dedupeMessagesByClientId( dedupeMessagesByClientId(
messages + loadPendingMessages(it).filter { p -> resolved + loadPendingMessages(it).filter { p ->
val cid = p.client_message_id 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) { suspend fun upsertPublicMessage(message: Message) {
ProfileCache.mergePreviewFromPublicMessage(message) val resolved = message.resolvePublicAttachmentLayout()
upsertSingle(conversationIdForPublic(), message) ProfileCache.mergePreviewFromPublicMessage(resolved)
upsertSingle(conversationIdForPublic(), resolved)
} }
suspend fun markSendFailed(conversationId: String, clientMessageId: String) { suspend fun markSendFailed(conversationId: String, clientMessageId: String) {
@@ -286,14 +301,50 @@ object MessageCacheStore {
} }
suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) { suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) {
ProfileCache.mergePreviewFromPublicMessage(confirmed) withContext(Dispatchers.Default) {
confirmMessage(conversationIdForPublic(), clientMessageId, confirmed) var resolved = confirmed.resolvePublicAttachmentLayout()
resolved = hydrateAttachmentPreviewFromDisk(resolved)
ProfileCache.mergePreviewFromPublicMessage(resolved)
confirmMessage(conversationIdForPublic(), clientMessageId, resolved)
}
} }
suspend fun confirmDmMessage(otherUserId: Int, clientMessageId: String, confirmed: Message) { suspend fun confirmDmMessage(otherUserId: Int, clientMessageId: String, confirmed: Message) {
confirmMessage(conversationIdForDm(otherUserId), clientMessageId, confirmed) 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. */ /** After decrypt, persist [localPreviewUri] so reopen skips network. */
suspend fun patchDmMessageLocalPreview( suspend fun patchDmMessageLocalPreview(
otherUserId: Int, otherUserId: Int,
@@ -856,7 +907,12 @@ object MessageCacheStore {
private suspend fun confirmMessage(conversationId: String, clientMessageId: String, confirmed: Message) { private suspend fun confirmMessage(conversationId: String, clientMessageId: String, confirmed: Message) {
val iid = instanceId() 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) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.transaction { db.messageDatabaseQueries.transaction {
val existingReplyToId = db.messageDatabaseQueries val existingReplyToId = db.messageDatabaseQueries
@@ -901,7 +957,10 @@ object MessageCacheStore {
validatedOrEmpty( validatedOrEmpty(
conversationId, conversationId,
dedupeMessagesByClientId( dedupeMessagesByClientId(
enrichQueuedOutboundUi(withoutSuperseded, conversationId), enrichQueuedOutboundUi(
hydrateAttachmentPreviewsFromDisk(withoutSuperseded),
conversationId,
),
), ),
), ),
) )
@@ -915,6 +974,7 @@ object MessageCacheStore {
.selectRecentMessagesByConversation(iid, conversationId, limit) .selectRecentMessagesByConversation(iid, conversationId, limit)
.executeAsList() .executeAsList()
hydrateReplyReferencesFromRows(rows).reversed() hydrateReplyReferencesFromRows(rows).reversed()
.let { hydrateAttachmentPreviewsFromDisk(it) }
} }
} }
@@ -951,7 +1011,9 @@ object MessageCacheStore {
it.conversationId == conversationId && it.conversationId == conversationId &&
( (
it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT || 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 if (attachmentOutbox.isEmpty()) return messages
@@ -961,11 +1023,33 @@ object MessageCacheStore {
.toSet() .toSet()
val payloads = attachmentOutbox.associate { row -> val payloads = attachmentOutbox.associate { row ->
row.clientMessageId to runCatching { row.clientMessageId to runCatching {
Triple( when (row.kind) {
outboxJson.decodeFromString<DmAttachmentOutboxPayload>(row.payloadJson), OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT,
row.bytesUploaded, OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK -> {
row.kind, 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() }.getOrNull()
} }
return messages.mapNotNull { msg -> return messages.mapNotNull { msg ->
@@ -974,32 +1058,43 @@ object MessageCacheStore {
if (cid.isNotEmpty() && cid in confirmedClientIds) return@mapNotNull null if (cid.isNotEmpty() && cid in confirmedClientIds) return@mapNotNull null
if (cid.isEmpty()) return@mapNotNull msg if (cid.isEmpty()) return@mapNotNull msg
val entry = payloads[cid] ?: return@mapNotNull msg val entry = payloads[cid] ?: return@mapNotNull msg
val (payload, bytesUploaded, kind) = entry val uploadFinished =
val uploadFinished = kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK entry.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK ||
entry.kind == OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK
val totalBytes = when { val totalBytes = when {
payload.encryptedFileSizeBytes > 0L -> payload.encryptedFileSizeBytes entry.encryptedFileSizeBytes > 0L -> entry.encryptedFileSizeBytes
else -> payload.fileSizeBytes else -> entry.fileSizeBytes
} }
val percent = when { val percent = when {
uploadFinished -> null uploadFinished -> null
totalBytes > 0L && bytesUploaded > 0L -> totalBytes > 0L && entry.bytesUploaded > 0L ->
((bytesUploaded.toDouble() / totalBytes.toDouble()) * 100.0).toInt().coerceIn(0, 99) ((entry.bytesUploaded.toDouble() / totalBytes.toDouble()) * 100.0).toInt().coerceIn(0, 99)
bytesUploaded > 0L -> 1 entry.bytesUploaded > 0L -> 1
else -> msg.uploadProgress ?: 0 else -> msg.uploadProgress ?: 0
} }
msg.copy( msg.copy(
pendingFileUri = payload.fileUri, pendingFileUri = entry.fileUri,
pendingFilename = payload.filename, pendingFilename = entry.filename,
pendingFileAspectRatio = payload.aspectRatio?.takeIf { it > 0f } pendingFileAspectRatio = entry.aspectRatio?.takeIf { it > 0f }
?: msg.pendingFileAspectRatio, ?: msg.pendingFileAspectRatio,
uploadJobId = cid, uploadJobId = cid,
uploadProgress = percent, uploadProgress = percent,
fileSizes = msg.fileSizes 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( private fun purgeSupersededPendingRows(
instanceId: String, instanceId: String,
conversationId: String, conversationId: String,
@@ -1088,15 +1183,16 @@ object MessageCacheStore {
reply_to = null, reply_to = null,
client_message_id = clientMessageId, client_message_id = clientMessageId,
reactions = null, reactions = null,
files = parsed.envelope?.files, files = parsed.files ?: parsed.envelope?.files,
dmEnvelope = parsed.envelope, dmEnvelope = parsed.envelope,
fileThumbnails = parsed.fileThumbnails, fileThumbnails = parsed.fileThumbnails,
fileAspectRatios = parsed.fileAspectRatios, fileAspectRatios = parsed.fileAspectRatios,
fileSizes = parsed.fileSizes, fileSizes = parsed.fileSizes,
fileDimensions = parsed.fileDimensions, fileDimensions = parsed.fileDimensions,
fileAspectRatioPairs = parsed.fileAspectRatioPairs,
isContentCorrupted = parsed.isContentCorrupted, isContentCorrupted = parsed.isContentCorrupted,
) )
return base.copy( val hydrated = base.copy(
pendingFileUri = parsed.pendingFileUri pendingFileUri = parsed.pendingFileUri
?: parsed.localPreviewUri ?: parsed.localPreviewUri
?: resolveLocalPreviewUri(base), ?: resolveLocalPreviewUri(base),
@@ -1109,11 +1205,36 @@ object MessageCacheStore {
}, },
uploadError = if (sendStatus == "failed") SEND_ERROR_FAILED else null, 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 { private fun storedMessageContent(msg: Message): String = when {
msg.id < 0 -> encodeOptimisticOutboundMessage(msg) msg.id < 0 -> encodeOptimisticOutboundMessage(msg)
!msg.files.isNullOrEmpty() && msg.dmEnvelope != null -> encodePersistedDmMessage(msg) !msg.files.isNullOrEmpty() && msg.dmEnvelope != null -> encodePersistedDmMessage(msg)
!msg.files.isNullOrEmpty() -> encodePersistedPublicMessage(msg)
else -> msg.content else -> msg.content
} }
@@ -121,6 +121,10 @@ object LocalDecodedImageCache {
return bitmap return bitmap
} }
fun putThumb(storageKey: String, bitmap: ImageBitmap) {
PlatformDecodedBitmapCache.put(storageKey + THUMB_SUFFIX, bitmap)
}
fun evict(storageKey: String) { fun evict(storageKey: String) {
PlatformDecodedBitmapCache.remove(previewCacheKey(storageKey)) PlatformDecodedBitmapCache.remove(previewCacheKey(storageKey))
PlatformDecodedBitmapCache.remove(fullscreenCacheKey(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.cache.readOutboundFileBytes
import ru.fromchat.api.local.mimeTypeForFilename import ru.fromchat.api.local.mimeTypeForFilename
import ru.fromchat.ui.chat.isImageFilename import ru.fromchat.ui.chat.isImageFilename
import ru.fromchat.ui.chat.utils.attachmentDecodeCacheKeys
import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap
data class SavableMessageImage( data class SavableMessageImage(
val fileIndex: Int, val fileIndex: Int,
@@ -25,13 +27,20 @@ fun mimeTypeForImageFilename(filename: String): String = mimeTypeForFilename(fil
fun isMessageImageFullyLoaded(message: Message, fileIndex: Int): Boolean { fun isMessageImageFullyLoaded(message: Message, fileIndex: Int): Boolean {
val file = message.files?.getOrNull(fileIndex) val file = message.files?.getOrNull(fileIndex)
if (file != null && isImageFilename(file.name)) { if (file != null && isImageFilename(file.name)) {
if (message.dmEnvelope != null) { val cacheKeys = attachmentDecodeCacheKeys(
return DecryptedImageCache.getCached( messageId = message.id,
fileIndex = fileIndex,
clientMessageId = message.client_message_id,
)
if (DecryptedImageCache.getCached(
messageId = message.id, messageId = message.id,
fileIndex = fileIndex, fileIndex = fileIndex,
clientMessageId = message.client_message_id, clientMessageId = message.client_message_id,
) != null ) != null
) {
return true
} }
if (peekDecodedAttachmentBitmap(cacheKeys) != null) return true
return false return false
} }
if (fileIndex != 0) return false if (fileIndex != 0) return false
@@ -557,6 +557,9 @@ object DmAttachmentOutboxHandler {
MessageDatabaseProvider.database.messageDatabaseQueries MessageDatabaseProvider.database.messageDatabaseQueries
.selectPendingOutboxForInstance(instanceId) .selectPendingOutboxForInstance(instanceId)
.executeAsList() .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, 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( data class StagedOutboundPreview(
val stagedUri: String, val stagedUri: String,
/** Decrypted-image cache URI for UI preview (preferred over [stagedUri]). */
val previewUri: String? = null,
val aspectRatio: Float?, val aspectRatio: Float?,
val sizeBytes: Long = 0L, val sizeBytes: Long = 0L,
) )
@@ -30,7 +32,7 @@ suspend fun prepareOutboundImageForSend(
}.getOrNull() ?: return@withContext null }.getOrNull() ?: return@withContext null
if (staged.sizeBytes <= 0L) return@withContext null if (staged.sizeBytes <= 0L) return@withContext null
DecryptedImageCache.seedFromLocalFile( val previewUri = DecryptedImageCache.seedFromLocalFile(
messageId = optimisticMessageId, messageId = optimisticMessageId,
fileIndex = 0, fileIndex = 0,
localFileUri = staged.uri, localFileUri = staged.uri,
@@ -39,9 +41,18 @@ suspend fun prepareOutboundImageForSend(
val storageKey = DecryptedImageCache.storageKey(optimisticMessageId, 0, clientMessageId) val storageKey = DecryptedImageCache.storageKey(optimisticMessageId, 0, clientMessageId)
val decodeTarget = previewSeedDecodeSize(aspectRatio) 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). */ /** 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 encryptedFileSizeBytes: Long = 0L,
val uploadId: String = "", 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) 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) { suspend fun clearAttachmentOutboxAfterAck(clientMessageId: String) {
val cid = clientMessageId.trim() val cid = clientMessageId.trim()
if (cid.isEmpty()) return 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). */ /** Re-queues a failed public / text outbound row (outbox row must still exist). */
fun retryOutboundMessage(clientMessageId: String, conversationId: String) { fun retryOutboundMessage(clientMessageId: String, conversationId: String) {
val cid = clientMessageId.trim() val cid = clientMessageId.trim()
@@ -316,11 +379,15 @@ object OutgoingMessageCoordinator {
val row = MessageDatabaseProvider.database.messageDatabaseQueries val row = MessageDatabaseProvider.database.messageDatabaseQueries
.selectOutboxItem(instanceId, cid) .selectOutboxItem(instanceId, cid)
.executeAsOneOrNull() .executeAsOneOrNull()
if (row?.kind == KIND_SEND_DM_ATTACHMENT) { when (row?.kind) {
runCatching { KIND_SEND_DM_ATTACHMENT -> runCatching {
val payload = json.decodeFromString<DmAttachmentOutboxPayload>(row.payloadJson) val payload = json.decodeFromString<DmAttachmentOutboxPayload>(row.payloadJson)
abortDmServerUploadIfNeeded(payload.uploadId) abortDmServerUploadIfNeeded(payload.uploadId)
} }
KIND_SEND_PUBLIC_ATTACHMENT -> runCatching {
val payload = json.decodeFromString<PublicAttachmentOutboxPayload>(row.payloadJson)
abortPublicServerUploadIfNeeded(payload.uploadId)
}
} }
MessageDatabaseProvider.database.messageDatabaseQueries.deleteOutboxItem(instanceId, cid) MessageDatabaseProvider.database.messageDatabaseQueries.deleteOutboxItem(instanceId, cid)
MessageCacheStore.deleteMessageByClientMessageId(conversationId, cid) MessageCacheStore.deleteMessageByClientMessageId(conversationId, cid)
@@ -366,7 +433,14 @@ object OutgoingMessageCoordinator {
scheduleOutboxRetry(id) 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 allOk
@@ -378,6 +452,9 @@ object OutgoingMessageCoordinator {
const val KIND_SEND_DM_ATTACHMENT = "send_dm_attachment" 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. */ /** 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_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) { suspend fun pruneStaleAttachmentOutboxForInstance(instanceId: String) {
val id = instanceId.trim() val id = instanceId.trim()
@@ -392,7 +469,9 @@ object OutgoingMessageCoordinator {
for (row in rows) { for (row in rows) {
when (row.kind) { when (row.kind) {
KIND_SEND_DM_ATTACHMENT, 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( if (!MessageCacheStore.hasSentMessageWithClientId(
row.conversationId, row.conversationId,
row.clientMessageId, 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.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.send.DmAttachmentOutboxPayload import ru.fromchat.api.local.send.DmAttachmentOutboxPayload
import ru.fromchat.api.local.send.OutgoingMessageCoordinator 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.send.scheduleOutboxProcessing
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.repairInterruptedUploadArtifacts import ru.fromchat.api.local.cache.repairInterruptedUploadArtifacts
@@ -53,9 +54,19 @@ object AttachmentTransferBootstrap {
.selectPendingOutboxForInstance(instanceId) .selectPendingOutboxForInstance(instanceId)
.executeAsList() .executeAsList()
for (row in rows) { 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 { val clientMessageId = runCatching {
when (row.kind) {
OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT ->
json.decodeFromString<DmAttachmentOutboxPayload>(row.payloadJson).clientMessageId.trim() json.decodeFromString<DmAttachmentOutboxPayload>(row.payloadJson).clientMessageId.trim()
else ->
json.decodeFromString<PublicAttachmentOutboxPayload>(row.payloadJson).clientMessageId.trim()
}
}.getOrNull().orEmpty() }.getOrNull().orEmpty()
if (clientMessageId.isEmpty()) continue if (clientMessageId.isEmpty()) continue
repairInterruptedUploadArtifacts(instanceId, clientMessageId) repairInterruptedUploadArtifacts(instanceId, clientMessageId)
@@ -25,7 +25,7 @@ object AttachmentUploadNotifier {
"msg" to msg, "msg" to msg,
) )
is AttachmentUploadProgress.InProgress -> { 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( AttachmentMediaLog.upload(
"progress", "progress",
"job" to progress.jobId, "job" to progress.jobId,
@@ -33,20 +33,36 @@ object AttachmentUploadNotifier {
"file" to progress.filename, "file" to progress.filename,
"msg" to msg, "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) 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( AttachmentMediaLog.upload(
"failed", "failed",
"job" to progress.jobId, "job" to progress.jobId,
"err" to progress.error, "err" to progress.error,
"msg" to msg, "msg" to msg,
) )
AttachmentMediaLog.send(
"upload_failed",
"job" to progress.jobId.take(12),
"err" to progress.error,
)
} }
}
// Prefer immediate delivery so the determinate indicator tracks chunks.
if (!_progressFlow.tryEmit(progress)) {
mainScope.launch { mainScope.launch {
_progressFlow.emit(progress) _progressFlow.emit(progress)
} }
} }
}
} }
@@ -38,13 +38,18 @@ data class Message(
@Transient val uploadError: String? = null, @Transient val uploadError: String? = null,
/** For DM file decryption; not serialized over network. */ /** For DM file decryption; not serialized over network. */
@Transient val dmEnvelope: DmEnvelope? = null, @Transient val dmEnvelope: DmEnvelope? = null,
/** Blurhashes for image files (by index); from decrypted message JSON. */ /** Base64 JPEG thumbnails for image files (by index); public API or decrypted DM JSON. */
@Transient val fileThumbnails: List<String>? = null, @SerialName("fileThumbnails") val fileThumbnails: List<String>? = null,
/** Aspect ratios (width/height) for image files (by index); from decrypted message JSON. */ /**
* 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, @Transient val fileAspectRatios: List<Float>? = null,
/** File sizes in bytes (by index); from decrypted message JSON. */ /** Image dimensions (width, height) for image files (by index). */
@Transient val fileSizes: List<Long>? = null,
/** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */
@Transient val fileDimensions: List<Pair<Int, Int>>? = null, @Transient val fileDimensions: List<Pair<Int, Int>>? = null,
/** True when DM plaintext could not be decrypted and [content] shows the corrupted placeholder. */ /** True when DM plaintext could not be decrypted and [content] shows the corrupted placeholder. */
@Transient val isContentCorrupted: Boolean = false, @Transient val isContentCorrupted: Boolean = false,
@@ -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 content: String,
val reply_to_id: Int? = null, val reply_to_id: Int? = null,
val client_message_id: String? = 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AttachFile import androidx.compose.material.icons.rounded.AttachFile
import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Download
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon 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.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.pr0gramm3r101.utils.conditional
import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.crypto.Base64
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.stringResource 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.CancellableAttachmentProgressIndicator
import ru.fromchat.ui.chat.components.ChatFileAttachmentTile import ru.fromchat.ui.chat.components.ChatFileAttachmentTile
import ru.fromchat.ui.chat.components.FileAttachmentLeadingSlot 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.attachmentImageCornerShape
import ru.fromchat.ui.chat.utils.attachmentTileLayout
import ru.fromchat.ui.chat.utils.coalesceDecodeTarget import ru.fromchat.ui.chat.utils.coalesceDecodeTarget
import ru.fromchat.ui.chat.utils.decodeSizeChangedMeaningfully import ru.fromchat.ui.chat.utils.decodeSizeChangedMeaningfully
import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import com.pr0gramm3r101.utils.scaleOnPress import com.pr0gramm3r101.utils.scaleOnPress
import ru.fromchat.ui.chat.MessageGroupInfo 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 const val BLUR_FADE_MS = 450
private enum class AttachmentTileVisual {
Empty,
Thumb,
Full,
}
internal fun isImageFilename(name: String): Boolean = internal fun isImageFilename(name: String): Boolean =
name.endsWith(".png", true) || name.endsWith(".jpg", true) || name.endsWith(".png", true) || name.endsWith(".jpg", true) ||
name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", 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 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 showImageTile = isPendingImage || isConfirmedImage
val isPendingFile = pendingFileUri != null && !isImage val isPendingFile = pendingFileUri != null && !isImage
@@ -213,18 +217,9 @@ fun AttachmentPreview(
Modifier Modifier
} }
) )
.conditional( // Explicit px size from dp max + aspect — do not wrap to thumb intrinsics
fileAspectRatio != null && fileAspectRatio > 0f, // (IntrinsicSize.Max bubbles otherwise shrink to ~80px ≈ 3× too small).
`if` = { .attachmentTileLayout(aspectRatio = fileAspectRatio)
Modifier
.heightIn(max = IMAGE_MAX_HEIGHT)
.widthIn(max = IMAGE_SIZE)
.aspectRatio(fileAspectRatio!!, matchHeightConstraintsFirst = true)
},
`else` = {
Modifier.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_MAX_HEIGHT)
}
)
.clip(attachmentImageCornerShape(isAuthor, messageGroup)) .clip(attachmentImageCornerShape(isAuthor, messageGroup))
.then( .then(
if (onImageBounds != null && showImageTile) { if (onImageBounds != null && showImageTile) {
@@ -251,8 +246,11 @@ fun AttachmentPreview(
.fillMaxSize() .fillMaxSize()
.graphicsLayer { alpha = if (isExpanded) 0f else 1f } .graphicsLayer { alpha = if (isExpanded) 0f else 1f }
) { ) {
val imageStableKey = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } val imageStableKey = when {
(messageId ?: 0) > 0 -> "img:$messageId:${fileIndex ?: 0}"
else -> clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
?: "img:${messageId ?: 0}:${fileIndex ?: 0}" ?: "img:${messageId ?: 0}:${fileIndex ?: 0}"
}
key(imageStableKey) { key(imageStableKey) {
ChatImageTileContent( ChatImageTileContent(
messageId = messageId ?: -1, messageId = messageId ?: -1,
@@ -314,45 +312,74 @@ private fun ChatImageTileContent(
val clipShape = attachmentImageCornerShape(isAuthor, messageGroup) val clipShape = attachmentImageCornerShape(isAuthor, messageGroup)
val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f } 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 seedDecodeSize = remember(layoutAspect) { previewSeedDecodeSize(layoutAspect) }
val decryptCacheKey = remember(messageId, fileIndex, cacheClientId) { val decodeCacheKeys = remember(cacheClientId, messageId, fileIndex) {
DecryptedImageCache.storageKey(messageId, fileIndex, cacheClientId) 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) { val decodeSize = remember(tileDecodeSize, fallbackDecodeSize, seedDecodeSize) {
coalesceDecodeTarget(tileDecodeSize, fallbackDecodeSize, seedDecodeSize) coalesceDecodeTarget(tileDecodeSize, fallbackDecodeSize, seedDecodeSize)
} }
val initialFull = remember(decryptCacheKey) { LocalDecodedImageCache.peekFull(decryptCacheKey) } var cachedPath by remember(bitmapStateKey) {
val hadInstantFull = initialFull != null
var cachedPath by remember(decryptCacheKey) {
mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)) mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId))
} }
val diskCacheUri = cachedPath val diskCacheUri = cachedPath
val hasDiskCache = diskCacheUri != null val hasDiskCache = diskCacheUri != null
val displayLocalUri = localUri?.trim()?.takeIf { it.isNotEmpty() } ?: diskCacheUri val displayLocalUri = localUri?.trim()?.takeIf { it.isNotEmpty() } ?: diskCacheUri
val hasLocalSource = displayLocalUri != null 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) mutableStateOf(initialFull)
} }
var didRevealFull by remember(decryptCacheKey) { mutableStateOf(hadInstantFull || hasDiskCache) } val placeholderMemoryKey = remember(decryptCacheKey) { "${decryptCacheKey}#placeholder" }
val thumbBlurAlpha = remember(decryptCacheKey, useBlurredThumbPlaceholder, hasLocalSource) { val thumbnailBytes = remember(thumbnailBase64) {
Animatable( thumbnailBase64?.let { decodeAttachmentThumbnailBase64(it) }
when { }
hadInstantFull || hasDiskCache -> 0f val initialPlaceholder = remember(bitmapStateKey) {
useBlurredThumbPlaceholder || hasLocalSource -> 1f DecryptedImageCache.peekPlaceholderThumb(
else -> 0f memoryKey = placeholderMemoryKey,
}, messageId = messageId,
fileIndex = fileIndex,
clientMessageId = cacheClientId,
) )
} }
val fullRevealAlpha = remember(decryptCacheKey) { val placeholderBitmap by produceState(
Animatable(if (hadInstantFull) 1f else 0f) 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) { val outboundOverlayAlpha = remember(showOutboundBlurOverlay) {
Animatable(if (showOutboundBlurOverlay) 1f else 0f) Animatable(if (showOutboundBlurOverlay) 1f else 0f)
} }
@@ -372,50 +399,27 @@ private fun ChatImageTileContent(
AttachmentDownloadNotifier.isCancelled(messageId, fileIndex, cacheClientId) || AttachmentDownloadNotifier.isCancelled(messageId, fileIndex, cacheClientId) ||
AttachmentDownloadNotifier.hasResumablePartial(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( AttachmentDownloadNotifier.restorePausedForAttachment(
messageId = messageId, messageId = messageId,
fileIndex = fileIndex, fileIndex = fileIndex,
clientMessageId = cacheClientId, clientMessageId = cacheClientId,
) )
} }
var isAwaitingNetworkFull by remember(decryptCacheKey) { mutableStateOf(false) } var isAwaitingNetworkFull by remember(bitmapStateKey) { mutableStateOf(false) }
var loadAttempt by remember(decryptCacheKey) { mutableIntStateOf(0) } var loadAttempt by remember(bitmapStateKey) { mutableIntStateOf(0) }
LaunchedEffect(showOutboundBlurOverlay) { LaunchedEffect(showOutboundBlurOverlay) {
if (showOutboundBlurOverlay) { if (showOutboundBlurOverlay) {
outboundOverlayAlpha.snapTo(1f) outboundOverlayAlpha.snapTo(1f)
} else { } else {
if (fullBitmap != null) {
didRevealFull = true
fullRevealAlpha.snapTo(1f)
thumbBlurAlpha.snapTo(0f)
}
outboundOverlayAlpha.animateTo(0f, tween(BLUR_FADE_MS, easing = FastOutSlowInEasing)) 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 imageContentScale = ContentScale.Fit
val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f) val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f)
@@ -445,6 +449,26 @@ private fun ChatImageTileContent(
"diskCache" to (diskCacheUri?.take(48) ?: "null"), "diskCache" to (diskCacheUri?.take(48) ?: "null"),
"target" to "${decodeSize.widthPx}x${decodeSize.heightPx}", "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 val diskUri = diskCacheUri
?: DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.also { cachedPath = it } ?: DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.also { cachedPath = it }
val localPaths = buildList { val localPaths = buildList {
@@ -452,13 +476,14 @@ private fun ChatImageTileContent(
diskUri?.let { cached -> if (none { it == cached }) add(cached) } diskUri?.let { cached -> if (none { it == cached }) add(cached) }
} }
if (localPaths.isNotEmpty()) { if (localPaths.isNotEmpty()) {
val quickDecode = previewSeedDecodeSize(aspectRatio)
val loaded = withContext(Dispatchers.Default) { val loaded = withContext(Dispatchers.Default) {
LocalDecodedImageCache.peekFull(decryptCacheKey) peekDecodedAttachmentBitmap(decodeCacheKeys)
?: localPaths.firstNotNullOfOrNull { path -> ?: localPaths.firstNotNullOfOrNull { path ->
LocalDecodedImageCache.loadFull( LocalDecodedImageCache.loadFull(
decryptCacheKey, decryptCacheKey,
path.removePrefix("file://"), path.removePrefix("file://"),
decodeSize, quickDecode,
) )
} }
} }
@@ -472,26 +497,67 @@ private fun ChatImageTileContent(
fullBitmap = loaded fullBitmap = loaded
cachedPath = diskUri ?: localPaths.firstOrNull() cachedPath = diskUri ?: localPaths.firstOrNull()
decryptFinished = true decryptFinished = true
didRevealFull = true AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
fullRevealAlpha.snapTo(1f)
thumbBlurAlpha.snapTo(0f)
onFullyLoaded(true) onFullyLoaded(true)
return@LaunchedEffect // Own sends with a local preview never need a network round-trip for display.
} if (isOutboundPending || isAuthor) return@LaunchedEffect
} else {
AttachmentMediaLog.tileLoad( AttachmentMediaLog.tileLoad(
"load_local_miss", "load_local_miss",
"key" to decryptCacheKey, "key" to decryptCacheKey,
"paths" to localPaths.size, "paths" to localPaths.size,
) )
} }
if (isOutboundPending) { }
decryptFinished = localPaths.isEmpty() && thumbBitmap == null && thumbnailBytes == null // Own sends: never fall through to network while a local/disk preview exists (or is expected).
if (fullBitmap != null || thumbBitmap != null) onFullyLoaded(true) 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 return@LaunchedEffect
} }
if (serverFile == null || envelope == null) { }
}
if (fullBitmap != null || placeholderBitmap != null || localPaths.isNotEmpty() ||
cachedPath != null
) {
decryptFinished = true decryptFinished = true
AttachmentMediaLog.tileLoad("load_skip_no_envelope", "key" to decryptCacheKey) AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
onFullyLoaded(true)
return@LaunchedEffect
}
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 (serverFile == null) {
decryptFinished = true
AttachmentMediaLog.tileLoad("load_skip_no_file", "key" to decryptCacheKey)
return@LaunchedEffect return@LaunchedEffect
} }
if (diskUri != null) { if (diskUri != null) {
@@ -510,9 +576,6 @@ private fun ChatImageTileContent(
) )
fullBitmap = loaded fullBitmap = loaded
decryptFinished = true decryptFinished = true
didRevealFull = true
fullRevealAlpha.snapTo(1f)
thumbBlurAlpha.snapTo(0f)
onFullyLoaded(true) onFullyLoaded(true)
return@LaunchedEffect return@LaunchedEffect
} }
@@ -522,13 +585,23 @@ private fun ChatImageTileContent(
"uri" to diskUri, "uri" to diskUri,
) )
} }
val usePlainDownload = envelope == null
AttachmentMediaLog.tileLoad( AttachmentMediaLog.tileLoad(
"load_network_decrypt", if (usePlainDownload) "load_network_plain" else "load_network_decrypt",
"key" to decryptCacheKey, "key" to decryptCacheKey,
"file" to serverFile.path, "file" to serverFile.path,
) )
isAwaitingNetworkFull = true isAwaitingNetworkFull = true
val uri = try { val uri = try {
if (usePlainDownload) {
DecryptedImageCache.getOrDownloadPlain(
messageId = messageId,
fileIndex = fileIndex,
file = serverFile,
clientMessageId = cacheClientId,
messageLabel = messageLabel,
)
} else {
DecryptedImageCache.getOrDecrypt( DecryptedImageCache.getOrDecrypt(
messageId = messageId, messageId = messageId,
fileIndex = fileIndex, fileIndex = fileIndex,
@@ -538,6 +611,7 @@ private fun ChatImageTileContent(
clientMessageId = cacheClientId, clientMessageId = cacheClientId,
messageLabel = messageLabel, messageLabel = messageLabel,
) )
}
} catch (error: Throwable) { } catch (error: Throwable) {
AttachmentMediaLog.tileLoad( AttachmentMediaLog.tileLoad(
"load_exception", "load_exception",
@@ -566,9 +640,6 @@ private fun ChatImageTileContent(
) )
if (fullBitmap != null) { if (fullBitmap != null) {
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
didRevealFull = true
fullRevealAlpha.snapTo(1f)
thumbBlurAlpha.snapTo(0f)
onFullyLoaded(true) onFullyLoaded(true)
} }
} }
@@ -596,53 +667,39 @@ private fun ChatImageTileContent(
} }
} }
LaunchedEffect(fullBitmap, thumbBitmap, hadInstantFull, hasDiskCache) { LaunchedEffect(placeholderBitmap) {
when { if (placeholderBitmap != null) decryptFinished = true
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(treatAsOutbound, messageId, fileIndex, cacheClientId) {
if (treatAsOutbound) {
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
} }
} }
val isDownloadingFullImage = !isOutboundPending && fullBitmap == null && LaunchedEffect(hasLocalSource, hasDiskCache, fullBitmap, placeholderBitmap, isAuthor, messageId, fileIndex, cacheClientId) {
(downloadProgress != null || isAwaitingNetworkFull) if (isAuthor && (hasLocalSource || hasDiskCache || fullBitmap != null || placeholderBitmap != null)) {
val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay && !downloadCancelled AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
val showDownloadCancelledOverlay = downloadCancelled && fullBitmap == null && !isOutboundPending }
val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !isOutboundPending && !downloadCancelled }
val showSpinnerOnly = fullBitmap == null && thumbBitmap == null && !hasLocalSource &&
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 && !showLoadFailedOverlay &&
!showOutboundBlurOverlay && !showOutboundBlurOverlay &&
(isDownloadingFullImage || (!decryptFinished && !isOutboundPending)) !showDownloadProgressOverlay &&
!showDownloadCancelledOverlay &&
(!decryptFinished && !treatAsOutbound)
Box( Box(
modifier = Modifier modifier = Modifier
@@ -660,52 +717,52 @@ private fun ChatImageTileContent(
) { ) {
when { when {
else -> { else -> {
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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(tilePlaceholderColor), .background(tilePlaceholderColor),
) )
if (fullBitmap == null && hasLocalSource && !showOutboundBlurOverlay) {
AsyncImage(
model = displayLocalUri,
contentDescription = null,
contentScale = imageContentScale,
modifier = Modifier.fillMaxSize(),
)
} }
thumbBitmap?.let { thumb -> AttachmentTileVisual.Thumb -> {
placeholderBitmap?.let { thumb ->
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.then( .hazeEffect(style = HazeMaterials.thin()),
if (thumbBlurAlpha.value > 0.01f) {
Modifier.hazeEffect(style = HazeMaterials.thin())
} else {
Modifier
},
),
) { ) {
CachedAttachmentImage( CachedAttachmentImage(
bitmap = thumb, bitmap = thumb,
contentDescription = serverFile?.name, contentDescription = serverFile?.name,
contentScale = imageContentScale, contentScale = imageContentScale,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.alpha(thumbBlurAlpha.value.coerceIn(0f, 1f)),
) )
} }
} }
}
AttachmentTileVisual.Full -> {
fullBitmap?.let { full -> fullBitmap?.let { full ->
CachedAttachmentImage( CachedAttachmentImage(
bitmap = full, bitmap = full,
contentDescription = serverFile?.name, contentDescription = serverFile?.name,
contentScale = imageContentScale, contentScale = imageContentScale,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.alpha(fullRevealAlpha.value.coerceIn(0f, 1f)),
) )
LaunchedEffect(full) { onFullyLoaded(true) } LaunchedEffect(full) { onFullyLoaded(true) }
} }
}
}
}
AnimatedVisibility( AnimatedVisibility(
visible = showDownloadProgressOverlay, visible = showDownloadProgressOverlay,
enter = scaleIn( enter = scaleIn(
@@ -766,7 +823,7 @@ private fun ChatImageTileContent(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
IndefiniteCircularProgress(modifier = Modifier.size(28.dp)) ExpressiveUploadIndicator(uploadProgress = null, modifier = Modifier.size(28.dp))
} }
} }
if (showLoadFailedOverlay) { if (showLoadFailedOverlay) {
@@ -785,8 +842,12 @@ private fun ChatImageTileContent(
} }
if (showOutboundBlurOverlay && outboundOverlayAlpha.value > 0.01f) { if (showOutboundBlurOverlay && outboundOverlayAlpha.value > 0.01f) {
UploadingImageOverlay( UploadingImageOverlay(
model = localUri ?: displayLocalUri!!, model = localUri ?: displayLocalUri,
uploadProgress = if (isUploading || awaitingServerAck) uploadProgress else null, previewBitmap = fullBitmap,
uploadProgress = when {
isUploading || awaitingServerAck -> uploadProgress ?: 0
else -> null
},
clipShape = clipShape, clipShape = clipShape,
contentScale = imageContentScale, contentScale = imageContentScale,
onCancelUpload = if (isUploading && !awaitingServerAck) onCancelUpload else null, onCancelUpload = if (isUploading && !awaitingServerAck) onCancelUpload else null,
@@ -795,7 +856,7 @@ private fun ChatImageTileContent(
.alpha(outboundOverlayAlpha.value), .alpha(outboundOverlayAlpha.value),
) )
} }
if (isOutboundPending && !uploadError.isNullOrBlank() && onRetryUpload != null) { if (treatAsOutbound && !uploadError.isNullOrBlank() && onRetryUpload != null) {
AttachmentUploadFailedOverlay( AttachmentUploadFailedOverlay(
isAuthor = isAuthor, isAuthor = isAuthor,
errorKey = uploadError, errorKey = uploadError,
@@ -849,7 +910,8 @@ private fun DownloadCancelledImageOverlay(
@OptIn(ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
private fun UploadingImageOverlay( private fun UploadingImageOverlay(
model: String, model: String?,
previewBitmap: ImageBitmap?,
uploadProgress: Int?, uploadProgress: Int?,
clipShape: RoundedCornerShape, clipShape: RoundedCornerShape,
contentScale: ContentScale = ContentScale.Fit, contentScale: ContentScale = ContentScale.Fit,
@@ -863,6 +925,16 @@ private fun UploadingImageOverlay(
.clip(clipShape) .clip(clipShape)
.hazeEffect(style = HazeMaterials.thin()) .hazeEffect(style = HazeMaterials.thin())
) { ) {
when {
previewBitmap != null -> {
CachedAttachmentImage(
bitmap = previewBitmap,
contentDescription = null,
contentScale = contentScale,
modifier = Modifier.fillMaxSize(),
)
}
!model.isNullOrBlank() -> {
AsyncImage( AsyncImage(
model = model, model = model,
contentDescription = null, contentDescription = null,
@@ -870,11 +942,18 @@ private fun UploadingImageOverlay(
contentScale = contentScale, contentScale = contentScale,
) )
} }
else -> {
Box( Box(
modifier = Modifier Modifier
.matchParentSize() .fillMaxSize()
.padding(16.dp), .background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center )
}
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { ) {
if (onCancelUpload != null) { if (onCancelUpload != null) {
CancellableAttachmentProgressIndicator( CancellableAttachmentProgressIndicator(
@@ -903,9 +982,11 @@ internal fun ExpressiveUploadIndicator(
) { ) {
// Latch first percent so we do not swap indeterminate ↔ determinate indicators (that resets animation). // Latch first percent so we do not swap indeterminate ↔ determinate indicators (that resets animation).
var latchedPercent by remember { mutableStateOf<Int?>(null) } var latchedPercent by remember { mutableStateOf<Int?>(null) }
LaunchedEffect(uploadProgress) {
if (uploadProgress != null) { if (uploadProgress != null) {
latchedPercent = uploadProgress.coerceIn(0, 100) latchedPercent = uploadProgress.coerceIn(0, 100)
} }
}
val clampedProgress = latchedPercent val clampedProgress = latchedPercent
val indeterminate = clampedProgress == null val indeterminate = clampedProgress == null
val waveActive = clampedProgress != null && clampedProgress < 100 val waveActive = clampedProgress != null && clampedProgress < 100
@@ -930,9 +1011,12 @@ internal fun ExpressiveUploadIndicator(
val defaultIndicatorAmplitude = WavyProgressIndicatorDefaults.indicatorAmplitude val defaultIndicatorAmplitude = WavyProgressIndicatorDefaults.indicatorAmplitude
Box(modifier = modifier, contentAlignment = Alignment.Center) { Box(modifier = modifier, contentAlignment = Alignment.Center) {
val indicatorModifier = Modifier
.fillMaxHeight()
.aspectRatio(1f)
if (indeterminate) { if (indeterminate) {
CircularWavyProgressIndicator( CircularWavyProgressIndicator(
modifier = Modifier.fillMaxSize(), modifier = indicatorModifier,
color = primary, color = primary,
trackColor = trackColor, trackColor = trackColor,
amplitude = animatedWaveStrength amplitude = animatedWaveStrength
@@ -940,7 +1024,7 @@ internal fun ExpressiveUploadIndicator(
} else { } else {
CircularWavyProgressIndicator( CircularWavyProgressIndicator(
progress = { animatedProgress }, progress = { animatedProgress },
modifier = Modifier.fillMaxSize(), modifier = indicatorModifier,
color = primary, color = primary,
trackColor = trackColor, trackColor = trackColor,
amplitude = { p -> animatedWaveStrength * defaultIndicatorAmplitude(p) } amplitude = { p -> animatedWaveStrength * defaultIndicatorAmplitude(p) }
@@ -984,6 +1068,7 @@ private fun PendingImageContent(
if (uploading) { if (uploading) {
UploadingImageOverlay( UploadingImageOverlay(
model = uri, model = uri,
previewBitmap = null,
uploadProgress = uploadProgress, uploadProgress = uploadProgress,
clipShape = attachmentImageCornerShape(isAuthor = false), clipShape = attachmentImageCornerShape(isAuthor = false),
modifier = Modifier.matchParentSize(), modifier = Modifier.matchParentSize(),
@@ -999,14 +1084,10 @@ private fun PendingImageContent(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (isUploading) { if (isUploading) {
if (uploadProgress != null) { ExpressiveUploadIndicator(
DeterminateCircularProgress( uploadProgress = uploadProgress,
progress = uploadProgress, modifier = Modifier.size(40.dp),
modifier = Modifier.size(40.dp)
) )
} else {
IndefiniteCircularProgress(modifier = Modifier.size(40.dp))
}
} else { } else {
Icon( Icon(
imageVector = Icons.Rounded.AttachFile, 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 @Composable
internal fun CachedAttachmentImage( internal fun CachedAttachmentImage(
bitmap: ImageBitmap, bitmap: ImageBitmap,
@@ -239,7 +239,7 @@ fun ChatInput(
onClearReply: () -> Unit, onClearReply: () -> Unit,
onClearEdit: () -> Unit, onClearEdit: () -> Unit,
hazeState: HazeState, hazeState: HazeState,
recipientId: Int? = null, supportsAttachments: Boolean = false,
currentUserId: Int? = null, currentUserId: Int? = null,
isReadOnly: Boolean = false, isReadOnly: Boolean = false,
onReadOnlyMessageClick: () -> Unit = {}, onReadOnlyMessageClick: () -> Unit = {},
@@ -477,7 +477,7 @@ fun ChatInput(
}, },
) )
if (recipientId != null) { if (supportsAttachments) {
Box( Box(
modifier = Modifier modifier = Modifier
.padding(vertical = ChatInputIconSlotVerticalInset) .padding(vertical = ChatInputIconSlotVerticalInset)
@@ -9,15 +9,19 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.api.local.messages.generateClientMessageId import ru.fromchat.api.local.messages.generateClientMessageId
import ru.fromchat.api.local.messages.nowMessageTimestampIso import ru.fromchat.api.local.messages.nowMessageTimestampIso
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.messages.Message 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.api.schema.websocket.WebSocketMessage
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.api.local.send.clearOutboundFileCaches import ru.fromchat.api.local.send.clearOutboundFileCaches
import ru.fromchat.api.local.send.clearOutboundImageCaches 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.TypingHandler
import ru.fromchat.ui.chat.utils.TypingUser import ru.fromchat.ui.chat.utils.TypingUser
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId 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. */ /** Public chat: registered user count; null until first successful load or WS update. */
val publicGroupMemberCount: Int? = null, val publicGroupMemberCount: Int? = null,
/** Public chat: true until first count response (HTTP or WebSocket). */ /** 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 @Serializable
@@ -242,11 +248,7 @@ abstract class ChatPanel(
open suspend fun cancelQueuedMessage(message: Message) { open suspend fun cancelQueuedMessage(message: Message) {
val cid = message.client_message_id?.trim().orEmpty() val cid = message.client_message_id?.trim().orEmpty()
if (cid.isEmpty()) return if (cid.isEmpty()) return
if (message.pendingFileUri != null) { if (!beginMessageDissolve(message)) return
clearOutboundImageCaches(cid, message.id)
clearOutboundFileCaches(cid, message.id)
}
removeMessage(message.id)
OutgoingMessageCoordinator.cancelOutboundMessage(cid, outboxConversationId()) OutgoingMessageCoordinator.cancelOutboundMessage(cid, outboxConversationId())
} }
@@ -259,6 +261,59 @@ abstract class ChatPanel(
cancelQueuedMessage(message) 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 * 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) { fun handleMessageConfirmed(tempId: String, confirmedMessage: Message) {
val pending = pendingMessages.remove(tempId) val pending = pendingMessages.remove(tempId)
pending?.first?.cancel() 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 -> updateState { currentState ->
val optimistic = currentState.messages.find { it.client_message_id == tempId } val optimistic = currentState.messages.find { it.client_message_id == tempId }
@@ -343,12 +407,32 @@ abstract class ChatPanel(
} else { } else {
confirmedMessage confirmedMessage
} }
val resolvedConfirmed = if (withClientId.reply_to == null) { val withReply = if (withClientId.reply_to == null) {
val reply = optimistic?.reply_to val reply = optimistic?.reply_to
if (reply != null) withClientId.copy(reply_to = reply) else withClientId if (reply != null) withClientId.copy(reply_to = reply) else withClientId
} else { } else {
withClientId 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) { val withoutDupReal = if (resolvedConfirmed.id > 0) {
currentState.messages.filter { it.id != resolvedConfirmed.id } currentState.messages.filter { it.id != resolvedConfirmed.id }
} else { } else {
@@ -369,10 +453,122 @@ abstract class ChatPanel(
) )
} }
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val resolved = _state.messages.find { it.client_message_id == tempId } val optimistic = pending?.second
?: _state.messages.find { it.id == confirmedMessage.id } ?: _state.messages.find { it.client_message_id == tempId }
val toPersist = resolved ?: confirmedMessage 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) } 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. */ /** DM recipient ID for attachment uploads; null for non-DM panels. */
open fun getRecipientId(): Int? = null 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 open val showUsernamesInMessages: Boolean
get() = true get() = true
@@ -36,6 +36,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -82,6 +83,7 @@ import ru.fromchat.Logger
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.db.store.ConnectionStatus import ru.fromchat.api.local.db.store.ConnectionStatus
@@ -212,15 +214,44 @@ fun ChatScreen(
mutableStateOf(setOf<String>()) mutableStateOf(setOf<String>())
} }
// Visit-scoped (not saveable): reopen must re-seed history, never replay enter. // Visit-scoped (not saveable): reopen must re-seed history, never replay enter.
var lastAnimatedMessageKeys by remember(panelId) { mutableStateOf(setOf<String>()) } val openingMessages = remember(panelId) { panel.getState().messages }
var knownEnterIdentities by remember(panelId) { mutableStateOf(setOf<String>()) } 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 enterCoordinator = remember(panelId) { MessageEnterCoordinator(scope) }
val activeEnterAnimation by enterCoordinator.currentItem.collectAsState() val activeEnterAnimation by enterCoordinator.currentItem.collectAsState()
val pendingNewMessageKeys by enterCoordinator.pendingNewMessageKeys.collectAsState() val pendingNewMessageKeys by enterCoordinator.pendingNewMessageKeys.collectAsState()
val queuedEnter by enterCoordinator.queuedEnter.collectAsState() val queuedEnter by enterCoordinator.queuedEnter.collectAsState()
var previousNewestFingerprint by remember(panelId) { mutableStateOf("") } var previousNewestFingerprint by remember(panelId) { mutableStateOf("") }
var previousEnterMessageCount by remember(panelId) { mutableIntStateOf(0) } 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) { LaunchedEffect(panelState.messages) {
val messages = panelState.messages val messages = panelState.messages
@@ -255,6 +286,7 @@ fun ChatScreen(
previousNewestFingerprint = fingerprint previousNewestFingerprint = fingerprint
previousEnterMessageCount = messages.size previousEnterMessageCount = messages.size
enterAnimationsSeeded = true enterAnimationsSeeded = true
visitEnterSyncComplete = true
} }
// First non-empty load for this visit: never animate existing history. // First non-empty load for this visit: never animate existing history.
@@ -299,6 +331,10 @@ fun ChatScreen(
seedWithoutAnimating("skip_new_not_newest") seedWithoutAnimating("skip_new_not_newest")
return@LaunchedEffect return@LaunchedEffect
} }
if (newestKey in mountSnapshotKeys || newestIdentity in mountSnapshotIdentities) {
seedWithoutAnimating("skip_opening_snapshot")
return@LaunchedEffect
}
if (newestKey in lastAnimatedMessageKeys || newestIdentity in knownEnterIdentities) { if (newestKey in lastAnimatedMessageKeys || newestIdentity in knownEnterIdentities) {
seedWithoutAnimating("skip_already_known") seedWithoutAnimating("skip_already_known")
return@LaunchedEffect return@LaunchedEffect
@@ -658,26 +694,47 @@ fun ChatScreen(
} }
LaunchedEffect(panel) { LaunchedEffect(panel) {
if (panel.getRecipientId() != null) { if (panel.supportsAttachments) {
AttachmentUploadNotifier.progressFlow.collect { progress -> AttachmentUploadNotifier.progressFlow.collect { progress ->
when (progress) { when (progress) {
is AttachmentUploadProgress.Pending -> is AttachmentUploadProgress.Pending -> {
AttachmentMediaLog.send(
"ui_progress_pending",
"job" to progress.jobId.take(12),
)
panel.updateMessageByClientMessageId(progress.jobId) { panel.updateMessageByClientMessageId(progress.jobId) {
it.copy(uploadProgress = 0, uploadError = null) 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) { panel.updateMessageByClientMessageId(progress.jobId) {
it.copy(uploadProgress = progress.percent, uploadError = null) 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) { panel.updateMessageByClientMessageId(progress.jobId) {
it.copy(uploadProgress = null) it.copy(uploadProgress = null)
} }
}
is AttachmentUploadProgress.Failed -> { is AttachmentUploadProgress.Failed -> {
if (progress.error == "Cancelled") return@collect 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) { panel.updateMessageByClientMessageId(progress.jobId) {
it.copy(uploadProgress = null, uploadError = progress.error) it.copy(uploadProgress = null, uploadError = progress.error)
} }
@@ -756,12 +813,21 @@ fun ChatScreen(
scope.launch { scope.launch {
val replyToId = replyTo?.id?.takeIf { it > 0 } val replyToId = replyTo?.id?.takeIf { it > 0 }
val recipientId = panel.getRecipientId() val recipientId = panel.getRecipientId()
if (attachments.isNotEmpty() && recipientId != null) { if (attachments.isNotEmpty() && panel.supportsAttachments) {
val plaintext = text.ifBlank { "" } val plaintext = text.ifBlank { "" }
attachments.forEach { att -> attachments.forEach { att ->
val jobId = generateClientMessageId() val jobId = generateClientMessageId()
val tempId = optimisticMessageIdForClientMessageId(jobId) val tempId = optimisticMessageIdForClientMessageId(jobId)
val isImage = att.isImage 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) { scope.launch(Dispatchers.Default) {
val imageDimensions = if (isImage) { val imageDimensions = if (isImage) {
getImageDimensions(att.uri) getImageDimensions(att.uri)
@@ -776,6 +842,13 @@ fun ChatScreen(
} else { } else {
null 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) { val staged = if (isImage) {
prepareOutboundImageForSend( prepareOutboundImageForSend(
clientMessageId = jobId, clientMessageId = jobId,
@@ -792,9 +865,24 @@ fun ChatScreen(
) )
} }
if (staged == null) { if (staged == null) {
AttachmentMediaLog.send(
"staging_failed",
"job" to jobId.take(12),
"elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0),
)
return@launch 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( val optimisticMessage = Message(
id = tempId, id = tempId,
user_id = currentUserId ?: -1, user_id = currentUserId ?: -1,
@@ -809,7 +897,7 @@ fun ChatScreen(
client_message_id = jobId, client_message_id = jobId,
reactions = null, reactions = null,
files = null, files = null,
pendingFileUri = fileUri, pendingFileUri = previewUri,
pendingFilename = att.filename, pendingFilename = att.filename,
uploadJobId = jobId, uploadJobId = jobId,
uploadProgress = 0, uploadProgress = 0,
@@ -819,6 +907,13 @@ fun ChatScreen(
) )
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
panel.addMessage(optimisticMessage) 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( AttachmentUploadNotifier.emit(
AttachmentUploadProgress.InProgress( AttachmentUploadProgress.InProgress(
@@ -828,17 +923,46 @@ fun ChatScreen(
), ),
messageLabel = plaintext, messageLabel = plaintext,
) )
if (recipientId != null) {
AttachmentMediaLog.send(
"enqueue_dm",
"job" to jobId.take(12),
"peer" to recipientId,
"elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0),
)
OutgoingMessageCoordinator.enqueueDmAttachment( OutgoingMessageCoordinator.enqueueDmAttachment(
recipientId = recipientId, recipientId = recipientId,
plaintext = plaintext, plaintext = plaintext,
clientMessageId = jobId, clientMessageId = jobId,
replyToId = replyToId, replyToId = replyToId,
fileUri = fileUri, fileUri = uploadUri,
filename = att.filename, filename = att.filename,
optimisticMessage = optimisticMessage, optimisticMessage = optimisticMessage,
aspectRatio = staged.aspectRatio ?: aspectRatio, aspectRatio = staged.aspectRatio ?: aspectRatio,
fileSizeBytes = staged.sizeBytes, 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),
)
} }
} }
} else if (text.isNotBlank()) { } else if (text.isNotBlank()) {
@@ -859,7 +983,7 @@ fun ChatScreen(
inputText = "" inputText = ""
}, },
hazeState = hazeState, hazeState = hazeState,
recipientId = panel.getRecipientId(), supportsAttachments = panel.supportsAttachments,
isReadOnly = isReadOnly, isReadOnly = isReadOnly,
onReadOnlyMessageClick = { onReadOnlyMessageClick = {
if (isReadOnly) { if (isReadOnly) {
@@ -948,10 +1072,16 @@ fun ChatScreen(
// Keep the newest bubble as NewMessage before enqueue runs. // Keep the newest bubble as NewMessage before enqueue runs.
val newest = panelState.messages.lastOrNull() val newest = panelState.messages.lastOrNull()
val newestListKey = newest?.let { messageListKey(it) } val newestListKey = newest?.let { messageListKey(it) }
val newestEnterIdentity = newest?.let { messageEnterIdentity(it) }
val compositionPendingNewest = val compositionPendingNewest =
visitEnterSyncComplete &&
enterAnimationsSeeded && enterAnimationsSeeded &&
newestListKey != null && newestListKey != null &&
newestEnterIdentity != null &&
newestListKey !in mountSnapshotKeys &&
newestEnterIdentity !in mountSnapshotIdentities &&
newestListKey !in lastAnimatedMessageKeys && newestListKey !in lastAnimatedMessageKeys &&
newestEnterIdentity !in knownEnterIdentities &&
newestListKey !in pendingNewMessageKeys && newestListKey !in pendingNewMessageKeys &&
activeEnterAnimation?.newMessageKey != newestListKey activeEnterAnimation?.newMessageKey != newestListKey
val pendingKeysForRole = 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), modifier = Modifier.padding(top = item.spacingAbove),
isContextMenuOpen = contextMenuState.isOpen, isContextMenuOpen = contextMenuState.isOpen,
isContextMenuForThisMessage = isContextMenuForThisMessage =
@@ -80,7 +80,10 @@ import ru.fromchat.api.local.messages.formatMessageDateTimeLocal
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.more 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.imageAspectRatioForMessage
import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap
import ru.fromchat.ui.components.BackHandler import ru.fromchat.ui.components.BackHandler
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import kotlin.math.abs import kotlin.math.abs
@@ -124,29 +127,39 @@ fun ImageFullscreenPreview(
val envelope = message.dmEnvelope val envelope = message.dmEnvelope
val cacheClientId = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() } val cacheClientId = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
val decryptCacheKey = remember(message.id, fileIndex, cacheClientId) { val decodeCacheKeys = remember(message.id, fileIndex, cacheClientId) {
DecryptedImageCache.storageKey(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) LocalDecodedImageCache.fullscreenCacheKey(decryptCacheKey)
} }
var cachedPath by remember(decryptCacheKey) { var cachedPath by remember(bitmapStateKey) {
mutableStateOf(DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId)) mutableStateOf(DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId))
} }
var previewBitmap by remember(decryptCacheKey) { var previewBitmap by remember(bitmapStateKey) {
mutableStateOf(LocalDecodedImageCache.peekFull(decryptCacheKey)) mutableStateOf(peekDecodedAttachmentBitmap(decodeCacheKeys))
} }
var fullscreenBitmap by remember(fsCacheKey) { var fullscreenBitmap by remember(bitmapStateKey) {
mutableStateOf(LocalDecodedImageCache.peekFullscreen(decryptCacheKey)) mutableStateOf(
decodeCacheKeys.firstNotNullOfOrNull { key ->
LocalDecodedImageCache.peekFullscreen(key)
},
)
} }
val hasInstantBitmap = previewBitmap != null || fullscreenBitmap != null 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( val layoutAspectHint = imageAspectRatioForMessage(
fileAspectRatios = message.fileAspectRatios, fileAspectRatios = message.fileAspectRatios,
fileDimensions = message.fileDimensions, fileDimensions = message.fileDimensions,
pendingFileAspectRatio = message.pendingFileAspectRatio, pendingFileAspectRatio = message.pendingFileAspectRatio,
fileAspectRatioPairs = message.fileAspectRatioPairs,
fileIndex = fileIndex, fileIndex = fileIndex,
confirmed = message.id > 0, confirmed = message.id > 0,
hasLocalPreview = !message.pendingFileUri.isNullOrBlank() || cachedPath != null,
) )
var animationBounds by remember(decryptCacheKey) { mutableStateOf<Rect?>(null) } var animationBounds by remember(decryptCacheKey) { mutableStateOf<Rect?>(null) }
if (animationBounds == null && thumbnailBounds != null) { if (animationBounds == null && thumbnailBounds != null) {
@@ -158,7 +171,7 @@ fun ImageFullscreenPreview(
var menusVisible by remember { mutableStateOf(true) } var menusVisible by remember { mutableStateOf(true) }
var dismissRequested by remember { mutableStateOf(false) } var dismissRequested by remember { mutableStateOf(false) }
val backgroundAlpha = remember { Animatable(1f) } 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 isOpenAnimationPlaying by remember { mutableStateOf(false) }
var dismissProgress by remember { mutableStateOf(0f) } var dismissProgress by remember { mutableStateOf(0f) }
val isInitialOpenState = animationBounds != null && !hasPlayedOpenAnimation val isInitialOpenState = animationBounds != null && !hasPlayedOpenAnimation
@@ -205,9 +218,26 @@ fun ImageFullscreenPreview(
) )
} }
LaunchedEffect(decryptCacheKey, fsCacheKey, fullscreenDecodeSize) { LaunchedEffect(bitmapStateKey, fsCacheKey, fullscreenDecodeSize, layoutAspectHint) {
val uri = cachedPath ?: DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId) if (previewBitmap == null) {
?: DecryptedImageCache.getOrDecrypt( 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, messageId = message.id,
fileIndex = fileIndex, fileIndex = fileIndex,
file = file, file = file,
@@ -215,8 +245,29 @@ fun ImageFullscreenPreview(
currentUserId = currentUserId, currentUserId = currentUserId,
clientMessageId = cacheClientId, clientMessageId = cacheClientId,
messageLabel = message.content, messageLabel = message.content,
).also { cachedPath = it } )
} else {
DecryptedImageCache.getOrDownloadPlain(
messageId = message.id,
fileIndex = fileIndex,
file = file,
clientMessageId = cacheClientId,
messageLabel = message.content,
)
}
)?.also { cachedPath = it }
if (uri == null) return@LaunchedEffect 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) { val hiRes = withContext(Dispatchers.Default) {
LocalDecodedImageCache.loadFullscreen(decryptCacheKey, uri, fullscreenDecodeSize) LocalDecodedImageCache.loadFullscreen(decryptCacheKey, uri, fullscreenDecodeSize)
} }
@@ -226,17 +277,12 @@ fun ImageFullscreenPreview(
} }
val displayBitmap = fullscreenBitmap ?: previewBitmap val displayBitmap = fullscreenBitmap ?: previewBitmap
val displayAspect = displayBitmap?.let { bmp -> val hasLocalFile = cachedPath != null ||
bmp.width.toFloat() / bmp.height.toFloat() !message.pendingFileUri.isNullOrBlank() ||
} ?: message.fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f } DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId) != null
val contentHeightAtScale1 = if (displayAspect != null) {
containerWidth / displayAspect
} else {
containerHeight
}
when { when {
displayBitmap == null -> { displayBitmap == null && !hasLocalFile -> {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -253,6 +299,9 @@ fun ImageFullscreenPreview(
) )
} }
else -> { else -> {
val displayAspect = displayBitmap?.let { bmp ->
bmp.width.toFloat() / bmp.height.toFloat()
}
val layoutAspect = openAspectFromBitmap val layoutAspect = openAspectFromBitmap
?: thumbLayoutAspect ?: thumbLayoutAspect
?: layoutAspectHint ?: layoutAspectHint
@@ -260,7 +309,7 @@ fun ImageFullscreenPreview(
val layoutContentHeight = if (layoutAspect != null) { val layoutContentHeight = if (layoutAspect != null) {
containerWidth / layoutAspect containerWidth / layoutAspect
} else { } else {
contentHeightAtScale1 containerHeight
} }
val initial = remember( val initial = remember(
animationBounds, containerWidth, containerHeight, layoutContentHeight, animationBounds, containerWidth, containerHeight, layoutContentHeight,
@@ -635,8 +684,9 @@ fun ImageFullscreenPreview(
.then(sharedElementModifier) .then(sharedElementModifier)
.offset { IntOffset(offsetXAnim.value.roundToInt(), offsetYAnim.value.roundToInt()) } .offset { IntOffset(offsetXAnim.value.roundToInt(), offsetYAnim.value.roundToInt()) }
) { ) {
displayBitmap?.let { bmp ->
FullscreenBitmapImage( FullscreenBitmapImage(
bitmap = displayBitmap, bitmap = bmp,
contentDescription = file.name, contentDescription = file.name,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -646,16 +696,19 @@ fun ImageFullscreenPreview(
translationX = offsetXAnim.value - offsetXAnim.value.roundToInt() translationX = offsetXAnim.value - offsetXAnim.value.roundToInt()
translationY = offsetYAnim.value - offsetYAnim.value.roundToInt() translationY = offsetYAnim.value - offsetYAnim.value.roundToInt()
val scale = maxOf(scaleAnim.value, 0.01f) val scale = maxOf(scaleAnim.value, 0.01f)
shape = androidx.compose.foundation.shape.RoundedCornerShape((cornerRadiusAnim.value / scale).dp) shape = androidx.compose.foundation.shape.RoundedCornerShape(
(cornerRadiusAnim.value / scale).dp,
)
clip = true clip = true
}, },
contentScale = ContentScale.Fit contentScale = ContentScale.Fit,
) )
} }
} }
} }
} }
} }
}
// Top bar: back, display name + date/time, 3-dot menu // Top bar: back, display name + date/time, 3-dot menu
Box( Box(
@@ -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.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
@@ -95,10 +98,13 @@ internal fun isFilenameOnlyMessageCaption(message: Message): Boolean {
private fun isMessageCorrupted(message: Message): Boolean { private fun isMessageCorrupted(message: Message): Boolean {
val files = message.files ?: return false val files = message.files ?: return false
return files.withIndex().any { (index, file) -> return files.withIndex().any { (index, file) ->
isImageFilename(file.name) && ( 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.dmEnvelope == null ||
message.fileThumbnails?.getOrNull(index)?.isBlank() != false message.fileThumbnails?.getOrNull(index)?.isBlank() != false
)
} }
} }
@@ -154,6 +160,9 @@ fun MessageItem(
showTimestamp: Boolean = true, showTimestamp: Boolean = true,
onBubbleTap: (() -> Unit)? = null, onBubbleTap: (() -> Unit)? = null,
enterAnimationRole: EnterAnimationRole = EnterAnimationRole.None, 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) { val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) {
isMessageCorrupted(message) isMessageCorrupted(message)
@@ -262,7 +271,8 @@ fun MessageItem(
enterScale.snapTo(0f) 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. // Single effect: start the spring as soon as this bubble is marked for enter.
LaunchedEffect(enterIdentity, runEnterAnimation) { LaunchedEffect(enterIdentity, runEnterAnimation) {
if (!runEnterAnimation) return@LaunchedEffect if (!runEnterAnimation) return@LaunchedEffect
@@ -285,6 +295,17 @@ fun MessageItem(
"spring_end identity=${enterIdentity.take(12)} role=${enterAnimationRole.name}", "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) { LaunchedEffect(enterIdentity, enterAnimationRole, showSendingIndicator, sendFailed) {
when (enterAnimationRole) { when (enterAnimationRole) {
EnterAnimationRole.PreviousLast -> { EnterAnimationRole.PreviousLast -> {
@@ -295,7 +316,7 @@ fun MessageItem(
} }
EnterAnimationRole.NewMessage -> Unit EnterAnimationRole.NewMessage -> Unit
else -> { else -> {
if (!runEnterAnimation && enterScale.value != 1f) { if (!isExiting && !runEnterAnimation && enterScale.value != 1f) {
enterScale.snapTo(1f) enterScale.snapTo(1f)
} }
timestampForceAlpha.snapTo(1f) timestampForceAlpha.snapTo(1f)
@@ -398,12 +419,15 @@ fun MessageItem(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onGloballyPositioned { rowLayoutCoords = it } .onGloballyPositioned { rowLayoutCoords = it }
.graphicsLayer {
alpha = if (isExiting) exitAlpha.value else 1f
}
.then(rowLongPress) .then(rowLongPress)
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
.enterLayoutHeight( .enterLayoutHeight(
scale = enterScale.value, scale = if (isExiting) layoutCollapse.value else enterScale.value,
minHeightPx = minEnterHeightPx, minHeightPx = minEnterHeightPx,
active = runEnterAnimation, active = shrinkLayoutForEnter || isExiting,
), ),
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start, horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
@@ -701,6 +725,37 @@ fun MessageItem(
(primaryFile != null && !primaryIsImage) (primaryFile != null && !primaryIsImage)
if (showPrimaryImageSlot) { if (showPrimaryImageSlot) {
val imageKey = imageAttachmentKey(message, 0) 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 = val awaitingServer =
message.id < 0 && message.files.isNullOrEmpty() message.id < 0 && message.files.isNullOrEmpty()
val isOutboundPendingImage = val isOutboundPendingImage =
@@ -723,19 +778,7 @@ fun MessageItem(
fileThumbnail = message.fileThumbnails fileThumbnail = message.fileThumbnails
?.firstOrNull() ?.firstOrNull()
?.takeIf { it.isNotBlank() }, ?.takeIf { it.isNotBlank() },
fileAspectRatio = imageAspectRatioForMessage( fileAspectRatio = layoutAspect,
fileAspectRatios = message.fileAspectRatios,
fileDimensions = message.fileDimensions,
pendingFileAspectRatio =
message.pendingFileAspectRatio,
fileIndex = 0,
confirmed = message.id > 0,
hasLocalPreview =
DecryptedImageCache
.isDecryptedImageCacheUri(
message.pendingFileUri,
),
),
fileSizeBytes = fileSizeBytes =
message.fileSizes?.firstOrNull(), message.fileSizes?.firstOrNull(),
messageId = message.id, messageId = message.id,
@@ -845,6 +888,7 @@ fun MessageItem(
fileDimensions = message.fileDimensions, fileDimensions = message.fileDimensions,
pendingFileAspectRatio = pendingFileAspectRatio =
message.pendingFileAspectRatio, message.pendingFileAspectRatio,
fileAspectRatioPairs = message.fileAspectRatioPairs,
fileIndex = index, fileIndex = index,
confirmed = message.id > 0, confirmed = message.id > 0,
hasLocalPreview = index == 0 && hasLocalPreview = index == 0 &&
@@ -120,6 +120,10 @@ fun ChatFileAttachmentTile(
val displayUploadProgress = if (isUploading) uploadProgress ?: 0 else uploadProgress val displayUploadProgress = if (isUploading) uploadProgress ?: 0 else uploadProgress
val openableLocalUri = resolvedCacheUri val openableLocalUri = resolvedCacheUri
?: pendingFileUri?.takeIf { isPendingLocal } ?: 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 { val onRowClick: (() -> Unit)? = when {
openableLocalUri != null -> { openableLocalUri != null -> {
@@ -132,7 +136,7 @@ fun ChatFileAttachmentTile(
} }
} }
} }
downloadPaused && file != null && dmEnvelope != null -> { downloadPaused && canDownload -> file?.let { downloadFile ->
{ {
AttachmentDownloadNotifier.beginDownload( AttachmentDownloadNotifier.beginDownload(
messageId = messageId, messageId = messageId,
@@ -141,11 +145,11 @@ fun ChatFileAttachmentTile(
mirrorAsFileAttachment = true, mirrorAsFileAttachment = true,
) )
scope.launch { scope.launch {
val ok = DmFileDownloader.downloadToCache( val ok = downloadAttachmentToCache(
messageId = messageId, messageId = messageId,
fileIndex = fileIndex, fileIndex = fileIndex,
file = file, file = downloadFile,
envelope = dmEnvelope, dmEnvelope = dmEnvelope,
currentUserId = currentUserId, currentUserId = currentUserId,
clientMessageId = clientMessageId, clientMessageId = clientMessageId,
messageLabel = messageLabel, messageLabel = messageLabel,
@@ -162,7 +166,7 @@ fun ChatFileAttachmentTile(
} }
} }
} }
file != null && dmEnvelope != null && !isDownloading && !downloadPaused -> { canDownload && !isDownloading && !downloadPaused -> file?.let { downloadFile ->
{ {
AttachmentDownloadNotifier.beginDownload( AttachmentDownloadNotifier.beginDownload(
messageId = messageId, messageId = messageId,
@@ -171,11 +175,11 @@ fun ChatFileAttachmentTile(
mirrorAsFileAttachment = true, mirrorAsFileAttachment = true,
) )
scope.launch { scope.launch {
val ok = DmFileDownloader.downloadToCache( val ok = downloadAttachmentToCache(
messageId = messageId, messageId = messageId,
fileIndex = fileIndex, fileIndex = fileIndex,
file = file, file = downloadFile,
envelope = dmEnvelope, dmEnvelope = dmEnvelope,
currentUserId = currentUserId, currentUserId = currentUserId,
clientMessageId = clientMessageId, clientMessageId = clientMessageId,
messageLabel = messageLabel, 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) cancelQueuedMessage(queued)
return return
} }
val clientId = _state.messages.find { it.id == messageId }?.client_message_id val message = _state.messages.find { it.id == messageId } ?: return
deleteMessageImmediately(messageId) val clientId = message.client_message_id
beginMessageDissolve(message)
DownloadedFileRegistry.invalidateForMessage(messageId) DownloadedFileRegistry.invalidateForMessage(messageId)
DecryptedImageCache.invalidateForMessage(messageId) DecryptedImageCache.invalidateForMessage(messageId)
DecryptedFileCache.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.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.local.messages.conversationIdForGroup import ru.fromchat.api.local.messages.conversationIdForGroup
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay 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.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.publicchat.SendMessageResponse 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.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.MessageDeletedData import ru.fromchat.api.schema.websocket.types.MessageDeletedData
import ru.fromchat.api.schema.websocket.types.ReactionUpdateData 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.TypingHandler
import ru.fromchat.ui.chat.utils.attachPublicReplyReferences import ru.fromchat.ui.chat.utils.attachPublicReplyReferences
import ru.fromchat.ui.chat.utils.mergeDatabaseMessagesWithPanelState import ru.fromchat.ui.chat.utils.mergeDatabaseMessagesWithPanelState
import ru.fromchat.ui.chat.utils.mergeMessageUiFields
import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting
class PublicChatPanel( class PublicChatPanel(
@@ -76,7 +80,7 @@ class PublicChatPanel(
val fresh = byId[message.id] ?: return@map message val fresh = byId[message.id] ?: return@map message
ProfileCache.mergePreviewFromPublicMessage(fresh) ProfileCache.mergePreviewFromPublicMessage(fresh)
ProfileCache.enrichPublicMessageForDisplay( ProfileCache.enrichPublicMessageForDisplay(
message.copy( mergeMessageUiFields(fresh, message).copy(
username = fresh.username, username = fresh.username,
profile_picture = fresh.profile_picture, profile_picture = fresh.profile_picture,
verified = fresh.verified, verified = fresh.verified,
@@ -93,6 +97,9 @@ class PublicChatPanel(
override val usesPublicGroupSubtitle: Boolean override val usesPublicGroupSubtitle: Boolean
get() = true get() = true
override val supportsAttachments: Boolean
get() = true
init { init {
val instanceId = CacheContext.activeInstanceId.value.trim() val instanceId = CacheContext.activeInstanceId.value.trim()
if (instanceId.isNotEmpty()) { 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 { scope.launch {
typingHandler.typingUsers.collect { users -> typingHandler.typingUsers.collect { users ->
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}") 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). * Match optimistic rows via [Message.client_message_id] from the server ack (never by text).
*/ */
private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) { private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) {
val laidOut = newMsg.resolvePublicAttachmentLayout()
val uid = currentUserId val uid = currentUserId
if (uid != null && newMsg.user_id == uid) { if (uid != null && laidOut.user_id == uid) {
val cid = newMsg.client_message_id?.trim().orEmpty() val cid = laidOut.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty()) { if (cid.isNotEmpty()) {
handleMessageConfirmed(cid, newMsg) handleMessageConfirmed(cid, laidOut)
return return
} }
if (newMsg.id > 0 && _state.messages.any { it.id == newMsg.id }) { if (laidOut.id > 0 && _state.messages.any { it.id == laidOut.id }) {
return return
} }
} }
ingestIncomingPublicMessage(newMsg) ingestIncomingPublicMessage(laidOut)
} }
private suspend fun ingestIncomingPublicMessage(newMsg: Message) { private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
@@ -238,11 +261,20 @@ class PublicChatPanel(
} }
private fun mergeNetworkHistoryWithShown(shown: List<Message>, fromNetwork: List<Message>): List<Message> { 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 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 } 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( 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) { override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) {
withContext(Dispatchers.Default) { 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() val response = responseResult.getOrNull()
if (response != null && response.messages.isNotEmpty()) { if (response != null && response.messages.isNotEmpty()) {
ProfileCache.mergePreviewFromPublicMessages(response.messages) val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val shown = _state.messages 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") 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) { if (withSenders != shown) {
updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) } updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) }
} }
@@ -311,7 +348,7 @@ class PublicChatPanel(
batchStateUpdates { batchStateUpdates {
val merged = preserveReplyToFromExisting( val merged = preserveReplyToFromExisting(
shown, shown,
mergeNetworkHistoryWithShown(shown, response.messages), mergeNetworkHistoryWithShown(shown, networkMessages),
) )
clearMessages() clearMessages()
addMessages( addMessages(
@@ -323,7 +360,7 @@ class PublicChatPanel(
} }
} }
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val mergedForCache = mergeNetworkHistoryWithShown(_state.messages, response.messages) val mergedForCache = mergeNetworkHistoryWithShown(_state.messages, networkMessages)
MessageCacheStore.replacePublicMessages(mergedForCache) MessageCacheStore.replacePublicMessages(mergedForCache)
} }
} else if (responseResult.isFailure) { } else if (responseResult.isFailure) {
@@ -369,8 +406,9 @@ class PublicChatPanel(
ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id) ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id)
} }
if (response.messages.isNotEmpty()) { if (response.messages.isNotEmpty()) {
ProfileCache.mergePreviewFromPublicMessages(response.messages) val olderRaw = response.messages.map { it.resolvePublicAttachmentLayout() }
val older = ProfileCache.enrichPublicMessagesForDisplay(response.messages.reversed()) ProfileCache.mergePreviewFromPublicMessages(olderRaw)
val older = ProfileCache.enrichPublicMessagesForDisplay(olderRaw.reversed())
updateState { currentState -> updateState { currentState ->
currentState.copy( currentState.copy(
messages = older + currentState.messages messages = older + currentState.messages
@@ -499,9 +537,12 @@ class PublicChatPanel(
} }
override suspend fun handleDeleteMessage(messageId: Int) { override suspend fun handleDeleteMessage(messageId: Int) {
deleteMessageImmediately(messageId) val message = _state.messages.find { it.id == messageId } ?: return
clearReplyReferencesTo(messageId) if (messageId < 0) {
cancelQueuedMessage(message)
return
}
beginMessageDissolve(message)
ApiClient.deleteMessage(messageId) ApiClient.deleteMessage(messageId)
} }
@@ -1,18 +1,87 @@
package ru.fromchat.ui.chat.utils package ru.fromchat.ui.chat.utils
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.shape.RoundedCornerShape 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 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.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.ChatPreviewDecodeSize
import ru.fromchat.api.local.download.LocalDecodedImageCache
import ru.fromchat.ui.chat.MessageGroupInfo import ru.fromchat.ui.chat.MessageGroupInfo
import ru.fromchat.ui.chat.bubbleTopRadii 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). */ /** Padding between bubble edge and attachment image (must match MessageItem image padding). */
internal val ATTACHMENT_IMAGE_INSET = 2.dp internal val ATTACHMENT_IMAGE_INSET = 2.dp
/** Slight rounding on image preview bottom corners (less than bubble). */ /** Slight rounding on image preview bottom corners (less than bubble). */
private val IMAGE_BOTTOM_CORNER = 4.dp 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. */ /** Inner clip: top corners follow bubble minus inset; bottom corners lightly rounded. */
internal fun attachmentImageCornerShape( internal fun attachmentImageCornerShape(
isAuthor: Boolean, 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( internal fun imageAspectRatioForMessage(
fileAspectRatios: List<Float>?, fileAspectRatios: List<Float>?,
fileDimensions: List<Pair<Int, Int>>?, fileDimensions: List<Pair<Int, Int>>?,
pendingFileAspectRatio: Float?, pendingFileAspectRatio: Float?,
fileAspectRatioPairs: List<List<Int>>? = null,
fileIndex: Int = 0, fileIndex: Int = 0,
@Suppress("UNUSED_PARAMETER") confirmed: Boolean = true, @Suppress("UNUSED_PARAMETER") confirmed: Boolean = true,
@Suppress("UNUSED_PARAMETER") hasLocalPreview: Boolean = false, @Suppress("UNUSED_PARAMETER") hasLocalPreview: Boolean = false,
): Float? { ): Float? {
fileDimensions?.getOrNull(fileIndex)?.let { (w, h) -> val localRatio = pendingFileAspectRatio?.takeIf { fileIndex == 0 && it > 0f }
if (w > 0 && h > 0) { 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) return aspectRatioFromDimensionPair(w, h)
} }
} }
fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }?.let { return it } serverRatio?.takeIf { !isPlaceholderAttachmentAspectRatio(it) }?.let { return it }
return pendingFileAspectRatio?.takeIf { fileIndex == 0 && it > 0f } return localRatio
} }
internal fun coalesceDecodeTarget(vararg sizes: ChatPreviewDecodeSize?): ChatPreviewDecodeSize { 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.ApiClient
import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.aspectRatioFromDimensionPair 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.local.messages.sortMessagesForChatDisplay
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.messages.publicchat.resolvePublicAttachmentLayout
internal fun resolveDmReplyToId( internal fun resolveDmReplyToId(
envelope: DmEnvelope?, envelope: DmEnvelope?,
@@ -80,37 +82,82 @@ internal fun mergeDatabaseMessagesWithPanelState(
} }
internal fun mergeMessageUiFields(db: Message, panel: Message?): Message { 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 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) } val localPreview = db.pendingFileUri?.takeIf { DecryptedImageCache.isDecryptedImageCacheUri(it) }
?: panel.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 { pendingFileUri = when {
confirmed -> localPreview ?: db.pendingFileUri confirmed -> localPreview
else -> panel.pendingFileUri ?: db.pendingFileUri else -> panel.pendingFileUri ?: db.pendingFileUri
}, },
pendingFilename = if (confirmed) null else panel.pendingFilename ?: db.pendingFilename, pendingFilename = if (confirmed) null else panel.pendingFilename ?: db.pendingFilename,
uploadJobId = if (confirmed) null else panel.uploadJobId ?: db.uploadJobId, uploadJobId = if (confirmed) null else panel.uploadJobId ?: db.uploadJobId,
pendingFileAspectRatio = if (confirmed) { pendingFileAspectRatio = when {
db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) } confirmed && dbHasRealLayout -> null
?: db.fileAspectRatios?.firstOrNull() confirmed -> localAspect
?: serverDim?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
?: serverRatio
?: db.pendingFileAspectRatio ?: db.pendingFileAspectRatio
} else { else -> panel.pendingFileAspectRatio ?: db.pendingFileAspectRatio
panel.pendingFileAspectRatio ?: db.pendingFileAspectRatio
}, },
uploadProgress = if (confirmed) null else panel.uploadProgress ?: db.uploadProgress, uploadProgress = if (confirmed) null else panel.uploadProgress ?: db.uploadProgress,
uploadError = if (confirmed) null else panel.uploadError ?: db.uploadError, uploadError = if (confirmed) null else panel.uploadError ?: db.uploadError,
files = db.files ?: panel.files, files = db.files ?: panel.files,
dmEnvelope = db.dmEnvelope ?: panel.dmEnvelope, dmEnvelope = db.dmEnvelope ?: panel.dmEnvelope,
fileThumbnails = db.fileThumbnails ?: panel.fileThumbnails, 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, 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 }, content = db.content.ifBlank { panel.content },
isContentCorrupted = panel.isContentCorrupted || db.isContentCorrupted, isContentCorrupted = panel.isContentCorrupted || db.isContentCorrupted,
replyToId = db.replyToId ?: panel.replyToId, replyToId = db.replyToId ?: panel.replyToId,
reply_to = db.reply_to ?: panel.reply_to, 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. */ /** 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