feat: Update color handling for typing indicator and verified badge in dark theme
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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<List<TransportState>>(emptyList())
|
||||
val uploading: StateFlow<List<TransportState>> = _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 }
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user