feat: Update color handling for typing indicator and verified badge in dark theme

This commit is contained in:
2026-02-25 18:35:43 +05:00
parent 0cbd2b84c3
commit 31d100e9ac
6 changed files with 106 additions and 18 deletions

View File

@@ -3,6 +3,7 @@ package com.rosetta.messenger
import android.app.Application import android.app.Application
import com.airbnb.lottie.L import com.airbnb.lottie.L
import com.rosetta.messenger.data.DraftManager import com.rosetta.messenger.data.DraftManager
import com.rosetta.messenger.network.TransportManager
import com.rosetta.messenger.update.UpdateManager import com.rosetta.messenger.update.UpdateManager
import com.rosetta.messenger.utils.CrashReportManager import com.rosetta.messenger.utils.CrashReportManager
@@ -27,6 +28,9 @@ class RosettaApplication : Application() {
// Инициализируем менеджер черновиков // Инициализируем менеджер черновиков
DraftManager.init(this) DraftManager.init(this)
// Инициализируем менеджер транспорта файлов (streaming download)
TransportManager.init(this)
// Инициализируем менеджер обновлений (SDU) // Инициализируем менеджер обновлений (SDU)
UpdateManager.init() UpdateManager.init()

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.network package com.rosetta.messenger.network
import android.content.Context
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -8,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -32,6 +34,7 @@ object TransportManager {
private const val INITIAL_BACKOFF_MS = 1000L private const val INITIAL_BACKOFF_MS = 1000L
private var transportServer: String? = null private var transportServer: String? = null
private var appContext: Context? = null
private val _uploading = MutableStateFlow<List<TransportState>>(emptyList()) private val _uploading = MutableStateFlow<List<TransportState>>(emptyList())
val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow() val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow()
@@ -45,6 +48,13 @@ object TransportManager {
.writeTimeout(5, TimeUnit.MINUTES) // 5 минут для больших файлов .writeTimeout(5, TimeUnit.MINUTES) // 5 минут для больших файлов
.build() .build()
/**
* Инициализация с контекстом приложения (вызывать из Application.onCreate)
*/
fun init(context: Context) {
appContext = context.applicationContext
}
/** /**
* Установить адрес транспортного сервера * Установить адрес транспортного сервера
*/ */
@@ -83,8 +93,8 @@ object TransportManager {
repeat(MAX_RETRIES) { attempt -> repeat(MAX_RETRIES) { attempt ->
try { try {
return block() return block()
} catch (e: IOException) { } catch (e: Exception) {
lastException = e lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
if (attempt < MAX_RETRIES - 1) { if (attempt < MAX_RETRIES - 1) {
delay(INITIAL_BACKOFF_MS shl attempt) // 1s, 2s, 4s delay(INITIAL_BACKOFF_MS shl attempt) // 1s, 2s, 4s
} }
@@ -194,13 +204,17 @@ object TransportManager {
} }
/** /**
* Скачать файл с транспортного сервера * Скачать файл с транспортного сервера — СТРИМИНГ на диск.
* Файл скачивается чанками по 64KB во временный файл, потом читается как String.
* Это предотвращает OutOfMemoryError для больших файлов (30MB+).
*
* @param id Уникальный ID файла (для трекинга) * @param id Уникальный ID файла (для трекинга)
* @param tag Tag файла на сервере * @param tag Tag файла на сервере
* @return Содержимое файла * @return Содержимое файла
*/ */
suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) { suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) {
val server = getActiveServer() val server = getActiveServer()
ProtocolManager.addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
// Добавляем в список скачиваний // Добавляем в список скачиваний
_downloading.value = _downloading.value + TransportState(id, 0) _downloading.value = _downloading.value + TransportState(id, 0)
@@ -228,16 +242,65 @@ object TransportManager {
throw IOException("Download failed: ${response.code}") throw IOException("Download failed: ${response.code}")
} }
val content = response.body?.string() val body = response.body ?: throw IOException("Empty response body")
?: throw IOException("Empty response") val contentLength = body.contentLength()
// Для маленьких файлов (<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
}
// Для больших файлов — стримим на диск чанками
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)
// Обновляем прогресс до 100%
_downloading.value = _downloading.value.map { _downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = 100) else it if (it.id == id) it.copy(progress = 100) else it
} }
ProtocolManager.addLog("✅ Download OK (stream): id=${id.take(8)}, size=$totalRead")
content 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 { } finally {
// Удаляем из списка скачиваний // Удаляем из списка скачиваний
_downloading.value = _downloading.value.filter { it.id != id } _downloading.value = _downloading.value.filter { it.id != id }

View File

@@ -1142,13 +1142,10 @@ fun ChatDetailScreen(
isSavedMessages -> isSavedMessages ->
Color.White.copy(alpha = 0.7f) Color.White.copy(alpha = 0.7f)
isOnline -> isOnline ->
Color( if (isDarkTheme) Color(0xFF7FE084)
0xFF7FE084 else Color.White.copy(alpha = 0.7f)
) // Зелёный когда онлайн (светлый на синем фоне)
else -> else ->
Color.White.copy(alpha = 0.7f) // Белый полупрозрачный Color.White.copy(alpha = 0.7f)
// для
// offline
}, },
maxLines = 1 maxLines = 1
) )

View File

@@ -217,7 +217,7 @@ fun DateHeader(dateText: String, secondaryTextColor: Color) {
*/ */
@Composable @Composable
fun TypingIndicator(isDarkTheme: Boolean) { 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") val infiniteTransition = rememberInfiniteTransition(label = "typing")
// Each dot animates through a 0→1→0 cycle, staggered by 150 ms // Each dot animates through a 0→1→0 cycle, staggered by 150 ms

View File

@@ -1026,7 +1026,30 @@ fun extractImagesFromMessages(
) )
} ?: emptyList() } ?: 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) } .filter { seenIds.add(it.attachmentId) }
.sortedBy { it.timestamp } .sortedBy { it.timestamp }

View File

@@ -1935,7 +1935,8 @@ private fun CollapsingOtherProfileHeader(
VerifiedBadge( VerifiedBadge(
verified = if (verified > 0) verified else 1, verified = if (verified > 0) verified else 1,
size = (nameFontSize.value * 0.8f).toInt(), size = (nameFontSize.value * 0.8f).toInt(),
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
badgeTint = if (isDarkTheme) null else Color.White
) )
} }
} }