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