From 1a57d8f4d05c8455b0ca17418f14566ee2c860a9 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 17 Apr 2026 21:49:51 +0500 Subject: [PATCH] =?UTF-8?q?dev:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=20=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B8=D1=85=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=BE=D0=B2=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=B0,=20=D1=81=D0=B8=D0=BD=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=20send-flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/data/MessageRepository.kt | 14 + .../com/rosetta/messenger/network/Protocol.kt | 429 ++++--- .../messenger/network/ProtocolManager.kt | 102 +- .../messenger/ui/chats/ChatDetailScreen.kt | 195 +++- .../messenger/ui/chats/ChatViewModel.kt | 1000 ++++++++++++++--- .../messenger/ui/chats/ChatsListViewModel.kt | 41 +- .../chats/components/AttachmentComponents.kt | 4 +- .../AttachmentDownloadDebugLogger.kt | 9 + .../chats/components/ChatDetailComponents.kt | 141 ++- 9 files changed, 1542 insertions(+), 393 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 51c1e30..27a7a67 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -244,6 +244,13 @@ class MessageRepository private constructor(private val context: Context) { opponentUsername = existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME } ?: SYSTEM_SAFE_USERNAME, + lastMessage = encryptedPlainMessage, + lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp), + hasContent = 1, + lastMessageFromMe = 0, + lastMessageDelivered = DeliveryStatus.DELIVERED.value, + lastMessageRead = 0, + lastMessageAttachments = "[]", isOnline = existing?.isOnline ?: 0, lastSeen = existing?.lastSeen ?: 0, verified = maxOf(existing?.verified ?: 0, 1), @@ -323,6 +330,13 @@ class MessageRepository private constructor(private val context: Context) { opponentUsername = existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME } ?: SYSTEM_UPDATES_USERNAME, + lastMessage = encryptedPlainMessage, + lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp), + hasContent = 1, + lastMessageFromMe = 0, + lastMessageDelivered = DeliveryStatus.DELIVERED.value, + lastMessageRead = 0, + lastMessageAttachments = "[]", isOnline = existing?.isOnline ?: 0, lastSeen = existing?.lastSeen ?: 0, verified = maxOf(existing?.verified ?: 0, 1), diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index c482449..9da7556 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -4,12 +4,12 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.channels.Channel import okhttp3.* import okio.ByteString import java.util.Locale import java.util.concurrent.TimeUnit +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong @@ -191,10 +191,96 @@ class Protocol( private var connectingSinceMs = 0L private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val lifecycleMutex = Mutex() private val connectionGeneration = AtomicLong(0L) @Volatile private var activeConnectionGeneration: Long = 0L private val instanceId = INSTANCE_COUNTER.incrementAndGet() + + /** + * Single-writer session loop for all lifecycle mutations. + * Replaces ad-hoc Mutex locking and guarantees strict FIFO ordering. + */ + private sealed interface SessionEvent { + data class Connect(val trigger: String = "api_connect") : SessionEvent + data class HandleDisconnect(val source: String) : SessionEvent + data class Disconnect(val manual: Boolean, val reason: String) : SessionEvent + data class FastReconnect(val reason: String) : SessionEvent + data class AccountSwitchReconnect(val reason: String = "Account switch reconnect") : SessionEvent + data class HandshakeResponse(val packet: PacketHandshake) : SessionEvent + data class DeviceVerificationAccepted(val deviceId: String) : SessionEvent + data class DeviceVerificationDeclined( + val deviceId: String, + val observedState: ProtocolState + ) : SessionEvent + data class SocketOpened( + val generation: Long, + val socket: WebSocket, + val responseCode: Int + ) : SessionEvent + data class SocketClosed( + val generation: Long, + val socket: WebSocket, + val code: Int, + val reason: String + ) : SessionEvent + data class SocketFailure( + val generation: Long, + val socket: WebSocket, + val throwable: Throwable, + val responseCode: Int?, + val responseMessage: String? + ) : SessionEvent + } + + private val sessionEvents = Channel(Channel.UNLIMITED) + private val sessionLoopJob = + scope.launch { + for (event in sessionEvents) { + try { + when (event) { + is SessionEvent.Connect -> connectLocked() + is SessionEvent.HandleDisconnect -> handleDisconnectLocked(event.source) + is SessionEvent.Disconnect -> + disconnectLocked(manual = event.manual, reason = event.reason) + is SessionEvent.FastReconnect -> reconnectNowIfNeededLocked(event.reason) + is SessionEvent.AccountSwitchReconnect -> { + disconnectLocked(manual = false, reason = event.reason) + connectLocked() + } + is SessionEvent.HandshakeResponse -> handleHandshakeResponse(event.packet) + is SessionEvent.DeviceVerificationAccepted -> + handleDeviceVerificationAccepted(event.deviceId) + is SessionEvent.DeviceVerificationDeclined -> { + handshakeComplete = false + handshakeJob?.cancel() + packetQueue.clear() + if (webSocket != null) { + setState( + ProtocolState.CONNECTED, + "Device verification declined, waiting for retry" + ) + } else { + setState( + ProtocolState.DISCONNECTED, + "Device verification declined without active socket" + ) + } + log( + "⛔ DEVICE DECLINE APPLIED: deviceId=${shortKey(event.deviceId, 12)} " + + "observed=${event.observedState} current=${_state.value}" + ) + } + is SessionEvent.SocketOpened -> handleSocketOpened(event) + is SessionEvent.SocketClosed -> handleSocketClosed(event) + is SessionEvent.SocketFailure -> handleSocketFailure(event) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log("❌ Session event failed: ${event::class.java.simpleName} ${e.message}") + e.printStackTrace() + } + } + } private val _state = MutableStateFlow(ProtocolState.DISCONNECTED) val state: StateFlow = _state.asStateFlow() @@ -227,17 +313,126 @@ class Protocol( } } - private fun launchLifecycleOperation(operation: String, block: suspend () -> Unit) { - scope.launch { - lifecycleMutex.withLock { - try { - block() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log("❌ Lifecycle operation '$operation' failed: ${e.message}") - e.printStackTrace() + private fun enqueueSessionEvent(event: SessionEvent) { + val result = sessionEvents.trySend(event) + if (result.isFailure) { + log( + "⚠️ Session event dropped: ${event::class.java.simpleName} " + + "reason=${result.exceptionOrNull()?.message ?: "channel_closed"}" + ) + } + } + + private fun handleSocketOpened(event: SessionEvent.SocketOpened) { + if (isStaleSocketEvent("onOpen", event.generation, event.socket)) return + log( + "✅ WebSocket OPEN: response=${event.responseCode}, " + + "hasCredentials=${lastPublicKey != null}, gen=${event.generation}" + ) + + isConnecting = false + connectingSinceMs = 0L + + setState(ProtocolState.CONNECTED, "WebSocket onOpen callback") + // Flush queue as soon as socket is open. + // Auth-required packets will remain queued until handshake completes. + flushPacketQueue() + + if (_state.value != ProtocolState.HANDSHAKING && _state.value != ProtocolState.AUTHENTICATED) { + lastPublicKey?.let { publicKey -> + lastPrivateHash?.let { privateHash -> + log("🤝 Auto-starting handshake with saved credentials") + startHandshake(publicKey, privateHash, lastDevice) } + } ?: log("⚠️ No saved credentials, waiting for manual handshake") + } else { + log("⚠️ Skipping auto-handshake: already in state ${_state.value}") + } + } + + private fun handleSocketClosed(event: SessionEvent.SocketClosed) { + if (isStaleSocketEvent("onClosed", event.generation, event.socket)) return + log( + "❌ WebSocket CLOSED: code=${event.code} reason='${event.reason}' state=${_state.value} " + + "manuallyClosed=$isManuallyClosed gen=${event.generation}" + ) + isConnecting = false + connectingSinceMs = 0L + handleDisconnectLocked("onClosed") + } + + private fun handleSocketFailure(event: SessionEvent.SocketFailure) { + if (isStaleSocketEvent("onFailure", event.generation, event.socket)) return + log("❌ WebSocket FAILURE: ${event.throwable.message}") + log(" Response: ${event.responseCode} ${event.responseMessage}") + log(" State: ${_state.value}") + log(" Manually closed: $isManuallyClosed") + log(" Reconnect attempts: $reconnectAttempts") + log(" Generation: ${event.generation}") + event.throwable.printStackTrace() + isConnecting = false + connectingSinceMs = 0L + _lastError.value = event.throwable.message + handleDisconnectLocked("onFailure") + } + + private fun handleHandshakeResponse(packet: PacketHandshake) { + handshakeJob?.cancel() + + when (packet.handshakeState) { + HandshakeState.COMPLETED -> { + log("✅ HANDSHAKE COMPLETE: protocol=${packet.protocolVersion}, heartbeat=${packet.heartbeatInterval}s") + handshakeComplete = true + setState(ProtocolState.AUTHENTICATED, "Handshake completed") + flushPacketQueue() + } + + HandshakeState.NEED_DEVICE_VERIFICATION -> { + log("🔐 HANDSHAKE NEEDS DEVICE VERIFICATION") + handshakeComplete = false + setState( + ProtocolState.DEVICE_VERIFICATION_REQUIRED, + "Handshake requires device verification" + ) + packetQueue.clear() + } + } + + // Keep heartbeat in both handshake states to maintain server session. + startHeartbeat(packet.heartbeatInterval) + } + + private fun handleDeviceVerificationAccepted(deviceId: String) { + log("✅ DEVICE VERIFICATION ACCEPTED (deviceId=${shortKey(deviceId, 12)})") + val stateAtAccept = _state.value + if (stateAtAccept == ProtocolState.AUTHENTICATED) { + log("✅ ACCEPT ignored: already authenticated") + return + } + + if (stateAtAccept == ProtocolState.DEVICE_VERIFICATION_REQUIRED) { + setState(ProtocolState.CONNECTED, "Device verification accepted") + } + + val publicKey = lastPublicKey + val privateHash = lastPrivateHash + if (publicKey.isNullOrBlank() || privateHash.isNullOrBlank()) { + log("⚠️ ACCEPT received but credentials are missing, waiting for reconnect") + return + } + + when (_state.value) { + ProtocolState.DISCONNECTED -> { + log("🔄 ACCEPT while disconnected -> reconnecting") + connectLocked() + } + + ProtocolState.CONNECTING -> { + log("⏳ ACCEPT while connecting -> waiting for onOpen auto-handshake") + } + + else -> { + startHandshake(publicKey, privateHash, lastDevice) } } } @@ -270,7 +465,8 @@ class Protocol( val lastError: StateFlow = _lastError.asStateFlow() // Packet waiters - callbacks for specific packet types (thread-safe) - private val packetWaiters = java.util.concurrent.ConcurrentHashMap Unit>>() + private val packetWaiters = + java.util.concurrent.ConcurrentHashMap Unit>>() // Packet queue for packets sent before handshake complete (thread-safe) private val packetQueue = java.util.Collections.synchronizedList(mutableListOf()) @@ -326,29 +522,7 @@ class Protocol( // Register handshake response handler waitPacket(0x00) { packet -> if (packet is PacketHandshake) { - handshakeJob?.cancel() - - when (packet.handshakeState) { - HandshakeState.COMPLETED -> { - log("✅ HANDSHAKE COMPLETE: protocol=${packet.protocolVersion}, heartbeat=${packet.heartbeatInterval}s") - handshakeComplete = true - setState(ProtocolState.AUTHENTICATED, "Handshake completed") - flushPacketQueue() - } - - HandshakeState.NEED_DEVICE_VERIFICATION -> { - log("🔐 HANDSHAKE NEEDS DEVICE VERIFICATION") - handshakeComplete = false - setState( - ProtocolState.DEVICE_VERIFICATION_REQUIRED, - "Handshake requires device verification" - ) - packetQueue.clear() - } - } - - // Keep heartbeat in both handshake states to maintain server session. - startHeartbeat(packet.heartbeatInterval) + enqueueSessionEvent(SessionEvent.HandshakeResponse(packet)) } } @@ -360,38 +534,9 @@ class Protocol( val resolve = packet as? PacketDeviceResolve ?: return@waitPacket when (resolve.solution) { DeviceResolveSolution.ACCEPT -> { - log("✅ DEVICE VERIFICATION ACCEPTED (deviceId=${shortKey(resolve.deviceId, 12)})") - val stateAtAccept = _state.value - if (stateAtAccept == ProtocolState.AUTHENTICATED) { - log("✅ ACCEPT ignored: already authenticated") - return@waitPacket - } - - if (stateAtAccept == ProtocolState.DEVICE_VERIFICATION_REQUIRED) { - setState(ProtocolState.CONNECTED, "Device verification accepted") - } - - val publicKey = lastPublicKey - val privateHash = lastPrivateHash - if (publicKey.isNullOrBlank() || privateHash.isNullOrBlank()) { - log("⚠️ ACCEPT received but credentials are missing, waiting for reconnect") - return@waitPacket - } - - when (_state.value) { - ProtocolState.DISCONNECTED -> { - log("🔄 ACCEPT while disconnected -> reconnecting") - connect() - } - - ProtocolState.CONNECTING -> { - log("⏳ ACCEPT while connecting -> waiting for onOpen auto-handshake") - } - - else -> { - startHandshake(publicKey, privateHash, lastDevice) - } - } + enqueueSessionEvent( + SessionEvent.DeviceVerificationAccepted(deviceId = resolve.deviceId) + ) } DeviceResolveSolution.DECLINE -> { val stateAtDecline = _state.value @@ -406,22 +551,12 @@ class Protocol( stateAtDecline == ProtocolState.DEVICE_VERIFICATION_REQUIRED || stateAtDecline == ProtocolState.HANDSHAKING ) { - launchLifecycleOperation("device_verification_declined") { - handshakeComplete = false - handshakeJob?.cancel() - packetQueue.clear() - if (webSocket != null) { - setState( - ProtocolState.CONNECTED, - "Device verification declined, waiting for retry" - ) - } else { - setState( - ProtocolState.DISCONNECTED, - "Device verification declined without active socket" - ) - } - } + enqueueSessionEvent( + SessionEvent.DeviceVerificationDeclined( + deviceId = resolve.deviceId, + observedState = stateAtDecline + ) + ) } } } @@ -511,9 +646,7 @@ class Protocol( * Initialize connection to server */ fun connect() { - launchLifecycleOperation("connect") { - connectLocked() - } + enqueueSessionEvent(SessionEvent.Connect()) } private fun connectLocked() { @@ -597,30 +730,13 @@ class Protocol( webSocket = client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { - if (isStaleSocketEvent("onOpen", generation, webSocket)) return - log("✅ WebSocket OPEN: response=${response.code}, hasCredentials=${lastPublicKey != null}, gen=$generation") - - // Сбрасываем флаг подключения - isConnecting = false - connectingSinceMs = 0L - - setState(ProtocolState.CONNECTED, "WebSocket onOpen callback") - // Flush queue as soon as socket is open. - // Auth-required packets will remain queued until handshake completes. - flushPacketQueue() - - // КРИТИЧНО: проверяем что не идет уже handshake - if (_state.value != ProtocolState.HANDSHAKING && _state.value != ProtocolState.AUTHENTICATED) { - // If we have saved credentials, start handshake automatically - lastPublicKey?.let { publicKey -> - lastPrivateHash?.let { privateHash -> - log("🤝 Auto-starting handshake with saved credentials") - startHandshake(publicKey, privateHash, lastDevice) - } - } ?: log("⚠️ No saved credentials, waiting for manual handshake") - } else { - log("⚠️ Skipping auto-handshake: already in state ${_state.value}") - } + enqueueSessionEvent( + SessionEvent.SocketOpened( + generation = generation, + socket = webSocket, + responseCode = response.code + ) + ) } override fun onMessage(webSocket: WebSocket, bytes: ByteString) { @@ -649,26 +765,26 @@ class Protocol( } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - if (isStaleSocketEvent("onClosed", generation, webSocket)) return - log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed gen=$generation") - isConnecting = false // Сбрасываем флаг - connectingSinceMs = 0L - handleDisconnect("onClosed") + enqueueSessionEvent( + SessionEvent.SocketClosed( + generation = generation, + socket = webSocket, + code = code, + reason = reason + ) + ) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - if (isStaleSocketEvent("onFailure", generation, webSocket)) return - log("❌ WebSocket FAILURE: ${t.message}") - log(" Response: ${response?.code} ${response?.message}") - log(" State: ${_state.value}") - log(" Manually closed: $isManuallyClosed") - log(" Reconnect attempts: $reconnectAttempts") - log(" Generation: $generation") - t.printStackTrace() - isConnecting = false // Сбрасываем флаг - connectingSinceMs = 0L - _lastError.value = t.message - handleDisconnect("onFailure") + enqueueSessionEvent( + SessionEvent.SocketFailure( + generation = generation, + socket = webSocket, + throwable = t, + responseCode = response?.code, + responseMessage = response?.message + ) + ) } }) } @@ -698,10 +814,9 @@ class Protocol( // If switching accounts, force disconnect and reconnect with new credentials if (switchingAccount) { log("🔄 Account switch detected, forcing reconnect with new credentials") - launchLifecycleOperation("account_switch_reconnect") { - disconnectLocked(manual = false, reason = "Account switch reconnect") - connectLocked() // Will auto-handshake with saved credentials on connect - } + enqueueSessionEvent( + SessionEvent.AccountSwitchReconnect(reason = "Account switch reconnect") + ) return } @@ -892,9 +1007,7 @@ class Protocol( } private fun handleDisconnect(source: String = "unknown") { - launchLifecycleOperation("handle_disconnect:$source") { - handleDisconnectLocked(source) - } + enqueueSessionEvent(SessionEvent.HandleDisconnect(source)) } private fun handleDisconnectLocked(source: String) { @@ -969,25 +1082,39 @@ class Protocol( * Register callback for specific packet type */ fun waitPacket(packetId: Int, callback: (Packet) -> Unit) { - packetWaiters.getOrPut(packetId) { mutableListOf() }.add(callback) - val count = packetWaiters[packetId]?.size ?: 0 - log("📝 waitPacket(0x${Integer.toHexString(packetId)}) registered. Total handlers for 0x${Integer.toHexString(packetId)}: $count") + val waiters = packetWaiters.computeIfAbsent(packetId) { CopyOnWriteArrayList() } + if (waiters.contains(callback)) { + log( + "📝 waitPacket(0x${Integer.toHexString(packetId)}) skipped duplicate callback. " + + "Total handlers for 0x${Integer.toHexString(packetId)}: ${waiters.size}" + ) + return + } + waiters.add(callback) + log( + "📝 waitPacket(0x${Integer.toHexString(packetId)}) registered. " + + "Total handlers for 0x${Integer.toHexString(packetId)}: ${waiters.size}" + ) } /** * Unregister callback for specific packet type */ fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) { - packetWaiters[packetId]?.remove(callback) + val waiters = packetWaiters[packetId] ?: return + waiters.remove(callback) + if (waiters.isEmpty()) { + packetWaiters.remove(packetId, waiters) + } } /** * Disconnect from server */ fun disconnect() { - launchLifecycleOperation("disconnect_manual") { - disconnectLocked(manual = true, reason = "User disconnected") - } + enqueueSessionEvent( + SessionEvent.Disconnect(manual = true, reason = "User disconnected") + ) } private fun disconnectLocked(manual: Boolean, reason: String) { @@ -1018,9 +1145,7 @@ class Protocol( * on app resume we should not wait scheduled exponential backoff. */ fun reconnectNowIfNeeded(reason: String = "foreground") { - launchLifecycleOperation("fast_reconnect:$reason") { - reconnectNowIfNeededLocked(reason) - } + enqueueSessionEvent(SessionEvent.FastReconnect(reason)) } private fun reconnectNowIfNeededLocked(reason: String) { @@ -1087,9 +1212,17 @@ class Protocol( * Release resources */ fun destroy() { + enqueueSessionEvent( + SessionEvent.Disconnect(manual = true, reason = "Destroy protocol") + ) + runCatching { sessionEvents.close() } runBlocking { - lifecycleMutex.withLock { - disconnectLocked(manual = true, reason = "Destroy protocol") + val drained = withTimeoutOrNull(2_000L) { + sessionLoopJob.join() + true + } ?: false + if (!drained) { + sessionLoopJob.cancelAndJoin() } } heartbeatJob?.cancel() diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index e1b6ed6..1b11f73 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -24,6 +24,7 @@ import java.util.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong import kotlin.coroutines.resume /** @@ -124,6 +125,11 @@ object ProtocolManager { val syncInProgress: StateFlow = _syncInProgress.asStateFlow() @Volatile private var resyncRequiredAfterAccountInit = false @Volatile private var lastForegroundSyncTime = 0L + private val authenticatedSessionCounter = AtomicLong(0L) + @Volatile private var activeAuthenticatedSessionId = 0L + @Volatile private var lastBootstrappedSessionId = 0L + @Volatile private var deferredAuthBootstrap = false + private val authBootstrapMutex = Mutex() // Desktop parity: sequential task queue matching dialogQueue.ts (promise chain). // Uses Channel to guarantee strict FIFO ordering (Mutex+lastInboundJob had a race // condition: Dispatchers.IO doesn't guarantee FIFO, so the last-launched job could @@ -274,6 +280,8 @@ object ProtocolManager { // New authenticated websocket session: always allow fresh push subscribe. lastSubscribedToken = null stopWaitingForNetwork("authenticated") + activeAuthenticatedSessionId = authenticatedSessionCounter.incrementAndGet() + deferredAuthBootstrap = false onAuthenticated() } if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) { @@ -282,6 +290,7 @@ object ProtocolManager { setSyncInProgress(false) // Connection/session dropped: force re-subscribe on next AUTHENTICATED. lastSubscribedToken = null + deferredAuthBootstrap = false // iOS parity: cancel all pending outgoing retries on disconnect. // They will be retried via retryWaitingMessages() on next handshake. cancelAllOutgoingRetries() @@ -309,6 +318,9 @@ object ProtocolManager { setSyncInProgress(false) clearTypingState() messageRepository?.initialize(normalizedPublicKey, normalizedPrivateKey) + if (deferredAuthBootstrap && protocol?.isAuthenticated() == true) { + addLog("🔁 AUTH bootstrap resume after initializeAccount") + } val shouldResync = resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true if (shouldResync) { @@ -320,6 +332,7 @@ object ProtocolManager { addLog("🔄 Account initialized (${shortKeyForLog(normalizedPublicKey)}) -> force sync") requestSynchronize() } + tryRunPostAuthBootstrap("initialize_account") // Send "Rosetta Updates" message on version change (like desktop useUpdateMessage) scope.launch { messageRepository?.checkAndSendVersionUpdateMessage() @@ -524,32 +537,43 @@ object ProtocolManager { if (searchPacket.users.isNotEmpty()) { scope.launch(Dispatchers.IO) { - val ownPublicKey = getProtocol().getPublicKey() + val ownPublicKey = + getProtocol().getPublicKey()?.trim().orEmpty().ifBlank { + messageRepository?.getCurrentAccountKey()?.trim().orEmpty() + } searchPacket.users.forEach { user -> + val normalizedUserPublicKey = user.publicKey.trim() // 🔍 Кэшируем всех пользователей (desktop parity: cachedUsers) - userInfoCache[user.publicKey] = user + userInfoCache[normalizedUserPublicKey] = user - // Resume pending resolves for this publicKey - pendingResolves.remove(user.publicKey)?.forEach { cont -> - try { cont.resume(user) } catch (_: Exception) {} - } + // Resume pending resolves for this publicKey (case-insensitive). + pendingResolves + .keys + .filter { it.equals(normalizedUserPublicKey, ignoreCase = true) } + .forEach { key -> + pendingResolves.remove(key)?.forEach { cont -> + try { cont.resume(user) } catch (_: Exception) {} + } + } // Обновляем инфо в диалогах (для всех пользователей) messageRepository?.updateDialogUserInfo( - user.publicKey, + normalizedUserPublicKey, user.title, user.username, user.verified ) // Если это наш own profile — сохраняем username/name в AccountManager - if (user.publicKey == ownPublicKey && appContext != null) { + if (ownPublicKey.isNotBlank() && + normalizedUserPublicKey.equals(ownPublicKey, ignoreCase = true) && + appContext != null) { val accountManager = AccountManager(appContext!!) if (user.title.isNotBlank() && !isPlaceholderAccountName(user.title)) { - accountManager.updateAccountName(user.publicKey, user.title) + accountManager.updateAccountName(ownPublicKey, user.title) } if (user.username.isNotBlank()) { - accountManager.updateAccountUsername(user.publicKey, user.username) + accountManager.updateAccountUsername(ownPublicKey, user.username) } _ownProfileUpdated.value = System.currentTimeMillis() } @@ -777,13 +801,53 @@ object ProtocolManager { } } + private fun canRunPostAuthBootstrap(): Boolean { + val repository = messageRepository ?: return false + if (!repository.isInitialized()) return false + val repositoryAccount = repository.getCurrentAccountKey()?.trim().orEmpty() + if (repositoryAccount.isBlank()) return false + val protocolAccount = getProtocol().getPublicKey()?.trim().orEmpty() + if (protocolAccount.isBlank()) return true + return repositoryAccount.equals(protocolAccount, ignoreCase = true) + } + + private fun tryRunPostAuthBootstrap(trigger: String) { + val sessionId = activeAuthenticatedSessionId + if (sessionId <= 0L) return + scope.launch { + authBootstrapMutex.withLock { + if (sessionId != activeAuthenticatedSessionId) return@withLock + if (sessionId == lastBootstrappedSessionId) return@withLock + if (!canRunPostAuthBootstrap()) { + deferredAuthBootstrap = true + val repositoryAccount = + messageRepository?.getCurrentAccountKey()?.let { shortKeyForLog(it) } + ?: "" + val protocolAccount = + getProtocol().getPublicKey()?.let { shortKeyForLog(it) } + ?: "" + addLog( + "⏳ AUTH bootstrap deferred trigger=$trigger repo=$repositoryAccount proto=$protocolAccount" + ) + return@withLock + } + + deferredAuthBootstrap = false + setSyncInProgress(false) + addLog("🚀 AUTH bootstrap start session=$sessionId trigger=$trigger") + TransportManager.requestTransportServer() + com.rosetta.messenger.update.UpdateManager.requestSduServer() + fetchOwnProfile() + requestSynchronize() + subscribePushTokenIfAvailable() + lastBootstrappedSessionId = sessionId + addLog("✅ AUTH bootstrap complete session=$sessionId trigger=$trigger") + } + } + } + private fun onAuthenticated() { - setSyncInProgress(false) - TransportManager.requestTransportServer() - com.rosetta.messenger.update.UpdateManager.requestSduServer() - fetchOwnProfile() - requestSynchronize() - subscribePushTokenIfAvailable() + tryRunPostAuthBootstrap("state_authenticated") } private fun finishSyncCycle(reason: String) { @@ -1789,6 +1853,9 @@ object ProtocolManager { clearSyncRequestTimeout() setSyncInProgress(false) resyncRequiredAfterAccountInit = false + deferredAuthBootstrap = false + activeAuthenticatedSessionId = 0L + lastBootstrappedSessionId = 0L lastSubscribedToken = null // reset so token is re-sent on next connect } @@ -1809,6 +1876,9 @@ object ProtocolManager { clearSyncRequestTimeout() setSyncInProgress(false) resyncRequiredAfterAccountInit = false + deferredAuthBootstrap = false + activeAuthenticatedSessionId = 0L + lastBootstrappedSessionId = 0L scope.cancel() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 1add3e6..396f015 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -34,12 +34,15 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import compose.icons.TablerIcons @@ -73,6 +76,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset @@ -111,6 +115,8 @@ import com.rosetta.messenger.ui.chats.utils.* import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.VerifiedBadge +import com.rosetta.messenger.ui.components.metaball.DevicePerformanceClass +import com.rosetta.messenger.ui.components.metaball.PerformanceClass import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.settings.ThemeWallpapers import com.rosetta.messenger.ui.utils.NavigationModeUtils @@ -806,6 +812,7 @@ fun ChatDetailScreen( // Состояние выпадающего меню var showMenu by remember { mutableStateOf(false) } + var showChatOpenMetricsDialog by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) } var showBlockConfirm by remember { mutableStateOf(false) } var showUnblockConfirm by remember { mutableStateOf(false) } @@ -838,6 +845,17 @@ fun ChatDetailScreen( // If typing, the user is obviously online — never show "offline" while typing val isOnline = rawIsOnline || isTyping val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона + val chatOpenMetrics by viewModel.chatOpenMetrics.collectAsState() + val performanceClass = + remember(context.applicationContext) { + DevicePerformanceClass.get(context.applicationContext) + } + val useLightweightColdOpen = + remember(performanceClass, chatOpenMetrics.firstListLayoutMs) { + (performanceClass == PerformanceClass.LOW || + performanceClass == PerformanceClass.AVERAGE) && + chatOpenMetrics.firstListLayoutMs == null + } val groupRequiresRejoin by viewModel.groupRequiresRejoin.collectAsState() val showMessageSkeleton by produceState(initialValue = false, key1 = isLoading) { @@ -1428,11 +1446,24 @@ fun ChatDetailScreen( // 🔥 Подписываемся на forward trigger для перезагрузки при forward в тот же чат val forwardTrigger by ForwardManager.forwardTrigger.collectAsState() + var deferredEmojiPreloadStarted by remember(user.publicKey) { mutableStateOf(false) } // Инициализируем ViewModel с ключами и открываем диалог // forwardTrigger добавлен чтобы перезагрузить диалог при forward в тот же чат - LaunchedEffect(user.publicKey, forwardTrigger) { - viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey) + LaunchedEffect( + user.publicKey, + forwardTrigger, + currentUserPublicKey, + currentUserPrivateKey + ) { + val normalizedPublicKey = currentUserPublicKey.trim() + val normalizedPrivateKey = currentUserPrivateKey.trim() + viewModel.setUserKeys(normalizedPublicKey, normalizedPrivateKey) + if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) { + // Fresh registration path can render Chat UI before account keys arrive. + // Avoid opening dialog with empty sender/private key. + return@LaunchedEffect + } viewModel.openDialog(user.publicKey, user.title, user.username, user.verified) viewModel.markVisibleMessagesAsRead() // 🔥 Убираем уведомление этого чата из шторки при заходе @@ -1442,8 +1473,32 @@ fun ChatDetailScreen( if (!isSavedMessages && !isGroupChat) { viewModel.subscribeToOnlineStatus() } - // 🔥 Предзагружаем эмодзи в фоне - com.rosetta.messenger.ui.components.EmojiCache.preload(context) + } + + // Фиксируем момент, когда список впервые реально отрисовался. + LaunchedEffect(listState, user.publicKey) { + snapshotFlow { + val hasVisibleItems = listState.layoutInfo.visibleItemsInfo.isNotEmpty() + hasVisibleItems && messagesWithDates.isNotEmpty() + } + .distinctUntilChanged() + .collect { isReady -> + if (isReady) { + viewModel.markFirstListLayoutReady() + } + } + } + + // Отложенный preload эмодзи после первого layout списка, чтобы не мешать открытию диалога. + LaunchedEffect(user.publicKey, chatOpenMetrics.firstListLayoutMs) { + if (!deferredEmojiPreloadStarted && chatOpenMetrics.firstListLayoutMs != null) { + deferredEmojiPreloadStarted = true + delay(300) + viewModel.addChatOpenTraceEvent("deferred_emoji_preload_start") + withContext(Dispatchers.Default) { + com.rosetta.messenger.ui.components.EmojiCache.preload(context) + } + } } // Consume pending forward messages for this chat @@ -2298,6 +2353,10 @@ fun ChatDetailScreen( isSystemAccount, isBlocked = isBlocked, + onChatOpenMetricsClick = { + showMenu = false + showChatOpenMetricsDialog = true + }, onSearchInChatClick = { showMenu = false hideInputOverlays() @@ -2849,6 +2908,12 @@ fun ChatDetailScreen( isSavedMessages = isSavedMessages, onSend = { isSendingMessage = true + viewModel.ensureSendContext( + publicKey = user.publicKey, + title = user.title, + username = user.username, + verified = user.verified + ) viewModel.sendMessage() scope.launch { delay(100) @@ -2859,6 +2924,12 @@ fun ChatDetailScreen( }, onSendVoiceMessage = { voiceHex, durationSec, waves -> isSendingMessage = true + viewModel.ensureSendContext( + publicKey = user.publicKey, + title = user.title, + username = user.username, + verified = user.verified + ) viewModel.sendVoiceMessage( voiceHex = voiceHex, durationSec = durationSec, @@ -3248,6 +3319,16 @@ fun ChatDetailScreen( key = { _, item -> item.first .id + }, + contentType = { _, item -> + val hasAttachments = + item.first.attachments + .isNotEmpty() + when { + item.second -> "message_with_date" + hasAttachments -> "message_with_attachments" + else -> "message_text_only" + } } ) { index, @@ -3315,14 +3396,7 @@ fun ChatDetailScreen( isGroupStart)) val isDeleting = message.id in pendingDeleteIds - androidx.compose.animation.AnimatedVisibility( - visible = !isDeleting, - exit = androidx.compose.animation.shrinkVertically( - animationSpec = androidx.compose.animation.core.tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing) - ) + androidx.compose.animation.fadeOut( - animationSpec = androidx.compose.animation.core.tween(200) - ) - ) { + val messageItemContent: @Composable () -> Unit = { Column { if (showDate ) { @@ -3391,6 +3465,8 @@ fun ChatDetailScreen( .contains( senderPublicKeyForMessage ), + deferHeavyAttachmentsUntilReady = + useLightweightColdOpen, currentUserPublicKey = currentUserPublicKey, currentUserUsername = @@ -3735,8 +3811,35 @@ fun ChatDetailScreen( } // contextMenuContent ) } - } // AnimatedVisibility } + + if (pendingDeleteIds.isEmpty()) { + if (!isDeleting) { + messageItemContent() + } + } else { + androidx.compose.animation.AnimatedVisibility( + visible = !isDeleting, + exit = + androidx.compose.animation.shrinkVertically( + animationSpec = + androidx.compose.animation.core.tween( + 250, + easing = + androidx.compose.animation.core.FastOutSlowInEasing + ) + ) + + androidx.compose.animation.fadeOut( + animationSpec = + androidx.compose.animation.core.tween( + 200 + ) + ) + ) { + messageItemContent() + } + } + } } androidx.compose.animation.AnimatedVisibility( visible = @@ -4080,9 +4183,44 @@ fun ChatDetailScreen( } ) } - } // Закрытие Box wrapper для Scaffold content + } // Закрытие Box wrapper для Scaffold content } // Закрытие Box + if (showChatOpenMetricsDialog) { + val metricsReport = remember(chatOpenMetrics) { buildChatOpenMetricsReport(chatOpenMetrics) } + AlertDialog( + onDismissRequest = { showChatOpenMetricsDialog = false }, + containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + title = { + Text( + text = "Chat Open Metrics", + fontWeight = FontWeight.Bold, + color = textColor + ) + }, + text = { + SelectionContainer { + Text( + text = metricsReport, + color = if (isDarkTheme) Color(0xFFD8D8D8) else Color(0xFF2F2F2F), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 18.sp, + modifier = + Modifier.fillMaxWidth() + .heightIn(max = 440.dp) + .verticalScroll(rememberScrollState()) + ) + } + }, + confirmButton = { + TextButton(onClick = { showChatOpenMetricsDialog = false }) { + Text("Close", color = PrimaryBlue) + } + } + ) + } + // Диалог подтверждения удаления чата if (showDeleteConfirm) { val isLeaveGroupDialog = user.publicKey.startsWith("#group:") @@ -4619,3 +4757,32 @@ private fun ChatWallpaperBackground( contentScale = ContentScale.Crop ) } + +private fun formatMetricDuration(valueMs: Long?): String = + if (valueMs == null || valueMs < 0L) "n/a" else "${valueMs}ms" + +private fun buildChatOpenMetricsReport(metrics: ChatViewModel.ChatOpenMetricsSnapshot): String { + val chat = metrics.chat.ifBlank { "" } + val logSection = + if (metrics.eventLog.isEmpty()) { + "" + } else { + metrics.eventLog.joinToString(separator = "\n") + } + return buildString { + appendLine("chat=$chat") + appendLine("messages=${metrics.messages}") + appendLine("messagesWithDates=${metrics.messagesWithDates}") + appendLine("isLoading=${metrics.isLoading}") + appendLine("openDialog=${formatMetricDuration(metrics.openDialogMs)}") + appendLine("firstMessages=${formatMetricDuration(metrics.firstMessagesMs)}") + appendLine( + "messagesWithDatesReady=${formatMetricDuration(metrics.messagesWithDatesReadyMs)}" + ) + appendLine("firstListLayout=${formatMetricDuration(metrics.firstListLayoutMs)}") + appendLine("loadingFinished=${formatMetricDuration(metrics.loadingFinishedMs)}") + appendLine() + appendLine("Event Log") + append(logSection) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index b6e18ff..713385a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import android.graphics.Bitmap import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever +import android.os.SystemClock import android.util.Base64 import android.webkit.MimeTypeMap import androidx.lifecycle.AndroidViewModel @@ -14,6 +15,8 @@ import com.rosetta.messenger.data.ForwardManager import com.rosetta.messenger.database.MessageEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.* +import com.rosetta.messenger.ui.components.metaball.DevicePerformanceClass +import com.rosetta.messenger.ui.components.metaball.PerformanceClass import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.MessageLogger @@ -42,7 +45,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { companion object { private const val TAG = "ChatViewModel" - private const val PAGE_SIZE = 30 + private const val PAGINATION_PAGE_SIZE = 30 + private const val INITIAL_PAGE_SIZE_LOW = 12 + private const val INITIAL_PAGE_SIZE_AVERAGE = 18 + private const val INITIAL_PAGE_SIZE_HIGH = 30 private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM @@ -147,6 +153,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { "yyyyMMdd", java.util.Locale.US ) // single instance, thread-safe via Default dispatcher + + data class ChatOpenMetricsSnapshot( + val chat: String = "", + val messages: Int = 0, + val messagesWithDates: Int = 0, + val isLoading: Boolean = false, + val openDialogMs: Long? = null, + val firstMessagesMs: Long? = null, + val messagesWithDatesReadyMs: Long? = null, + val firstListLayoutMs: Long? = null, + val loadingFinishedMs: Long? = null, + val eventLog: List = emptyList() + ) + + private val _chatOpenMetrics = MutableStateFlow(ChatOpenMetricsSnapshot()) + val chatOpenMetrics: StateFlow = _chatOpenMetrics.asStateFlow() + + @Volatile private var chatOpenTraceStartedAtMs: Long = 0L + @OptIn(FlowPreview::class) val messagesWithDates: StateFlow>> = _messages @@ -159,7 +184,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { incoming = rawMessages ) normalizedMessagesDescCache = normalized - buildMessagesWithDateHeaders(normalized) + val withDates = buildMessagesWithDateHeaders(normalized) + updateChatOpenMetricsCounts( + messagesCount = rawMessages.size, + messagesWithDatesCount = withDates.size + ) + if (rawMessages.isNotEmpty()) { + markFirstMessagesLoadedIfNeeded(rawMessages.size) + } + if (withDates.isNotEmpty()) { + markMessagesWithDatesReadyIfNeeded(withDates.size) + } + withDates } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) @@ -179,7 +215,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val typingDisplayName: StateFlow = _typingDisplayName.asStateFlow() private val _typingDisplayPublicKey = MutableStateFlow("") val typingDisplayPublicKey: StateFlow = _typingDisplayPublicKey.asStateFlow() - private var typingTimeoutJob: kotlinx.coroutines.Job? = null + private var typingSnapshotJob: kotlinx.coroutines.Job? = null private var typingNameResolveJob: kotlinx.coroutines.Job? = null @Volatile private var typingSenderPublicKey: String? = null @Volatile private var typingUsersCount: Int = 1 @@ -190,6 +226,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private val _opponentLastSeen = MutableStateFlow(0L) val opponentLastSeen: StateFlow = _opponentLastSeen.asStateFlow() + private var opponentOnlineStatusJob: Job? = null // Input state private val _inputText = MutableStateFlow("") @@ -231,9 +268,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private var currentOffset = 0 private var hasMoreMessages = true private var isLoadingMessages = false + private val initialPageSize: Int by lazy { + when (DevicePerformanceClass.get(getApplication())) { + PerformanceClass.LOW -> INITIAL_PAGE_SIZE_LOW + PerformanceClass.AVERAGE -> INITIAL_PAGE_SIZE_AVERAGE + PerformanceClass.HIGH -> INITIAL_PAGE_SIZE_HIGH + } + } // Защита от двойной отправки private var isSending = false + private var pendingTextSendRequested = false + private var pendingTextSendReason: String = "" + private var pendingSendRecoveryJob: Job? = null // 🔥 Throttling перенесён в глобальный MessageThrottleManager // Job для отмены загрузки при смене диалога @@ -248,6 +295,136 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Отслеживание прочитанных сообщений - храним timestamp последнего прочитанного private var lastReadMessageTimestamp = 0L + private fun chatTraceElapsedMs(): Long = + if (chatOpenTraceStartedAtMs <= 0L) 0L + else (SystemClock.elapsedRealtime() - chatOpenTraceStartedAtMs).coerceAtLeast(0L) + + private fun shortTraceKey(value: String, maxLength: Int = 14): String = + if (value.length <= maxLength) value else value.take(maxLength) + + @Synchronized + private fun appendChatOpenTraceEvent(event: String, details: String = "") { + val traceLine = + buildString { + append("[+") + append(chatTraceElapsedMs()) + append("ms] ") + append(event) + if (details.isNotBlank()) { + append(" ") + append(details) + } + } + _chatOpenMetrics.update { current -> + val trimmedLog = + if (current.eventLog.size >= 120) current.eventLog.takeLast(119) + else current.eventLog + current.copy(eventLog = trimmedLog + traceLine) + } + } + + @Synchronized + private fun resetChatOpenTrace(dialogKey: String, reason: String) { + chatOpenTraceStartedAtMs = SystemClock.elapsedRealtime() + _chatOpenMetrics.value = + ChatOpenMetricsSnapshot( + chat = dialogKey, + messages = 0, + messagesWithDates = 0, + isLoading = false, + eventLog = emptyList() + ) + appendChatOpenTraceEvent("trace_reset:", reason) + appendChatOpenTraceEvent("dialog_open_started", "user=${shortTraceKey(dialogKey)}") + } + + fun addChatOpenTraceEvent(event: String, details: String = "") { + appendChatOpenTraceEvent(event, details) + } + + private fun updateChatOpenMetricsCounts(messagesCount: Int, messagesWithDatesCount: Int) { + _chatOpenMetrics.update { current -> + current.copy( + messages = messagesCount, + messagesWithDates = messagesWithDatesCount, + isLoading = _isLoading.value + ) + } + } + + @Synchronized + private fun markFirstMessagesLoadedIfNeeded(messagesCount: Int) { + val current = _chatOpenMetrics.value + if (current.firstMessagesMs == null) { + val elapsed = chatTraceElapsedMs() + _chatOpenMetrics.update { + it.copy( + messages = messagesCount, + firstMessagesMs = elapsed + ) + } + appendChatOpenTraceEvent("first_messages_loaded", "count=$messagesCount") + } else if (current.messages != messagesCount) { + _chatOpenMetrics.update { it.copy(messages = messagesCount) } + } + } + + @Synchronized + private fun markMessagesWithDatesReadyIfNeeded(messagesWithDatesCount: Int) { + val current = _chatOpenMetrics.value + if (current.messagesWithDatesReadyMs == null) { + val elapsed = chatTraceElapsedMs() + _chatOpenMetrics.update { + it.copy( + messagesWithDates = messagesWithDatesCount, + messagesWithDatesReadyMs = elapsed + ) + } + appendChatOpenTraceEvent( + "messages_with_dates_ready", + "count=$messagesWithDatesCount" + ) + } else if (current.messagesWithDates != messagesWithDatesCount) { + _chatOpenMetrics.update { it.copy(messagesWithDates = messagesWithDatesCount) } + } + } + + @Synchronized + private fun markOpenDialogFinishedIfNeeded() { + val current = _chatOpenMetrics.value + if (current.openDialogMs == null) { + _chatOpenMetrics.update { it.copy(openDialogMs = chatTraceElapsedMs()) } + } + } + + @Synchronized + private fun markLoadingFinishedIfNeeded() { + val current = _chatOpenMetrics.value + if (current.loadingFinishedMs == null) { + val elapsed = chatTraceElapsedMs() + _chatOpenMetrics.update { it.copy(loadingFinishedMs = elapsed, isLoading = false) } + appendChatOpenTraceEvent("loading_finished") + } + } + + @Synchronized + fun markFirstListLayoutReady() { + val current = _chatOpenMetrics.value + if (current.firstListLayoutMs == null && current.messagesWithDates > 0) { + val elapsed = chatTraceElapsedMs() + _chatOpenMetrics.update { it.copy(firstListLayoutMs = elapsed) } + appendChatOpenTraceEvent("first_list_layout_ready") + } + } + + private fun setLoading(value: Boolean) { + _isLoading.value = value + _chatOpenMetrics.update { it.copy(isLoading = value) } + if (!value) { + markLoadingFinishedIfNeeded() + } + } + private fun sortMessagesAscending(messages: List): List = messages.sortedWith(chatMessageAscComparator) @@ -353,58 +530,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🟢 Флаг что уже подписаны на онлайн статус собеседника private var subscribedToOnlineStatus = false - // 🔥 Сохраняем ссылки на обработчики для очистки в onCleared() - // ВАЖНО: Должны быть определены ДО init блока! - private val typingPacketHandler: (Packet) -> Unit = typingPacketHandler@{ packet -> - val typingPacket = packet as PacketTyping - val currentDialog = opponentKey?.trim().orEmpty() - val currentAccount = myPublicKey?.trim().orEmpty() - if (currentDialog.isBlank() || currentAccount.isBlank()) { - return@typingPacketHandler - } - - val fromPublicKey = typingPacket.fromPublicKey.trim() - val toPublicKey = typingPacket.toPublicKey.trim() - if (fromPublicKey.isBlank() || toPublicKey.isBlank()) { - return@typingPacketHandler - } - if (fromPublicKey.equals(currentAccount, ignoreCase = true)) { - return@typingPacketHandler - } - - val shouldShowTyping = - if (isGroupDialogKey(currentDialog)) { - normalizeGroupId(toPublicKey).equals(normalizeGroupId(currentDialog), ignoreCase = true) - } else { - fromPublicKey.equals(currentDialog, ignoreCase = true) && - toPublicKey.equals(currentAccount, ignoreCase = true) - } - - if (shouldShowTyping) { - if (isGroupDialogKey(currentDialog)) { - val typingUsers = ProtocolManager.getTypingUsersForDialog(currentDialog).toMutableSet() - typingUsers.add(fromPublicKey) - showTypingIndicator( - senderPublicKey = fromPublicKey, - typingUsersCount = typingUsers.size - ) - } else { - showTypingIndicator() - } - } - } - - private val onlinePacketHandler: (Packet) -> Unit = { packet -> - val onlinePacket = packet as PacketOnlineState - onlinePacket.publicKeysState.forEach { item -> - if (item.publicKey == opponentKey) { - _opponentOnline.value = item.state == OnlineState.ONLINE - } - } - } - init { - setupPacketListeners() + setupTypingStateObserver() setupNewMessageListener() setupDeliveryStatusListener() } @@ -579,15 +706,100 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { updateCacheFromCurrentMessages() } - private fun setupPacketListeners() { - // ✅ Обработчики 0x06, 0x07, 0x08 обрабатываются в ProtocolManager → MessageRepository - // ChatViewModel получает обновления через deliveryStatusEvents SharedFlow + private fun setupTypingStateObserver() { + typingSnapshotJob?.cancel() + typingSnapshotJob = + viewModelScope.launch { + ProtocolManager.typingUsersByDialogSnapshot.collect { snapshot -> + val currentDialog = opponentKey?.trim().orEmpty() + val currentAccount = myPublicKey?.trim().orEmpty() - // Typing - нужен здесь для UI текущего чата - ProtocolManager.waitPacket(0x0B, typingPacketHandler) + if (currentDialog.isBlank()) { + clearTypingIndicatorState() + return@collect + } - // 🟢 Онлайн статус - нужен здесь для UI текущего чата - ProtocolManager.waitPacket(0x05, onlinePacketHandler) + val activeTypingUsers = + resolveTypingUsersForCurrentDialog(snapshot, currentDialog) + .asSequence() + .map { it.trim() } + .filter { it.isNotBlank() } + .filterNot { + currentAccount.isNotBlank() && + it.equals(currentAccount, ignoreCase = true) + } + .distinct() + .sorted() + .toList() + + if (activeTypingUsers.isEmpty()) { + clearTypingIndicatorState() + return@collect + } + + if (isGroupDialogKey(currentDialog)) { + showTypingIndicator( + senderPublicKey = activeTypingUsers.first(), + typingUsersCount = activeTypingUsers.size + ) + } else { + showTypingIndicator() + } + } + } + } + + private fun resolveTypingUsersForCurrentDialog( + snapshot: Map>, + dialogPublicKey: String + ): Set { + val normalizedDialog = dialogPublicKey.trim() + if (normalizedDialog.isBlank()) return emptySet() + + return if (isGroupDialogKey(normalizedDialog)) { + val groupId = normalizeGroupId(normalizedDialog) + snapshot.entries.firstOrNull { (key, _) -> + isGroupDialogKey(key) && + normalizeGroupId(key).equals(groupId, ignoreCase = true) + }?.value ?: emptySet() + } else { + snapshot.entries.firstOrNull { (key, _) -> + !isGroupDialogKey(key) && key.equals(normalizedDialog, ignoreCase = true) + }?.value ?: emptySet() + } + } + + private fun observeOpponentOnlineStatus() { + opponentOnlineStatusJob?.cancel() + + val account = myPublicKey?.trim().orEmpty() + val opponent = opponentKey?.trim().orEmpty() + if ( + account.isBlank() || + opponent.isBlank() || + account.equals(opponent, ignoreCase = true) || + isGroupDialogKey(opponent) + ) { + _opponentOnline.value = false + _opponentLastSeen.value = 0L + return + } + + opponentOnlineStatusJob = + viewModelScope.launch { + messageRepository.observeUserOnlineStatus(opponent).collect { (isOnline, lastSeen) -> + val currentAccount = myPublicKey?.trim().orEmpty() + val currentDialog = opponentKey?.trim().orEmpty() + if ( + !currentAccount.equals(account, ignoreCase = true) || + !currentDialog.equals(opponent, ignoreCase = true) + ) { + return@collect + } + _opponentOnline.value = isOnline + _opponentLastSeen.value = lastSeen + } + } } // ✅ handleIncomingMessage удалён - обработка входящих сообщений теперь ТОЛЬКО в @@ -765,7 +977,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** Установить ключи пользователя */ fun setUserKeys(publicKey: String, privateKey: String) { - if (myPublicKey != publicKey) { + val normalizedPublicKey = publicKey.trim() + val normalizedPrivateKey = privateKey.trim() + + if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) { + // Never keep empty keys in memory. Empty sender/private key leads to + // broken outgoing packets (e.g. from=) until next app relog. + if (!myPublicKey.isNullOrBlank() || !myPrivateKey.isNullOrBlank()) { + dialogMessagesCache.clear() + decryptionCache.clear() + groupKeyCache.clear() + groupSenderNameCache.clear() + groupSenderResolveRequested.clear() + } + myPublicKey = null + myPrivateKey = null + return + } + + if (myPublicKey != normalizedPublicKey) { // Clear caches on account switch to prevent cross-account data leakage // Безусловная очистка (даже если myPublicKey == null) — свежий ViewModel // может получить стейтный кэш от предыдущего аккаунта @@ -775,8 +1005,37 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { groupSenderNameCache.clear() groupSenderResolveRequested.clear() } - myPublicKey = publicKey - myPrivateKey = privateKey + myPublicKey = normalizedPublicKey + myPrivateKey = normalizedPrivateKey + if (!opponentKey.isNullOrBlank()) { + observeOpponentOnlineStatus() + } + triggerPendingTextSendIfReady("keys_updated") + } + + /** + * Lightweight binding used by input send actions. + * Unlike openDialog(), this does not reset messages/input and is safe right before send. + */ + fun ensureSendContext(publicKey: String, title: String = "", username: String = "", verified: Int = 0) { + val normalizedPublicKey = publicKey.trim() + if (normalizedPublicKey.isBlank()) return + + val currentDialogKey = opponentKey?.trim().orEmpty() + if (currentDialogKey.equals(normalizedPublicKey, ignoreCase = true)) { + triggerPendingTextSendIfReady("send_context_ready") + return + } + + opponentKey = normalizedPublicKey + opponentTitle = title + opponentUsername = username + opponentVerified = verified.coerceAtLeast(0) + if (!isGroupDialogKey(normalizedPublicKey)) { + groupKeyCache.remove(normalizedPublicKey) + } + ProtocolManager.addLog("🔧 SEND_CONTEXT opponent=${shortSendKey(normalizedPublicKey)}") + triggerPendingTextSendIfReady("send_context_bound") } /** Открыть диалог */ @@ -789,6 +1048,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Отменяем предыдущую загрузку loadingJob?.cancel() + resetChatOpenTrace(publicKey, reason = "dialog_enter") + appendChatOpenTraceEvent("open_dialog_requested") opponentKey = publicKey opponentTitle = title @@ -819,20 +1080,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (cachedMessages != null && cachedMessages.isNotEmpty()) { // Мгновенная подстановка из кэша — пользователь не увидит пустой экран _messages.value = cachedMessages - _isLoading.value = false + setLoading(false) } else { // Нет кэша — показываем skeleton вместо пустого Lottie - _isLoading.value = true + setLoading(true) _messages.value = emptyList() } _opponentOnline.value = false - _opponentTyping.value = false - _typingDisplayName.value = "" - _typingDisplayPublicKey.value = "" - typingSenderPublicKey = null - typingUsersCount = 1 - typingTimeoutJob?.cancel() - typingNameResolveJob?.cancel() + _opponentLastSeen.value = 0L + clearTypingIndicatorState() currentOffset = 0 hasMoreMessages = true isLoadingMessages = false @@ -874,6 +1130,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Подписываемся на онлайн статус subscribeToOnlineStatus() + observeOpponentOnlineStatus() // 📌 Подписываемся на pinned messages pinnedCollectionJob?.cancel() @@ -888,6 +1145,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } + markOpenDialogFinishedIfNeeded() + + // First-run auth race: if user tapped send before dialog binding finished, + // flush pending send as soon as dialog context becomes valid. + triggerPendingTextSendIfReady("open_dialog") + // � P1.2: Загружаем сообщения СРАЗУ — параллельно с анимацией SwipeBackContainer loadMessagesFromDatabase(delayMs = 0L) } @@ -898,7 +1161,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { */ fun closeDialog() { isDialogActive = false - typingTimeoutJob?.cancel() + clearTypingIndicatorState() + opponentOnlineStatusJob?.cancel() } /** @@ -918,6 +1182,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val account = myPublicKey ?: return val opponent = opponentKey ?: return val dialogKey = getDialogKey(account, opponent) + appendChatOpenTraceEvent("load_messages_started", "pageSize=$initialPageSize") // 📁 Проверяем является ли это Saved Messages val isSavedMessages = (opponent == account) @@ -936,7 +1201,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { _groupRequiresRejoin.value = !hasGroupKey if (!hasGroupKey) { _messages.value = emptyList() - _isLoading.value = false + setLoading(false) } } if (!hasGroupKey) { @@ -955,7 +1220,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (cachedMessages != null && cachedMessages.isNotEmpty()) { withContext(Dispatchers.Main.immediate) { _messages.value = cachedMessages - _isLoading.value = false + setLoading(false) } // Фоновое обновление из БД (новые сообщения) @@ -984,7 +1249,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Пустой диалог - сразу показываем пустое состояние без скелетона withContext(Dispatchers.Main.immediate) { _messages.value = emptyList() - _isLoading.value = false + setLoading(false) } isLoadingMessages = false return@launch @@ -994,7 +1259,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // анимации if (delayMs > 0) { withContext(Dispatchers.Main.immediate) { - _isLoading.value = true // Показываем скелетон + setLoading(true) // Показываем скелетон } delay(delayMs) } @@ -1005,19 +1270,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (isSavedMessages) { messageDao.getMessagesForSavedDialog( account, - limit = PAGE_SIZE, + limit = initialPageSize, offset = 0 ) } else { messageDao.getMessages( account, dialogKey, - limit = PAGE_SIZE, + limit = initialPageSize, offset = 0 ) } - hasMoreMessages = entities.size >= PAGE_SIZE + hasMoreMessages = entities.size >= initialPageSize currentOffset = entities.size // � P2.2: ПАРАЛЛЕЛЬНАЯ расшифровка с Semaphore @@ -1055,7 +1320,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (deduplicatedList.size != newList.size) {} _messages.value = deduplicatedList - _isLoading.value = false + setLoading(false) } // 🔥 Фоновые операции БЕЗ блокировки UI @@ -1086,7 +1351,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isLoadingMessages = false } catch (e: Exception) { - withContext(Dispatchers.Main.immediate) { _isLoading.value = false } + withContext(Dispatchers.Main.immediate) { setLoading(false) } isLoadingMessages = false } } @@ -1108,9 +1373,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { try { val entities = if (isSavedMessages) { - messageDao.getMessagesForSavedDialog(account, limit = PAGE_SIZE, offset = 0) + messageDao.getMessagesForSavedDialog(account, limit = initialPageSize, offset = 0) } else { - messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0) + messageDao.getMessages(account, dialogKey, limit = initialPageSize, offset = 0) } // 🔥 Берём ТЕКУЩЕЕ состояние UI (может уже содержать новые сообщения) @@ -1167,7 +1432,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { withContext(Dispatchers.Main.immediate) { _messages.value = updatedMessages } } - hasMoreMessages = entities.size >= PAGE_SIZE + hasMoreMessages = entities.size >= initialPageSize // 🔥 ИСПРАВЛЕНИЕ: НЕ сбрасываем offset если уже загружено больше сообщений! // Это предотвращает потерю прогресса пагинации при refresh if (currentOffset < entities.size) { @@ -1220,19 +1485,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (isSavedMessages) { messageDao.getMessagesForSavedDialog( account, - limit = PAGE_SIZE, + limit = PAGINATION_PAGE_SIZE, offset = currentOffset ) } else { messageDao.getMessages( account, dialogKey, - limit = PAGE_SIZE, + limit = PAGINATION_PAGE_SIZE, offset = currentOffset ) } - hasMoreMessages = entities.size >= PAGE_SIZE + hasMoreMessages = entities.size >= PAGINATION_PAGE_SIZE currentOffset += entities.size if (entities.isNotEmpty()) { @@ -3295,45 +3560,204 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) } - fun sendMessage() { - val text = _inputText.value.trim() - val recipient = opponentKey - val sender = myPublicKey - val privateKey = myPrivateKey - val replyMsgs = _replyMessages.value - val replyMsgsToSend = replyMsgs.toList() - val isForward = _isForwardMode.value + private data class SendCommand( + val messageId: String, + val timestamp: Long, + val text: String, + val replyMessages: List, + val isForward: Boolean, + val senderPublicKey: String, + val senderPrivateKey: String, + val recipientPublicKey: String + ) { + val dialogThrottleKey: String + get() = "$senderPublicKey:$recipientPublicKey" + } - // Разрешаем отправку пустого текста если есть reply/forward - if (text.isEmpty() && replyMsgs.isEmpty()) { + fun sendMessage() { + trySendMessage(allowPendingRecovery = true) + } + + private fun shortSendKey(value: String?): String { + val normalized = value?.trim().orEmpty() + if (normalized.isEmpty()) return "" + return if (normalized.length <= 12) normalized else "${normalized.take(12)}…" + } + + private fun logSendBlocked( + reason: String, + textLength: Int, + hasReply: Boolean, + recipient: String?, + sender: String?, + hasPrivateKey: Boolean + ) { + ProtocolManager.addLog( + "⚠️ SEND_BLOCKED reason=$reason textLen=$textLength hasReply=$hasReply recipient=${shortSendKey(recipient)} sender=${shortSendKey(sender)} hasPriv=$hasPrivateKey isSending=$isSending" + ) + } + + private fun recoverRuntimeKeysIfMissing(): Boolean { + val hasKeysInViewModel = !myPublicKey.isNullOrBlank() && !myPrivateKey.isNullOrBlank() + if (hasKeysInViewModel) return true + + val repositoryPublicKey = messageRepository.getCurrentAccountKey()?.trim().orEmpty() + val repositoryPrivateKey = messageRepository.getCurrentPrivateKey()?.trim().orEmpty() + + if (repositoryPublicKey.isNotEmpty() && repositoryPrivateKey.isNotEmpty()) { + setUserKeys(repositoryPublicKey, repositoryPrivateKey) + ProtocolManager.addLog( + "🔄 SEND_RECOVERY restored_keys pk=${shortSendKey(repositoryPublicKey)}" + ) + } + + return !myPublicKey.isNullOrBlank() && !myPrivateKey.isNullOrBlank() + } + + private fun schedulePendingTextSendRecovery(reason: String, hasPayload: Boolean) { + if (!hasPayload) return + pendingTextSendRequested = true + pendingTextSendReason = reason + + if (pendingSendRecoveryJob?.isActive == true) return + + ProtocolManager.addLog("⏳ SEND_RECOVERY queued reason=$reason") + pendingSendRecoveryJob = + viewModelScope.launch { + repeat(10) { attempt -> + delay(if (attempt < 4) 180L else 350L) + recoverRuntimeKeysIfMissing() + triggerPendingTextSendIfReady("timer_${attempt + 1}") + if (!pendingTextSendRequested) return@launch + } + + if (pendingTextSendRequested) { + ProtocolManager.addLog("⚠️ SEND_RECOVERY timeout reason=$pendingTextSendReason") + } + pendingTextSendRequested = false + pendingTextSendReason = "" + pendingSendRecoveryJob = null + } + } + + private fun triggerPendingTextSendIfReady(trigger: String) { + if (!pendingTextSendRequested) return + + val hasPayload = _inputText.value.trim().isNotEmpty() || _replyMessages.value.isNotEmpty() + if (!hasPayload) { + pendingTextSendRequested = false + pendingTextSendReason = "" + pendingSendRecoveryJob?.cancel() + pendingSendRecoveryJob = null return } + + val recipientReady = !opponentKey.isNullOrBlank() + val keysReady = !myPublicKey.isNullOrBlank() && !myPrivateKey.isNullOrBlank() + if (!recipientReady || !keysReady || isSending) return + + ProtocolManager.addLog("🚀 SEND_RECOVERY flush trigger=$trigger") + pendingTextSendRequested = false + pendingTextSendReason = "" + pendingSendRecoveryJob?.cancel() + pendingSendRecoveryJob = null + trySendMessage(allowPendingRecovery = false) + } + + private fun trySendMessage(allowPendingRecovery: Boolean) { + val text = _inputText.value.trim() + val replyMsgsToSend = _replyMessages.value.toList() + val isForward = _isForwardMode.value + val hasPayload = text.isNotEmpty() || replyMsgsToSend.isNotEmpty() + + // Разрешаем отправку пустого текста если есть reply/forward + if (!hasPayload) { + return + } + + if (myPublicKey.isNullOrBlank() || myPrivateKey.isNullOrBlank()) { + recoverRuntimeKeysIfMissing() + } + + val recipient = opponentKey?.trim()?.takeIf { it.isNotEmpty() } + val sender = myPublicKey?.trim()?.takeIf { it.isNotEmpty() } + val privateKey = myPrivateKey?.trim()?.takeIf { it.isNotEmpty() } + if (recipient == null) { + logSendBlocked( + reason = "no_dialog", + textLength = text.length, + hasReply = replyMsgsToSend.isNotEmpty(), + recipient = null, + sender = sender, + hasPrivateKey = privateKey != null + ) + if (allowPendingRecovery) { + schedulePendingTextSendRecovery(reason = "no_dialog", hasPayload = hasPayload) + } return } if (sender == null || privateKey == null) { + logSendBlocked( + reason = "no_keys", + textLength = text.length, + hasReply = replyMsgsToSend.isNotEmpty(), + recipient = recipient, + sender = sender, + hasPrivateKey = privateKey != null + ) + if (allowPendingRecovery) { + schedulePendingTextSendRecovery(reason = "no_keys", hasPayload = hasPayload) + } return } if (isSending) { + logSendBlocked( + reason = "is_sending", + textLength = text.length, + hasReply = replyMsgsToSend.isNotEmpty(), + recipient = recipient, + sender = sender, + hasPrivateKey = true + ) return } // 🔥 Глобальный throttle - защита от спама сообщениями (app-wide) - val dialogKey = "$sender:$recipient" - if (!MessageThrottleManager.canSendWithContent(dialogKey, text.hashCode())) { + val command = + SendCommand( + messageId = UUID.randomUUID().toString().replace("-", "").take(32), + timestamp = System.currentTimeMillis(), + text = text, + replyMessages = replyMsgsToSend, + isForward = isForward, + senderPublicKey = sender, + senderPrivateKey = privateKey, + recipientPublicKey = recipient + ) + + if (!MessageThrottleManager.canSendWithContent(command.dialogThrottleKey, command.text.hashCode())) { + logSendBlocked( + reason = "throttle", + textLength = command.text.length, + hasReply = command.replyMessages.isNotEmpty(), + recipient = command.recipientPublicKey, + sender = command.senderPublicKey, + hasPrivateKey = true + ) return } isSending = true - val messageId = UUID.randomUUID().toString().replace("-", "").take(32) - val timestamp = System.currentTimeMillis() + val messageId = command.messageId + val timestamp = command.timestamp // 🔥 Формируем ReplyData для отображения в UI (только первое сообщение) // Используется для обычного reply (не forward). val replyData: ReplyData? = - if (replyMsgsToSend.isNotEmpty()) { - val firstReply = replyMsgsToSend.first() + if (command.replyMessages.isNotEmpty()) { + val firstReply = command.replyMessages.first() // 🖼️ Получаем attachments из текущих сообщений для превью // Fallback на firstReply.attachments для forward из другого чата val replyAttachments = @@ -3349,23 +3773,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { senderName = firstReplySenderName, text = resolveReplyPreviewText(firstReply.text, replyAttachments), isFromMe = firstReply.isOutgoing, - isForwarded = isForward, + isForwarded = command.isForward, forwardedFromName = - if (isForward) firstReplySenderName else "", + if (command.isForward) firstReplySenderName else "", attachments = replyAttachments, senderPublicKey = firstReply.publicKey.ifEmpty { - if (firstReply.isOutgoing) myPublicKey ?: "" - else opponentKey ?: "" + if (firstReply.isOutgoing) command.senderPublicKey + else command.recipientPublicKey }, - recipientPrivateKey = myPrivateKey ?: "" + recipientPrivateKey = command.senderPrivateKey ) } else null // 📨 В forward режиме показываем ВСЕ пересылаемые сообщения в optimistic bubble, // а не только первое. Иначе визуально выглядит как будто отправилось одно сообщение. val optimisticForwardedMessages: List = - if (isForward && replyMsgsToSend.isNotEmpty()) { - replyMsgsToSend.map { msg -> + if (command.isForward && command.replyMessages.isNotEmpty()) { + command.replyMessages.map { msg -> val senderDisplayName = if (msg.isOutgoing) "You" else msg.senderName.ifEmpty { @@ -3383,9 +3807,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { forwardedFromName = senderDisplayName, attachments = resolvedAttachments, senderPublicKey = msg.publicKey.ifEmpty { - if (msg.isOutgoing) myPublicKey ?: "" else opponentKey ?: "" + if (msg.isOutgoing) command.senderPublicKey + else command.recipientPublicKey }, - recipientPrivateKey = myPrivateKey ?: "" + recipientPrivateKey = command.senderPrivateKey ) } } else { @@ -3393,13 +3818,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } // Сохраняем режим forward для отправки ПЕРЕД очисткой - val isForwardToSend = isForward + val isForwardToSend = command.isForward // 1. 🚀 Optimistic UI - мгновенно показываем сообщение с reply bubble val optimisticMessage = ChatMessage( id = messageId, - text = text, // Только основной текст, без prefix + text = command.text, // Только основной текст, без prefix isOutgoing = true, timestamp = Date(timestamp), status = MessageStatus.SENDING, @@ -3412,37 +3837,37 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { _inputText.value = "" // � Очищаем черновик после отправки - opponentKey?.let { com.rosetta.messenger.data.DraftManager.clearDraft(it) } + com.rosetta.messenger.data.DraftManager.clearDraft(command.recipientPublicKey) // �🔥 Очищаем reply после отправки - данные сохраняются в displayReplyMessages для анимации clearReplyMessages() // Кэшируем текст - decryptionCache[messageId] = text + decryptionCache[messageId] = command.text // 2. 🔥 Шифрование и отправка в IO потоке viewModelScope.launch(Dispatchers.IO) { try { val encryptionContext = buildEncryptionContext( - plaintext = text, - recipient = recipient, - privateKey = privateKey + plaintext = command.text, + recipient = command.recipientPublicKey, + privateKey = command.senderPrivateKey ) ?: throw IllegalStateException("Cannot resolve chat encryption context") val encryptedContent = encryptionContext.encryptedContent val encryptedKey = encryptionContext.encryptedKey val aesChachaKey = encryptionContext.aesChachaKey - val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) + val privateKeyHash = CryptoManager.generatePrivateKeyHash(command.senderPrivateKey) // 🔥 Формируем attachments с reply (как в React Native) val messageAttachments = mutableListOf() var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом) - val isSavedMessages = (sender == recipient) + val isSavedMessages = (command.senderPublicKey == command.recipientPublicKey) val forwardSources = - if (isForwardToSend && replyMsgsToSend.isNotEmpty()) { - replyMsgsToSend.map { msg -> + if (isForwardToSend && command.replyMessages.isNotEmpty()) { + command.replyMessages.map { msg -> ForwardSourceMessage( messageId = msg.messageId, senderPublicKey = msg.publicKey, @@ -3458,7 +3883,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { context = getApplication(), sourceMessages = forwardSources, encryptionContext = encryptionContext, - privateKey = privateKey, + privateKey = command.senderPrivateKey, isSavedMessages = isSavedMessages, timestamp = timestamp ) @@ -3469,11 +3894,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ?.joinToString("") { "%02x".format(it) } .orEmpty() - if (replyMsgsToSend.isNotEmpty()) { + if (command.replyMessages.isNotEmpty()) { // Формируем JSON массив с цитируемыми сообщениями (как в Desktop) val replyJsonArray = JSONArray() - replyMsgsToSend.forEach { msg -> + command.replyMessages.forEach { msg -> val attachmentsArray = JSONArray() msg.attachments.forEach { att -> // Для forward IMAGE: подставляем НОВЫЙ id/preview/transport. @@ -3548,7 +3973,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext) replyBlobForDatabase = - CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey) + CryptoManager.encryptWithPassword(replyBlobPlaintext, command.senderPrivateKey) val replyAttachmentId = "reply_${timestamp}" messageAttachments.add( @@ -3563,8 +3988,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val packet = PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient + fromPublicKey = command.senderPublicKey + toPublicKey = command.recipientPublicKey content = encryptedContent chachaKey = encryptedKey this.aesChachaKey = aesChachaKey @@ -3579,7 +4004,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 📁 Для Saved Messages - НЕ отправляем пакет на сервер if (!isSavedMessages) { - ProtocolManager.send(packet) + ProtocolManager.sendMessageWithRetry(packet) } withContext(Dispatchers.Main) { @@ -3633,7 +4058,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (encryptionContext.isGroup) { buildStoredGroupKey( encryptionContext.attachmentPassword, - privateKey + command.senderPrivateKey ) } else { encryptedKey @@ -3643,18 +4068,34 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { delivered = if (isSavedMessages) 1 else 0, // 📁 Saved Messages: сразу DELIVERED (1), иначе SENDING (0) - attachmentsJson = attachmentsJson + attachmentsJson = attachmentsJson, + accountPublicKey = command.senderPublicKey, + accountPrivateKey = command.senderPrivateKey, + opponentPublicKey = command.recipientPublicKey ) - saveDialog(text, timestamp) + saveDialog( + lastMessage = command.text, + timestamp = timestamp, + accountPublicKey = command.senderPublicKey, + accountPrivateKey = command.senderPrivateKey, + opponentPublicKey = command.recipientPublicKey + ) } catch (e: Exception) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) - saveDialog(text, timestamp) + saveDialog( + lastMessage = command.text, + timestamp = timestamp, + accountPublicKey = command.senderPublicKey, + accountPrivateKey = command.senderPrivateKey, + opponentPublicKey = command.recipientPublicKey + ) } finally { isSending = false + triggerPendingTextSendIfReady("send_finished") } } } @@ -3869,6 +4310,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isFromMe = true, delivered = if (isSavedMessages) 1 else 0, attachmentsJson = optimisticAttachmentsJson, + accountPublicKey = sender, + accountPrivateKey = privateKey, opponentPublicKey = recipientPublicKey ) refreshTargetDialog() @@ -3913,7 +4356,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { attachments = finalMessageAttachments } if (!isSavedMessages) { - ProtocolManager.send(packet) + ProtocolManager.sendMessageWithRetry(packet) } val finalAttachmentsJson = @@ -4064,6 +4507,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isFromMe = true, delivered = 0, attachmentsJson = optimisticAttachmentsJson, + accountPublicKey = sender, + accountPrivateKey = privateKey, opponentPublicKey = recipient ) @@ -4072,6 +4517,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { saveDialog( lastMessage = if (text.isNotEmpty()) text else "photo", timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = privateKey, opponentPublicKey = recipient ) logPhotoPipeline(messageId, "optimistic dialog updated") @@ -4274,7 +4721,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Отправляем пакет if (!isSavedMessages) { - ProtocolManager.send(packet) + ProtocolManager.sendMessageWithRetry(packet) packetSentToProtocol = true logPhotoPipeline(messageId, "packet sent to protocol") } else { @@ -4329,6 +4776,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { saveDialog( lastMessage = if (caption.isNotEmpty()) caption else "photo", timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = privateKey, opponentPublicKey = recipient ) logPhotoPipeline( @@ -4472,7 +4921,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Отправляем пакет (без blob!) if (!isSavedMessages) { - ProtocolManager.send(packet) + ProtocolManager.sendMessageWithRetry(packet) } // 💾 Сохраняем изображение в файл локально (как в desktop) @@ -4521,6 +4970,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isFromMe = true, delivered = if (isSavedMessages) 1 else 0, // SENDING для обычных attachmentsJson = attachmentsJson, + accountPublicKey = sender, + accountPrivateKey = privateKey, opponentPublicKey = recipient ) @@ -4532,6 +4983,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { saveDialog( lastMessage = if (text.isNotEmpty()) text else "photo", timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = privateKey, opponentPublicKey = recipient ) } catch (e: Exception) { @@ -4647,12 +5100,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isFromMe = true, delivered = 0, attachmentsJson = optimisticAttachmentsJson, + accountPublicKey = sender, + accountPrivateKey = privateKey, opponentPublicKey = recipient ) saveDialog( lastMessage = if (text.isNotEmpty()) text else "📷 ${imageUris.size} photos", timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = privateKey, opponentPublicKey = recipient ) } catch (_: Exception) { @@ -4796,7 +5253,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } if (!isSavedMessages) { - ProtocolManager.send(packet) + ProtocolManager.sendMessageWithRetry(packet) } updateMessageStatusAndAttachmentsInDb( @@ -4833,6 +5290,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { saveDialog( lastMessage = if (text.isNotEmpty()) text else "📷 ${imageUris.size} photos", timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = privateKey, opponentPublicKey = recipient ) logPhotoPipeline( @@ -5016,7 +5475,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Для Saved Messages не отправляем на сервер val isSavedMessages = (sender == recipient) if (!isSavedMessages) { - ProtocolManager.send(packet) + ProtocolManager.sendMessageWithRetry(packet) logPhotoPipeline(messageId, "group packet sent") } @@ -5038,6 +5497,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isFromMe = true, delivered = if (isSavedMessages) 1 else 0, attachmentsJson = attachmentsJsonArray.toString(), + accountPublicKey = sender, + accountPrivateKey = privateKey, opponentPublicKey = recipient ) @@ -5049,6 +5510,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { saveDialog( lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos", timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = privateKey, opponentPublicKey = recipient ) logPhotoPipeline( @@ -5194,7 +5657,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Отправляем пакет (без blob!) if (!isSavedMessages) { - ProtocolManager.send(packet) + ProtocolManager.sendMessageWithRetry(packet) } // ⚠️ НЕ сохраняем файл локально - они слишком большие @@ -5234,7 +5697,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { delivered = if (isSavedMessages) 1 else 0, // SENDING для обычных, DELIVERED для saved - attachmentsJson = attachmentsJson + attachmentsJson = attachmentsJson, + accountPublicKey = sender, + accountPrivateKey = privateKey, + opponentPublicKey = recipient ) // Для обычных чатов статус подтверждаем только по PacketDelivery(messageId). @@ -5244,11 +5710,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } - saveDialog(if (text.isNotEmpty()) text else "file", timestamp) + saveDialog( + lastMessage = if (text.isNotEmpty()) text else "file", + timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = privateKey, + opponentPublicKey = recipient + ) } catch (e: Exception) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) - saveDialog(if (text.isNotEmpty()) text else "file", timestamp) + saveDialog( + lastMessage = if (text.isNotEmpty()) text else "file", + timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = privateKey, + opponentPublicKey = recipient + ) } finally { isSending = false } @@ -5431,9 +5909,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isFromMe = true, delivered = 0, attachmentsJson = optimisticAttachmentsJson, + accountPublicKey = sender, + accountPrivateKey = privateKey, + opponentPublicKey = recipient + ) + saveDialog( + lastMessage = "Video message", + timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = privateKey, opponentPublicKey = recipient ) - saveDialog("Video message", timestamp, opponentPublicKey = recipient) } catch (_: Exception) { } @@ -5537,7 +6023,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } if (!isSavedMessages) { - ProtocolManager.send(packet) + ProtocolManager.sendMessageWithRetry(packet) packetSentToProtocol = true } @@ -5579,7 +6065,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { updateMessageStatus(messageId, MessageStatus.SENT) updateMessageAttachments(messageId, null) } - saveDialog("Video message", timestamp, opponentPublicKey = recipient) + saveDialog( + lastMessage = "Video message", + timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = privateKey, + opponentPublicKey = recipient + ) } catch (_: Exception) { if (packetSentToProtocol) { withContext(Dispatchers.Main) { @@ -5711,7 +6203,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } if (!isSavedMessages) { - ProtocolManager.send(packet) + ProtocolManager.sendMessageWithRetry(packet) } // Для отправителя сохраняем voice blob локально в encrypted cache. @@ -5757,7 +6249,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { timestamp = timestamp, isFromMe = true, delivered = if (isSavedMessages) 1 else 0, - attachmentsJson = attachmentsJson + attachmentsJson = attachmentsJson, + accountPublicKey = sender, + accountPrivateKey = privateKey, + opponentPublicKey = recipient ) withContext(Dispatchers.Main) { @@ -5766,13 +6261,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } - saveDialog("Voice message", timestamp) + saveDialog( + lastMessage = "Voice message", + timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = privateKey, + opponentPublicKey = recipient + ) } catch (_: Exception) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) - saveDialog("Voice message", timestamp) + saveDialog( + lastMessage = "Voice message", + timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = privateKey, + opponentPublicKey = recipient + ) } finally { isSending = false } @@ -5950,7 +6457,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } if (!isSavedMessages) { - ProtocolManager.send(packet) + ProtocolManager.sendMessageWithRetry(packet) } // 💾 Сохраняем аватар в файл локально (как IMAGE - с приватным ключом) @@ -5995,7 +6502,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { timestamp = timestamp, isFromMe = true, delivered = if (isSavedMessages) 1 else 0, // Как в sendImageMessage - attachmentsJson = attachmentsJson + attachmentsJson = attachmentsJson, + accountPublicKey = sender, + accountPrivateKey = userPrivateKey, + opponentPublicKey = recipient ) // Обновляем UI: для обычных чатов остаёмся в SENDING до PacketDelivery(messageId). @@ -6005,7 +6515,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } - saveDialog("\$a=Avatar", timestamp) + saveDialog( + lastMessage = "\$a=Avatar", + timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = userPrivateKey, + opponentPublicKey = recipient + ) } catch (e: Exception) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) @@ -6017,7 +6533,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { .show() } updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) - saveDialog("\$a=Avatar", timestamp) + saveDialog( + lastMessage = "\$a=Avatar", + timestamp = timestamp, + accountPublicKey = sender, + accountPrivateKey = userPrivateKey, + opponentPublicKey = recipient + ) } finally { isSending = false } @@ -6028,9 +6550,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { * Сохранить диалог в базу данных 🔥 Используем updateDialogFromMessages для пересчета счетчиков * из messages 📁 SAVED MESSAGES: Использует специальный метод для saved messages */ - private suspend fun saveDialog(lastMessage: String, timestamp: Long, opponentPublicKey: String? = null) { - val account = myPublicKey ?: return - val opponent = opponentPublicKey ?: opponentKey ?: return + private suspend fun saveDialog( + lastMessage: String, + timestamp: Long, + accountPublicKey: String, + accountPrivateKey: String, + opponentPublicKey: String + ) { + val account = accountPublicKey.trim().takeIf { it.isNotEmpty() } ?: return + val opponent = opponentPublicKey.trim().takeIf { it.isNotEmpty() } ?: return try { // 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики @@ -6040,8 +6568,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { dialogDao.updateSavedMessagesDialogFromMessages(account) } else { dialogDao.updateDialogFromMessages(account, opponent) + dialogDao.markIHaveSent(account, opponent) } + ensureDialogVisibleAfterSend( + account = account, + opponent = opponent, + fallbackMessage = lastMessage, + timestamp = timestamp, + accountPrivateKey = accountPrivateKey + ) + if (isGroupDialogKey(opponent)) { val groupId = normalizeGroupId(opponent) val group = groupDao.getGroup(account, groupId) @@ -6066,6 +6603,109 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } catch (e: Exception) {} } + private suspend fun ensureDialogVisibleAfterSend( + account: String, + opponent: String, + fallbackMessage: String, + timestamp: Long, + accountPrivateKey: String + ) { + val existing = dialogDao.getDialog(account, opponent) + val needsFallback = + existing == null || + existing.hasContent != 1 || + (opponent != account && existing.iHaveSent != 1) || + existing.lastMessageTimestamp < timestamp + if (!needsFallback) return + + val privateKey = accountPrivateKey.trim().takeIf { it.isNotEmpty() } + val dialogKey = getDialogKey(account, opponent) + val latestMessage = runCatching { dialogDao.getLastMessageByDialogKey(account, dialogKey) }.getOrNull() + val encryptedFallback = + if (fallbackMessage.isNotBlank() && !privateKey.isNullOrBlank()) { + runCatching { CryptoManager.encryptWithPassword(fallbackMessage, privateKey) } + .getOrNull() + .orEmpty() + } else { + "" + } + + val fallbackLastMessage = + latestMessage?.plainMessage + ?.takeIf { it.isNotBlank() } + ?: existing?.lastMessage?.takeIf { it.isNotBlank() } + ?: encryptedFallback + val fallbackTimestamp = + maxOf( + timestamp, + latestMessage?.timestamp ?: 0L, + existing?.lastMessageTimestamp ?: 0L + ) + val fallbackAttachmentType = + latestMessage?.primaryAttachmentType ?: existing?.lastMessageAttachmentType ?: -1 + val fallbackAttachments = + latestMessage?.attachments?.takeIf { it.isNotBlank() } + ?: existing?.lastMessageAttachments + ?: "[]" + val fallbackFromMe = latestMessage?.fromMe ?: 1 + val fallbackDelivered = + if (fallbackFromMe == 1) { + latestMessage?.delivered ?: existing?.lastMessageDelivered ?: 0 + } else { + 0 + } + val fallbackRead = + if (fallbackFromMe == 1) { + latestMessage?.read ?: existing?.lastMessageRead ?: 0 + } else { + 0 + } + + dialogDao.insertDialog( + com.rosetta.messenger.database.DialogEntity( + id = existing?.id ?: 0L, + account = account, + opponentKey = opponent, + opponentTitle = + when { + opponent == account -> + existing?.opponentTitle?.ifBlank { "Saved Messages" } + ?: "Saved Messages" + opponentTitle.isNotBlank() -> opponentTitle + else -> existing?.opponentTitle.orEmpty() + }, + opponentUsername = + when { + opponent == account -> existing?.opponentUsername.orEmpty() + opponentUsername.isNotBlank() -> opponentUsername + else -> existing?.opponentUsername.orEmpty() + }, + lastMessage = fallbackLastMessage, + lastMessageTimestamp = fallbackTimestamp, + unreadCount = existing?.unreadCount ?: 0, + isOnline = existing?.isOnline ?: 0, + lastSeen = existing?.lastSeen ?: 0L, + verified = existing?.verified ?: 0, + iHaveSent = 1, + hasContent = 1, + lastMessageAttachmentType = fallbackAttachmentType, + lastSenderKey = latestMessage?.fromPublicKey ?: existing?.lastSenderKey.orEmpty(), + lastMessageFromMe = fallbackFromMe, + lastMessageDelivered = fallbackDelivered, + lastMessageRead = fallbackRead, + lastMessageAttachments = fallbackAttachments + ) + ) + + if (opponent != account) { + dialogDao.markIHaveSent(account, opponent) + } + + ProtocolManager.addLog( + "🛠️ DIALOG_FALLBACK upserted opponent=${opponent.take(12)} ts=$fallbackTimestamp hasContent=1" + ) + } + /** * Обновить диалог при входящем сообщении 📁 SAVED MESSAGES: Использует специальный метод для * saved messages @@ -6130,11 +6770,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isFromMe: Boolean, delivered: Int = 0, attachmentsJson: String = "[]", - opponentPublicKey: String? = null + accountPublicKey: String, + accountPrivateKey: String, + opponentPublicKey: String ) { - val account = myPublicKey ?: return - val opponent = opponentPublicKey ?: opponentKey ?: return - val privateKey = myPrivateKey ?: return + val account = accountPublicKey.trim().takeIf { it.isNotEmpty() } ?: return + val opponent = opponentPublicKey.trim().takeIf { it.isNotEmpty() } ?: return + val privateKey = accountPrivateKey.trim().takeIf { it.isNotEmpty() } ?: return try { val dialogKey = getDialogKey(account, opponent) @@ -6258,6 +6900,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } } else { + typingNameResolveJob?.cancel() typingSenderPublicKey = null _typingDisplayName.value = "" _typingDisplayPublicKey.value = "" @@ -6265,17 +6908,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } _opponentTyping.value = true - // Отменяем предыдущий таймер, чтобы избежать race condition - typingTimeoutJob?.cancel() - typingTimeoutJob = - viewModelScope.launch(Dispatchers.Default) { - kotlinx.coroutines.delay(3000) - _opponentTyping.value = false - _typingDisplayName.value = "" - _typingDisplayPublicKey.value = "" - typingSenderPublicKey = null - this@ChatViewModel.typingUsersCount = 1 - } + } + + private fun clearTypingIndicatorState() { + typingNameResolveJob?.cancel() + _opponentTyping.value = false + _typingDisplayName.value = "" + _typingDisplayPublicKey.value = "" + typingSenderPublicKey = null + typingUsersCount = 1 } /** @@ -6524,17 +7165,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { override fun onCleared() { super.onCleared() isCleared = true - typingTimeoutJob?.cancel() + typingSnapshotJob?.cancel() + opponentOnlineStatusJob?.cancel() typingNameResolveJob?.cancel() draftSaveJob?.cancel() pinnedCollectionJob?.cancel() + pendingSendRecoveryJob?.cancel() + pendingSendRecoveryJob = null + pendingTextSendRequested = false + pendingTextSendReason = "" outgoingImageUploadJobs.values.forEach { it.cancel() } outgoingImageUploadJobs.clear() - // 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов - ProtocolManager.unwaitPacket(0x0B, typingPacketHandler) - ProtocolManager.unwaitPacket(0x05, onlinePacketHandler) - lastReadMessageTimestamp = 0L subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке opponentKey = null diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index e88cb37..8352429 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -289,6 +289,40 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio return deduped.values.sortedByDescending { it.lastMessageTimestamp } } + /** + * During sync we keep list stable only when there are truly no visible dialog changes. + * This lets local sends/new system dialogs appear immediately even if sync is active. + */ + private fun canFreezeDialogsDuringSync( + dialogsList: List + ): Boolean { + val currentDialogs = _dialogs.value + if (currentDialogs.isEmpty()) return false + if (dialogsList.size != currentDialogs.size) return false + + val currentByKey = currentDialogs.associateBy { it.opponentKey } + return dialogsList.all { entity -> + val current = currentByKey[entity.opponentKey] ?: return@all false + current.lastMessageTimestamp == entity.lastMessageTimestamp && + current.unreadCount == entity.unreadCount && + current.isOnline == entity.isOnline && + current.lastSeen == entity.lastSeen && + current.verified == entity.verified && + current.opponentTitle == entity.opponentTitle && + current.opponentUsername == entity.opponentUsername && + current.lastMessageFromMe == entity.lastMessageFromMe && + current.lastMessageDelivered == entity.lastMessageDelivered && + current.lastMessageRead == entity.lastMessageRead && + current.lastMessage == entity.lastMessage && + current.lastMessageAttachmentType == + resolveAttachmentType( + attachmentType = entity.lastMessageAttachmentType, + decryptedLastMessage = current.lastMessage, + lastMessageAttachments = entity.lastMessageAttachments + ) + } + } + private suspend fun mapDialogListIncremental( dialogsList: List, privateKey: String, @@ -448,10 +482,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio dialogsList to syncing } .mapLatest { (dialogsList, syncing) -> - // Desktop behavior parity: - // while sync is active we keep current chats list stable (no per-message UI churn), - // then apply one consolidated update when sync finishes. - if (syncing && _dialogs.value.isNotEmpty()) { + // Keep list stable during sync only when the snapshot is effectively unchanged. + // Otherwise (new message/dialog/status) update immediately. + if (syncing && canFreezeDialogsDuringSync(dialogsList)) { null } else { mapDialogListIncremental( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index a1dc386..e2fa3d8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -75,6 +75,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.exifinterface.media.ExifInterface +import com.rosetta.messenger.BuildConfig import com.rosetta.messenger.R import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.MessageCrypto @@ -116,11 +117,12 @@ import androidx.core.content.FileProvider private const val TAG = "AttachmentComponents" private const val MAX_BITMAP_DECODE_DIMENSION = 4096 -private const val VOICE_WAVE_DEBUG_LOG = true +private val VOICE_WAVE_DEBUG_LOG = BuildConfig.DEBUG private val whitespaceRegex = "\\s+".toRegex() private val LocalOnCancelImageUpload = staticCompositionLocalOf<(String) -> Unit> { {} } private fun rosettaDev1AttachmentLog(context: Context, tag: String, message: String) { + if (!VOICE_WAVE_DEBUG_LOG) return runCatching { val dir = java.io.File(context.filesDir, "crash_reports") if (!dir.exists()) dir.mkdirs() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentDownloadDebugLogger.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentDownloadDebugLogger.kt index 0df58ce..8d2d08c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentDownloadDebugLogger.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentDownloadDebugLogger.kt @@ -1,5 +1,6 @@ package com.rosetta.messenger.ui.chats.components +import com.rosetta.messenger.BuildConfig import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -9,12 +10,20 @@ object AttachmentDownloadDebugLogger { val logs: StateFlow> = _logs.asStateFlow() private var appContext: android.content.Context? = null + @Volatile private var forceEnabled = false + + fun isEnabled(): Boolean = BuildConfig.DEBUG || forceEnabled + + fun setEnabled(enabled: Boolean) { + forceEnabled = enabled + } fun init(context: android.content.Context) { appContext = context.applicationContext } fun log(message: String) { + if (!isEnabled()) return val ctx = appContext ?: return try { val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date()) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index a1f1d90..d2b1fdf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -315,6 +315,59 @@ fun TypingIndicator( } } +@Composable +private fun DeferredAttachmentPlaceholder( + attachments: List, + isOutgoing: Boolean, + isDarkTheme: Boolean +) { + val hasImage = attachments.any { it.type == AttachmentType.IMAGE } + val hasVideo = attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } + val hasVoice = attachments.any { it.type == AttachmentType.VOICE } + val hasFile = attachments.any { it.type == AttachmentType.FILE } + val label = + when { + hasImage -> "Loading photo..." + hasVideo -> "Loading video..." + hasVoice -> "Loading voice..." + hasFile -> "Loading attachment..." + else -> "Loading attachment..." + } + val backgroundColor = + if (isOutgoing) { + Color.White.copy(alpha = 0.14f) + } else if (isDarkTheme) { + Color.White.copy(alpha = 0.08f) + } else { + Color.Black.copy(alpha = 0.06f) + } + val textColor = + if (isOutgoing) { + Color.White.copy(alpha = 0.9f) + } else if (isDarkTheme) { + Color(0xFFE6E6E8) + } else { + Color(0xFF4A4A4A) + } + Box( + modifier = + Modifier.fillMaxWidth() + .heightIn(min = 56.dp) + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .padding(horizontal = 12.dp, vertical = 10.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = label, + color = textColor, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + /** Message bubble with Telegram-style design and animations */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -353,6 +406,7 @@ fun MessageBubble( onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, onMentionClick: (username: String) -> Unit = {}, onGroupInviteOpen: (SearchUser) -> Unit = {}, + deferHeavyAttachmentsUntilReady: Boolean = false, onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {}, contextMenuContent: @Composable () -> Unit = {} ) { @@ -769,6 +823,12 @@ fun MessageBubble( } val hasVoiceAttachment = message.attachments.any { it.type == AttachmentType.VOICE } + val shouldDeferAttachmentRendering = + deferHeavyAttachmentsUntilReady && + message.attachments.isNotEmpty() && + message.attachments.any { + it.type != AttachmentType.AVATAR + } val isStandaloneGroupInvite = message.attachments.isEmpty() && @@ -1063,37 +1123,45 @@ fun MessageBubble( // 📎 Attachments (IMAGE, FILE, AVATAR) if (message.attachments.isNotEmpty()) { - val attachmentDisplayStatus = - if (isSavedMessages) MessageStatus.READ - else message.status - MessageAttachments( - attachments = message.attachments, - chachaKey = message.chachaKey, - chachaKeyPlainHex = message.chachaKeyPlainHex, - privateKey = privateKey, - isOutgoing = message.isOutgoing, - isDarkTheme = isDarkTheme, - senderPublicKey = senderPublicKey, - senderDisplayName = senderName, - dialogPublicKey = dialogPublicKey, - isGroupChat = isGroupChat, - timestamp = message.timestamp, - messageStatus = attachmentDisplayStatus, - avatarRepository = avatarRepository, - currentUserPublicKey = currentUserPublicKey, - hasCaption = hasImageWithCaption, - showTail = showTail, // Передаём для формы - // пузырька - isSelectionMode = isSelectionMode, - onLongClick = onLongClick, - onCancelUpload = onCancelPhotoUpload, - // В selection mode блокируем открытие фото - onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick, - onVoiceWaveGestureActiveChanged = { active -> - isVoiceWaveGestureActive = active - onVoiceWaveGestureActiveChanged(active) - } - ) + if (shouldDeferAttachmentRendering) { + DeferredAttachmentPlaceholder( + attachments = message.attachments, + isOutgoing = message.isOutgoing, + isDarkTheme = isDarkTheme + ) + } else { + val attachmentDisplayStatus = + if (isSavedMessages) MessageStatus.READ + else message.status + MessageAttachments( + attachments = message.attachments, + chachaKey = message.chachaKey, + chachaKeyPlainHex = message.chachaKeyPlainHex, + privateKey = privateKey, + isOutgoing = message.isOutgoing, + isDarkTheme = isDarkTheme, + senderPublicKey = senderPublicKey, + senderDisplayName = senderName, + dialogPublicKey = dialogPublicKey, + isGroupChat = isGroupChat, + timestamp = message.timestamp, + messageStatus = attachmentDisplayStatus, + avatarRepository = avatarRepository, + currentUserPublicKey = currentUserPublicKey, + hasCaption = hasImageWithCaption, + showTail = showTail, // Передаём для формы + // пузырька + isSelectionMode = isSelectionMode, + onLongClick = onLongClick, + onCancelUpload = onCancelPhotoUpload, + // В selection mode блокируем открытие фото + onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick, + onVoiceWaveGestureActiveChanged = { active -> + isVoiceWaveGestureActive = active + onVoiceWaveGestureActiveChanged(active) + } + ) + } } // 🖼️ Caption под фото (Telegram-style) @@ -3502,6 +3570,7 @@ fun KebabMenu( isGroupChat: Boolean = false, isSystemAccount: Boolean = false, isBlocked: Boolean, + onChatOpenMetricsClick: (() -> Unit)? = null, onSearchInChatClick: () -> Unit = {}, onGroupInfoClick: () -> Unit = {}, onSearchMembersClick: () -> Unit = {}, @@ -3541,6 +3610,16 @@ fun KebabMenu( tintColor = iconColor, textColor = textColor ) + onChatOpenMetricsClick?.let { onMetricsClick -> + Divider(color = dividerColor) + KebabMenuItem( + icon = TelegramIcons.Info, + text = "Chat Open Metrics", + onClick = onMetricsClick, + tintColor = iconColor, + textColor = textColor + ) + } if (isGroupChat) { Divider(color = dividerColor)