Fix image and profile bugs

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-12 20:21:51 +03:00
Unverified
parent e0ba6db41e
commit 3c08830755
18 changed files with 363 additions and 57 deletions
@@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.scale import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import java.io.ByteArrayInputStream
actual object PlatformDecodedBitmapCache { actual object PlatformDecodedBitmapCache {
private val cache: LruCache<String, ImageBitmap> = object : LruCache<String, ImageBitmap>(maxCacheBytes()) { private val cache: LruCache<String, ImageBitmap> = object : LruCache<String, ImageBitmap>(maxCacheBytes()) {
@@ -85,23 +86,34 @@ private fun decodeSampledFromBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightP
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val orientation = runCatching {
ExifInterface(ByteArrayInputStream(bytes)).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
val (orientedW, orientedH) = orientedDimensions(bounds.outWidth, bounds.outHeight, orientation)
return scaleBitmapToFitWithin( return scaleBitmapToFitWithin(
BitmapFactory.decodeByteArray( applyExifOrientation(
bytes, BitmapFactory.decodeByteArray(
0, bytes,
bytes.size, 0,
BitmapFactory.Options().apply { bytes.size,
inSampleSize = calculateInSampleSize( BitmapFactory.Options().apply {
bounds.outWidth, inSampleSize = calculateInSampleSize(
bounds.outHeight, orientedW,
reqWidthPx, orientedH,
reqHeightPx reqWidthPx,
) reqHeightPx,
inPreferredConfig = Bitmap.Config.ARGB_8888 )
} inPreferredConfig = Bitmap.Config.ARGB_8888
) ?: return null, },
) ?: return null,
orientation,
),
reqWidthPx, reqWidthPx,
reqHeightPx reqHeightPx,
) )
} }
@@ -2,6 +2,7 @@ package ru.fromchat.api.local.download
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import java.io.ByteArrayInputStream
internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? { internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
@@ -13,11 +14,28 @@ internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, In
ExifInterface.ORIENTATION_NORMAL, ExifInterface.ORIENTATION_NORMAL,
) )
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL) }.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
return when (orientation) { return orientedPixelDimensions(bounds.outWidth, bounds.outHeight, orientation)
}
internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair<Int, Int>? {
if (data.isEmpty()) return null
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(ByteArrayInputStream(data), null, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val orientation = runCatching {
ExifInterface(ByteArrayInputStream(data)).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
return orientedPixelDimensions(bounds.outWidth, bounds.outHeight, orientation)
}
private fun orientedPixelDimensions(width: Int, height: Int, orientation: Int): Pair<Int, Int> =
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_ROTATE_90,
ExifInterface.ORIENTATION_ROTATE_270, ExifInterface.ORIENTATION_ROTATE_270,
ExifInterface.ORIENTATION_TRANSPOSE, ExifInterface.ORIENTATION_TRANSPOSE,
ExifInterface.ORIENTATION_TRANSVERSE -> bounds.outHeight to bounds.outWidth ExifInterface.ORIENTATION_TRANSVERSE -> height to width
else -> bounds.outWidth to bounds.outHeight else -> width to height
} }
}
@@ -58,6 +58,7 @@
<string name="coming_soon">Скоро…</string> <string name="coming_soon">Скоро…</string>
<string name="contacts_empty_title">Скоро</string> <string name="contacts_empty_title">Скоро</string>
<string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string> <string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string>
<string name="chat_scroll_to_bottom_cd">Прокрутить к новым сообщениям</string>
<string name="public_chat">Общий чат</string> <string name="public_chat">Общий чат</string>
<string name="chat_last_mesaage">Вы: последнее сообщение</string> <string name="chat_last_mesaage">Вы: последнее сообщение</string>
<string name="chat_preview_attachment">Вложение</string> <string name="chat_preview_attachment">Вложение</string>
@@ -66,6 +66,7 @@
<string name="coming_soon">Coming soon…</string> <string name="coming_soon">Coming soon…</string>
<string name="contacts_empty_title">Coming soon</string> <string name="contacts_empty_title">Coming soon</string>
<string name="contacts_empty_body">Your contacts will appear here when this feature is ready.</string> <string name="contacts_empty_body">Your contacts will appear here when this feature is ready.</string>
<string name="chat_scroll_to_bottom_cd">Scroll to latest messages</string>
<string name="public_chat">Main chat</string> <string name="public_chat">Main chat</string>
<string name="chat_last_mesaage">You: last message</string> <string name="chat_last_mesaage">You: last message</string>
<string name="chat_preview_attachment">Attachment</string> <string name="chat_preview_attachment">Attachment</string>
@@ -16,6 +16,7 @@ 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 import ru.fromchat.api.local.download.readLocalImageDimensions
import ru.fromchat.ui.chat.isImageFilename
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@@ -118,6 +119,7 @@ fun resolveLocalPreviewUri(message: Message): String? {
message.pendingFileUri?.takeIf { uri -> message.pendingFileUri?.takeIf { uri ->
DecryptedImageCache.isDecryptedImageCacheUri(uri) && localPreviewFileExists(uri) DecryptedImageCache.isDecryptedImageCacheUri(uri) && localPreviewFileExists(uri)
}?.let { return it } }?.let { return it }
if (!messageQualifiesForImageCacheHydration(message)) return null
val cid = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() } val cid = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
if (cid != null) { if (cid != null) {
DecryptedImageCache.getCached(message.id, fileIndex = 0, cid) DecryptedImageCache.getCached(message.id, fileIndex = 0, cid)
@@ -132,6 +134,35 @@ fun resolveLocalPreviewUri(message: Message): String? {
return null return null
} }
/** True when disk lookup may attach a decrypted image preview to [message]. */
internal fun messageQualifiesForImageCacheHydration(message: Message): Boolean {
if (message.files.orEmpty().any { isImageFilename(it.name) }) return true
if (message.dmEnvelope?.files.orEmpty().any { isImageFilename(it.name) }) return true
if (message.pendingFileAspectRatio != null) return true
message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() }?.let { name ->
if (isImageFilename(name)) return true
}
message.pendingFileUri?.trim()?.takeIf { it.isNotEmpty() }?.let { uri ->
if (DecryptedImageCache.isDecryptedImageCacheUri(uri)) return true
val name = uri.substringAfterLast('/').substringBefore('?')
if (isImageFilename(name)) return true
}
val parsed = parseDmMessageContent(message.content)
if (!parsed.fileThumbnails.isNullOrEmpty() && !parsed.files.isNullOrEmpty()) return true
return false
}
/** Restores pending preview URIs from DB without attaching orphaned image cache files to text rows. */
internal fun resolveStoredPendingFileUri(
message: Message,
parsed: ParsedDmMessageContent,
): String? {
parsed.pendingFileUri?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
if (!messageQualifiesForImageCacheHydration(message)) return null
parsed.localPreviewUri?.takeIf { localPreviewFileExists(it) }?.let { return it }
return resolveLocalPreviewUri(message)
}
/** Sync disk lookup for cold-start chat open (no suspend alias copy). */ /** Sync disk lookup for cold-start chat open (no suspend alias copy). */
fun hydrateAttachmentPreviewFromDiskSync(message: Message): Message { fun hydrateAttachmentPreviewFromDiskSync(message: Message): Message {
val previewUri = resolveLocalPreviewUri(message) ?: return hydrateDiskAspectRatioSync(message) val previewUri = resolveLocalPreviewUri(message) ?: return hydrateDiskAspectRatioSync(message)
@@ -26,8 +26,8 @@ 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.encodePersistedPublicMessage
import ru.fromchat.api.local.db.parseDmMessageContent import ru.fromchat.api.local.db.parseDmMessageContent
import ru.fromchat.api.local.db.resolveStoredPendingFileUri
import ru.fromchat.api.local.db.hydrateAttachmentPreviewFromDiskSync import ru.fromchat.api.local.db.hydrateAttachmentPreviewFromDiskSync
import ru.fromchat.api.local.db.resolveLocalPreviewUri
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay import ru.fromchat.api.local.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.PublicAttachmentOutboxPayload
@@ -45,6 +45,8 @@ import ru.fromchat.api.crypto.decryptEnvelope
import ru.fromchat.api.local.cache.DecryptedFileCache 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.api.local.download.readImageDimensionsFromBytes
import com.pr0gramm3r101.utils.crypto.Base64
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.isImageFilename
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
@@ -166,21 +168,20 @@ object MessageCacheStore {
} }
suspend fun replacePublicMessages(messages: List<Message>) { suspend fun replacePublicMessages(messages: List<Message>) {
val convId = conversationIdForPublic()
val resolved = messages.map { it.resolvePublicAttachmentLayout() } val resolved = messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(resolved) ProfileCache.mergePreviewFromPublicMessages(resolved)
conversationIdForPublic().let { val pending = loadPendingMessages(convId)
replaceMessages( val stillPending = filterStillPendingForReplace(convId, pending, resolved)
it, val before = resolved + stillPending
sortMessagesForChatDisplay( val merged = dedupeMessagesByClientId(
dedupeMessagesByClientId( dropSupersededOptimisticMessages(before, ApiClient.user?.id),
resolved + loadPendingMessages(it).filter { p -> ).let { sortMessagesForChatDisplay(it) }
val cid = p.client_message_id val iid = instanceId()
cid == null || resolved.none { it.client_message_id == cid } withContext(Dispatchers.Default) {
} purgeSupersededPendingRows(iid, convId, before, merged)
)
)
)
} }
replaceMessages(convId, merged)
} }
suspend fun clearPublicMessages() { suspend fun clearPublicMessages() {
@@ -305,6 +306,62 @@ object MessageCacheStore {
var resolved = confirmed.resolvePublicAttachmentLayout() var resolved = confirmed.resolvePublicAttachmentLayout()
resolved = hydrateAttachmentPreviewFromDisk(resolved) resolved = hydrateAttachmentPreviewFromDisk(resolved)
ProfileCache.mergePreviewFromPublicMessage(resolved) ProfileCache.mergePreviewFromPublicMessage(resolved)
// If we have a pending optimistic row with a local aspect, prefer it when server
// aspect appears to be a rotated reciprocal (common when EXIF/metadata were swapped).
try {
val convId = conversationIdForPublic()
val pending = loadPendingMessages(convId)
val local = pending.firstOrNull { it.client_message_id == clientMessageId }
if (local != null && !local.files.isNullOrEmpty()) {
val localAspect = local.pendingFileAspectRatio
?: local.fileDimensions?.firstOrNull()?.let { (w, h) ->
if (h > 0) w.toFloat() / h.toFloat() else null
}
val serverAspect = resolved.fileAspectRatios?.firstOrNull()
?: resolved.fileDimensions?.firstOrNull()?.let { (w, h) ->
if (h > 0) w.toFloat() / h.toFloat() else null
}
if (localAspect != null && serverAspect != null) {
val product = localAspect * serverAspect
if (product in 0.92f..1.08f && kotlin.math.abs(localAspect - serverAspect) > 0.15f) {
// Swap to local aspect to preserve correct orientation
resolved = resolved.copy(
fileAspectRatios = listOf(localAspect),
fileDimensions = local.fileDimensions ?: resolved.fileDimensions,
)
}
}
}
} catch (_: Exception) {
// Best-effort only; fall back to server-resolved layout on any failure.
}
// If no local optimistic row, prefer decoded server thumbnail dims when available
try {
val convId = conversationIdForPublic()
val pending = loadPendingMessages(convId)
val local = pending.firstOrNull { it.client_message_id == clientMessageId }
if (local == null) {
val thumbB64 = resolved.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() }
if (!thumbB64.isNullOrBlank()) {
val bytes = runCatching { Base64.decode(thumbB64) }.getOrNull()
val thumbDims = bytes?.let { readImageDimensionsFromBytes(it) }
val decodedAspect = thumbDims?.let { (w, h) -> if (h > 0) w.toFloat() / h.toFloat() else null }
val serverAspect = resolved.fileAspectRatios?.firstOrNull()
?: resolved.fileDimensions?.firstOrNull()?.let { (w, h) -> if (h > 0) w.toFloat() / h.toFloat() else null }
if (decodedAspect != null && serverAspect != null) {
val product = decodedAspect * serverAspect
if (product in 0.92f..1.08f && kotlin.math.abs(decodedAspect - serverAspect) > 0.15f) {
resolved = resolved.copy(
fileAspectRatios = listOf(decodedAspect),
fileDimensions = thumbDims?.let { listOf(it.first to it.second) } ?: resolved.fileDimensions,
)
}
}
}
}
} catch (_: Exception) {
// Best-effort only
}
confirmMessage(conversationIdForPublic(), clientMessageId, resolved) confirmMessage(conversationIdForPublic(), clientMessageId, resolved)
} }
} }
@@ -1220,9 +1277,7 @@ object MessageCacheStore {
isContentCorrupted = parsed.isContentCorrupted, isContentCorrupted = parsed.isContentCorrupted,
) )
val hydrated = base.copy( val hydrated = base.copy(
pendingFileUri = parsed.pendingFileUri pendingFileUri = resolveStoredPendingFileUri(base, parsed),
?: parsed.localPreviewUri
?: resolveLocalPreviewUri(base),
pendingFilename = parsed.pendingFilename ?: base.pendingFilename, pendingFilename = parsed.pendingFilename ?: base.pendingFilename,
uploadJobId = parsed.uploadJobId ?: base.uploadJobId, uploadJobId = parsed.uploadJobId ?: base.uploadJobId,
fileSizes = parsed.fileSizes ?: base.fileSizes, fileSizes = parsed.fileSizes ?: base.fileSizes,
@@ -2,3 +2,6 @@ package ru.fromchat.api.local.download
/** EXIF-oriented width/height from a local file (bounds read only; no full decode). */ /** EXIF-oriented width/height from a local file (bounds read only; no full decode). */
internal expect fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? internal expect fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>?
/** Bounds-only width/height from encoded image bytes (JPEG EXIF when supported). */
internal expect fun readImageDimensionsFromBytes(data: ByteArray): Pair<Int, Int>?
@@ -1,5 +1,6 @@
package ru.fromchat.api.local.messages package ru.fromchat.api.local.messages
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.parseDmMessageContent import ru.fromchat.api.local.db.parseDmMessageContent
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
@@ -36,6 +37,7 @@ fun messageHasImageAttachment(message: Message): Boolean {
if (isImageFilename(file.name)) return true if (isImageFilename(file.name)) return true
} }
if (message.pendingFileUri != null) { if (message.pendingFileUri != null) {
if (DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri)) return true
val pendingName = message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() } val pendingName = message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() }
?: message.pendingFileUri.substringAfterLast('/').substringBefore('?') ?: message.pendingFileUri.substringAfterLast('/').substringBefore('?')
if (isImageFilename(pendingName)) return true if (isImageFilename(pendingName)) return true
@@ -9,6 +9,8 @@ import kotlinx.coroutines.launch
sealed class OutboundSendProgress { sealed class OutboundSendProgress {
data class Pending(val clientMessageId: String) : OutboundSendProgress() data class Pending(val clientMessageId: String) : OutboundSendProgress()
data class Success(val clientMessageId: String, val message: ru.fromchat.api.schema.messages.Message) :
OutboundSendProgress()
data class Failed(val clientMessageId: String, val error: String) : OutboundSendProgress() data class Failed(val clientMessageId: String, val error: String) : OutboundSendProgress()
} }
@@ -30,6 +30,7 @@ import ru.fromchat.api.local.db.buildDmOutboundPlaintext
import ru.fromchat.api.local.cache.clearUploadArtifacts import ru.fromchat.api.local.cache.clearUploadArtifacts
import ru.fromchat.api.local.cache.clearUploadSecretsOnly import ru.fromchat.api.local.cache.clearUploadSecretsOnly
import ru.fromchat.api.local.AttachmentMediaLog import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.Logger
/** /**
* Single entry point for enqueueing outbound messages (DB row + outbox + worker). * Single entry point for enqueueing outbound messages (DB row + outbox + worker).
@@ -66,15 +67,21 @@ object OutgoingMessageCoordinator {
} }
var ok = true var ok = true
sendResult.onSuccess { confirmed -> sendResult.onSuccess { confirmed ->
val resolved = confirmed.copy(client_message_id = row.clientMessageId)
Logger.d("OutgoingMessageCoordinator", "handlePublicOutboxSend: success clientId=${row.clientMessageId.take(12)} realId=${resolved.id}")
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageCacheStore.confirmPublicMessage( MessageCacheStore.confirmPublicMessage(
row.clientMessageId, row.clientMessageId,
confirmed.copy(client_message_id = row.clientMessageId), resolved,
) )
MessageDatabaseProvider.database.messageDatabaseQueries MessageDatabaseProvider.database.messageDatabaseQueries
.deleteOutboxItem(instanceId, row.clientMessageId) .deleteOutboxItem(instanceId, row.clientMessageId)
} }
OutboundSendNotifier.emit(
OutboundSendProgress.Success(row.clientMessageId, resolved),
)
}.onFailure { error -> }.onFailure { error ->
Logger.d("OutgoingMessageCoordinator", "handlePublicOutboxSend: failure clientId=${row.clientMessageId.take(12)} err=${error.message ?: error::class.simpleName}")
when { when {
error.isOutboundPermanentFailure() -> { error.isOutboundPermanentFailure() -> {
val errorKey = outboundFailureErrorKey(error) val errorKey = outboundFailureErrorKey(error)
@@ -152,6 +159,7 @@ object OutgoingMessageCoordinator {
val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID) val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.upsertPublicMessage(optimisticMessage) MessageRepository.upsertPublicMessage(optimisticMessage)
Logger.d("OutgoingMessageCoordinator", "enqueuePublicMessage: clientId=${clientMessageId.take(12)} contentLen=${content.length}")
val payload = json.encodeToString(PublicOutboxPayload(content, replyToId)) val payload = json.encodeToString(PublicOutboxPayload(content, replyToId))
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox( MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
instanceId = instanceId, instanceId = instanceId,
@@ -105,6 +105,7 @@ 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.chat.utils.peekDecodedAttachmentBitmap
import ru.fromchat.ui.chat.utils.preferDecodedAspectRatio
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
@@ -202,6 +203,9 @@ fun AttachmentPreview(
var isFullyLoaded by remember(messageId, fileIndex, file?.path, pendingFileUri) { var isFullyLoaded by remember(messageId, fileIndex, file?.path, pendingFileUri) {
mutableStateOf(false) mutableStateOf(false)
} }
var effectiveAspect by remember(messageId, fileIndex, file?.path, pendingFileUri, fileAspectRatio) {
mutableStateOf(fileAspectRatio)
}
Box( Box(
modifier = modifier modifier = modifier
.then( .then(
@@ -219,7 +223,7 @@ fun AttachmentPreview(
) )
// Explicit px size from dp max + aspect — do not wrap to thumb intrinsics // Explicit px size from dp max + aspect — do not wrap to thumb intrinsics
// (IntrinsicSize.Max bubbles otherwise shrink to ~80px ≈ 3× too small). // (IntrinsicSize.Max bubbles otherwise shrink to ~80px ≈ 3× too small).
.attachmentTileLayout(aspectRatio = fileAspectRatio) .attachmentTileLayout(aspectRatio = effectiveAspect)
.clip(attachmentImageCornerShape(isAuthor, messageGroup)) .clip(attachmentImageCornerShape(isAuthor, messageGroup))
.then( .then(
if (onImageBounds != null && showImageTile) { if (onImageBounds != null && showImageTile) {
@@ -273,6 +277,12 @@ fun AttachmentPreview(
onCancelUpload = onCancelUpload, onCancelUpload = onCancelUpload,
onFullyLoaded = { if (it) isFullyLoaded = true }, onFullyLoaded = { if (it) isFullyLoaded = true },
messageGroup = messageGroup, messageGroup = messageGroup,
onResolvedAspectRatio = { width, height ->
val resolved = preferDecodedAspectRatio(fileAspectRatio, width, height)
if (resolved != effectiveAspect) {
effectiveAspect = resolved
}
},
) )
} }
} }
@@ -307,6 +317,7 @@ private fun ChatImageTileContent(
hasSameAuthorAbove = false, hasSameAuthorAbove = false,
hasSameAuthorBelow = false, hasSameAuthorBelow = false,
), ),
onResolvedAspectRatio: ((width: Int, height: Int) -> Unit)? = null,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipShape = attachmentImageCornerShape(isAuthor, messageGroup) val clipShape = attachmentImageCornerShape(isAuthor, messageGroup)
@@ -671,6 +682,11 @@ private fun ChatImageTileContent(
if (placeholderBitmap != null) decryptFinished = true if (placeholderBitmap != null) decryptFinished = true
} }
LaunchedEffect(fullBitmap, placeholderBitmap) {
val bitmap = fullBitmap ?: placeholderBitmap ?: return@LaunchedEffect
onResolvedAspectRatio?.invoke(bitmap.width, bitmap.height)
}
LaunchedEffect(treatAsOutbound, messageId, fileIndex, cacheClientId) { LaunchedEffect(treatAsOutbound, messageId, fileIndex, cacheClientId) {
if (treatAsOutbound) { if (treatAsOutbound) {
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
@@ -93,7 +93,10 @@ abstract class ChatPanel(
addMessageMutex.withLock { addMessageMutex.withLock {
batchStateUpdates { batchStateUpdates {
updateState { current -> updateState { current ->
val merged = mergeDatabaseMessagesWithPanelState(current.messages, messages) val merged = mergeDatabaseMessagesWithPanelState(
panelMessagesForDbMerge(),
messages,
)
val withReplies = attachPublicReplyReferences(merged) val withReplies = attachPublicReplyReferences(merged)
if (current.messages == withReplies) current if (current.messages == withReplies) current
else current.copy(messages = withReplies) else current.copy(messages = withReplies)
@@ -345,14 +348,39 @@ abstract class ChatPanel(
updateState { it.copy(messages = emptyList()) } updateState { it.copy(messages = emptyList()) }
} }
/** In-flight sends only (active [pendingMessages]), not stale cache optimistics. */ /** In-flight sends ([pendingMessages]), including rows cleared from [_state] by a DB refresh. */
protected fun snapshotPendingOptimisticMessages(): List<Message> { protected fun snapshotPendingOptimisticMessages(): List<Message> {
if (pendingMessages.isEmpty()) return emptyList() if (pendingMessages.isEmpty()) return emptyList()
val pendingClientIds = pendingMessages.keys val pendingClientIds = pendingMessages.keys
return _state.messages.filter { msg -> val fromState = _state.messages.filter { msg ->
val cid = msg.client_message_id?.trim().orEmpty() val cid = msg.client_message_id?.trim().orEmpty()
cid.isNotEmpty() && cid in pendingClientIds cid.isNotEmpty() && cid in pendingClientIds
} }
val coveredClientIds = fromState.mapNotNull { it.client_message_id?.trim()?.takeIf { it.isNotEmpty() } }.toSet()
val fromMap = pendingMessages.values.map { it.second }.filter { msg ->
val cid = msg.client_message_id?.trim().orEmpty()
cid.isEmpty() || cid !in coveredClientIds
}
if (fromMap.isEmpty()) return fromState
return ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(fromState + fromMap)
}
protected fun pendingOptimisticMessage(clientMessageId: String): Message? {
val cid = clientMessageId.trim()
if (cid.isEmpty()) return null
return _state.messages.find { it.client_message_id == cid }
?: pendingMessages[cid]?.second
}
/** Returns comma-separated client ids of in-flight pending messages (for debug). */
protected fun debugPendingKeys(): String =
pendingMessages.keys.joinToString(",")
/** Panel snapshot for DB observe merges — keeps in-flight sends when SQL omits them. */
protected fun panelMessagesForDbMerge(): List<Message> {
val pending = snapshotPendingOptimisticMessages()
if (pending.isEmpty()) return _state.messages
return ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(_state.messages + pending)
} }
protected suspend fun restorePendingOptimisticMessages(messages: List<Message>) { protected suspend fun restorePendingOptimisticMessages(messages: List<Message>) {
@@ -23,15 +23,20 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.runtime.Composable 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
@@ -73,6 +78,8 @@ import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
@@ -119,6 +126,7 @@ import ru.fromchat.back
import ru.fromchat.action_delete_chat import ru.fromchat.action_delete_chat
import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.cd_call import ru.fromchat.cd_call
import ru.fromchat.chat_scroll_to_bottom_cd
import ru.fromchat.chat_group_label import ru.fromchat.chat_group_label
import ru.fromchat.status_connecting import ru.fromchat.status_connecting
import ru.fromchat.status_updating import ru.fromchat.status_updating
@@ -175,6 +183,16 @@ fun ChatScreen(
val listState = rememberSaveable(panelId, saver = LazyListState.Saver) { val listState = rememberSaveable(panelId, saver = LazyListState.Saver) {
LazyListState(0, 0) LazyListState(0, 0)
} }
var isNearBottom by rememberSaveable(panelId) { mutableStateOf(true) }
LaunchedEffect(listState, panelState.messages.size) {
snapshotFlow {
val minVisibleIndex = listState.layoutInfo.visibleItemsInfo.minOfOrNull { it.index }
?: Int.MAX_VALUE
minVisibleIndex <= 2
}
.distinctUntilChanged()
.collect { nearBottom -> isNearBottom = nearBottom }
}
val density = LocalDensity.current val density = LocalDensity.current
val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } } val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -428,6 +446,7 @@ fun ChatScreen(
val statusUpdating = stringResource(Res.string.status_updating) val statusUpdating = stringResource(Res.string.status_updating)
val presenceRecently = stringResource(Res.string.presence_recently) val presenceRecently = stringResource(Res.string.presence_recently)
val chatGroupLabel = stringResource(Res.string.chat_group_label) val chatGroupLabel = stringResource(Res.string.chat_group_label)
val scrollToBottomCd = stringResource(Res.string.chat_scroll_to_bottom_cd)
val cdCall = stringResource(Res.string.cd_call) val cdCall = stringResource(Res.string.cd_call)
LaunchedEffect(currentTypingUsers) { LaunchedEffect(currentTypingUsers) {
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}") Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
@@ -482,9 +501,13 @@ fun ChatScreen(
} }
} }
// Scroll to specific message when requested (e.g., from notification click) // Scroll to specific message once when requested (e.g., notification / deep link).
LaunchedEffect(scrollToMessageId, panelState.messages) { LaunchedEffect(scrollToMessageId) {
scrollToMessageId?.let(scrollToChatMessage) val messageId = scrollToMessageId ?: return@LaunchedEffect
snapshotFlow { lazyIndexForMessageId(listItems, messageId) }
.filterNotNull()
.first()
scrollToChatMessage(messageId)
} }
// UI state // UI state
@@ -693,6 +716,8 @@ fun ChatScreen(
panel.updateMessageByClientMessageId(progress.clientMessageId) { panel.updateMessageByClientMessageId(progress.clientMessageId) {
it.copy(uploadError = null) it.copy(uploadError = null)
} }
is OutboundSendProgress.Success ->
panel.handleMessageConfirmed(progress.clientMessageId, progress.message)
is OutboundSendProgress.Failed -> is OutboundSendProgress.Failed ->
panel.updateMessageByClientMessageId(progress.clientMessageId) { panel.updateMessageByClientMessageId(progress.clientMessageId) {
it.copy(uploadError = progress.error) it.copy(uploadError = progress.error)
@@ -1271,6 +1296,34 @@ fun ChatScreen(
item { Spacer(modifier.height(floatingHeaderClearance)) } item { Spacer(modifier.height(floatingHeaderClearance)) }
} }
val showScrollToBottomFab = panel.usesPublicGroupSubtitle &&
!isNearBottom &&
panelState.messages.isNotEmpty() &&
!contextMenuState.isOpen
AnimatedVisibility(
visible = showScrollToBottomFab,
enter = fadeIn(tween(150)) +
expandVertically(expandFrom = Alignment.Bottom),
exit = fadeOut(tween(120)) +
shrinkVertically(shrinkTowards = Alignment.Bottom),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp),
) {
SmallFloatingActionButton(
onClick = {
scope.launch { listState.animateScrollToItem(0) }
},
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = scrollToBottomCd,
)
}
}
ChatTopBar( ChatTopBar(
hazeState = hazeState, hazeState = hazeState,
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
@@ -478,6 +478,8 @@ fun MessageItem(
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
) { ) {
val pendingIsImage = when { val pendingIsImage = when {
message.pendingFileUri != null &&
DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri) -> true
message.pendingFilename?.isNotBlank() == true -> message.pendingFilename?.isNotBlank() == true ->
isImageFilename(message.pendingFilename) isImageFilename(message.pendingFilename)
message.pendingFileUri != null -> isImageFilename( message.pendingFileUri != null -> isImageFilename(
@@ -487,7 +489,8 @@ fun MessageItem(
} }
val pendingHasOutboundFile = message.pendingFileUri != null && val pendingHasOutboundFile = message.pendingFileUri != null &&
message.files.isNullOrEmpty() && message.files.isNullOrEmpty() &&
!pendingIsImage !pendingIsImage &&
!DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri)
val uploadFailed = !message.uploadError.isNullOrBlank() val uploadFailed = !message.uploadError.isNullOrBlank()
val canCancelUpload = message.isQueuedOutbound() && isAuthor && val canCancelUpload = message.isQueuedOutbound() && isAuthor &&
!uploadFailed && !uploadFailed &&
@@ -725,6 +728,13 @@ fun MessageItem(
(primaryFile != null && !primaryIsImage) (primaryFile != null && !primaryIsImage)
if (showPrimaryImageSlot) { if (showPrimaryImageSlot) {
val imageKey = imageAttachmentKey(message, 0) val imageKey = imageAttachmentKey(message, 0)
val primaryThumbBytes = remember(
message.id,
message.fileThumbnails?.firstOrNull(),
) {
message.fileThumbnails?.firstOrNull()
?.let { decodeAttachmentThumbnailBase64(it) }
}
val layoutAspect = imageAspectRatioForMessage( val layoutAspect = imageAspectRatioForMessage(
fileAspectRatios = message.fileAspectRatios, fileAspectRatios = message.fileAspectRatios,
fileDimensions = message.fileDimensions, fileDimensions = message.fileDimensions,
@@ -733,6 +743,7 @@ fun MessageItem(
fileIndex = 0, fileIndex = 0,
confirmed = message.id > 0, confirmed = message.id > 0,
hasLocalPreview = !message.pendingFileUri.isNullOrBlank(), hasLocalPreview = !message.pendingFileUri.isNullOrBlank(),
thumbnailBytes = primaryThumbBytes,
) )
LaunchedEffect( LaunchedEffect(
message.id, message.id,
@@ -896,6 +907,9 @@ fun MessageItem(
.isDecryptedImageCacheUri( .isDecryptedImageCacheUri(
message.pendingFileUri, message.pendingFileUri,
), ),
thumbnailBytes = message.fileThumbnails
?.getOrNull(index)
?.let { decodeAttachmentThumbnailBase64(it) },
), ),
fileSizeBytes = fileSizeBytes =
message.fileSizes?.getOrNull(index), message.fileSizes?.getOrNull(index),
@@ -1121,6 +1135,7 @@ private fun MessageReplyQuote(
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(quoteBg) .background(quoteBg)
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
@@ -203,7 +203,10 @@ class PublicChatPanel(
else -> { else -> {
// Never replace the in-memory list with the DB snapshot alone — that // Never replace the in-memory list with the DB snapshot alone — that
// dropped paginated / ahead-of-network rows when reopening (e.g. profile). // dropped paginated / ahead-of-network rows when reopening (e.g. profile).
val merged = mergeDatabaseMessagesWithPanelState(shown, cached) val merged = mergeDatabaseMessagesWithPanelState(
panelMessagesForDbMerge(),
cached,
)
if (merged != shown) { if (merged != shown) {
updateState { it.copy(messages = sortMessagesForChatDisplay(merged)) } updateState { it.copy(messages = sortMessagesForChatDisplay(merged)) }
} }
@@ -272,16 +275,26 @@ class PublicChatPanel(
} }
} }
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 merged val inFlight = shown.filter { msg ->
msg.id < 0 && (
!msg.client_message_id.isNullOrBlank() ||
msg.pendingFileUri != null ||
!msg.uploadJobId.isNullOrBlank()
)
}
if (ahead.isEmpty() && inFlight.isEmpty()) return merged
val combined = merged + ahead + inFlight
return ru.fromchat.api.local.messages.sortMessagesForChatDisplay( return ru.fromchat.api.local.messages.sortMessagesForChatDisplay(
ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(merged + ahead), ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(combined),
) )
} }
private fun snapshotUiMessagesForNetworkMerge(): List<Message> = panelMessagesForDbMerge()
override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) { override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) {
val cid = clientMessageId?.trim().orEmpty() val cid = clientMessageId?.trim().orEmpty()
if (cid.isEmpty()) return if (cid.isEmpty()) return
val optimistic = _state.messages.find { it.client_message_id == cid } ?: return val optimistic = pendingOptimisticMessage(cid) ?: return
OutgoingMessageCoordinator.enqueuePublicMessage( OutgoingMessageCoordinator.enqueuePublicMessage(
content = content, content = content,
replyToId = replyToId, replyToId = replyToId,
@@ -334,8 +347,15 @@ class PublicChatPanel(
if (response != null && response.messages.isNotEmpty()) { if (response != null && response.messages.isNotEmpty()) {
val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() } val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(networkMessages) ProfileCache.mergePreviewFromPublicMessages(networkMessages)
val optimisticSnapshot = snapshotPendingOptimisticMessages()
val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)"
val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf<String>() }
val loadMsg = "loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds stateCount=${_state.messages.size}"
Logger.d("PublicChatPanel", loadMsg)
var mergedForCache: List<Message>? = null
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val shown = _state.messages val shown = snapshotUiMessagesForNetworkMerge()
Logger.d("PublicChatPanel", "loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}")
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) { 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, networkMessages) val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
@@ -344,6 +364,7 @@ class PublicChatPanel(
} }
if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false) if (_state.isLoading) setLoading(false)
mergedForCache = mergeNetworkHistoryWithShown(shown, networkMessages)
} else { } else {
batchStateUpdates { batchStateUpdates {
val merged = preserveReplyToFromExisting( val merged = preserveReplyToFromExisting(
@@ -354,14 +375,22 @@ class PublicChatPanel(
addMessages( addMessages(
ProfileCache.enrichPublicMessagesForDisplay(merged), ProfileCache.enrichPublicMessagesForDisplay(merged),
) )
Logger.d("PublicChatPanel", "loadMessages: after addMessages mergedSize=${merged.size} restoring optimistic count=${optimisticSnapshot.size}")
restorePendingOptimisticMessages(optimisticSnapshot)
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
setLoading(false) setLoading(false)
mergedForCache = mergeNetworkHistoryWithShown(
panelMessagesForDbMerge(),
networkMessages,
)
} }
} }
} }
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val mergedForCache = mergeNetworkHistoryWithShown(_state.messages, networkMessages) val toPersist = mergedForCache
MessageCacheStore.replacePublicMessages(mergedForCache) ?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages)
Logger.d("PublicChatPanel", "loadMessages: persisting to cache messages=${toPersist.size}")
MessageCacheStore.replacePublicMessages(toPersist)
} }
} else if (responseResult.isFailure) { } else if (responseResult.isFailure) {
val cause = responseResult.exceptionOrNull() val cause = responseResult.exceptionOrNull()
@@ -135,6 +135,7 @@ internal fun imageAspectRatioForMessage(
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,
thumbnailBytes: ByteArray? = null,
): Float? { ): Float? {
val localRatio = pendingFileAspectRatio?.takeIf { fileIndex == 0 && it > 0f } val localRatio = pendingFileAspectRatio?.takeIf { fileIndex == 0 && it > 0f }
val pair = fileAspectRatioPairs?.getOrNull(fileIndex) val pair = fileAspectRatioPairs?.getOrNull(fileIndex)
@@ -148,14 +149,42 @@ internal fun imageAspectRatioForMessage(
val serverDim = fileDimensions?.getOrNull(fileIndex) val serverDim = fileDimensions?.getOrNull(fileIndex)
val serverRatio = fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f } val serverRatio = fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }
pairRatio?.let { return it } val metadataAspect = pairRatio
serverDim?.let { (w, h) -> ?: serverDim?.let { (w, h) ->
if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) { if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) {
return aspectRatioFromDimensionPair(w, h) aspectRatioFromDimensionPair(w, h)
} else {
null
}
}
?: serverRatio?.takeIf { !isPlaceholderAttachmentAspectRatio(it) }
?: localRatio
val thumbDims = thumbnailBytes?.let { ru.fromchat.api.local.download.readImageDimensionsFromBytes(it) }
if (thumbDims != null) {
val (tw, th) = thumbDims
if (tw > 0 && th > 0 && !isPlaceholderAttachmentDimensions(tw, th)) {
return preferDecodedAspectRatio(metadataAspect, tw, th)
} }
} }
serverRatio?.takeIf { !isPlaceholderAttachmentAspectRatio(it) }?.let { return it } return metadataAspect
return localRatio }
/**
* Prefer decoded / thumbnail pixels when metadata ignored EXIF (common on huge JPEGs):
* metadata and decoded aspects are reciprocals (~90° apart).
*/
internal fun preferDecodedAspectRatio(
metadataAspect: Float?,
decodedWidth: Int,
decodedHeight: Int,
): Float {
if (decodedWidth <= 0 || decodedHeight <= 0) {
return metadataAspect?.takeIf { it.isFinite() && it > 0f } ?: 1f
}
// Always trust decoded pixels for layout; metadata can disagree on EXIF orientation,
// especially for very large images or when dimensions were cached incorrectly.
return decodedWidth.toFloat() / decodedHeight.toFloat()
} }
internal fun coalesceDecodeTarget(vararg sizes: ChatPreviewDecodeSize?): ChatPreviewDecodeSize { internal fun coalesceDecodeTarget(vararg sizes: ChatPreviewDecodeSize?): ChatPreviewDecodeSize {
@@ -43,3 +43,4 @@ fun DisplayName(
} }
} }
} }
@@ -1,3 +1,5 @@
package ru.fromchat.api.local.download package ru.fromchat.api.local.download
internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? = null internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? = null
internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair<Int, Int>? = null