dev: перенос текущих фиксов протокола, синка и send-flow
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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<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)
|
||||
val state: StateFlow<ProtocolState> = _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<String?> = _lastError.asStateFlow()
|
||||
|
||||
// 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)
|
||||
private val packetQueue = java.util.Collections.synchronizedList(mutableListOf<Packet>())
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Boolean> = _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) }
|
||||
?: "<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() {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { "<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)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<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(
|
||||
dialogsList: List<com.rosetta.messenger.database.DialogEntity>,
|
||||
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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<List<String>> = _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())
|
||||
|
||||
@@ -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 */
|
||||
@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)
|
||||
|
||||
Reference in New Issue
Block a user