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