Refactor and optimize various components

- Updated RosettaFirebaseMessagingService to use IO dispatcher for blocking calls.
- Enhanced AvatarRepository with LRU caching and improved coroutine handling for avatar loading.
- Implemented timeout for websocket connection in UnlockScreen.
- Added selection mode functionality in ChatsListScreen with haptic feedback and improved UI for chat actions.
- Improved animated dots in AttachmentComponents for a smoother visual effect.
- Refactored image downloading and caching logic in ChatDetailComponents to streamline the process.
- Optimized SwipeBackContainer to simplify gesture handling.
- Adjusted swipe back behavior in OtherProfileScreen based on image viewer state.
This commit is contained in:
2026-02-12 15:38:30 +05:00
parent 263d00b783
commit ea537ccce1
16 changed files with 775 additions and 1370 deletions

View File

@@ -57,8 +57,11 @@ import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
class MainActivity : FragmentActivity() {
private lateinit var preferencesManager: PreferencesManager
@@ -386,37 +389,39 @@ class MainActivity : FragmentActivity() {
/** 🔔 Инициализация Firebase Cloud Messaging */
private fun initializeFirebase() {
try {
addFcmLog("🔔 Инициализация Firebase...")
// Инициализируем Firebase
FirebaseApp.initializeApp(this)
addFcmLog("✅ Firebase инициализирован")
lifecycleScope.launch(Dispatchers.Default) {
try {
addFcmLog("🔔 Инициализация Firebase...")
// Инициализируем Firebase (тяжёлая операция — не на Main)
FirebaseApp.initializeApp(this@MainActivity)
addFcmLog("✅ Firebase инициализирован")
// Получаем FCM токен
addFcmLog("📲 Запрос FCM токена...")
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (!task.isSuccessful) {
addFcmLog("❌ Ошибка получения токена: ${task.exception?.message}")
return@addOnCompleteListener
// Получаем FCM токен
addFcmLog("📲 Запрос FCM токена...")
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (!task.isSuccessful) {
addFcmLog("❌ Ошибка получения токена: ${task.exception?.message}")
return@addOnCompleteListener
}
val token = task.result
if (token != null) {
val shortToken = "${token.take(12)}...${token.takeLast(8)}"
addFcmLog("✅ FCM токен получен: $shortToken")
// Сохраняем токен локально
saveFcmToken(token)
addFcmLog("💾 Токен сохранен локально")
} else {
addFcmLog("⚠️ Токен пустой")
}
// Токен будет отправлен на сервер после успешной аутентификации
// (см. вызов sendFcmTokenToServer в onAccountLogin)
}
val token = task.result
if (token != null) {
val shortToken = "${token.take(12)}...${token.takeLast(8)}"
addFcmLog("✅ FCM токен получен: $shortToken")
// Сохраняем токен локально
saveFcmToken(token)
addFcmLog("💾 Токен сохранен локально")
} else {
addFcmLog("⚠️ Токен пустой")
}
// Токен будет отправлен на сервер после успешной аутентификации
// (см. вызов sendFcmTokenToServer в onAccountLogin)
} catch (e: Exception) {
addFcmLog("❌ Ошибка Firebase: ${e.message}")
}
} catch (e: Exception) {
addFcmLog("❌ Ошибка Firebase: ${e.message}")
}
}
@@ -446,15 +451,12 @@ class MainActivity : FragmentActivity() {
addFcmLog("⏳ Ожидание аутентификации...")
// 🔥 КРИТИЧНО: Ждем пока протокол станет AUTHENTICATED
var waitAttempts = 0
while (ProtocolManager.state.value != ProtocolState.AUTHENTICATED &&
waitAttempts < 50) {
delay(100) // Ждем 100ms
waitAttempts++
val authenticated = withTimeoutOrNull(5000) {
ProtocolManager.state.first { it == ProtocolState.AUTHENTICATED }
}
if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) {
addFcmLog("❌ Таймаут аутентификации (${waitAttempts * 100}ms)")
if (authenticated == null) {
addFcmLog("❌ Таймаут аутентификации (5000ms)")
return@launch
}

View File

@@ -24,6 +24,10 @@ object MessageCrypto {
private const val CHACHA_KEY_SIZE = 32
private const val XCHACHA_NONCE_SIZE = 24
private const val POLY1305_TAG_SIZE = 16
// Кэш PBKDF2-SHA256 ключей: password → derived key bytes
// PBKDF2 с 1000 итерациями ~50-100ms, кэш убирает повторные вычисления
private val pbkdf2Cache = java.util.concurrent.ConcurrentHashMap<String, ByteArray>()
init {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
@@ -608,6 +612,13 @@ object MessageCrypto {
* но PBEKeySpec в Java использует UTF-16! Поэтому используем ручную реализацию.
*/
private fun generatePBKDF2Key(password: String, salt: String = "rosetta", iterations: Int = 1000): ByteArray {
// Кэшируем только для дефолтных salt/iterations (99% вызовов)
if (salt == "rosetta" && iterations == 1000) {
return pbkdf2Cache.getOrPut(password) {
val passwordBytes = password.toByteArray(Charsets.UTF_8)
generatePBKDF2KeyFromBytes(passwordBytes, salt.toByteArray(Charsets.UTF_8), iterations)
}
}
// Crypto-js: WordArray.create(password) использует UTF-8
val passwordBytes = password.toByteArray(Charsets.UTF_8)
return generatePBKDF2KeyFromBytes(passwordBytes, salt.toByteArray(Charsets.UTF_8), iterations)

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.network
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -29,7 +30,9 @@ data class TransportState(
*/
object TransportManager {
private const val TAG = "TransportManager"
private const val MAX_RETRIES = 3
private const val INITIAL_BACKOFF_MS = 1000L
// Fallback transport server (CDN)
private const val FALLBACK_TRANSPORT_SERVER = "https://cdn.rosetta-im.com"
@@ -67,6 +70,24 @@ object TransportManager {
return server
}
/**
* Retry с exponential backoff: 1с, 2с, 4с
*/
private suspend fun <T> withRetry(block: suspend () -> T): T {
var lastException: Exception? = null
repeat(MAX_RETRIES) { attempt ->
try {
return block()
} catch (e: IOException) {
lastException = e
if (attempt < MAX_RETRIES - 1) {
delay(INITIAL_BACKOFF_MS shl attempt) // 1s, 2s, 4s
}
}
}
throw lastException!!
}
/**
* Запросить адрес транспортного сервера с сервера протокола
*/
@@ -83,80 +104,81 @@ object TransportManager {
*/
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
val server = getActiveServer()
// Добавляем в список загрузок
_uploading.value = _uploading.value + TransportState(id, 0)
try {
// 🔥 КРИТИЧНО: Преобразуем строку в байты (как desktop делает new Blob([content]))
val contentBytes = content.toByteArray(Charsets.UTF_8)
val totalSize = contentBytes.size.toLong()
// 🔥 RequestBody с отслеживанием прогресса загрузки
val progressRequestBody = object : RequestBody() {
override fun contentType() = "application/octet-stream".toMediaType()
override fun contentLength() = totalSize
override fun writeTo(sink: okio.BufferedSink) {
val source = okio.Buffer().write(contentBytes)
var uploaded = 0L
val bufferSize = 8 * 1024L // 8 KB chunks
while (true) {
val read = source.read(sink.buffer, bufferSize)
if (read == -1L) break
uploaded += read
sink.flush()
// Обновляем прогресс
val progress = ((uploaded * 100) / totalSize).toInt()
_uploading.value = _uploading.value.map {
if (it.id == id) it.copy(progress = progress) else it
withRetry {
// 🔥 КРИТИЧНО: Преобразуем строку в байты (как desktop делает new Blob([content]))
val contentBytes = content.toByteArray(Charsets.UTF_8)
val totalSize = contentBytes.size.toLong()
// 🔥 RequestBody с отслеживанием прогресса загрузки
val progressRequestBody = object : RequestBody() {
override fun contentType() = "application/octet-stream".toMediaType()
override fun contentLength() = totalSize
override fun writeTo(sink: okio.BufferedSink) {
val source = okio.Buffer().write(contentBytes)
var uploaded = 0L
val bufferSize = 8 * 1024L // 8 KB chunks
while (true) {
val read = source.read(sink.buffer, bufferSize)
if (read == -1L) break
uploaded += read
sink.flush()
// Обновляем прогресс
val progress = ((uploaded * 100) / totalSize).toInt()
_uploading.value = _uploading.value.map {
if (it.id == id) it.copy(progress = progress) else it
}
}
}
}
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", id, progressRequestBody)
.build()
val request = Request.Builder()
.url("$server/u")
.post(requestBody)
.build()
val response = suspendCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
})
}
if (!response.isSuccessful) {
throw IOException("Upload failed: ${response.code}")
}
val responseBody = response.body?.string()
?: throw IOException("Empty response")
// Parse JSON response to get tag
val tag = org.json.JSONObject(responseBody).getString("t")
// Обновляем прогресс до 100%
_uploading.value = _uploading.value.map {
if (it.id == id) it.copy(progress = 100) else it
}
tag
}
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", id, progressRequestBody)
.build()
val request = Request.Builder()
.url("$server/u")
.post(requestBody)
.build()
val response = suspendCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
})
}
if (!response.isSuccessful) {
val errorBody = response.body?.string() ?: "No error body"
throw IOException("Upload failed: ${response.code}")
}
val responseBody = response.body?.string()
?: throw IOException("Empty response")
// Parse JSON response to get tag
val tag = org.json.JSONObject(responseBody).getString("t")
// Обновляем прогресс до 100%
_uploading.value = _uploading.value.map {
if (it.id == id) it.copy(progress = 100) else it
}
tag
} finally {
// Удаляем из списка загрузок
_uploading.value = _uploading.value.filter { it.id != id }
@@ -171,43 +193,43 @@ object TransportManager {
*/
suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) {
val server = getActiveServer()
// Добавляем в список скачиваний
_downloading.value = _downloading.value + TransportState(id, 0)
try {
val request = Request.Builder()
.url("$server/d/$tag")
.get()
.build()
val response = suspendCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
})
withRetry {
val request = Request.Builder()
.url("$server/d/$tag")
.get()
.build()
val response = suspendCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
})
}
if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}")
}
val content = response.body?.string()
?: throw IOException("Empty response")
// Обновляем прогресс до 100%
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = 100) else it
}
content
}
if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}")
}
val content = response.body?.string()
?: throw IOException("Empty response")
// Обновляем прогресс до 100%
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = 100) else it
}
content
} finally {
// Удаляем из списка скачиваний
_downloading.value = _downloading.value.filter { it.id != id }

View File

@@ -197,7 +197,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
return runCatching {
val accountManager = AccountManager(applicationContext)
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
runBlocking {
runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).isChatMuted(currentAccount, senderPublicKey)
}
}.getOrDefault(false)

View File

@@ -5,8 +5,13 @@ import com.rosetta.messenger.database.AvatarCacheEntity
import com.rosetta.messenger.database.AvatarDao
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import java.io.Closeable
/**
* Репозиторий для работы с аватарами
@@ -23,20 +28,31 @@ class AvatarRepository(
private val context: Context,
private val avatarDao: AvatarDao,
private val currentPublicKey: String
) {
) : Closeable {
companion object {
private const val TAG = "AvatarRepository"
private const val MAX_AVATAR_HISTORY = 5 // Хранить последние N аватаров
private const val MAX_CACHE_SIZE = 100
}
// Repository scope для coroutines
private val supervisorJob = kotlinx.coroutines.SupervisorJob()
private val repositoryScope = kotlinx.coroutines.CoroutineScope(
kotlinx.coroutines.SupervisorJob() + Dispatchers.IO
supervisorJob + Dispatchers.IO
)
// In-memory cache (как decodedAvatarsCache в desktop)
// publicKey -> Flow<List<AvatarInfo>>
private val memoryCache = mutableMapOf<String, MutableStateFlow<List<AvatarInfo>>>()
// In-memory LRU cache: publicKey -> (Flow, Job)
// При вытеснении отменяем Job подписки на БД
private data class CacheEntry(val flow: MutableStateFlow<List<AvatarInfo>>, val job: Job?)
private val memoryCache = object : LinkedHashMap<String, CacheEntry>(MAX_CACHE_SIZE, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, CacheEntry>?): Boolean {
if (size > MAX_CACHE_SIZE) {
eldest?.value?.job?.cancel()
return true
}
return false
}
}
/**
* Получить аватары пользователя
@@ -44,22 +60,21 @@ class AvatarRepository(
* @param allDecode true = вся история, false = только последний (для списков)
*/
fun getAvatars(publicKey: String, allDecode: Boolean = false): StateFlow<List<AvatarInfo>> {
// Проверяем memory cache
if (memoryCache.containsKey(publicKey)) {
return memoryCache[publicKey]!!.asStateFlow()
}
// Проверяем LRU cache (accessOrder=true обновляет позицию при get)
memoryCache[publicKey]?.let { return it.flow.asStateFlow() }
// Создаем новый flow для этого пользователя
val flow = MutableStateFlow<List<AvatarInfo>>(emptyList())
memoryCache[publicKey] = flow
// Подписываемся на изменения в БД с использованием repository scope
avatarDao.getAvatars(publicKey)
// Подписываемся на изменения в БД
val job = avatarDao.getAvatars(publicKey)
.onEach { entities ->
val avatars = if (allDecode) {
// Загружаем всю историю
entities.mapNotNull { entity ->
loadAndDecryptAvatar(entity)
// Параллельная загрузка всей истории
coroutineScope {
entities.map { entity -> async { loadAndDecryptAvatar(entity) } }
.awaitAll()
.filterNotNull()
}
} else {
// Загружаем только последний
@@ -70,7 +85,8 @@ class AvatarRepository(
flow.value = avatars
}
.launchIn(repositoryScope)
memoryCache[publicKey] = CacheEntry(flow, job)
return flow.asStateFlow()
}
@@ -107,13 +123,12 @@ class AvatarRepository(
avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY)
// 🔄 Обновляем memory cache если он существует
val cachedFlow = memoryCache[fromPublicKey]
if (cachedFlow != null) {
val cached = memoryCache[fromPublicKey]
if (cached != null) {
val avatarInfo = loadAndDecryptAvatar(entity)
if (avatarInfo != null) {
cachedFlow.value = listOf(avatarInfo)
cached.flow.value = listOf(avatarInfo)
}
} else {
}
} catch (e: Exception) {
@@ -172,8 +187,8 @@ class AvatarRepository(
// Удаляем из БД
avatarDao.deleteAllAvatars(currentPublicKey)
// Очищаем memory cache
memoryCache.remove(currentPublicKey)
// Очищаем memory cache + отменяем Job
memoryCache.remove(currentPublicKey)?.job?.cancel()
} catch (e: Exception) {
throw e
@@ -206,8 +221,14 @@ class AvatarRepository(
* Очистить memory cache (для освобождения памяти)
*/
fun clearMemoryCache() {
memoryCache.values.forEach { it.job?.cancel() }
memoryCache.clear()
}
override fun close() {
clearMemoryCache()
supervisorJob.cancel()
}
/**
* Предзагрузить системные аватары (для ботов/системных аккаунтов)
@@ -216,14 +237,14 @@ class AvatarRepository(
suspend fun preloadSystemAvatars(systemAccounts: Map<String, String>) {
withContext(Dispatchers.IO) {
systemAccounts.forEach { (publicKey, base64Avatar) ->
// Сохраняем только в memory cache, не в БД
// Сохраняем только в memory cache, не в БД (job=null — нет подписки)
val flow = MutableStateFlow(listOf(
AvatarInfo(
base64Data = base64Avatar,
timestamp = 0
)
))
memoryCache[publicKey] = flow
memoryCache[publicKey] = CacheEntry(flow, job = null)
}
}
}

View File

@@ -54,6 +54,7 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
// Account model for dropdown
data class AccountItem(
@@ -122,13 +123,11 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
ProtocolManager.connect()
// Wait for websocket connection
var waitAttempts = 0
while (ProtocolManager.state.value == ProtocolState.DISCONNECTED && waitAttempts < 50) {
kotlinx.coroutines.delay(100)
waitAttempts++
val connected = withTimeoutOrNull(5000) {
ProtocolManager.state.first { it != ProtocolState.DISCONNECTED }
}
val connectTime = System.currentTimeMillis() - connectStart
if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) {
if (connected == null) {
onError("Failed to connect to server")
onUnlocking(false)
return

View File

@@ -47,11 +47,14 @@ import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.settings.BackgroundBlurPresets
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
@Immutable
data class Chat(
@@ -185,15 +188,6 @@ fun ChatsListScreen(
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
// 🔥 Перехватываем системный back gesture - не закрываем приложение
// Если drawer открыт - закрываем его, иначе игнорируем
BackHandler(enabled = true) {
if (drawerState.isOpen) {
scope.launch { drawerState.close() }
}
// Если drawer закрыт - ничего не делаем (не выходим из приложения)
}
// 🔥 ВСЕГДА закрываем клавиатуру при появлении ChatsListScreen
// Используем DisposableEffect чтобы срабатывало при каждом появлении экрана
DisposableEffect(Unit) {
@@ -287,6 +281,29 @@ fun ChatsListScreen(
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
// 🔥 Selection mode state
var selectedChatKeys by remember { mutableStateOf<Set<String>>(emptySet()) }
val isSelectionMode = selectedChatKeys.isNotEmpty()
val hapticFeedback = LocalHapticFeedback.current
var showSelectionMenu by remember { mutableStateOf(false) }
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val mutedChats by preferencesManager.mutedChatsForAccount(accountPublicKey)
.collectAsState(initial = emptySet())
// Перехватываем системный back gesture - не закрываем приложение
BackHandler(enabled = true) {
if (isSelectionMode) {
selectedChatKeys = emptySet()
} else if (drawerState.isOpen) {
scope.launch { drawerState.close() }
}
}
// Close selection when drawer opens
LaunchedEffect(drawerState.isOpen) {
if (drawerState.isOpen) selectedChatKeys = emptySet()
}
// Реактивный set заблокированных пользователей из ViewModel (Room Flow)
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
@@ -811,12 +828,146 @@ fun ChatsListScreen(
) {
Scaffold(
topBar = {
key(isDarkTheme, showRequestsScreen) {
TopAppBar(
key(isDarkTheme, showRequestsScreen, isSelectionMode) {
Crossfade(
targetState = isSelectionMode,
animationSpec = tween(200),
label = "headerCrossfade"
) { inSelection ->
if (inSelection) {
// ═══ SELECTION MODE HEADER ═══
TopAppBar(
navigationIcon = {
IconButton(onClick = { selectedChatKeys = emptySet() }) {
Icon(
TablerIcons.X,
contentDescription = "Close",
tint = Color.White
)
}
},
title = {
Text(
"${selectedChatKeys.size}",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = Color.White
)
},
actions = {
// Mute / Unmute
val allMuted = selectedChatKeys.all { mutedChats.contains(it) }
IconButton(onClick = {
val keys = selectedChatKeys.toSet()
selectedChatKeys = emptySet()
scope.launch {
keys.forEach { key ->
preferencesManager.setChatMuted(accountPublicKey, key, !allMuted)
}
}
}) {
Icon(
if (allMuted) TablerIcons.Bell else TablerIcons.BellOff,
contentDescription = if (allMuted) "Unmute" else "Mute",
tint = Color.White
)
}
// Delete
IconButton(onClick = {
val allDialogs = topLevelChatsState.dialogs
val first = selectedChatKeys.firstOrNull()
val dlg = allDialogs.find { it.opponentKey == first }
if (dlg != null) dialogToDelete = dlg
selectedChatKeys = emptySet()
}) {
Icon(
TablerIcons.Trash,
contentDescription = "Delete",
tint = Color.White
)
}
// Three dots menu
Box {
IconButton(onClick = { showSelectionMenu = true }) {
Icon(
TablerIcons.DotsVertical,
contentDescription = "More",
tint = Color.White
)
}
DropdownMenu(
expanded = showSelectionMenu,
onDismissRequest = { showSelectionMenu = false },
modifier = Modifier.background(if (isDarkTheme) Color(0xFF2C2C2E) else Color.White)
) {
// Pin / Unpin
val allPinned = selectedChatKeys.all { pinnedChats.contains(it) }
DropdownMenuItem(
text = {
Text(
if (allPinned) "Unpin" else "Pin",
color = if (isDarkTheme) Color.White else Color.Black
)
},
onClick = {
selectedChatKeys.forEach { onTogglePin(it) }
showSelectionMenu = false
selectedChatKeys = emptySet()
},
leadingIcon = {
Icon(
if (allPinned) TablerIcons.PinnedOff else TablerIcons.Pin,
contentDescription = null,
tint = if (isDarkTheme) Color.White else Color.Black
)
}
)
// Block
val anyBlocked = selectedChatKeys.any { blockedUsers.contains(it) }
DropdownMenuItem(
text = {
Text(
if (anyBlocked) "Unblock" else "Block",
color = Color(0xFFE53935)
)
},
onClick = {
val allDialogs = topLevelChatsState.dialogs
val first = selectedChatKeys.firstOrNull()
val dlg = allDialogs.find { it.opponentKey == first }
if (dlg != null) {
if (anyBlocked) dialogToUnblock = dlg
else dialogToBlock = dlg
}
showSelectionMenu = false
selectedChatKeys = emptySet()
},
leadingIcon = {
Icon(
TablerIcons.Ban,
contentDescription = null,
tint = Color(0xFFE53935)
)
}
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4),
scrolledContainerColor = if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4),
navigationIconContentColor = Color.White,
titleContentColor = Color.White,
actionIconContentColor = Color.White
)
)
} else {
// ═══ NORMAL HEADER ═══
TopAppBar(
navigationIcon = {
if (showRequestsScreen) {
// Back button for
// Requests
IconButton(
onClick = {
showRequestsScreen =
@@ -833,8 +984,6 @@ fun ChatsListScreen(
)
}
} else {
// Menu button for
// main screen
IconButton(
onClick = {
scope
@@ -870,7 +1019,6 @@ fun ChatsListScreen(
},
title = {
if (showRequestsScreen) {
// Requests title
Text(
"Requests",
fontWeight =
@@ -880,7 +1028,6 @@ fun ChatsListScreen(
color = Color.White
)
} else {
// Rosetta title or Connecting animation
if (protocolState == ProtocolState.AUTHENTICATED) {
Text(
"Rosetta",
@@ -903,8 +1050,6 @@ fun ChatsListScreen(
}
},
actions = {
// Search only on main
// screen
if (!showRequestsScreen) {
IconButton(
onClick = {
@@ -955,6 +1100,8 @@ fun ChatsListScreen(
Color.White
)
)
} // end else normal header
} // end Crossfade
}
},
floatingActionButton = {
@@ -1293,6 +1440,8 @@ fun ChatsListScreen(
isBlocked,
isSavedMessages =
isSavedMessages,
isMuted =
mutedChats.contains(dialog.opponentKey),
avatarRepository =
avatarRepository,
isDrawerOpen =
@@ -1303,6 +1452,8 @@ fun ChatsListScreen(
isSwipedOpen =
swipedItemKey ==
dialog.opponentKey,
isSelected =
selectedChatKeys.contains(dialog.opponentKey),
onSwipeStarted = {
swipedItemKey =
dialog.opponentKey
@@ -1315,16 +1466,31 @@ fun ChatsListScreen(
null
},
onClick = {
swipedItemKey =
null
val user =
chatsViewModel
.dialogToSearchUser(
dialog
)
onUserSelect(
user
)
if (isSelectionMode) {
// Toggle selection
selectedChatKeys = if (selectedChatKeys.contains(dialog.opponentKey))
selectedChatKeys - dialog.opponentKey
else
selectedChatKeys + dialog.opponentKey
} else {
swipedItemKey =
null
val user =
chatsViewModel
.dialogToSearchUser(
dialog
)
onUserSelect(
user
)
}
},
onLongClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
selectedChatKeys = if (selectedChatKeys.contains(dialog.opponentKey))
selectedChatKeys - dialog.opponentKey
else
selectedChatKeys + dialog.opponentKey
},
onDelete = {
dialogToDelete =
@@ -1676,6 +1842,7 @@ fun ChatItem(
chat: Chat,
isDarkTheme: Boolean,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
isMuted: Boolean = false,
onClick: () -> Unit
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -1722,6 +1889,16 @@ fun ChatItem(
modifier = Modifier.weight(1f)
)
if (isMuted) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
TablerIcons.BellOff,
contentDescription = "Muted",
tint = secondaryTextColor,
modifier = Modifier.size(14.dp)
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
// Read status
Icon(
@@ -1910,12 +2087,15 @@ fun SwipeableDialogItem(
isTyping: Boolean = false,
isBlocked: Boolean = false,
isSavedMessages: Boolean = false,
isMuted: Boolean = false,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
isDrawerOpen: Boolean = false,
isSwipedOpen: Boolean = false,
isSelected: Boolean = false,
onSwipeStarted: () -> Unit = {},
onSwipeClosed: () -> Unit = {},
onClick: () -> Unit,
onLongClick: () -> Unit = {},
onDelete: () -> Unit = {},
onBlock: () -> Unit = {},
onUnblock: () -> Unit = {},
@@ -1923,7 +2103,9 @@ fun SwipeableDialogItem(
onPin: () -> Unit = {}
) {
val targetBackgroundColor =
if (isPinned) {
if (isSelected) {
if (isDarkTheme) Color(0xFF1A3A5C) else Color(0xFFD6EAFF)
} else if (isPinned) {
if (isDarkTheme) Color(0xFF232323) else Color(0xFFE8E8ED)
} else {
if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
@@ -2098,6 +2280,7 @@ fun SwipeableDialogItem(
.pointerInput(Unit) {
val velocityTracker = VelocityTracker()
val touchSlop = viewConfiguration.touchSlop
val longPressTimeoutMs = viewConfiguration.longPressTimeoutMillis
awaitEachGesture {
val down =
@@ -2114,6 +2297,99 @@ fun SwipeableDialogItem(
var passedSlop = false
var claimed = false
// Phase 1: Determine gesture type (tap / long-press / drag)
// Wait up to longPressTimeout; if no up or slop → long press
var gestureType = "unknown"
val result = withTimeoutOrNull(longPressTimeoutMs) {
while (true) {
val event = awaitPointerEvent()
val change =
event.changes.firstOrNull {
it.id == down.id
}
if (change == null) {
gestureType = "cancelled"
return@withTimeoutOrNull Unit
}
if (change.changedToUpIgnoreConsumed()) {
change.consume()
gestureType = "tap"
return@withTimeoutOrNull Unit
}
val delta = change.positionChange()
totalDragX += delta.x
totalDragY += delta.y
val dist = kotlin.math.sqrt(
totalDragX * totalDragX +
totalDragY * totalDragY
)
if (dist >= touchSlop) {
gestureType = "drag"
return@withTimeoutOrNull Unit
}
}
@Suppress("UNREACHABLE_CODE")
Unit
}
// Timeout → long press
if (result == null) gestureType = "longpress"
when (gestureType) {
"tap" -> {
onClick()
return@awaitEachGesture
}
"cancelled" -> return@awaitEachGesture
"longpress" -> {
onLongClick()
// Consume remaining events until finger lifts
while (true) {
val event = awaitPointerEvent()
val change =
event.changes.firstOrNull {
it.id == down.id
} ?: break
change.consume()
if (change.changedToUpIgnoreConsumed()) break
}
return@awaitEachGesture
}
"drag" -> {
// Determine drag direction
val dominated =
kotlin.math.abs(totalDragX) >
kotlin.math.abs(totalDragY) * 2.0f
when {
// Horizontal left swipe — reveal action buttons
dominated && totalDragX < 0 -> {
passedSlop = true
claimed = true
onSwipeStarted()
}
// Horizontal right swipe with buttons open — close them
dominated && totalDragX > 0 && offsetX != 0f -> {
passedSlop = true
claimed = true
}
// Right swipe with buttons closed — let drawer handle
totalDragX > 0 && offsetX == 0f ->
return@awaitEachGesture
// Vertical/diagonal — close buttons if open, let LazyColumn scroll
else -> {
if (offsetX != 0f) {
offsetX = 0f
onSwipeClosed()
}
return@awaitEachGesture
}
}
}
}
// Phase 2: Continue tracking drag
while (true) {
val event = awaitPointerEvent()
val change =
@@ -2121,137 +2397,36 @@ fun SwipeableDialogItem(
it.id == down.id
}
?: break
if (change.changedToUpIgnoreConsumed()
) {
// Tap detected — finger went up before touchSlop
if (!passedSlop) {
change.consume()
onClick()
}
break
}
if (change.changedToUpIgnoreConsumed()) break
val delta = change.positionChange()
totalDragX += delta.x
totalDragY += delta.y
if (!passedSlop) {
val dist =
kotlin.math.sqrt(
totalDragX *
totalDragX +
totalDragY *
totalDragY
)
if (dist < touchSlop)
continue
val dominated =
kotlin.math.abs(
totalDragX
) >
kotlin.math
.abs(
totalDragY
) *
2.0f
when {
// Horizontal left
// swipe — reveal
// action buttons
dominated &&
totalDragX <
0 -> {
passedSlop =
true
claimed =
true
onSwipeStarted()
change.consume()
}
// Horizontal right
// swipe with
// buttons open —
// close them
dominated &&
totalDragX >
0 &&
offsetX !=
0f -> {
passedSlop =
true
claimed =
true
change.consume()
}
// Right swipe with
// buttons closed —
// let drawer handle
totalDragX > 0 &&
offsetX ==
0f ->
break
// Vertical/diagonal
// — close buttons
// if open, let
// LazyColumn scroll
else -> {
if (offsetX !=
0f
) {
offsetX =
0f
onSwipeClosed()
}
break
}
}
} else {
// Gesture is ours — update
// offset
val newOffset =
offsetX + delta.x
offsetX =
newOffset.coerceIn(
-swipeWidthPx,
0f
)
velocityTracker.addPosition(
change.uptimeMillis,
change.position
)
change.consume()
}
val newOffset = offsetX + delta.x
offsetX = newOffset.coerceIn(-swipeWidthPx, 0f)
velocityTracker.addPosition(
change.uptimeMillis,
change.position
)
change.consume()
}
// Snap animation
// Phase 3: Snap animation
if (claimed) {
val velocity =
velocityTracker
.calculateVelocity()
.x
when {
// Rightward fling — always
// close
velocity > 150f -> {
offsetX = 0f
onSwipeClosed()
}
// Strong leftward fling —
// always open
velocity < -300f -> {
offsetX =
-swipeWidthPx
offsetX = -swipeWidthPx
}
// Past halfway — stay open
kotlin.math.abs(offsetX) >
swipeWidthPx /
2 -> {
offsetX =
-swipeWidthPx
swipeWidthPx / 2 -> {
offsetX = -swipeWidthPx
}
// Less than halfway — close
else -> {
offsetX = 0f
onSwipeClosed()
@@ -2267,6 +2442,7 @@ fun SwipeableDialogItem(
isTyping = isTyping,
isPinned = isPinned,
isBlocked = isBlocked,
isMuted = isMuted,
avatarRepository = avatarRepository,
onClick = null // Tap handled by parent pointerInput
)
@@ -2290,6 +2466,7 @@ fun DialogItemContent(
isTyping: Boolean = false,
isPinned: Boolean = false,
isBlocked: Boolean = false,
isMuted: Boolean = false,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
onClick: (() -> Unit)? = null
) {
@@ -2480,6 +2657,15 @@ fun DialogItemContent(
modifier = Modifier.size(14.dp)
)
}
if (isMuted) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = TablerIcons.BellOff,
contentDescription = "Muted",
tint = secondaryTextColor,
modifier = Modifier.size(14.dp)
)
}
}
Row(

View File

@@ -6,7 +6,14 @@ import android.graphics.Matrix
import android.util.Base64
import android.util.LruCache
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.StartOffset
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
@@ -34,6 +41,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -53,6 +61,7 @@ import compose.icons.TablerIcons
import compose.icons.tablericons.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalConfiguration
@@ -62,8 +71,8 @@ import kotlin.math.min
private const val TAG = "AttachmentComponents"
/**
* 🔄 Анимированный текст с точками (Downloading... → Downloading. → Downloading.. → Downloading...)
* Как в Telegram - точки плавно появляются и исчезают
* Анимированный текст с волнообразными точками.
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
*/
@Composable
fun AnimatedDotsText(
@@ -72,34 +81,78 @@ fun AnimatedDotsText(
fontSize: androidx.compose.ui.unit.TextUnit = 12.sp,
fontWeight: FontWeight = FontWeight.Normal
) {
var dotCount by remember { mutableIntStateOf(0) }
// Анимация точек: 0 → 1 → 2 → 3 → 0 → ...
LaunchedEffect(Unit) {
while (true) {
delay(400) // Интервал между изменениями
dotCount = (dotCount + 1) % 4
}
}
val dots = ".".repeat(dotCount)
// Добавляем невидимые точки для фиксированной ширины текста
val invisibleDots = ".".repeat(3 - dotCount)
Row {
val infiniteTransition = rememberInfiniteTransition(label = "dots")
val dot0 by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1200
0f at 0
1f at 300
0f at 600
0f at 1200
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(0)
),
label = "dot0"
)
val dot1 by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1200
0f at 0
1f at 300
0f at 600
0f at 1200
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(200)
),
label = "dot1"
)
val dot2 by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1200
0f at 0
1f at 300
0f at 600
0f at 1200
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(400)
),
label = "dot2"
)
val dotValues = listOf(dot0, dot1, dot2)
val bounceHeight = with(LocalDensity.current) { fontSize.toPx() * 0.35f }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$baseText$dots",
text = baseText,
fontSize = fontSize,
fontWeight = fontWeight,
color = color
)
// Невидимые точки для сохранения ширины
Text(
text = invisibleDots,
fontSize = fontSize,
fontWeight = fontWeight,
color = Color.Transparent
)
dotValues.forEach { progress ->
Text(
text = ".",
fontSize = fontSize,
fontWeight = fontWeight,
color = color.copy(alpha = 0.4f + 0.6f * progress),
modifier = Modifier.graphicsLayer {
translationY = -bounceHeight * progress
}
)
}
}
}
@@ -112,29 +165,47 @@ object ImageBitmapCache {
// Размер кэша = 1/8 доступной памяти (стандартная практика Android)
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
private val cacheSize = maxMemory / 8
private val cache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(key: String, bitmap: Bitmap): Int {
// Размер в килобайтах
return bitmap.byteCount / 1024
}
}
// Flow для уведомления о новых записях (заменяет polling retry loops)
private val _updates = kotlinx.coroutines.flow.MutableSharedFlow<String>(extraBufferCapacity = 64)
val updates: kotlinx.coroutines.flow.SharedFlow<String> = _updates
fun get(key: String): Bitmap? = cache.get(key)
fun put(key: String, bitmap: Bitmap) {
if (cache.get(key) == null) {
cache.put(key, bitmap)
_updates.tryEmit(key)
}
}
fun remove(key: String) {
cache.remove(key)
}
fun clear() {
cache.evictAll()
}
/**
* Ждёт появления bitmap в кэше по ключу (вместо polling retry loop).
* Возвращает null при таймауте.
*/
suspend fun awaitCached(key: String, timeoutMs: Long = 3000): Bitmap? {
// Может уже быть в кэше
get(key)?.let { return it }
return kotlinx.coroutines.withTimeoutOrNull(timeoutMs) {
updates.first { it == key }
get(key)
}
}
}
/**
@@ -2037,6 +2108,63 @@ internal fun base64ToBitmap(base64: String): Bitmap? {
}
}
/**
* CDN download + decrypt + cache + save.
* Shared between ReplyBubble and ForwardedImagePreview.
*
* @return loaded Bitmap or null
*/
internal suspend fun downloadAndDecryptImage(
attachmentId: String,
downloadTag: String,
chachaKey: String,
privateKey: String,
cacheKey: String,
context: android.content.Context,
senderPublicKey: String,
recipientPrivateKey: String
): Bitmap? {
if (downloadTag.isEmpty() || chachaKey.isEmpty() || privateKey.isEmpty()) return null
return withContext(Dispatchers.IO) {
try {
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
if (encryptedContent.isEmpty()) return@withContext null
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
// Try decryptReplyBlob first (desktop decodeWithPassword)
var decrypted = try {
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
.takeIf { it.isNotEmpty() && it != encryptedContent }
} catch (_: Exception) { null }
// Fallback: decryptAttachmentBlobWithPlainKey
if (decrypted == null) {
decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent, plainKeyAndNonce
)
}
if (decrypted == null) return@withContext null
val base64Data = if (decrypted.contains(",")) decrypted.substringAfter(",") else decrypted
val bitmap = base64ToBitmap(base64Data) ?: return@withContext null
ImageBitmapCache.put(cacheKey, bitmap)
AttachmentFileManager.saveAttachment(
context = context,
blob = base64Data,
attachmentId = attachmentId,
publicKey = senderPublicKey,
privateKey = recipientPrivateKey
)
bitmap
} catch (_: Exception) { null }
}
}
/** Форматирование размера файла */
private fun formatFileSize(bytes: Long): String {
return when {

View File

@@ -46,8 +46,6 @@ import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.TransportManager
import com.rosetta.messenger.utils.AttachmentFileManager
import com.vanniktech.blurhash.BlurHash
import compose.icons.TablerIcons
@@ -1202,68 +1200,29 @@ fun ReplyBubble(
} catch (e: Exception) {}
}
// 5. Retry: фото может загрузиться в кэш параллельно
// 5. Ждём пока другой composable загрузит фото в кэш
if (imageBitmap == null) {
repeat(6) {
kotlinx.coroutines.delay(500)
val retry = ImageBitmapCache.get("img_${imageAttachment.id}")
if (retry != null) {
imageBitmap = retry
return@LaunchedEffect
}
val awaited = ImageBitmapCache.awaitCached("img_${imageAttachment.id}")
if (awaited != null) {
imageBitmap = awaited
return@LaunchedEffect
}
}
// 6. CDN download — для форвардов, где фото загружено на CDN
if (imageBitmap == null && imageAttachment.preview.isNotEmpty()) {
val downloadTag = getDownloadTag(imageAttachment.preview)
if (downloadTag.isNotEmpty()) {
try {
withContext(Dispatchers.IO) {
val encryptedContent = TransportManager.downloadFile(
imageAttachment.id, downloadTag
)
if (encryptedContent.isNotEmpty()) {
// Desktop: decryptKeyFromSender → decodeWithPassword
var decrypted: String? = null
if (chachaKey.isNotEmpty() && privateKey.isNotEmpty()) {
try {
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(
chachaKey, privateKey
)
// decryptReplyBlob = desktop decodeWithPassword
decrypted = try {
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
.takeIf { it.isNotEmpty() && it != encryptedContent }
} catch (_: Exception) { null }
if (decrypted == null) {
decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent, plainKeyAndNonce
)
}
} catch (_: Exception) {}
}
if (decrypted != null) {
val bitmap = base64ToBitmap(decrypted)
if (bitmap != null) {
imageBitmap = bitmap
ImageBitmapCache.put("img_${imageAttachment.id}", bitmap)
// Сохраняем на диск
AttachmentFileManager.saveAttachment(
context = context,
blob = decrypted,
attachmentId = imageAttachment.id,
publicKey = replyData.senderPublicKey,
privateKey = replyData.recipientPrivateKey
)
}
}
}
}
} catch (_: Exception) {}
}
val bitmap = downloadAndDecryptImage(
attachmentId = imageAttachment.id,
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
cacheKey = "img_${imageAttachment.id}",
context = context,
senderPublicKey = replyData.senderPublicKey,
recipientPrivateKey = replyData.recipientPrivateKey
)
if (bitmap != null) imageBitmap = bitmap
}
}
}
@@ -1609,54 +1568,26 @@ private fun ForwardedImagePreview(
}
}
} catch (_: Exception) {}
// CDN download — exactly like desktop useAttachment.ts
if (downloadTag.isNotEmpty() && chachaKey.isNotEmpty() && privateKey.isNotEmpty()) {
try {
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
if (encryptedContent.isNotEmpty()) {
// Desktop: decryptKeyFromSender → plainKeyAndNonce → decodeWithPassword
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
// decryptReplyBlob = exact same as desktop decodeWithPassword:
// bytesToJsUtf8String(plainKeyAndNonce) → PBKDF2(password,'rosetta',SHA256,1000) → AES-CBC → inflate
val decrypted = MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
if (decrypted.isNotEmpty() && decrypted != encryptedContent) {
val base64Data = if (decrypted.contains(",")) decrypted.substringAfter(",") else decrypted
val bitmap = base64ToBitmap(base64Data)
if (bitmap != null) {
imageBitmap = bitmap
ImageBitmapCache.put(cacheKey, bitmap)
AttachmentFileManager.saveAttachment(
context, base64Data, attachment.id,
senderPublicKey, recipientPrivateKey
)
return@withContext
}
}
// Fallback: try decryptAttachmentBlobWithPlainKey (same logic, different entry point)
val decrypted2 = MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKeyAndNonce)
if (decrypted2 != null) {
val base64Data = if (decrypted2.contains(",")) decrypted2.substringAfter(",") else decrypted2
val bitmap = base64ToBitmap(base64Data)
if (bitmap != null) {
imageBitmap = bitmap
ImageBitmapCache.put(cacheKey, bitmap)
AttachmentFileManager.saveAttachment(
context, base64Data, attachment.id,
senderPublicKey, recipientPrivateKey
)
}
}
}
} catch (_: Exception) {}
}
}
// Retry from cache (another composable may have loaded it)
// CDN download — exactly like desktop useAttachment.ts
if (imageBitmap == null) {
repeat(5) {
kotlinx.coroutines.delay(400)
ImageBitmapCache.get(cacheKey)?.let { imageBitmap = it; return@LaunchedEffect }
val bitmap = downloadAndDecryptImage(
attachmentId = attachment.id,
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
cacheKey = cacheKey,
context = context,
senderPublicKey = senderPublicKey,
recipientPrivateKey = recipientPrivateKey
)
if (bitmap != null) imageBitmap = bitmap
}
// Ждём пока другой composable загрузит фото в кэш
if (imageBitmap == null) {
ImageBitmapCache.awaitCached(cacheKey)?.let { imageBitmap = it; return@LaunchedEffect
}
}
}

View File

@@ -148,9 +148,9 @@ fun SwipeBackContainer(
alpha = currentAlpha
}
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
.then(
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
Modifier.pointerInput(Unit) {
.pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut) {
if (!swipeEnabled || isAnimatingIn || isAnimatingOut) return@pointerInput
val velocityTracker = VelocityTracker()
val touchSlop =
viewConfiguration.touchSlop *
@@ -304,11 +304,7 @@ fun SwipeBackContainer(
}
}
}
}
} else {
Modifier
}
)
}
) { content() }
}
}

View File

@@ -187,8 +187,8 @@ fun OtherProfileScreen(
}
val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp
val sharedPagerMinHeight = (screenHeightDp * 0.45f).coerceAtLeast(240.dp)
LaunchedEffect(selectedTab) {
onSwipeBackEnabledChanged(selectedTab == OtherProfileTab.MEDIA)
LaunchedEffect(showImageViewer) {
onSwipeBackEnabledChanged(!showImageViewer)
}
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)