dev: перенос текущих фиксов протокола, синка и send-flow

This commit is contained in:
2026-04-17 21:49:51 +05:00
parent 1cf645ea3f
commit 1a57d8f4d0
9 changed files with 1542 additions and 393 deletions

View File

@@ -244,6 +244,13 @@ class MessageRepository private constructor(private val context: Context) {
opponentUsername = opponentUsername =
existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME } existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME }
?: 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, isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0, lastSeen = existing?.lastSeen ?: 0,
verified = maxOf(existing?.verified ?: 0, 1), verified = maxOf(existing?.verified ?: 0, 1),
@@ -323,6 +330,13 @@ class MessageRepository private constructor(private val context: Context) {
opponentUsername = opponentUsername =
existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME } existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME }
?: 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, isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0, lastSeen = existing?.lastSeen ?: 0,
verified = maxOf(existing?.verified ?: 0, 1), verified = maxOf(existing?.verified ?: 0, 1),

View File

@@ -4,12 +4,12 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.withLock
import okhttp3.* import okhttp3.*
import okio.ByteString import okio.ByteString
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
@@ -191,11 +191,97 @@ class Protocol(
private var connectingSinceMs = 0L private var connectingSinceMs = 0L
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val lifecycleMutex = Mutex()
private val connectionGeneration = AtomicLong(0L) private val connectionGeneration = AtomicLong(0L)
@Volatile private var activeConnectionGeneration: Long = 0L @Volatile private var activeConnectionGeneration: Long = 0L
private val instanceId = INSTANCE_COUNTER.incrementAndGet() 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<SessionEvent>(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) private val _state = MutableStateFlow(ProtocolState.DISCONNECTED)
val state: StateFlow<ProtocolState> = _state.asStateFlow() val state: StateFlow<ProtocolState> = _state.asStateFlow()
@@ -227,17 +313,126 @@ class Protocol(
} }
} }
private fun launchLifecycleOperation(operation: String, block: suspend () -> Unit) { private fun enqueueSessionEvent(event: SessionEvent) {
scope.launch { val result = sessionEvents.trySend(event)
lifecycleMutex.withLock { if (result.isFailure) {
try { log(
block() "⚠️ Session event dropped: ${event::class.java.simpleName} " +
} catch (e: CancellationException) { "reason=${result.exceptionOrNull()?.message ?: "channel_closed"}"
throw e )
} catch (e: Exception) { }
log("❌ Lifecycle operation '$operation' failed: ${e.message}") }
e.printStackTrace()
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<String?> = _lastError.asStateFlow() val lastError: StateFlow<String?> = _lastError.asStateFlow()
// Packet waiters - callbacks for specific packet types (thread-safe) // Packet waiters - callbacks for specific packet types (thread-safe)
private val packetWaiters = java.util.concurrent.ConcurrentHashMap<Int, MutableList<(Packet) -> Unit>>() private val packetWaiters =
java.util.concurrent.ConcurrentHashMap<Int, CopyOnWriteArrayList<(Packet) -> Unit>>()
// Packet queue for packets sent before handshake complete (thread-safe) // Packet queue for packets sent before handshake complete (thread-safe)
private val packetQueue = java.util.Collections.synchronizedList(mutableListOf<Packet>()) private val packetQueue = java.util.Collections.synchronizedList(mutableListOf<Packet>())
@@ -326,29 +522,7 @@ class Protocol(
// Register handshake response handler // Register handshake response handler
waitPacket(0x00) { packet -> waitPacket(0x00) { packet ->
if (packet is PacketHandshake) { if (packet is PacketHandshake) {
handshakeJob?.cancel() enqueueSessionEvent(SessionEvent.HandshakeResponse(packet))
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)
} }
} }
@@ -360,38 +534,9 @@ class Protocol(
val resolve = packet as? PacketDeviceResolve ?: return@waitPacket val resolve = packet as? PacketDeviceResolve ?: return@waitPacket
when (resolve.solution) { when (resolve.solution) {
DeviceResolveSolution.ACCEPT -> { DeviceResolveSolution.ACCEPT -> {
log("✅ DEVICE VERIFICATION ACCEPTED (deviceId=${shortKey(resolve.deviceId, 12)})") enqueueSessionEvent(
val stateAtAccept = _state.value SessionEvent.DeviceVerificationAccepted(deviceId = resolve.deviceId)
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)
}
}
} }
DeviceResolveSolution.DECLINE -> { DeviceResolveSolution.DECLINE -> {
val stateAtDecline = _state.value val stateAtDecline = _state.value
@@ -406,22 +551,12 @@ class Protocol(
stateAtDecline == ProtocolState.DEVICE_VERIFICATION_REQUIRED || stateAtDecline == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
stateAtDecline == ProtocolState.HANDSHAKING stateAtDecline == ProtocolState.HANDSHAKING
) { ) {
launchLifecycleOperation("device_verification_declined") { enqueueSessionEvent(
handshakeComplete = false SessionEvent.DeviceVerificationDeclined(
handshakeJob?.cancel() deviceId = resolve.deviceId,
packetQueue.clear() observedState = stateAtDecline
if (webSocket != null) { )
setState( )
ProtocolState.CONNECTED,
"Device verification declined, waiting for retry"
)
} else {
setState(
ProtocolState.DISCONNECTED,
"Device verification declined without active socket"
)
}
}
} }
} }
} }
@@ -511,9 +646,7 @@ class Protocol(
* Initialize connection to server * Initialize connection to server
*/ */
fun connect() { fun connect() {
launchLifecycleOperation("connect") { enqueueSessionEvent(SessionEvent.Connect())
connectLocked()
}
} }
private fun connectLocked() { private fun connectLocked() {
@@ -597,30 +730,13 @@ class Protocol(
webSocket = client.newWebSocket(request, object : WebSocketListener() { webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
if (isStaleSocketEvent("onOpen", generation, webSocket)) return enqueueSessionEvent(
log("✅ WebSocket OPEN: response=${response.code}, hasCredentials=${lastPublicKey != null}, gen=$generation") SessionEvent.SocketOpened(
generation = generation,
// Сбрасываем флаг подключения socket = webSocket,
isConnecting = false responseCode = response.code
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}")
}
} }
override fun onMessage(webSocket: WebSocket, bytes: ByteString) { override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
@@ -649,26 +765,26 @@ class Protocol(
} }
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (isStaleSocketEvent("onClosed", generation, webSocket)) return enqueueSessionEvent(
log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed gen=$generation") SessionEvent.SocketClosed(
isConnecting = false // Сбрасываем флаг generation = generation,
connectingSinceMs = 0L socket = webSocket,
handleDisconnect("onClosed") code = code,
reason = reason
)
)
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (isStaleSocketEvent("onFailure", generation, webSocket)) return enqueueSessionEvent(
log("❌ WebSocket FAILURE: ${t.message}") SessionEvent.SocketFailure(
log(" Response: ${response?.code} ${response?.message}") generation = generation,
log(" State: ${_state.value}") socket = webSocket,
log(" Manually closed: $isManuallyClosed") throwable = t,
log(" Reconnect attempts: $reconnectAttempts") responseCode = response?.code,
log(" Generation: $generation") responseMessage = response?.message
t.printStackTrace() )
isConnecting = false // Сбрасываем флаг )
connectingSinceMs = 0L
_lastError.value = t.message
handleDisconnect("onFailure")
} }
}) })
} }
@@ -698,10 +814,9 @@ class Protocol(
// If switching accounts, force disconnect and reconnect with new credentials // If switching accounts, force disconnect and reconnect with new credentials
if (switchingAccount) { if (switchingAccount) {
log("🔄 Account switch detected, forcing reconnect with new credentials") log("🔄 Account switch detected, forcing reconnect with new credentials")
launchLifecycleOperation("account_switch_reconnect") { enqueueSessionEvent(
disconnectLocked(manual = false, reason = "Account switch reconnect") SessionEvent.AccountSwitchReconnect(reason = "Account switch reconnect")
connectLocked() // Will auto-handshake with saved credentials on connect )
}
return return
} }
@@ -892,9 +1007,7 @@ class Protocol(
} }
private fun handleDisconnect(source: String = "unknown") { private fun handleDisconnect(source: String = "unknown") {
launchLifecycleOperation("handle_disconnect:$source") { enqueueSessionEvent(SessionEvent.HandleDisconnect(source))
handleDisconnectLocked(source)
}
} }
private fun handleDisconnectLocked(source: String) { private fun handleDisconnectLocked(source: String) {
@@ -969,25 +1082,39 @@ class Protocol(
* Register callback for specific packet type * Register callback for specific packet type
*/ */
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) { fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
packetWaiters.getOrPut(packetId) { mutableListOf() }.add(callback) val waiters = packetWaiters.computeIfAbsent(packetId) { CopyOnWriteArrayList() }
val count = packetWaiters[packetId]?.size ?: 0 if (waiters.contains(callback)) {
log("📝 waitPacket(0x${Integer.toHexString(packetId)}) registered. Total handlers for 0x${Integer.toHexString(packetId)}: $count") 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 * Unregister callback for specific packet type
*/ */
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) { 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 * Disconnect from server
*/ */
fun disconnect() { fun disconnect() {
launchLifecycleOperation("disconnect_manual") { enqueueSessionEvent(
disconnectLocked(manual = true, reason = "User disconnected") SessionEvent.Disconnect(manual = true, reason = "User disconnected")
} )
} }
private fun disconnectLocked(manual: Boolean, reason: String) { private fun disconnectLocked(manual: Boolean, reason: String) {
@@ -1018,9 +1145,7 @@ class Protocol(
* on app resume we should not wait scheduled exponential backoff. * on app resume we should not wait scheduled exponential backoff.
*/ */
fun reconnectNowIfNeeded(reason: String = "foreground") { fun reconnectNowIfNeeded(reason: String = "foreground") {
launchLifecycleOperation("fast_reconnect:$reason") { enqueueSessionEvent(SessionEvent.FastReconnect(reason))
reconnectNowIfNeededLocked(reason)
}
} }
private fun reconnectNowIfNeededLocked(reason: String) { private fun reconnectNowIfNeededLocked(reason: String) {
@@ -1087,9 +1212,17 @@ class Protocol(
* Release resources * Release resources
*/ */
fun destroy() { fun destroy() {
enqueueSessionEvent(
SessionEvent.Disconnect(manual = true, reason = "Destroy protocol")
)
runCatching { sessionEvents.close() }
runBlocking { runBlocking {
lifecycleMutex.withLock { val drained = withTimeoutOrNull(2_000L) {
disconnectLocked(manual = true, reason = "Destroy protocol") sessionLoopJob.join()
true
} ?: false
if (!drained) {
sessionLoopJob.cancelAndJoin()
} }
} }
heartbeatJob?.cancel() heartbeatJob?.cancel()

View File

@@ -24,6 +24,7 @@ import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.resume import kotlin.coroutines.resume
/** /**
@@ -124,6 +125,11 @@ object ProtocolManager {
val syncInProgress: StateFlow<Boolean> = _syncInProgress.asStateFlow() val syncInProgress: StateFlow<Boolean> = _syncInProgress.asStateFlow()
@Volatile private var resyncRequiredAfterAccountInit = false @Volatile private var resyncRequiredAfterAccountInit = false
@Volatile private var lastForegroundSyncTime = 0L @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). // Desktop parity: sequential task queue matching dialogQueue.ts (promise chain).
// Uses Channel to guarantee strict FIFO ordering (Mutex+lastInboundJob had a race // 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 // 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. // New authenticated websocket session: always allow fresh push subscribe.
lastSubscribedToken = null lastSubscribedToken = null
stopWaitingForNetwork("authenticated") stopWaitingForNetwork("authenticated")
activeAuthenticatedSessionId = authenticatedSessionCounter.incrementAndGet()
deferredAuthBootstrap = false
onAuthenticated() onAuthenticated()
} }
if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) { if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) {
@@ -282,6 +290,7 @@ object ProtocolManager {
setSyncInProgress(false) setSyncInProgress(false)
// Connection/session dropped: force re-subscribe on next AUTHENTICATED. // Connection/session dropped: force re-subscribe on next AUTHENTICATED.
lastSubscribedToken = null lastSubscribedToken = null
deferredAuthBootstrap = false
// iOS parity: cancel all pending outgoing retries on disconnect. // iOS parity: cancel all pending outgoing retries on disconnect.
// They will be retried via retryWaitingMessages() on next handshake. // They will be retried via retryWaitingMessages() on next handshake.
cancelAllOutgoingRetries() cancelAllOutgoingRetries()
@@ -309,6 +318,9 @@ object ProtocolManager {
setSyncInProgress(false) setSyncInProgress(false)
clearTypingState() clearTypingState()
messageRepository?.initialize(normalizedPublicKey, normalizedPrivateKey) messageRepository?.initialize(normalizedPublicKey, normalizedPrivateKey)
if (deferredAuthBootstrap && protocol?.isAuthenticated() == true) {
addLog("🔁 AUTH bootstrap resume after initializeAccount")
}
val shouldResync = resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true val shouldResync = resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true
if (shouldResync) { if (shouldResync) {
@@ -320,6 +332,7 @@ object ProtocolManager {
addLog("🔄 Account initialized (${shortKeyForLog(normalizedPublicKey)}) -> force sync") addLog("🔄 Account initialized (${shortKeyForLog(normalizedPublicKey)}) -> force sync")
requestSynchronize() requestSynchronize()
} }
tryRunPostAuthBootstrap("initialize_account")
// Send "Rosetta Updates" message on version change (like desktop useUpdateMessage) // Send "Rosetta Updates" message on version change (like desktop useUpdateMessage)
scope.launch { scope.launch {
messageRepository?.checkAndSendVersionUpdateMessage() messageRepository?.checkAndSendVersionUpdateMessage()
@@ -524,32 +537,43 @@ object ProtocolManager {
if (searchPacket.users.isNotEmpty()) { if (searchPacket.users.isNotEmpty()) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val ownPublicKey = getProtocol().getPublicKey() val ownPublicKey =
getProtocol().getPublicKey()?.trim().orEmpty().ifBlank {
messageRepository?.getCurrentAccountKey()?.trim().orEmpty()
}
searchPacket.users.forEach { user -> searchPacket.users.forEach { user ->
val normalizedUserPublicKey = user.publicKey.trim()
// 🔍 Кэшируем всех пользователей (desktop parity: cachedUsers) // 🔍 Кэшируем всех пользователей (desktop parity: cachedUsers)
userInfoCache[user.publicKey] = user userInfoCache[normalizedUserPublicKey] = user
// Resume pending resolves for this publicKey // Resume pending resolves for this publicKey (case-insensitive).
pendingResolves.remove(user.publicKey)?.forEach { cont -> pendingResolves
try { cont.resume(user) } catch (_: Exception) {} .keys
} .filter { it.equals(normalizedUserPublicKey, ignoreCase = true) }
.forEach { key ->
pendingResolves.remove(key)?.forEach { cont ->
try { cont.resume(user) } catch (_: Exception) {}
}
}
// Обновляем инфо в диалогах (для всех пользователей) // Обновляем инфо в диалогах (для всех пользователей)
messageRepository?.updateDialogUserInfo( messageRepository?.updateDialogUserInfo(
user.publicKey, normalizedUserPublicKey,
user.title, user.title,
user.username, user.username,
user.verified user.verified
) )
// Если это наш own profile — сохраняем username/name в AccountManager // Если это наш 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!!) val accountManager = AccountManager(appContext!!)
if (user.title.isNotBlank() && !isPlaceholderAccountName(user.title)) { if (user.title.isNotBlank() && !isPlaceholderAccountName(user.title)) {
accountManager.updateAccountName(user.publicKey, user.title) accountManager.updateAccountName(ownPublicKey, user.title)
} }
if (user.username.isNotBlank()) { if (user.username.isNotBlank()) {
accountManager.updateAccountUsername(user.publicKey, user.username) accountManager.updateAccountUsername(ownPublicKey, user.username)
} }
_ownProfileUpdated.value = System.currentTimeMillis() _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) }
?: "<none>"
val protocolAccount =
getProtocol().getPublicKey()?.let { shortKeyForLog(it) }
?: "<none>"
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() { private fun onAuthenticated() {
setSyncInProgress(false) tryRunPostAuthBootstrap("state_authenticated")
TransportManager.requestTransportServer()
com.rosetta.messenger.update.UpdateManager.requestSduServer()
fetchOwnProfile()
requestSynchronize()
subscribePushTokenIfAvailable()
} }
private fun finishSyncCycle(reason: String) { private fun finishSyncCycle(reason: String) {
@@ -1789,6 +1853,9 @@ object ProtocolManager {
clearSyncRequestTimeout() clearSyncRequestTimeout()
setSyncInProgress(false) setSyncInProgress(false)
resyncRequiredAfterAccountInit = false resyncRequiredAfterAccountInit = false
deferredAuthBootstrap = false
activeAuthenticatedSessionId = 0L
lastBootstrappedSessionId = 0L
lastSubscribedToken = null // reset so token is re-sent on next connect lastSubscribedToken = null // reset so token is re-sent on next connect
} }
@@ -1809,6 +1876,9 @@ object ProtocolManager {
clearSyncRequestTimeout() clearSyncRequestTimeout()
setSyncInProgress(false) setSyncInProgress(false)
resyncRequiredAfterAccountInit = false resyncRequiredAfterAccountInit = false
deferredAuthBootstrap = false
activeAuthenticatedSessionId = 0L
lastBootstrappedSessionId = 0L
scope.cancel() scope.cancel()
} }

View File

@@ -34,12 +34,15 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import compose.icons.TablerIcons import compose.icons.TablerIcons
@@ -73,6 +76,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset 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.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge 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.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.settings.ThemeWallpapers import com.rosetta.messenger.ui.settings.ThemeWallpapers
import com.rosetta.messenger.ui.utils.NavigationModeUtils import com.rosetta.messenger.ui.utils.NavigationModeUtils
@@ -806,6 +812,7 @@ fun ChatDetailScreen(
// Состояние выпадающего меню // Состояние выпадающего меню
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
var showChatOpenMetricsDialog by remember { mutableStateOf(false) }
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
var showBlockConfirm by remember { mutableStateOf(false) } var showBlockConfirm by remember { mutableStateOf(false) }
var showUnblockConfirm 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 // If typing, the user is obviously online — never show "offline" while typing
val isOnline = rawIsOnline || isTyping val isOnline = rawIsOnline || isTyping
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона 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 groupRequiresRejoin by viewModel.groupRequiresRejoin.collectAsState()
val showMessageSkeleton by val showMessageSkeleton by
produceState(initialValue = false, key1 = isLoading) { produceState(initialValue = false, key1 = isLoading) {
@@ -1428,11 +1446,24 @@ fun ChatDetailScreen(
// 🔥 Подписываемся на forward trigger для перезагрузки при forward в тот же чат // 🔥 Подписываемся на forward trigger для перезагрузки при forward в тот же чат
val forwardTrigger by ForwardManager.forwardTrigger.collectAsState() val forwardTrigger by ForwardManager.forwardTrigger.collectAsState()
var deferredEmojiPreloadStarted by remember(user.publicKey) { mutableStateOf(false) }
// Инициализируем ViewModel с ключами и открываем диалог // Инициализируем ViewModel с ключами и открываем диалог
// forwardTrigger добавлен чтобы перезагрузить диалог при forward в тот же чат // forwardTrigger добавлен чтобы перезагрузить диалог при forward в тот же чат
LaunchedEffect(user.publicKey, forwardTrigger) { LaunchedEffect(
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey) 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.openDialog(user.publicKey, user.title, user.username, user.verified)
viewModel.markVisibleMessagesAsRead() viewModel.markVisibleMessagesAsRead()
// 🔥 Убираем уведомление этого чата из шторки при заходе // 🔥 Убираем уведомление этого чата из шторки при заходе
@@ -1442,8 +1473,32 @@ fun ChatDetailScreen(
if (!isSavedMessages && !isGroupChat) { if (!isSavedMessages && !isGroupChat) {
viewModel.subscribeToOnlineStatus() 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 // Consume pending forward messages for this chat
@@ -2298,6 +2353,10 @@ fun ChatDetailScreen(
isSystemAccount, isSystemAccount,
isBlocked = isBlocked =
isBlocked, isBlocked,
onChatOpenMetricsClick = {
showMenu = false
showChatOpenMetricsDialog = true
},
onSearchInChatClick = { onSearchInChatClick = {
showMenu = false showMenu = false
hideInputOverlays() hideInputOverlays()
@@ -2849,6 +2908,12 @@ fun ChatDetailScreen(
isSavedMessages = isSavedMessages, isSavedMessages = isSavedMessages,
onSend = { onSend = {
isSendingMessage = true isSendingMessage = true
viewModel.ensureSendContext(
publicKey = user.publicKey,
title = user.title,
username = user.username,
verified = user.verified
)
viewModel.sendMessage() viewModel.sendMessage()
scope.launch { scope.launch {
delay(100) delay(100)
@@ -2859,6 +2924,12 @@ fun ChatDetailScreen(
}, },
onSendVoiceMessage = { voiceHex, durationSec, waves -> onSendVoiceMessage = { voiceHex, durationSec, waves ->
isSendingMessage = true isSendingMessage = true
viewModel.ensureSendContext(
publicKey = user.publicKey,
title = user.title,
username = user.username,
verified = user.verified
)
viewModel.sendVoiceMessage( viewModel.sendVoiceMessage(
voiceHex = voiceHex, voiceHex = voiceHex,
durationSec = durationSec, durationSec = durationSec,
@@ -3248,6 +3319,16 @@ fun ChatDetailScreen(
key = { _, item -> key = { _, item ->
item.first item.first
.id .id
},
contentType = { _, item ->
val hasAttachments =
item.first.attachments
.isNotEmpty()
when {
item.second -> "message_with_date"
hasAttachments -> "message_with_attachments"
else -> "message_text_only"
}
} }
) { ) {
index, index,
@@ -3315,14 +3396,7 @@ fun ChatDetailScreen(
isGroupStart)) isGroupStart))
val isDeleting = message.id in pendingDeleteIds val isDeleting = message.id in pendingDeleteIds
androidx.compose.animation.AnimatedVisibility( val messageItemContent: @Composable () -> Unit = {
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)
)
) {
Column { Column {
if (showDate if (showDate
) { ) {
@@ -3391,6 +3465,8 @@ fun ChatDetailScreen(
.contains( .contains(
senderPublicKeyForMessage senderPublicKeyForMessage
), ),
deferHeavyAttachmentsUntilReady =
useLightweightColdOpen,
currentUserPublicKey = currentUserPublicKey =
currentUserPublicKey, currentUserPublicKey,
currentUserUsername = currentUserUsername =
@@ -3735,8 +3811,35 @@ fun ChatDetailScreen(
} // contextMenuContent } // 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( androidx.compose.animation.AnimatedVisibility(
visible = visible =
@@ -4080,9 +4183,44 @@ fun ChatDetailScreen(
} }
) )
} }
} // Закрытие Box wrapper для Scaffold content } // Закрытие Box wrapper для Scaffold content
} // Закрытие Box } // Закрытие 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) { if (showDeleteConfirm) {
val isLeaveGroupDialog = user.publicKey.startsWith("#group:") val isLeaveGroupDialog = user.publicKey.startsWith("#group:")
@@ -4619,3 +4757,32 @@ private fun ChatWallpaperBackground(
contentScale = ContentScale.Crop 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 { "<empty>" }
val logSection =
if (metrics.eventLog.isEmpty()) {
"<empty>"
} 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)
}
}

View File

@@ -289,6 +289,40 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
return deduped.values.sortedByDescending { it.lastMessageTimestamp } 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<com.rosetta.messenger.database.DialogEntity>
): 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( private suspend fun mapDialogListIncremental(
dialogsList: List<com.rosetta.messenger.database.DialogEntity>, dialogsList: List<com.rosetta.messenger.database.DialogEntity>,
privateKey: String, privateKey: String,
@@ -448,10 +482,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialogsList to syncing dialogsList to syncing
} }
.mapLatest { (dialogsList, syncing) -> .mapLatest { (dialogsList, syncing) ->
// Desktop behavior parity: // Keep list stable during sync only when the snapshot is effectively unchanged.
// while sync is active we keep current chats list stable (no per-message UI churn), // Otherwise (new message/dialog/status) update immediately.
// then apply one consolidated update when sync finishes. if (syncing && canFreezeDialogsDuringSync(dialogsList)) {
if (syncing && _dialogs.value.isNotEmpty()) {
null null
} else { } else {
mapDialogListIncremental( mapDialogListIncremental(

View File

@@ -75,6 +75,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.crypto.MessageCrypto
@@ -116,11 +117,12 @@ import androidx.core.content.FileProvider
private const val TAG = "AttachmentComponents" private const val TAG = "AttachmentComponents"
private const val MAX_BITMAP_DECODE_DIMENSION = 4096 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 whitespaceRegex = "\\s+".toRegex()
private val LocalOnCancelImageUpload = staticCompositionLocalOf<(String) -> Unit> { {} } private val LocalOnCancelImageUpload = staticCompositionLocalOf<(String) -> Unit> { {} }
private fun rosettaDev1AttachmentLog(context: Context, tag: String, message: String) { private fun rosettaDev1AttachmentLog(context: Context, tag: String, message: String) {
if (!VOICE_WAVE_DEBUG_LOG) return
runCatching { runCatching {
val dir = java.io.File(context.filesDir, "crash_reports") val dir = java.io.File(context.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs() if (!dir.exists()) dir.mkdirs()

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.chats.components package com.rosetta.messenger.ui.chats.components
import com.rosetta.messenger.BuildConfig
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -9,12 +10,20 @@ object AttachmentDownloadDebugLogger {
val logs: StateFlow<List<String>> = _logs.asStateFlow() val logs: StateFlow<List<String>> = _logs.asStateFlow()
private var appContext: android.content.Context? = null 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) { fun init(context: android.content.Context) {
appContext = context.applicationContext appContext = context.applicationContext
} }
fun log(message: String) { fun log(message: String) {
if (!isEnabled()) return
val ctx = appContext ?: return val ctx = appContext ?: return
try { try {
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date()) val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())

View File

@@ -315,6 +315,59 @@ fun TypingIndicator(
} }
} }
@Composable
private fun DeferredAttachmentPlaceholder(
attachments: List<MessageAttachment>,
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 */ /** Message bubble with Telegram-style design and animations */
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -353,6 +406,7 @@ fun MessageBubble(
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
onMentionClick: (username: String) -> Unit = {}, onMentionClick: (username: String) -> Unit = {},
onGroupInviteOpen: (SearchUser) -> Unit = {}, onGroupInviteOpen: (SearchUser) -> Unit = {},
deferHeavyAttachmentsUntilReady: Boolean = false,
onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {}, onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {},
contextMenuContent: @Composable () -> Unit = {} contextMenuContent: @Composable () -> Unit = {}
) { ) {
@@ -769,6 +823,12 @@ fun MessageBubble(
} }
val hasVoiceAttachment = val hasVoiceAttachment =
message.attachments.any { it.type == AttachmentType.VOICE } message.attachments.any { it.type == AttachmentType.VOICE }
val shouldDeferAttachmentRendering =
deferHeavyAttachmentsUntilReady &&
message.attachments.isNotEmpty() &&
message.attachments.any {
it.type != AttachmentType.AVATAR
}
val isStandaloneGroupInvite = val isStandaloneGroupInvite =
message.attachments.isEmpty() && message.attachments.isEmpty() &&
@@ -1063,37 +1123,45 @@ fun MessageBubble(
// 📎 Attachments (IMAGE, FILE, AVATAR) // 📎 Attachments (IMAGE, FILE, AVATAR)
if (message.attachments.isNotEmpty()) { if (message.attachments.isNotEmpty()) {
val attachmentDisplayStatus = if (shouldDeferAttachmentRendering) {
if (isSavedMessages) MessageStatus.READ DeferredAttachmentPlaceholder(
else message.status attachments = message.attachments,
MessageAttachments( isOutgoing = message.isOutgoing,
attachments = message.attachments, isDarkTheme = isDarkTheme
chachaKey = message.chachaKey, )
chachaKeyPlainHex = message.chachaKeyPlainHex, } else {
privateKey = privateKey, val attachmentDisplayStatus =
isOutgoing = message.isOutgoing, if (isSavedMessages) MessageStatus.READ
isDarkTheme = isDarkTheme, else message.status
senderPublicKey = senderPublicKey, MessageAttachments(
senderDisplayName = senderName, attachments = message.attachments,
dialogPublicKey = dialogPublicKey, chachaKey = message.chachaKey,
isGroupChat = isGroupChat, chachaKeyPlainHex = message.chachaKeyPlainHex,
timestamp = message.timestamp, privateKey = privateKey,
messageStatus = attachmentDisplayStatus, isOutgoing = message.isOutgoing,
avatarRepository = avatarRepository, isDarkTheme = isDarkTheme,
currentUserPublicKey = currentUserPublicKey, senderPublicKey = senderPublicKey,
hasCaption = hasImageWithCaption, senderDisplayName = senderName,
showTail = showTail, // Передаём для формы dialogPublicKey = dialogPublicKey,
// пузырька isGroupChat = isGroupChat,
isSelectionMode = isSelectionMode, timestamp = message.timestamp,
onLongClick = onLongClick, messageStatus = attachmentDisplayStatus,
onCancelUpload = onCancelPhotoUpload, avatarRepository = avatarRepository,
// В selection mode блокируем открытие фото currentUserPublicKey = currentUserPublicKey,
onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick, hasCaption = hasImageWithCaption,
onVoiceWaveGestureActiveChanged = { active -> showTail = showTail, // Передаём для формы
isVoiceWaveGestureActive = active // пузырька
onVoiceWaveGestureActiveChanged(active) isSelectionMode = isSelectionMode,
} onLongClick = onLongClick,
) onCancelUpload = onCancelPhotoUpload,
// В selection mode блокируем открытие фото
onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick,
onVoiceWaveGestureActiveChanged = { active ->
isVoiceWaveGestureActive = active
onVoiceWaveGestureActiveChanged(active)
}
)
}
} }
// 🖼️ Caption под фото (Telegram-style) // 🖼️ Caption под фото (Telegram-style)
@@ -3502,6 +3570,7 @@ fun KebabMenu(
isGroupChat: Boolean = false, isGroupChat: Boolean = false,
isSystemAccount: Boolean = false, isSystemAccount: Boolean = false,
isBlocked: Boolean, isBlocked: Boolean,
onChatOpenMetricsClick: (() -> Unit)? = null,
onSearchInChatClick: () -> Unit = {}, onSearchInChatClick: () -> Unit = {},
onGroupInfoClick: () -> Unit = {}, onGroupInfoClick: () -> Unit = {},
onSearchMembersClick: () -> Unit = {}, onSearchMembersClick: () -> Unit = {},
@@ -3541,6 +3610,16 @@ fun KebabMenu(
tintColor = iconColor, tintColor = iconColor,
textColor = textColor textColor = textColor
) )
onChatOpenMetricsClick?.let { onMetricsClick ->
Divider(color = dividerColor)
KebabMenuItem(
icon = TelegramIcons.Info,
text = "Chat Open Metrics",
onClick = onMetricsClick,
tintColor = iconColor,
textColor = textColor
)
}
if (isGroupChat) { if (isGroupChat) {
Divider(color = dividerColor) Divider(color = dividerColor)