From 31d100e9ac19e58064b82b696a4aa95be142aed1 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Wed, 25 Feb 2026 18:35:43 +0500 Subject: [PATCH] feat: Update color handling for typing indicator and verified badge in dark theme --- .../rosetta/messenger/RosettaApplication.kt | 4 + .../messenger/network/TransportManager.kt | 81 ++++++++++++++++--- .../messenger/ui/chats/ChatDetailScreen.kt | 9 +-- .../chats/components/ChatDetailComponents.kt | 2 +- .../ui/chats/components/ImageViewerScreen.kt | 25 +++++- .../ui/settings/OtherProfileScreen.kt | 3 +- 6 files changed, 106 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt b/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt index 117492a..f4a7081 100644 --- a/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt +++ b/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt @@ -3,6 +3,7 @@ package com.rosetta.messenger import android.app.Application import com.airbnb.lottie.L import com.rosetta.messenger.data.DraftManager +import com.rosetta.messenger.network.TransportManager import com.rosetta.messenger.update.UpdateManager import com.rosetta.messenger.utils.CrashReportManager @@ -27,6 +28,9 @@ class RosettaApplication : Application() { // Инициализируем менеджер черновиков DraftManager.init(this) + // Инициализируем менеджер транспорта файлов (streaming download) + TransportManager.init(this) + // Инициализируем менеджер обновлений (SDU) UpdateManager.init() diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt index 243c920..c56f5a2 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -1,5 +1,6 @@ package com.rosetta.messenger.network +import android.content.Context import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -8,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext import okhttp3.* import okhttp3.MediaType.Companion.toMediaType +import java.io.File import java.io.IOException import java.util.concurrent.TimeUnit import kotlin.coroutines.resume @@ -32,6 +34,7 @@ object TransportManager { private const val INITIAL_BACKOFF_MS = 1000L private var transportServer: String? = null + private var appContext: Context? = null private val _uploading = MutableStateFlow>(emptyList()) val uploading: StateFlow> = _uploading.asStateFlow() @@ -45,6 +48,13 @@ object TransportManager { .writeTimeout(5, TimeUnit.MINUTES) // 5 минут для больших файлов .build() + /** + * Инициализация с контекстом приложения (вызывать из Application.onCreate) + */ + fun init(context: Context) { + appContext = context.applicationContext + } + /** * Установить адрес транспортного сервера */ @@ -83,8 +93,8 @@ object TransportManager { repeat(MAX_RETRIES) { attempt -> try { return block() - } catch (e: IOException) { - lastException = e + } catch (e: Exception) { + lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e) if (attempt < MAX_RETRIES - 1) { delay(INITIAL_BACKOFF_MS shl attempt) // 1s, 2s, 4s } @@ -194,13 +204,17 @@ object TransportManager { } /** - * Скачать файл с транспортного сервера + * Скачать файл с транспортного сервера — СТРИМИНГ на диск. + * Файл скачивается чанками по 64KB во временный файл, потом читается как String. + * Это предотвращает OutOfMemoryError для больших файлов (30MB+). + * * @param id Уникальный ID файла (для трекинга) * @param tag Tag файла на сервере * @return Содержимое файла */ suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) { val server = getActiveServer() + ProtocolManager.addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server") // Добавляем в список скачиваний _downloading.value = _downloading.value + TransportState(id, 0) @@ -228,16 +242,65 @@ object TransportManager { throw IOException("Download failed: ${response.code}") } - val content = response.body?.string() - ?: throw IOException("Empty response") + val body = response.body ?: throw IOException("Empty response body") + val contentLength = body.contentLength() - // Обновляем прогресс до 100% - _downloading.value = _downloading.value.map { - if (it.id == id) it.copy(progress = 100) else it + // Для маленьких файлов (<2MB) — читаем в память напрямую (быстрее) + if (contentLength in 1..2_000_000) { + val content = body.string() + _downloading.value = _downloading.value.map { + if (it.id == id) it.copy(progress = 100) else it + } + ProtocolManager.addLog("✅ Download OK (mem): id=${id.take(8)}, size=${content.length}") + return@withRetry content } - content + // Для больших файлов — стримим на диск чанками + val cacheDir = appContext?.cacheDir ?: throw IOException("No app context for streaming download") + val tempFile = File(cacheDir, "dl_${id.take(16)}_${System.currentTimeMillis()}.tmp") + + try { + var totalRead = 0L + val buffer = ByteArray(64 * 1024) // 64KB chunks + + body.byteStream().use { inputStream -> + tempFile.outputStream().use { outputStream -> + while (true) { + val bytesRead = inputStream.read(buffer) + if (bytesRead == -1) break + + outputStream.write(buffer, 0, bytesRead) + totalRead += bytesRead + + // Обновляем прогресс + if (contentLength > 0) { + val progress = ((totalRead * 100) / contentLength).toInt().coerceIn(0, 99) + _downloading.value = _downloading.value.map { + if (it.id == id) it.copy(progress = progress) else it + } + } + } + } + } + + // Читаем результат из файла + val content = tempFile.readText(Charsets.UTF_8) + + _downloading.value = _downloading.value.map { + if (it.id == id) it.copy(progress = 100) else it + } + + ProtocolManager.addLog("✅ Download OK (stream): id=${id.take(8)}, size=$totalRead") + content + } finally { + tempFile.delete() + } } + } catch (e: Exception) { + ProtocolManager.addLog( + "❌ Download failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" + ) + throw e } finally { // Удаляем из списка скачиваний _downloading.value = _downloading.value.filter { it.id != id } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 4af053a..3020ddf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -1142,13 +1142,10 @@ fun ChatDetailScreen( isSavedMessages -> Color.White.copy(alpha = 0.7f) isOnline -> - Color( - 0xFF7FE084 - ) // Зелёный когда онлайн (светлый на синем фоне) + if (isDarkTheme) Color(0xFF7FE084) + else Color.White.copy(alpha = 0.7f) else -> - Color.White.copy(alpha = 0.7f) // Белый полупрозрачный - // для - // offline + Color.White.copy(alpha = 0.7f) }, maxLines = 1 ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 658aa40..a75f205 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -217,7 +217,7 @@ fun DateHeader(dateText: String, secondaryTextColor: Color) { */ @Composable fun TypingIndicator(isDarkTheme: Boolean) { - val typingColor = Color(0xFF54A9EB) + val typingColor = if (isDarkTheme) Color(0xFF54A9EB) else Color.White.copy(alpha = 0.7f) val infiniteTransition = rememberInfiniteTransition(label = "typing") // Each dot animates through a 0→1→0 cycle, staggered by 150 ms diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt index 6be23f4..2d562f5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt @@ -1026,7 +1026,30 @@ fun extractImagesFromMessages( ) } ?: emptyList() - mainImages + replyImages + // Also include images from forwarded messages + val forwardedImages = message.forwardedMessages.flatMap { fwd -> + fwd.attachments + .filter { it.type == AttachmentType.IMAGE } + .map { attachment -> + val fwdSenderKey = fwd.senderPublicKey.ifEmpty { + if (fwd.isFromMe) currentPublicKey else opponentPublicKey + } + ViewableImage( + attachmentId = attachment.id, + preview = attachment.preview, + blob = attachment.blob, + chachaKey = message.chachaKey, + senderPublicKey = fwdSenderKey, + senderName = fwd.senderName, + timestamp = message.timestamp, + width = attachment.width, + height = attachment.height, + caption = fwd.text + ) + } + } + + mainImages + replyImages + forwardedImages } .filter { seenIds.add(it.attachmentId) } .sortedBy { it.timestamp } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 6ffa9fd..7309d57 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -1935,7 +1935,8 @@ private fun CollapsingOtherProfileHeader( VerifiedBadge( verified = if (verified > 0) verified else 1, size = (nameFontSize.value * 0.8f).toInt(), - isDarkTheme = isDarkTheme + isDarkTheme = isDarkTheme, + badgeTint = if (isDarkTheme) null else Color.White ) } }