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.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
@@ -214,8 +213,11 @@ class MainActivity : FragmentActivity() {
|
|||||||
else -> "main"
|
else -> "main"
|
||||||
},
|
},
|
||||||
transitionSpec = {
|
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"
|
label = "screenTransition"
|
||||||
) { screen ->
|
) { screen ->
|
||||||
@@ -603,14 +605,13 @@ fun MainScreen(
|
|||||||
// Состояние протокола для передачи в SearchScreen
|
// Состояние протокола для передачи в SearchScreen
|
||||||
val protocolState by ProtocolManager.state.collectAsState()
|
val protocolState by ProtocolManager.state.collectAsState()
|
||||||
|
|
||||||
// Перечитать username/name после получения own profile с сервера
|
// Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile()
|
||||||
// Аналог Desktop: useUserInformation автоматически обновляет UI при PacketSearch ответе
|
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState()
|
||||||
LaunchedEffect(protocolState) {
|
LaunchedEffect(ownProfileUpdated) {
|
||||||
if (protocolState == ProtocolState.AUTHENTICATED &&
|
if (ownProfileUpdated > 0L &&
|
||||||
accountPublicKey.isNotBlank() &&
|
accountPublicKey.isNotBlank() &&
|
||||||
accountPublicKey != "04c266b98ae5"
|
accountPublicKey != "04c266b98ae5"
|
||||||
) {
|
) {
|
||||||
delay(2000) // Ждём fetchOwnProfile() → PacketSearch → AccountManager update
|
|
||||||
val accountManager = AccountManager(context)
|
val accountManager = AccountManager(context)
|
||||||
val encryptedAccount = accountManager.getAccount(accountPublicKey)
|
val encryptedAccount = accountManager.getAccount(accountPublicKey)
|
||||||
accountUsername = encryptedAccount?.username ?: ""
|
accountUsername = encryptedAccount?.username ?: ""
|
||||||
|
|||||||
@@ -139,9 +139,15 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
/** Инициализация с текущим аккаунтом */
|
/** Инициализация с текущим аккаунтом */
|
||||||
fun initialize(publicKey: String, privateKey: String) {
|
fun initialize(publicKey: String, privateKey: String) {
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
// 🔥 Очищаем кэши при смене аккаунта
|
||||||
if (currentAccount != publicKey) {
|
if (currentAccount != publicKey) {
|
||||||
requestedUserInfoKeys.clear()
|
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
|
currentAccount = publicKey
|
||||||
|
|||||||
@@ -313,17 +313,29 @@ class Protocol(
|
|||||||
log(" Public key: ${publicKey.take(20)}...")
|
log(" Public key: ${publicKey.take(20)}...")
|
||||||
log(" Private hash: ${privateHash.take(20)}...")
|
log(" Private hash: ${privateHash.take(20)}...")
|
||||||
log(" Current state: ${_state.value}")
|
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
|
// Save credentials for reconnection
|
||||||
lastPublicKey = publicKey
|
lastPublicKey = publicKey
|
||||||
lastPrivateHash = privateHash
|
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, не начинаем заново
|
// КРИТИЧНО: если уже в handshake или authenticated, не начинаем заново
|
||||||
if (_state.value == ProtocolState.HANDSHAKING) {
|
if (_state.value == ProtocolState.HANDSHAKING) {
|
||||||
log("⚠️ HANDSHAKE IGNORED: Already handshaking")
|
log("⚠️ HANDSHAKE IGNORED: Already handshaking")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_state.value == ProtocolState.AUTHENTICATED) {
|
if (_state.value == ProtocolState.AUTHENTICATED) {
|
||||||
log("⚠️ HANDSHAKE IGNORED: Already authenticated")
|
log("⚠️ HANDSHAKE IGNORED: Already authenticated")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ object ProtocolManager {
|
|||||||
// Typing status
|
// Typing status
|
||||||
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
|
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)
|
// 🔍 Global user info cache (like Desktop's InformationProvider.cachedUsers)
|
||||||
// publicKey → SearchUser (resolved via PacketSearch 0x03)
|
// publicKey → SearchUser (resolved via PacketSearch 0x03)
|
||||||
@@ -200,6 +204,7 @@ object ProtocolManager {
|
|||||||
if (user.username.isNotBlank()) {
|
if (user.username.isNotBlank()) {
|
||||||
accountManager.updateAccountUsername(user.publicKey, user.username)
|
accountManager.updateAccountUsername(user.publicKey, user.username)
|
||||||
}
|
}
|
||||||
|
_ownProfileUpdated.value = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,15 +154,19 @@ class AuthStateManager(
|
|||||||
_state.update { it.copy(
|
_state.update { it.copy(
|
||||||
status = AuthStatus.Authenticated(decryptedAccount)
|
status = AuthStatus.Authenticated(decryptedAccount)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
loadAccounts()
|
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
|
// Step 8: Connect and authenticate with protocol
|
||||||
ProtocolManager.connect()
|
ProtocolManager.connect()
|
||||||
|
|
||||||
// Give WebSocket time to connect before authenticating
|
// Give WebSocket time to connect before authenticating
|
||||||
kotlinx.coroutines.delay(500)
|
kotlinx.coroutines.delay(500)
|
||||||
|
|
||||||
ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash)
|
ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash)
|
||||||
|
|
||||||
Result.success(decryptedAccount)
|
Result.success(decryptedAccount)
|
||||||
@@ -199,13 +203,17 @@ class AuthStateManager(
|
|||||||
_state.update { it.copy(
|
_state.update { it.copy(
|
||||||
status = AuthStatus.Authenticated(decryptedAccount)
|
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
|
// Connect and authenticate with protocol
|
||||||
ProtocolManager.connect()
|
ProtocolManager.connect()
|
||||||
|
|
||||||
// Give WebSocket time to connect before authenticating
|
// Give WebSocket time to connect before authenticating
|
||||||
kotlinx.coroutines.delay(500)
|
kotlinx.coroutines.delay(500)
|
||||||
|
|
||||||
ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash)
|
ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash)
|
||||||
|
|
||||||
Result.success(decryptedAccount)
|
Result.success(decryptedAccount)
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ fun ImportSeedPhraseScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.imePadding()
|
.imePadding()
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
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
|
// Import button
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
|
|||||||
@@ -43,15 +43,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
||||||
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
|
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (account|dialogKey -> List<ChatMessage>)
|
||||||
// Сделан глобальным чтобы можно было очистить при удалении диалога
|
// Ключ включает account для изоляции данных между аккаунтами
|
||||||
private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>()
|
private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>()
|
||||||
|
|
||||||
|
/** Формирует ключ кэша с привязкой к аккаунту */
|
||||||
|
private fun cacheKey(account: String, dialogKey: String) = "$account|$dialogKey"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 Обновить кэш с ограничением размера Сохраняет только последние MAX_CACHE_SIZE
|
* 🔥 Обновить кэш с ограничением размера Сохраняет только последние MAX_CACHE_SIZE
|
||||||
* сообщений для предотвращения OOM
|
* сообщений для предотвращения OOM
|
||||||
*/
|
*/
|
||||||
private fun updateCacheWithLimit(dialogKey: String, messages: List<ChatMessage>) {
|
private fun updateCacheWithLimit(account: String, dialogKey: String, messages: List<ChatMessage>) {
|
||||||
val limitedMessages =
|
val limitedMessages =
|
||||||
if (messages.size > MAX_CACHE_SIZE) {
|
if (messages.size > MAX_CACHE_SIZE) {
|
||||||
// Оставляем только последние сообщения (по timestamp)
|
// Оставляем только последние сообщения (по timestamp)
|
||||||
@@ -61,12 +64,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
} else {
|
} else {
|
||||||
messages
|
messages
|
||||||
}
|
}
|
||||||
dialogMessagesCache[dialogKey] = limitedMessages
|
dialogMessagesCache[cacheKey(account, dialogKey)] = limitedMessages
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 🗑️ Очистить кэш сообщений для диалога Вызывается при удалении диалога */
|
/** 🗑️ Очистить кэш сообщений для диалога Вызывается при удалении диалога */
|
||||||
fun clearDialogCache(dialogKey: String) {
|
fun clearDialogCache(dialogKey: String) {
|
||||||
dialogMessagesCache.remove(dialogKey)
|
// Очищаем для всех аккаунтов — при удалении диалога dialogKey не привязан к аккаунту
|
||||||
|
val keysToRemove = dialogMessagesCache.keys.filter { it.endsWith("|$dialogKey") }
|
||||||
|
keysToRemove.forEach { dialogMessagesCache.remove(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 🗑️ Очистить кэш по publicKey собеседника Удаляет все ключи содержащие этот publicKey */
|
/** 🗑️ Очистить кэш по publicKey собеседника Удаляет все ключи содержащие этот publicKey */
|
||||||
@@ -74,6 +79,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val keysToRemove = dialogMessagesCache.keys.filter { it.contains(opponentKey) }
|
val keysToRemove = dialogMessagesCache.keys.filter { it.contains(opponentKey) }
|
||||||
keysToRemove.forEach { dialogMessagesCache.remove(it) }
|
keysToRemove.forEach { dialogMessagesCache.remove(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 🗑️ Полная очистка кэша — вызывается при переключении аккаунта */
|
||||||
|
fun clearAllDialogCache() {
|
||||||
|
dialogMessagesCache.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
@@ -322,11 +332,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем кэш
|
// Обновляем кэш
|
||||||
val cachedMessages = dialogMessagesCache[dialogKey]
|
val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
|
||||||
if (cachedMessages != null) {
|
if (cachedMessages != null) {
|
||||||
updateCacheWithLimit(dialogKey, cachedMessages + newMessages)
|
updateCacheWithLimit(account, dialogKey, cachedMessages + newMessages)
|
||||||
} else {
|
} else {
|
||||||
updateCacheWithLimit(dialogKey, _messages.value)
|
updateCacheWithLimit(account, dialogKey, _messages.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
currentOffset += newMessages.size
|
currentOffset += newMessages.size
|
||||||
@@ -450,7 +460,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val account = myPublicKey ?: return
|
val account = myPublicKey ?: return
|
||||||
val opponent = opponentKey ?: return
|
val opponent = opponentKey ?: return
|
||||||
val dialogKey = getDialogKey(account, opponent)
|
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) {
|
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
|
myPublicKey = publicKey
|
||||||
myPrivateKey = privateKey
|
myPrivateKey = privateKey
|
||||||
}
|
}
|
||||||
@@ -510,7 +527,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val cachedMessages =
|
val cachedMessages =
|
||||||
if (account != null) {
|
if (account != null) {
|
||||||
val dialogKey = getDialogKey(account, publicKey)
|
val dialogKey = getDialogKey(account, publicKey)
|
||||||
dialogMessagesCache[dialogKey]
|
dialogMessagesCache[cacheKey(account, dialogKey)]
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
// Сбрасываем состояние
|
// Сбрасываем состояние
|
||||||
@@ -602,7 +619,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
// 🔥 МГНОВЕННАЯ загрузка из кэша если есть!
|
// 🔥 МГНОВЕННАЯ загрузка из кэша если есть!
|
||||||
val cachedMessages = dialogMessagesCache[dialogKey]
|
val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
|
||||||
|
|
||||||
if (cachedMessages != null && cachedMessages.isNotEmpty()) {
|
if (cachedMessages != null && cachedMessages.isNotEmpty()) {
|
||||||
withContext(Dispatchers.Main.immediate) {
|
withContext(Dispatchers.Main.immediate) {
|
||||||
@@ -687,7 +704,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Сохраняем в кэш для мгновенной повторной загрузки!
|
// 🔥 Сохраняем в кэш для мгновенной повторной загрузки!
|
||||||
updateCacheWithLimit(dialogKey, messages.toList())
|
updateCacheWithLimit(account, dialogKey, messages.toList())
|
||||||
|
|
||||||
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
|
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
|
||||||
// НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД
|
// НО сохраняем оптимистичные сообщения (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 allCachedIds = existingCache.map { it.id }.toSet()
|
||||||
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
|
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
|
||||||
if (trulyNewMessages.isNotEmpty()) {
|
if (trulyNewMessages.isNotEmpty()) {
|
||||||
updateCacheWithLimit(
|
updateCacheWithLimit(
|
||||||
|
account,
|
||||||
dialogKey,
|
dialogKey,
|
||||||
(existingCache + trulyNewMessages).sortedBy { it.timestamp }
|
(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 allCachedIds = existingCache.map { it.id }.toSet()
|
||||||
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
|
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
|
||||||
if (trulyNewMessages.isNotEmpty()) {
|
if (trulyNewMessages.isNotEmpty()) {
|
||||||
updateCacheWithLimit(
|
updateCacheWithLimit(
|
||||||
|
account,
|
||||||
dialogKey,
|
dialogKey,
|
||||||
(trulyNewMessages + existingCache).sortedBy { it.timestamp }
|
(trulyNewMessages + existingCache).sortedBy { it.timestamp }
|
||||||
)
|
)
|
||||||
@@ -3558,7 +3577,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
// Очищаем кэш
|
// Очищаем кэш
|
||||||
val dialogKey = getDialogKey(account, opponent)
|
val dialogKey = getDialogKey(account, opponent)
|
||||||
dialogMessagesCache.remove(dialogKey)
|
dialogMessagesCache.remove(cacheKey(account, dialogKey))
|
||||||
|
|
||||||
// Очищаем UI
|
// Очищаем UI
|
||||||
withContext(Dispatchers.Main) { _messages.value = emptyList() }
|
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.chats.components.AnimatedDotsText
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
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.components.VerifiedBadge
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
|
||||||
import com.rosetta.messenger.ui.settings.BackgroundBlurPresets
|
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
@@ -494,29 +492,15 @@ fun ChatsListScreen(
|
|||||||
)
|
)
|
||||||
val headerColor = avatarColors.backgroundColor
|
val headerColor = avatarColors.backgroundColor
|
||||||
|
|
||||||
// Header: avatar blur или цвет шапки chat list
|
// Header: цвет шапки сайдбара
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
if (backgroundBlurColorId == "avatar") {
|
Box(
|
||||||
// Avatar blur
|
modifier = Modifier
|
||||||
BlurredAvatarBackground(
|
.matchParentSize()
|
||||||
publicKey = accountPublicKey,
|
.background(
|
||||||
avatarRepository = avatarRepository,
|
if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content поверх фона
|
// Content поверх фона
|
||||||
Column(
|
Column(
|
||||||
@@ -1277,21 +1261,7 @@ fun ChatsListScreen(
|
|||||||
val requests = chatsState.requests
|
val requests = chatsState.requests
|
||||||
val requestsCount = chatsState.requestsCount
|
val requestsCount = chatsState.requestsCount
|
||||||
|
|
||||||
// 🔥 ИСПРАВЛЕНИЕ МЕРЦАНИЯ: Запоминаем, что контент УЖЕ был
|
val showSkeleton = isLoading
|
||||||
// показан
|
|
||||||
// Это предотвращает показ EmptyState при временных пустых
|
|
||||||
// обновлениях
|
|
||||||
var hasShownContent by rememberSaveable {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
if (chatsState.hasContent) {
|
|
||||||
hasShownContent = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🎯 Показываем Empty State только если контент НИКОГДА не
|
|
||||||
// показывался
|
|
||||||
val shouldShowEmptyState =
|
|
||||||
chatsState.isEmpty && !hasShownContent
|
|
||||||
|
|
||||||
// 🎬 Animated content transition between main list and
|
// 🎬 Animated content transition between main list and
|
||||||
// requests
|
// requests
|
||||||
@@ -1427,12 +1397,9 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} // Close Box wrapper
|
} // Close Box wrapper
|
||||||
} else if (isLoading) {
|
} else if (showSkeleton) {
|
||||||
// 🚀 Shimmer skeleton пока данные грузятся
|
|
||||||
ChatsListSkeleton(isDarkTheme = isDarkTheme)
|
ChatsListSkeleton(isDarkTheme = isDarkTheme)
|
||||||
} else if (shouldShowEmptyState) {
|
} else if (chatsState.isEmpty) {
|
||||||
// 🔥 Empty state - показываем только если
|
|
||||||
// контент НЕ был показан ранее
|
|
||||||
EmptyChatsState(
|
EmptyChatsState(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.rosetta.messenger.network.ProtocolManager
|
|||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
@@ -73,6 +74,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
|
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
|
||||||
private val subscribedOnlineKeys = mutableSetOf<String>()
|
private val subscribedOnlineKeys = mutableSetOf<String>()
|
||||||
|
|
||||||
|
// Job для отмены подписок при смене аккаунта
|
||||||
|
private var accountSubscriptionsJob: Job? = null
|
||||||
|
|
||||||
// Список диалогов с расшифрованными сообщениями
|
// Список диалогов с расшифрованными сообщениями
|
||||||
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
|
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
|
||||||
val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow()
|
val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow()
|
||||||
@@ -124,16 +128,32 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
|
|
||||||
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||||
requestedUserInfoKeys.clear()
|
requestedUserInfoKeys.clear()
|
||||||
|
subscribedOnlineKeys.clear()
|
||||||
|
|
||||||
|
// 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта
|
||||||
|
// чтобы избежать показа сообщений с неправильным isOutgoing
|
||||||
|
ChatViewModel.clearAllDialogCache()
|
||||||
|
|
||||||
|
// Отменяем старые подписки от предыдущего аккаунта
|
||||||
|
accountSubscriptionsJob?.cancel()
|
||||||
|
|
||||||
currentAccount = publicKey
|
currentAccount = publicKey
|
||||||
currentPrivateKey = privateKey
|
currentPrivateKey = privateKey
|
||||||
|
|
||||||
|
// 🔥 Очищаем устаревшие данные от предыдущего аккаунта
|
||||||
|
_dialogs.value = emptyList()
|
||||||
|
_requests.value = emptyList()
|
||||||
|
_requestsCount.value = 0
|
||||||
|
|
||||||
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
|
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
|
||||||
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
|
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
|
||||||
|
|
||||||
|
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
|
||||||
|
accountSubscriptionsJob = viewModelScope.launch {
|
||||||
|
|
||||||
// Подписываемся на обычные диалоги
|
// Подписываемся на обычные диалоги
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
viewModelScope.launch {
|
launch {
|
||||||
dialogDao
|
dialogDao
|
||||||
.getDialogsFlow(publicKey)
|
.getDialogsFlow(publicKey)
|
||||||
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
||||||
@@ -293,7 +313,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
|
|
||||||
// 📬 Подписываемся на requests (запросы от новых пользователей)
|
// 📬 Подписываемся на requests (запросы от новых пользователей)
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
viewModelScope.launch {
|
launch {
|
||||||
dialogDao
|
dialogDao
|
||||||
.getRequestsFlow(publicKey)
|
.getRequestsFlow(publicKey)
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
@@ -422,7 +442,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 📊 Подписываемся на количество requests
|
// 📊 Подписываемся на количество requests
|
||||||
viewModelScope.launch {
|
launch {
|
||||||
dialogDao
|
dialogDao
|
||||||
.getRequestsCountFlow(publicKey)
|
.getRequestsCountFlow(publicKey)
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
@@ -432,7 +452,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
|
|
||||||
// 🔥 Подписываемся на блоклист — Room автоматически переэмитит при
|
// 🔥 Подписываемся на блоклист — Room автоматически переэмитит при
|
||||||
// blockUser()/unblockUser()
|
// blockUser()/unblockUser()
|
||||||
viewModelScope.launch {
|
launch {
|
||||||
database.blacklistDao()
|
database.blacklistDao()
|
||||||
.getBlockedUsers(publicKey)
|
.getBlockedUsers(publicKey)
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
@@ -440,6 +460,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { blockedSet -> _blockedUsers.value = blockedSet }
|
.collect { blockedSet -> _blockedUsers.value = blockedSet }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} // end accountSubscriptionsJob
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
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.ByteArrayInputStream
|
||||||
|
import java.io.File
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
private const val TAG = "AttachmentComponents"
|
private const val TAG = "AttachmentComponents"
|
||||||
@@ -1344,13 +1348,37 @@ fun FileAttachment(
|
|||||||
label = "progress"
|
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) {
|
LaunchedEffect(attachment.id) {
|
||||||
downloadStatus =
|
downloadStatus = if (isDownloadTag(preview)) {
|
||||||
if (isDownloadTag(preview)) {
|
// Проверяем, был ли файл уже скачан ранее
|
||||||
DownloadStatus.NOT_DOWNLOADED
|
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
||||||
} else {
|
else DownloadStatus.NOT_DOWNLOADED
|
||||||
DownloadStatus.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 = {
|
val download: () -> Unit = {
|
||||||
@@ -1364,8 +1392,6 @@ fun FileAttachment(
|
|||||||
|
|
||||||
downloadStatus = DownloadStatus.DECRYPTING
|
downloadStatus = DownloadStatus.DECRYPTING
|
||||||
|
|
||||||
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
|
|
||||||
// Сначала расшифровываем его, получаем raw bytes
|
|
||||||
val decryptedKeyAndNonce =
|
val decryptedKeyAndNonce =
|
||||||
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||||
|
|
||||||
@@ -1377,7 +1403,16 @@ fun FileAttachment(
|
|||||||
downloadProgress = 0.9f
|
downloadProgress = 0.9f
|
||||||
|
|
||||||
if (decrypted != null) {
|
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
|
downloadProgress = 1f
|
||||||
downloadStatus = DownloadStatus.DOWNLOADED
|
downloadStatus = DownloadStatus.DOWNLOADED
|
||||||
} else {
|
} else {
|
||||||
@@ -1398,8 +1433,14 @@ fun FileAttachment(
|
|||||||
.clickable(
|
.clickable(
|
||||||
enabled =
|
enabled =
|
||||||
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
|
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
|
||||||
|
downloadStatus == DownloadStatus.DOWNLOADED ||
|
||||||
downloadStatus == DownloadStatus.ERROR
|
downloadStatus == DownloadStatus.ERROR
|
||||||
) { download() }
|
) {
|
||||||
|
when (downloadStatus) {
|
||||||
|
DownloadStatus.DOWNLOADED -> openFile()
|
||||||
|
else -> download()
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -42,12 +42,6 @@ fun BoxScope.BlurredAvatarBackground(
|
|||||||
overlayColors: List<Color>? = null,
|
overlayColors: List<Color>? = null,
|
||||||
isDarkTheme: Boolean = true
|
isDarkTheme: Boolean = true
|
||||||
) {
|
) {
|
||||||
// В светлой теме с дефолтным фоном (avatar, без overlay) — синий как шапка chat list
|
|
||||||
if (!isDarkTheme && (overlayColors == null || overlayColors.isEmpty())) {
|
|
||||||
Box(modifier = Modifier.matchParentSize().background(Color(0xFF0D8CF4)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если выбран цвет в Appearance — рисуем блюр аватарки + полупрозрачный overlay поверх
|
// Если выбран цвет в Appearance — рисуем блюр аватарки + полупрозрачный overlay поверх
|
||||||
// (одинаково для светлой и тёмной темы, чтобы цвет совпадал с превью в Appearance)
|
// (одинаково для светлой и тёмной темы, чтобы цвет совпадал с превью в Appearance)
|
||||||
if (overlayColors != null && overlayColors.isNotEmpty()) {
|
if (overlayColors != null && overlayColors.isNotEmpty()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user