feat: enhance avatar handling and file attachment functionality with improved UI interactions
This commit is contained in:
@@ -58,7 +58,6 @@ 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
|
||||
@@ -214,8 +213,11 @@ class MainActivity : FragmentActivity() {
|
||||
else -> "main"
|
||||
},
|
||||
transitionSpec = {
|
||||
fadeIn(animationSpec = tween(600)) togetherWith
|
||||
fadeOut(animationSpec = tween(600))
|
||||
// Новый экран плавно появляется ПОВЕРХ старого.
|
||||
// Старый остаётся видимым (alpha=1) пока новый не готов →
|
||||
// нет белой вспышки от Surface.
|
||||
fadeIn(animationSpec = tween(350)) togetherWith
|
||||
fadeOut(animationSpec = tween(1, delayMillis = 350))
|
||||
},
|
||||
label = "screenTransition"
|
||||
) { screen ->
|
||||
@@ -603,14 +605,13 @@ fun MainScreen(
|
||||
// Состояние протокола для передачи в SearchScreen
|
||||
val protocolState by ProtocolManager.state.collectAsState()
|
||||
|
||||
// Перечитать username/name после получения own profile с сервера
|
||||
// Аналог Desktop: useUserInformation автоматически обновляет UI при PacketSearch ответе
|
||||
LaunchedEffect(protocolState) {
|
||||
if (protocolState == ProtocolState.AUTHENTICATED &&
|
||||
// Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile()
|
||||
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState()
|
||||
LaunchedEffect(ownProfileUpdated) {
|
||||
if (ownProfileUpdated > 0L &&
|
||||
accountPublicKey.isNotBlank() &&
|
||||
accountPublicKey != "04c266b98ae5"
|
||||
) {
|
||||
delay(2000) // Ждём fetchOwnProfile() → PacketSearch → AccountManager update
|
||||
val accountManager = AccountManager(context)
|
||||
val encryptedAccount = accountManager.getAccount(accountPublicKey)
|
||||
accountUsername = encryptedAccount?.username ?: ""
|
||||
|
||||
@@ -139,9 +139,15 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
/** Инициализация с текущим аккаунтом */
|
||||
fun initialize(publicKey: String, privateKey: String) {
|
||||
val start = System.currentTimeMillis()
|
||||
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||
// 🔥 Очищаем кэши при смене аккаунта
|
||||
if (currentAccount != publicKey) {
|
||||
requestedUserInfoKeys.clear()
|
||||
// Clear message Flow cache — dialogKey is account-independent (sorted pair),
|
||||
// so stale Flows from previous account would return wrong data
|
||||
messageCache.clear()
|
||||
// Clear processed messageIds — prevents blocking delivery of messages
|
||||
// that were already processed under the previous account
|
||||
clearProcessedCache()
|
||||
}
|
||||
|
||||
currentAccount = publicKey
|
||||
|
||||
@@ -313,17 +313,29 @@ class Protocol(
|
||||
log(" Public key: ${publicKey.take(20)}...")
|
||||
log(" Private hash: ${privateHash.take(20)}...")
|
||||
log(" Current state: ${_state.value}")
|
||||
|
||||
|
||||
// Detect account switch: already authenticated but with different credentials
|
||||
val switchingAccount = (_state.value == ProtocolState.AUTHENTICATED || _state.value == ProtocolState.HANDSHAKING) &&
|
||||
lastPublicKey != null && lastPublicKey != publicKey
|
||||
|
||||
// Save credentials for reconnection
|
||||
lastPublicKey = publicKey
|
||||
lastPrivateHash = privateHash
|
||||
|
||||
|
||||
// If switching accounts, force disconnect and reconnect with new credentials
|
||||
if (switchingAccount) {
|
||||
log("🔄 Account switch detected, forcing reconnect with new credentials")
|
||||
disconnect()
|
||||
connect() // Will auto-handshake with saved credentials (publicKey, privateHash) on connect
|
||||
return
|
||||
}
|
||||
|
||||
// КРИТИЧНО: если уже в handshake или authenticated, не начинаем заново
|
||||
if (_state.value == ProtocolState.HANDSHAKING) {
|
||||
log("⚠️ HANDSHAKE IGNORED: Already handshaking")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (_state.value == ProtocolState.AUTHENTICATED) {
|
||||
log("⚠️ HANDSHAKE IGNORED: Already authenticated")
|
||||
return
|
||||
|
||||
@@ -35,6 +35,10 @@ object ProtocolManager {
|
||||
// Typing status
|
||||
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
|
||||
|
||||
// Сигнал обновления own profile (username/name загружены с сервера)
|
||||
private val _ownProfileUpdated = MutableStateFlow(0L)
|
||||
val ownProfileUpdated: StateFlow<Long> = _ownProfileUpdated.asStateFlow()
|
||||
|
||||
// 🔍 Global user info cache (like Desktop's InformationProvider.cachedUsers)
|
||||
// publicKey → SearchUser (resolved via PacketSearch 0x03)
|
||||
@@ -200,6 +204,7 @@ object ProtocolManager {
|
||||
if (user.username.isNotBlank()) {
|
||||
accountManager.updateAccountUsername(user.publicKey, user.username)
|
||||
}
|
||||
_ownProfileUpdated.value = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,15 +154,19 @@ class AuthStateManager(
|
||||
_state.update { it.copy(
|
||||
status = AuthStatus.Authenticated(decryptedAccount)
|
||||
)}
|
||||
|
||||
|
||||
loadAccounts()
|
||||
|
||||
|
||||
// Initialize MessageRepository BEFORE connecting/authenticating
|
||||
// so incoming messages from server are stored under the correct account
|
||||
ProtocolManager.initializeAccount(keyPair.publicKey, keyPair.privateKey)
|
||||
|
||||
// Step 8: Connect and authenticate with protocol
|
||||
ProtocolManager.connect()
|
||||
|
||||
|
||||
// Give WebSocket time to connect before authenticating
|
||||
kotlinx.coroutines.delay(500)
|
||||
|
||||
|
||||
ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash)
|
||||
|
||||
Result.success(decryptedAccount)
|
||||
@@ -199,13 +203,17 @@ class AuthStateManager(
|
||||
_state.update { it.copy(
|
||||
status = AuthStatus.Authenticated(decryptedAccount)
|
||||
)}
|
||||
|
||||
|
||||
// Initialize MessageRepository BEFORE connecting/authenticating
|
||||
// so incoming messages from server are stored under the correct account
|
||||
ProtocolManager.initializeAccount(decryptedAccount.publicKey, decryptedAccount.privateKey)
|
||||
|
||||
// Connect and authenticate with protocol
|
||||
ProtocolManager.connect()
|
||||
|
||||
|
||||
// Give WebSocket time to connect before authenticating
|
||||
kotlinx.coroutines.delay(500)
|
||||
|
||||
|
||||
ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash)
|
||||
|
||||
Result.success(decryptedAccount)
|
||||
|
||||
@@ -72,7 +72,8 @@ fun ImportSeedPhraseScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 24.dp),
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -233,8 +234,8 @@ fun ImportSeedPhraseScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Import button
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
|
||||
@@ -43,15 +43,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
||||
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
|
||||
// Сделан глобальным чтобы можно было очистить при удалении диалога
|
||||
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (account|dialogKey -> List<ChatMessage>)
|
||||
// Ключ включает account для изоляции данных между аккаунтами
|
||||
private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>()
|
||||
|
||||
/** Формирует ключ кэша с привязкой к аккаунту */
|
||||
private fun cacheKey(account: String, dialogKey: String) = "$account|$dialogKey"
|
||||
|
||||
/**
|
||||
* 🔥 Обновить кэш с ограничением размера Сохраняет только последние MAX_CACHE_SIZE
|
||||
* сообщений для предотвращения OOM
|
||||
*/
|
||||
private fun updateCacheWithLimit(dialogKey: String, messages: List<ChatMessage>) {
|
||||
private fun updateCacheWithLimit(account: String, dialogKey: String, messages: List<ChatMessage>) {
|
||||
val limitedMessages =
|
||||
if (messages.size > MAX_CACHE_SIZE) {
|
||||
// Оставляем только последние сообщения (по timestamp)
|
||||
@@ -61,12 +64,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
} else {
|
||||
messages
|
||||
}
|
||||
dialogMessagesCache[dialogKey] = limitedMessages
|
||||
dialogMessagesCache[cacheKey(account, dialogKey)] = limitedMessages
|
||||
}
|
||||
|
||||
/** 🗑️ Очистить кэш сообщений для диалога Вызывается при удалении диалога */
|
||||
fun clearDialogCache(dialogKey: String) {
|
||||
dialogMessagesCache.remove(dialogKey)
|
||||
// Очищаем для всех аккаунтов — при удалении диалога dialogKey не привязан к аккаунту
|
||||
val keysToRemove = dialogMessagesCache.keys.filter { it.endsWith("|$dialogKey") }
|
||||
keysToRemove.forEach { dialogMessagesCache.remove(it) }
|
||||
}
|
||||
|
||||
/** 🗑️ Очистить кэш по publicKey собеседника Удаляет все ключи содержащие этот publicKey */
|
||||
@@ -74,6 +79,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val keysToRemove = dialogMessagesCache.keys.filter { it.contains(opponentKey) }
|
||||
keysToRemove.forEach { dialogMessagesCache.remove(it) }
|
||||
}
|
||||
|
||||
/** 🗑️ Полная очистка кэша — вызывается при переключении аккаунта */
|
||||
fun clearAllDialogCache() {
|
||||
dialogMessagesCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Database
|
||||
@@ -322,11 +332,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
// Обновляем кэш
|
||||
val cachedMessages = dialogMessagesCache[dialogKey]
|
||||
val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
|
||||
if (cachedMessages != null) {
|
||||
updateCacheWithLimit(dialogKey, cachedMessages + newMessages)
|
||||
updateCacheWithLimit(account, dialogKey, cachedMessages + newMessages)
|
||||
} else {
|
||||
updateCacheWithLimit(dialogKey, _messages.value)
|
||||
updateCacheWithLimit(account, dialogKey, _messages.value)
|
||||
}
|
||||
|
||||
currentOffset += newMessages.size
|
||||
@@ -450,7 +460,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val account = myPublicKey ?: return
|
||||
val opponent = opponentKey ?: return
|
||||
val dialogKey = getDialogKey(account, opponent)
|
||||
updateCacheWithLimit(dialogKey, _messages.value)
|
||||
updateCacheWithLimit(account, dialogKey, _messages.value)
|
||||
}
|
||||
|
||||
/** Обновить статус сообщения в БД */
|
||||
@@ -480,6 +490,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
/** Установить ключи пользователя */
|
||||
fun setUserKeys(publicKey: String, privateKey: String) {
|
||||
if (myPublicKey != publicKey) {
|
||||
// Clear caches on account switch to prevent cross-account data leakage
|
||||
// Безусловная очистка (даже если myPublicKey == null) — свежий ViewModel
|
||||
// может получить стейтный кэш от предыдущего аккаунта
|
||||
dialogMessagesCache.clear()
|
||||
decryptionCache.clear()
|
||||
}
|
||||
myPublicKey = publicKey
|
||||
myPrivateKey = privateKey
|
||||
}
|
||||
@@ -510,7 +527,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val cachedMessages =
|
||||
if (account != null) {
|
||||
val dialogKey = getDialogKey(account, publicKey)
|
||||
dialogMessagesCache[dialogKey]
|
||||
dialogMessagesCache[cacheKey(account, dialogKey)]
|
||||
} else null
|
||||
|
||||
// Сбрасываем состояние
|
||||
@@ -602,7 +619,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// 🔥 МГНОВЕННАЯ загрузка из кэша если есть!
|
||||
val cachedMessages = dialogMessagesCache[dialogKey]
|
||||
val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
|
||||
|
||||
if (cachedMessages != null && cachedMessages.isNotEmpty()) {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
@@ -687,7 +704,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
// 🔥 Сохраняем в кэш для мгновенной повторной загрузки!
|
||||
updateCacheWithLimit(dialogKey, messages.toList())
|
||||
updateCacheWithLimit(account, dialogKey, messages.toList())
|
||||
|
||||
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
|
||||
// НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД
|
||||
@@ -787,11 +804,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш сохраняя ВСЕ сообщения, не только отображаемые!
|
||||
// Объединяем существующий кэш с новыми сообщениями
|
||||
val existingCache = dialogMessagesCache[dialogKey] ?: emptyList()
|
||||
val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
|
||||
val allCachedIds = existingCache.map { it.id }.toSet()
|
||||
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
|
||||
if (trulyNewMessages.isNotEmpty()) {
|
||||
updateCacheWithLimit(
|
||||
account,
|
||||
dialogKey,
|
||||
(existingCache + trulyNewMessages).sortedBy { it.timestamp }
|
||||
)
|
||||
@@ -881,11 +899,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш при загрузке старых сообщений!
|
||||
// Это предотвращает потерю сообщений при повторном открытии диалога
|
||||
val existingCache = dialogMessagesCache[dialogKey] ?: emptyList()
|
||||
val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
|
||||
val allCachedIds = existingCache.map { it.id }.toSet()
|
||||
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
|
||||
if (trulyNewMessages.isNotEmpty()) {
|
||||
updateCacheWithLimit(
|
||||
account,
|
||||
dialogKey,
|
||||
(trulyNewMessages + existingCache).sortedBy { it.timestamp }
|
||||
)
|
||||
@@ -3558,7 +3577,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Очищаем кэш
|
||||
val dialogKey = getDialogKey(account, opponent)
|
||||
dialogMessagesCache.remove(dialogKey)
|
||||
dialogMessagesCache.remove(cacheKey(account, dialogKey))
|
||||
|
||||
// Очищаем UI
|
||||
withContext(Dispatchers.Main) { _messages.value = emptyList() }
|
||||
|
||||
@@ -49,11 +49,9 @@ import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
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.onboarding.PrimaryBlueDark
|
||||
import com.rosetta.messenger.ui.settings.BackgroundBlurPresets
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
@@ -494,29 +492,15 @@ fun ChatsListScreen(
|
||||
)
|
||||
val headerColor = avatarColors.backgroundColor
|
||||
|
||||
// Header: avatar blur или цвет шапки chat list
|
||||
// Header: цвет шапки сайдбара
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
if (backgroundBlurColorId == "avatar") {
|
||||
// Avatar blur
|
||||
BlurredAvatarBackground(
|
||||
publicKey = accountPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
fallbackColor = if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue,
|
||||
blurRadius = 40f,
|
||||
alpha = 0.6f,
|
||||
overlayColors = null,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
// None или любой другой — стандартный цвет шапки
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue
|
||||
)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue
|
||||
)
|
||||
)
|
||||
|
||||
// Content поверх фона
|
||||
Column(
|
||||
@@ -1277,21 +1261,7 @@ fun ChatsListScreen(
|
||||
val requests = chatsState.requests
|
||||
val requestsCount = chatsState.requestsCount
|
||||
|
||||
// 🔥 ИСПРАВЛЕНИЕ МЕРЦАНИЯ: Запоминаем, что контент УЖЕ был
|
||||
// показан
|
||||
// Это предотвращает показ EmptyState при временных пустых
|
||||
// обновлениях
|
||||
var hasShownContent by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
if (chatsState.hasContent) {
|
||||
hasShownContent = true
|
||||
}
|
||||
|
||||
// 🎯 Показываем Empty State только если контент НИКОГДА не
|
||||
// показывался
|
||||
val shouldShowEmptyState =
|
||||
chatsState.isEmpty && !hasShownContent
|
||||
val showSkeleton = isLoading
|
||||
|
||||
// 🎬 Animated content transition between main list and
|
||||
// requests
|
||||
@@ -1427,12 +1397,9 @@ fun ChatsListScreen(
|
||||
}
|
||||
)
|
||||
} // Close Box wrapper
|
||||
} else if (isLoading) {
|
||||
// 🚀 Shimmer skeleton пока данные грузятся
|
||||
} else if (showSkeleton) {
|
||||
ChatsListSkeleton(isDarkTheme = isDarkTheme)
|
||||
} else if (shouldShowEmptyState) {
|
||||
// 🔥 Empty state - показываем только если
|
||||
// контент НЕ был показан ранее
|
||||
} else if (chatsState.isEmpty) {
|
||||
EmptyChatsState(
|
||||
isDarkTheme = isDarkTheme,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.*
|
||||
@@ -73,6 +74,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
|
||||
private val subscribedOnlineKeys = mutableSetOf<String>()
|
||||
|
||||
// Job для отмены подписок при смене аккаунта
|
||||
private var accountSubscriptionsJob: Job? = null
|
||||
|
||||
// Список диалогов с расшифрованными сообщениями
|
||||
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
|
||||
val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow()
|
||||
@@ -124,16 +128,32 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
|
||||
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||
requestedUserInfoKeys.clear()
|
||||
subscribedOnlineKeys.clear()
|
||||
|
||||
// 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта
|
||||
// чтобы избежать показа сообщений с неправильным isOutgoing
|
||||
ChatViewModel.clearAllDialogCache()
|
||||
|
||||
// Отменяем старые подписки от предыдущего аккаунта
|
||||
accountSubscriptionsJob?.cancel()
|
||||
|
||||
currentAccount = publicKey
|
||||
currentPrivateKey = privateKey
|
||||
|
||||
// 🔥 Очищаем устаревшие данные от предыдущего аккаунта
|
||||
_dialogs.value = emptyList()
|
||||
_requests.value = emptyList()
|
||||
_requestsCount.value = 0
|
||||
|
||||
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
|
||||
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
|
||||
|
||||
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
|
||||
accountSubscriptionsJob = viewModelScope.launch {
|
||||
|
||||
// Подписываемся на обычные диалоги
|
||||
@OptIn(FlowPreview::class)
|
||||
viewModelScope.launch {
|
||||
launch {
|
||||
dialogDao
|
||||
.getDialogsFlow(publicKey)
|
||||
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
||||
@@ -293,7 +313,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
|
||||
// 📬 Подписываемся на requests (запросы от новых пользователей)
|
||||
@OptIn(FlowPreview::class)
|
||||
viewModelScope.launch {
|
||||
launch {
|
||||
dialogDao
|
||||
.getRequestsFlow(publicKey)
|
||||
.flowOn(Dispatchers.IO)
|
||||
@@ -422,7 +442,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
}
|
||||
|
||||
// 📊 Подписываемся на количество requests
|
||||
viewModelScope.launch {
|
||||
launch {
|
||||
dialogDao
|
||||
.getRequestsCountFlow(publicKey)
|
||||
.flowOn(Dispatchers.IO)
|
||||
@@ -432,7 +452,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
|
||||
// 🔥 Подписываемся на блоклист — Room автоматически переэмитит при
|
||||
// blockUser()/unblockUser()
|
||||
viewModelScope.launch {
|
||||
launch {
|
||||
database.blacklistDao()
|
||||
.getBlockedUsers(publicKey)
|
||||
.flowOn(Dispatchers.IO)
|
||||
@@ -440,6 +460,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
.distinctUntilChanged()
|
||||
.collect { blockedSet -> _blockedUsers.value = blockedSet }
|
||||
}
|
||||
|
||||
} // end accountSubscriptionsJob
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,7 +64,11 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.content.FileProvider
|
||||
import android.content.Intent
|
||||
import android.webkit.MimeTypeMap
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import kotlin.math.min
|
||||
|
||||
private const val TAG = "AttachmentComponents"
|
||||
@@ -1344,13 +1348,37 @@ fun FileAttachment(
|
||||
label = "progress"
|
||||
)
|
||||
|
||||
// Путь к скачанному файлу (как Desktop: ~/Rosetta Downloads/filename)
|
||||
val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
|
||||
val savedFile = remember(fileName) { File(downloadsDir, fileName) }
|
||||
|
||||
LaunchedEffect(attachment.id) {
|
||||
downloadStatus =
|
||||
if (isDownloadTag(preview)) {
|
||||
DownloadStatus.NOT_DOWNLOADED
|
||||
} else {
|
||||
DownloadStatus.DOWNLOADED
|
||||
}
|
||||
downloadStatus = if (isDownloadTag(preview)) {
|
||||
// Проверяем, был ли файл уже скачан ранее
|
||||
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
||||
else DownloadStatus.NOT_DOWNLOADED
|
||||
} else {
|
||||
DownloadStatus.DOWNLOADED
|
||||
}
|
||||
}
|
||||
|
||||
// Открыть файл через системное приложение
|
||||
val openFile: () -> Unit = {
|
||||
try {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
savedFile
|
||||
)
|
||||
val ext = fileName.substringAfterLast('.', "").lowercase()
|
||||
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
|
||||
?: "application/octet-stream"
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mimeType)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
val download: () -> Unit = {
|
||||
@@ -1364,8 +1392,6 @@ fun FileAttachment(
|
||||
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
|
||||
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
|
||||
// Сначала расшифровываем его, получаем raw bytes
|
||||
val decryptedKeyAndNonce =
|
||||
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||
|
||||
@@ -1377,7 +1403,16 @@ fun FileAttachment(
|
||||
downloadProgress = 0.9f
|
||||
|
||||
if (decrypted != null) {
|
||||
// TODO: Save to Downloads folder
|
||||
withContext(Dispatchers.IO) {
|
||||
// Декодим base64 в байты (обработка data URL и plain base64)
|
||||
val base64Data = if (decrypted.contains(",")) {
|
||||
decrypted.substringAfter(",")
|
||||
} else {
|
||||
decrypted
|
||||
}
|
||||
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
|
||||
savedFile.writeBytes(bytes)
|
||||
}
|
||||
downloadProgress = 1f
|
||||
downloadStatus = DownloadStatus.DOWNLOADED
|
||||
} else {
|
||||
@@ -1398,8 +1433,14 @@ fun FileAttachment(
|
||||
.clickable(
|
||||
enabled =
|
||||
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
|
||||
downloadStatus == DownloadStatus.DOWNLOADED ||
|
||||
downloadStatus == DownloadStatus.ERROR
|
||||
) { download() }
|
||||
) {
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADED -> openFile()
|
||||
else -> download()
|
||||
}
|
||||
}
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
@@ -42,12 +42,6 @@ fun BoxScope.BlurredAvatarBackground(
|
||||
overlayColors: List<Color>? = null,
|
||||
isDarkTheme: Boolean = true
|
||||
) {
|
||||
// В светлой теме с дефолтным фоном (avatar, без overlay) — синий как шапка chat list
|
||||
if (!isDarkTheme && (overlayColors == null || overlayColors.isEmpty())) {
|
||||
Box(modifier = Modifier.matchParentSize().background(Color(0xFF0D8CF4)))
|
||||
return
|
||||
}
|
||||
|
||||
// Если выбран цвет в Appearance — рисуем блюр аватарки + полупрозрачный overlay поверх
|
||||
// (одинаково для светлой и тёмной темы, чтобы цвет совпадал с превью в Appearance)
|
||||
if (overlayColors != null && overlayColors.isNotEmpty()) {
|
||||
|
||||
Reference in New Issue
Block a user