feat: Enhance background color animation on theme change in SwipeableDialogItem

This commit is contained in:
2026-02-20 05:39:27 +05:00
parent f1252bf328
commit b6f266faa4
6 changed files with 272 additions and 34 deletions

View File

@@ -64,7 +64,6 @@ object TransportManager {
*/ */
private suspend fun getActiveServer(): String { private suspend fun getActiveServer(): String {
transportServer?.let { return it } transportServer?.let { return it }
requestTransportServer() requestTransportServer()
repeat(40) { // 10s total repeat(40) { // 10s total
val server = transportServer val server = transportServer
@@ -73,7 +72,6 @@ object TransportManager {
} }
delay(250) delay(250)
} }
throw IOException("Transport server is not set") throw IOException("Transport server is not set")
} }
@@ -111,17 +109,16 @@ object TransportManager {
*/ */
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) { suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
val server = getActiveServer() val server = getActiveServer()
ProtocolManager.addLog("📤 Upload start: id=${id.take(8)}, server=$server")
// Добавляем в список загрузок // Добавляем в список загрузок
_uploading.value = _uploading.value + TransportState(id, 0) _uploading.value = _uploading.value + TransportState(id, 0)
try { try {
withRetry { withRetry {
// 🔥 КРИТИЧНО: Преобразуем строку в байты (как desktop делает new Blob([content]))
val contentBytes = content.toByteArray(Charsets.UTF_8) val contentBytes = content.toByteArray(Charsets.UTF_8)
val totalSize = contentBytes.size.toLong() val totalSize = contentBytes.size.toLong()
// 🔥 RequestBody с отслеживанием прогресса загрузки
val progressRequestBody = object : RequestBody() { val progressRequestBody = object : RequestBody() {
override fun contentType() = "application/octet-stream".toMediaType() override fun contentType() = "application/octet-stream".toMediaType()
override fun contentLength() = totalSize override fun contentLength() = totalSize
@@ -129,7 +126,7 @@ object TransportManager {
override fun writeTo(sink: okio.BufferedSink) { override fun writeTo(sink: okio.BufferedSink) {
val source = okio.Buffer().write(contentBytes) val source = okio.Buffer().write(contentBytes)
var uploaded = 0L var uploaded = 0L
val bufferSize = 8 * 1024L // 8 KB chunks val bufferSize = 8 * 1024L
while (true) { while (true) {
val read = source.read(sink.buffer, bufferSize) val read = source.read(sink.buffer, bufferSize)
@@ -138,7 +135,6 @@ object TransportManager {
uploaded += read uploaded += read
sink.flush() sink.flush()
// Обновляем прогресс
val progress = ((uploaded * 100) / totalSize).toInt() val progress = ((uploaded * 100) / totalSize).toInt()
_uploading.value = _uploading.value.map { _uploading.value = _uploading.value.map {
if (it.id == id) it.copy(progress = progress) else it if (it.id == id) it.copy(progress = progress) else it
@@ -175,8 +171,6 @@ object TransportManager {
val responseBody = response.body?.string() val responseBody = response.body?.string()
?: throw IOException("Empty response") ?: throw IOException("Empty response")
// Parse JSON response to get tag
val tag = org.json.JSONObject(responseBody).getString("t") val tag = org.json.JSONObject(responseBody).getString("t")
// Обновляем прогресс до 100% // Обновляем прогресс до 100%
@@ -184,8 +178,15 @@ object TransportManager {
if (it.id == id) it.copy(progress = 100) else it if (it.id == id) it.copy(progress = 100) else it
} }
ProtocolManager.addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
tag tag
} }
} catch (e: Exception) {
ProtocolManager.addLog(
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
throw e
} finally { } finally {
// Удаляем из списка загрузок // Удаляем из списка загрузок
_uploading.value = _uploading.value.filter { it.id != id } _uploading.value = _uploading.value.filter { it.id != id }

View File

@@ -69,6 +69,7 @@ import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.components.* import com.rosetta.messenger.ui.chats.components.*
@@ -355,11 +356,18 @@ fun ChatDetailScreen(
} }
} }
DisposableEffect(Unit) {
ProtocolManager.enableUILogs(true)
onDispose { ProtocolManager.enableUILogs(false) }
}
// Состояние выпадающего меню // Состояние выпадающего меню
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
var showDebugLogs by remember { mutableStateOf(false) }
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
var showBlockConfirm by remember { mutableStateOf(false) } var showBlockConfirm by remember { mutableStateOf(false) }
var showUnblockConfirm by remember { mutableStateOf(false) } var showUnblockConfirm by remember { mutableStateOf(false) }
val debugLogs by ProtocolManager.debugLogs.collectAsState()
// Наблюдаем за статусом блокировки в реальном времени через Flow // Наблюдаем за статусом блокировки в реальном времени через Flow
val isBlocked by val isBlocked by
database.blacklistDao() database.blacklistDao()
@@ -1178,6 +1186,10 @@ fun ChatDetailScreen(
isSystemAccount, isSystemAccount,
isBlocked = isBlocked =
isBlocked, isBlocked,
onLogsClick = {
showMenu = false
showDebugLogs = true
},
onBlockClick = { onBlockClick = {
showMenu = showMenu =
false false
@@ -2480,4 +2492,13 @@ fun ChatDetailScreen(
recipientName = user.title recipientName = user.title
) )
} }
if (showDebugLogs) {
DebugLogsBottomSheet(
logs = debugLogs,
isDarkTheme = isDarkTheme,
onDismiss = { showDebugLogs = false },
onClearLogs = { ProtocolManager.clearLogs() }
)
}
} }

View File

@@ -446,6 +446,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
updateCacheFromCurrentMessages() updateCacheFromCurrentMessages()
} }
private fun shortPhotoId(value: String, limit: Int = 8): String {
val trimmed = value.trim()
if (trimmed.isEmpty()) return "unknown"
return if (trimmed.length <= limit) trimmed else trimmed.take(limit)
}
private fun logPhotoPipeline(messageId: String, message: String) {
ProtocolManager.addLog("📸 IMG ${shortPhotoId(messageId)} | $message")
}
private fun logPhotoPipelineError(messageId: String, stage: String, throwable: Throwable) {
val reason = throwable.message ?: "unknown"
ProtocolManager.addLog(
"❌ IMG ${shortPhotoId(messageId)} | $stage failed: ${throwable.javaClass.simpleName}: $reason"
)
}
/** 🔄 Очистить localUri в attachments сообщения (после успешной отправки) */ /** 🔄 Очистить localUri в attachments сообщения (после успешной отправки) */
private fun updateMessageAttachments(messageId: String, localUri: String?) { private fun updateMessageAttachments(messageId: String, localUri: String?) {
_messages.value = _messages.value =
@@ -2240,6 +2257,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val context = getApplication<Application>() val context = getApplication<Application>()
if (recipient == null || sender == null || privateKey == null) { if (recipient == null || sender == null || privateKey == null) {
ProtocolManager.addLog(
"❌ IMG send aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})"
)
return return
} }
@@ -2248,10 +2268,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val text = caption.trim() val text = caption.trim()
val attachmentId = "img_$timestamp" val attachmentId = "img_$timestamp"
logPhotoPipeline(
messageId,
"start: uri=${imageUri.lastPathSegment ?: "unknown"}, captionLen=${text.length}, attachment=${shortPhotoId(attachmentId, 12)}"
)
// 🔥 КРИТИЧНО: Получаем размеры СРАЗУ (быстрая операция - только читает заголовок файла) // 🔥 КРИТИЧНО: Получаем размеры СРАЗУ (быстрая операция - только читает заголовок файла)
// Это предотвращает "расширение" пузырька при первом показе // Это предотвращает "расширение" пузырька при первом показе
val (imageWidth, imageHeight) = val (imageWidth, imageHeight) =
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri) com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri)
logPhotoPipeline(messageId, "dimensions: ${imageWidth}x$imageHeight")
// 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri И РАЗМЕРАМИ // 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri И РАЗМЕРАМИ
// Используем URI напрямую для отображения (без конвертации в base64) // Используем URI напрямую для отображения (без конвертации в base64)
@@ -2282,11 +2308,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
) )
addMessageSafely(optimisticMessage) addMessageSafely(optimisticMessage)
_inputText.value = "" _inputText.value = ""
logPhotoPipeline(messageId, "optimistic UI added")
// 2. 🔄 В фоне, независимо от жизненного цикла экрана: // 2. 🔄 В фоне, независимо от жизненного цикла экрана:
// сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет. // сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет.
backgroundUploadScope.launch { backgroundUploadScope.launch {
try { try {
logPhotoPipeline(messageId, "persist optimistic message in DB")
val optimisticAttachmentsJson = val optimisticAttachmentsJson =
JSONArray() JSONArray()
.apply { .apply {
@@ -2323,16 +2351,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
timestamp = timestamp, timestamp = timestamp,
opponentPublicKey = recipient opponentPublicKey = recipient
) )
logPhotoPipeline(messageId, "optimistic dialog updated")
} catch (_: Exception) { } catch (_: Exception) {
logPhotoPipeline(messageId, "optimistic DB save skipped (non-fatal)")
} }
try { try {
val convertStartedAt = System.currentTimeMillis()
val (width, height) = val (width, height) =
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri) com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri)
val imageBase64 = val imageBase64 =
com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri) com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri)
if (imageBase64 == null) { if (imageBase64 == null) {
logPhotoPipeline(messageId, "base64 conversion returned null")
if (!isCleared) { if (!isCleared) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR) updateMessageStatus(messageId, MessageStatus.ERROR)
@@ -2340,14 +2372,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
return@launch return@launch
} }
logPhotoPipeline(
messageId,
"base64 ready: len=${imageBase64.length}, elapsed=${System.currentTimeMillis() - convertStartedAt}ms"
)
val blurhash = val blurhash =
com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri) com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri)
logPhotoPipeline(messageId, "blurhash ready: len=${blurhash.length}")
if (!isCleared) { if (!isCleared) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height) updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height)
} }
logPhotoPipeline(messageId, "optimistic payload updated in UI")
} }
sendImageMessageInternal( sendImageMessageInternal(
@@ -2362,7 +2400,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
sender = sender, sender = sender,
privateKey = privateKey privateKey = privateKey
) )
} catch (_: Exception) { logPhotoPipeline(messageId, "pipeline completed")
} catch (e: Exception) {
logPhotoPipelineError(messageId, "prepare+convert", e)
if (!isCleared) { if (!isCleared) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR) updateMessageStatus(messageId, MessageStatus.ERROR)
@@ -2415,27 +2455,53 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
) { ) {
try { try {
val context = getApplication<Application>() val context = getApplication<Application>()
val pipelineStartedAt = System.currentTimeMillis()
logPhotoPipeline(
messageId,
"internal send start: base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}, captionLen=${caption.length}"
)
// Шифрование текста // Шифрование текста
val encryptStartedAt = System.currentTimeMillis()
val encryptResult = MessageCrypto.encryptForSending(caption, recipient) val encryptResult = MessageCrypto.encryptForSending(caption, recipient)
val encryptedContent = encryptResult.ciphertext val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey) val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
logPhotoPipeline(
messageId,
"text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}, elapsed=${System.currentTimeMillis() - encryptStartedAt}ms"
)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
// 🚀 Шифруем изображение с ChaCha ключом для Transport Server // 🚀 Шифруем изображение с ChaCha ключом для Transport Server
val blobEncryptStartedAt = System.currentTimeMillis()
val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce) val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
logPhotoPipeline(
messageId,
"blob encrypted: len=${encryptedImageBlob.length}, elapsed=${System.currentTimeMillis() - blobEncryptStartedAt}ms"
)
val attachmentId = "img_$timestamp" val attachmentId = "img_$timestamp"
logPhotoPipeline(
messageId,
"attachment prepared: id=${shortPhotoId(attachmentId, 12)}, size=${width}x$height"
)
// 📤 Загружаем на Transport Server // 📤 Загружаем на Transport Server
val isSavedMessages = (sender == recipient) val isSavedMessages = (sender == recipient)
var uploadTag = "" var uploadTag = ""
if (!isSavedMessages) { if (!isSavedMessages) {
logPhotoPipeline(messageId, "upload start: attachment=${shortPhotoId(attachmentId, 12)}")
uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
logPhotoPipeline(
messageId,
"upload done: tag=${shortPhotoId(uploadTag, 12)}"
)
} else {
logPhotoPipeline(messageId, "saved-messages mode: upload skipped")
} }
// Preview содержит tag::blurhash // Preview содержит tag::blurhash
@@ -2467,16 +2533,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Отправляем пакет // Отправляем пакет
if (!isSavedMessages) { if (!isSavedMessages) {
ProtocolManager.send(packet) ProtocolManager.send(packet)
logPhotoPipeline(messageId, "packet sent to protocol")
} else {
logPhotoPipeline(messageId, "saved-messages mode: packet send skipped")
} }
// 💾 Сохраняем изображение в файл локально // 💾 Сохраняем изображение в файл локально
AttachmentFileManager.saveAttachment( val savedLocally =
AttachmentFileManager.saveAttachment(
context = context, context = context,
blob = imageBase64, blob = imageBase64,
attachmentId = attachmentId, attachmentId = attachmentId,
publicKey = sender, publicKey = sender,
privateKey = privateKey privateKey = privateKey
) )
logPhotoPipeline(messageId, "local file cache saved=$savedLocally")
// Сохраняем в БД // Сохраняем в БД
val attachmentsJson = val attachmentsJson =
@@ -2504,19 +2575,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} else { } else {
updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson) updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson)
} }
logPhotoPipeline(messageId, "db status+attachments updated")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT) updateMessageStatus(messageId, MessageStatus.SENT)
// Также очищаем localUri в UI // Также очищаем localUri в UI
updateMessageAttachments(messageId, null) updateMessageAttachments(messageId, null)
} }
logPhotoPipeline(messageId, "ui status switched to SENT")
saveDialog( saveDialog(
lastMessage = if (caption.isNotEmpty()) caption else "photo", lastMessage = if (caption.isNotEmpty()) caption else "photo",
timestamp = timestamp, timestamp = timestamp,
opponentPublicKey = recipient opponentPublicKey = recipient
) )
logPhotoPipeline(
messageId,
"dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms"
)
} catch (e: Exception) { } catch (e: Exception) {
logPhotoPipelineError(messageId, "internal-send", e)
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
} }
} }
@@ -2687,7 +2765,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient opponentPublicKey = recipient
) )
} catch (e: Exception) { } catch (e: Exception) {
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
} finally { } finally {
isSending = false isSending = false
} }
@@ -2719,10 +2797,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
val context = getApplication<Application>() val context = getApplication<Application>()
val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8)
ProtocolManager.addLog(
"📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}"
)
backgroundUploadScope.launch { backgroundUploadScope.launch {
val preparedImages = val preparedImages =
imageUris.mapNotNull { uri -> imageUris.mapIndexedNotNull { index, uri ->
val (width, height) = val (width, height) =
com.rosetta.messenger.utils.MediaUtils.getImageDimensions( com.rosetta.messenger.utils.MediaUtils.getImageDimensions(
context, context,
@@ -2733,12 +2815,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
context, context,
uri uri
) )
?: return@mapNotNull null ?: run {
ProtocolManager.addLog(
"❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed"
)
return@mapIndexedNotNull null
}
val blurhash = val blurhash =
com.rosetta.messenger.utils.MediaUtils.generateBlurhash( com.rosetta.messenger.utils.MediaUtils.generateBlurhash(
context, context,
uri uri
) )
ProtocolManager.addLog(
"📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}"
)
ImageData( ImageData(
base64 = imageBase64, base64 = imageBase64,
blurhash = blurhash, blurhash = blurhash,
@@ -2747,7 +2837,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
) )
} }
if (preparedImages.isEmpty()) return@launch if (preparedImages.isEmpty()) {
ProtocolManager.addLog(
"❌ IMG-GROUP $groupDebugId | no prepared images, send canceled"
)
return@launch
}
ProtocolManager.addLog(
"📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}"
)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
sendImageGroup(preparedImages, caption) sendImageGroup(preparedImages, caption)
@@ -2786,6 +2884,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
val text = caption.trim() val text = caption.trim()
logPhotoPipeline(
messageId,
"group start: count=${images.size}, captionLen=${text.length}"
)
// Создаём attachments для всех изображений // Создаём attachments для всех изображений
val attachmentsList = val attachmentsList =
@@ -2816,12 +2918,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
backgroundUploadScope.launch { backgroundUploadScope.launch {
try { try {
val groupStartedAt = System.currentTimeMillis()
// Шифрование текста // Шифрование текста
val encryptResult = MessageCrypto.encryptForSending(text, recipient) val encryptResult = MessageCrypto.encryptForSending(text, recipient)
val encryptedContent = encryptResult.ciphertext val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey) val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
logPhotoPipeline(
messageId,
"group text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}"
)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -2831,6 +2938,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
for ((index, imageData) in images.withIndex()) { for ((index, imageData) in images.withIndex()) {
val attachmentId = "img_${timestamp}_$index" val attachmentId = "img_${timestamp}_$index"
logPhotoPipeline(
messageId,
"group item#$index start: id=${shortPhotoId(attachmentId, 12)}, size=${imageData.width}x${imageData.height}"
)
// Шифруем изображение с ChaCha ключом // Шифруем изображение с ChaCha ключом
val encryptedImageBlob = val encryptedImageBlob =
@@ -2844,6 +2955,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} else { } else {
imageData.blurhash imageData.blurhash
} }
logPhotoPipeline(
messageId,
"group item#$index upload done: tag=${shortPhotoId(uploadTag, 12)}"
)
// Сохраняем в файл локально // Сохраняем в файл локально
AttachmentFileManager.saveAttachment( AttachmentFileManager.saveAttachment(
@@ -2897,6 +3012,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val isSavedMessages = (sender == recipient) val isSavedMessages = (sender == recipient)
if (!isSavedMessages) { if (!isSavedMessages) {
ProtocolManager.send(packet) ProtocolManager.send(packet)
logPhotoPipeline(messageId, "group packet sent")
} }
// Сохраняем в БД // Сохраняем в БД
@@ -2925,8 +3041,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
timestamp = timestamp, timestamp = timestamp,
opponentPublicKey = recipient opponentPublicKey = recipient
) )
logPhotoPipeline(
messageId,
"group completed; totalElapsed=${System.currentTimeMillis() - groupStartedAt}ms"
)
} catch (e: Exception) { } catch (e: Exception) {
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } logPhotoPipelineError(messageId, "group-send", e)
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
} finally { } finally {
isSending = false isSending = false
} }

View File

@@ -2544,10 +2544,15 @@ fun SwipeableDialogItem(
} else { } else {
if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
} }
var previousTheme by remember { mutableStateOf(isDarkTheme) }
val themeJustChanged = previousTheme != isDarkTheme
SideEffect { previousTheme = isDarkTheme }
val backgroundColor by val backgroundColor by
animateColorAsState( animateColorAsState(
targetValue = targetBackgroundColor, targetValue = targetBackgroundColor,
animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing), animationSpec =
if (themeJustChanged) snap()
else tween(durationMillis = 260, easing = FastOutSlowInEasing),
label = "pinnedBackground" label = "pinnedBackground"
) )
var offsetX by remember { mutableStateOf(0f) } var offsetX by remember { mutableStateOf(0f) }

View File

@@ -1863,6 +1863,7 @@ fun KebabMenu(
isSavedMessages: Boolean, isSavedMessages: Boolean,
isSystemAccount: Boolean = false, isSystemAccount: Boolean = false,
isBlocked: Boolean, isBlocked: Boolean,
onLogsClick: () -> Unit,
onBlockClick: () -> Unit, onBlockClick: () -> Unit,
onUnblockClick: () -> Unit, onUnblockClick: () -> Unit,
onDeleteClick: () -> Unit onDeleteClick: () -> Unit
@@ -1891,6 +1892,14 @@ fun KebabMenu(
dismissOnClickOutside = true dismissOnClickOutside = true
) )
) { ) {
KebabMenuItem(
icon = TelegramIcons.Info,
text = "Debug Logs",
onClick = onLogsClick,
tintColor = iconColor,
textColor = textColor
)
if (!isSavedMessages && !isSystemAccount) { if (!isSavedMessages && !isSystemAccount) {
KebabMenuItem( KebabMenuItem(
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block, icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,

View File

@@ -3,10 +3,13 @@ package com.rosetta.messenger.utils
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.graphics.Matrix import android.graphics.Matrix
import android.net.Uri import android.net.Uri
import android.os.Build
import android.util.Base64 import android.util.Base64
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.rosetta.messenger.network.ProtocolManager
import com.vanniktech.blurhash.BlurHash import com.vanniktech.blurhash.BlurHash
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -28,6 +31,10 @@ object MediaUtils {
// Android ограничение: файл + base64 + шифрование = ~3x памяти // Android ограничение: файл + base64 + шифрование = ~3x памяти
// 20 МБ файл = ~60 МБ RAM, безопасно для большинства устройств // 20 МБ файл = ~60 МБ RAM, безопасно для большинства устройств
const val MAX_FILE_SIZE_MB = 20 const val MAX_FILE_SIZE_MB = 20
private fun logImage(message: String) {
ProtocolManager.addLog("🧪 IMG-UTIL | $message")
}
/** /**
* Конвертировать изображение из Uri в Base64 PNG * Конвертировать изображение из Uri в Base64 PNG
@@ -35,8 +42,12 @@ object MediaUtils {
*/ */
suspend fun uriToBase64Image(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) { suspend fun uriToBase64Image(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) {
try { try {
val uriInfo = "${uri.scheme ?: "unknown"}:${uri.lastPathSegment ?: "unknown"}"
logImage("encode start: $uriInfo")
// Читаем EXIF ориентацию // Читаем EXIF ориентацию
val orientation = getExifOrientation(context, uri) val orientation = getExifOrientation(context, uri)
logImage("orientation=$orientation")
val boundsOptions = val boundsOptions =
BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.Options().apply { inJustDecodeBounds = true }
@@ -45,47 +56,117 @@ object MediaUtils {
} ?: return@withContext null } ?: return@withContext null
if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) { if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) {
logImage("bounds decode failed, trying direct decode fallback")
}
val sourceWidth = boundsOptions.outWidth.coerceAtLeast(1)
val sourceHeight = boundsOptions.outHeight.coerceAtLeast(1)
val initialSample =
calculateInSampleSize(
sourceWidth,
sourceHeight,
MAX_IMAGE_SIZE * 2
)
var bitmap: Bitmap? = null
var sample = initialSample.coerceAtLeast(1)
repeat(2) { attempt ->
if (bitmap != null) return@repeat
val decodeOptions =
BitmapFactory.Options().apply {
inSampleSize = sample
inPreferredConfig = Bitmap.Config.ARGB_8888
}
bitmap =
context.contentResolver.openInputStream(uri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream, null, decodeOptions)
}
if (bitmap == null) {
bitmap = context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor, null, decodeOptions)
}
}
if (bitmap == null) {
logImage("decode attempt ${attempt + 1} failed (sample=$sample)")
sample *= 2
}
}
if (bitmap == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
bitmap = decodeWithImageDecoder(context, uri)
if (bitmap != null) {
logImage("decoded via ImageDecoder fallback")
}
}
if (bitmap == null) {
logImage("decode failed after all fallbacks")
return@withContext null return@withContext null
} }
val decodeOptions = val decodedBitmap = bitmap
BitmapFactory.Options().apply { ?: run {
inSampleSize = logImage("decode failed: bitmap is null after fallbacks")
calculateInSampleSize( return@withContext null
boundsOptions.outWidth,
boundsOptions.outHeight,
MAX_IMAGE_SIZE * 2
)
inPreferredConfig = Bitmap.Config.ARGB_8888
} }
var bitmap =
context.contentResolver.openInputStream(uri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream, null, decodeOptions)
} ?: return@withContext null
// Применяем EXIF ориентацию (поворот/отражение) // Применяем EXIF ориентацию (поворот/отражение)
bitmap = applyExifOrientation(bitmap, orientation) val orientedBitmap = applyExifOrientation(decodedBitmap, orientation)
// Масштабируем если слишком большое // Масштабируем если слишком большое
val scaledBitmap = scaleDownBitmap(bitmap, MAX_IMAGE_SIZE) val scaledBitmap = scaleDownBitmap(orientedBitmap, MAX_IMAGE_SIZE)
if (scaledBitmap != bitmap) { if (scaledBitmap != orientedBitmap) {
bitmap.recycle() orientedBitmap.recycle()
} }
// Конвертируем в PNG Base64 // Конвертируем в PNG Base64
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
scaledBitmap.compress(Bitmap.CompressFormat.PNG, IMAGE_QUALITY, outputStream) val compressed = scaledBitmap.compress(Bitmap.CompressFormat.PNG, IMAGE_QUALITY, outputStream)
if (!compressed) {
logImage("bitmap compress failed")
scaledBitmap.recycle()
return@withContext null
}
val bytes = outputStream.toByteArray() val bytes = outputStream.toByteArray()
val base64 = "data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP) val base64 = "data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
scaledBitmap.recycle() scaledBitmap.recycle()
logImage("encode success: outBytes=${bytes.size}")
base64 base64
} catch (e: Exception) { } catch (e: Exception) {
logImage("encode exception: ${e.javaClass.simpleName}: ${e.message ?: "unknown"}")
null null
} catch (e: OutOfMemoryError) { } catch (e: OutOfMemoryError) {
logImage("encode OOM: ${e.message ?: "unknown"}")
null
}
}
private fun decodeWithImageDecoder(context: Context, uri: Uri): Bitmap? {
return try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
val source = ImageDecoder.createSource(context.contentResolver, uri)
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
val size = info.size
val maxDimension = maxOf(size.width, size.height)
if (maxDimension > MAX_IMAGE_SIZE * 2) {
val ratio = (MAX_IMAGE_SIZE * 2).toFloat() / maxDimension.toFloat()
val targetW = (size.width * ratio).toInt().coerceAtLeast(1)
val targetH = (size.height * ratio).toInt().coerceAtLeast(1)
decoder.setTargetSize(targetW, targetH)
}
}
} catch (_: Exception) {
null
} catch (_: OutOfMemoryError) {
null null
} }
} }