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 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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user