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