dev: перенос текущих фиксов протокола, синка и send-flow
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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,18 +313,127 @@ 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,27 +551,17 @@ 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"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start heartbeat to keep connection alive
|
* Start heartbeat to keep connection alive
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
.keys
|
||||||
|
.filter { it.equals(normalizedUserPublicKey, ignoreCase = true) }
|
||||||
|
.forEach { key ->
|
||||||
|
pendingResolves.remove(key)?.forEach { cont ->
|
||||||
try { cont.resume(user) } catch (_: Exception) {}
|
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 onAuthenticated() {
|
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)
|
setSyncInProgress(false)
|
||||||
|
addLog("🚀 AUTH bootstrap start session=$sessionId trigger=$trigger")
|
||||||
TransportManager.requestTransportServer()
|
TransportManager.requestTransportServer()
|
||||||
com.rosetta.messenger.update.UpdateManager.requestSduServer()
|
com.rosetta.messenger.update.UpdateManager.requestSduServer()
|
||||||
fetchOwnProfile()
|
fetchOwnProfile()
|
||||||
requestSynchronize()
|
requestSynchronize()
|
||||||
subscribePushTokenIfAvailable()
|
subscribePushTokenIfAvailable()
|
||||||
|
lastBootstrappedSessionId = sessionId
|
||||||
|
addLog("✅ AUTH bootstrap complete session=$sessionId trigger=$trigger")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onAuthenticated() {
|
||||||
|
tryRunPostAuthBootstrap("state_authenticated")
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +1473,33 @@ fun ChatDetailScreen(
|
|||||||
if (!isSavedMessages && !isGroupChat) {
|
if (!isSavedMessages && !isGroupChat) {
|
||||||
viewModel.subscribeToOnlineStatus()
|
viewModel.subscribeToOnlineStatus()
|
||||||
}
|
}
|
||||||
// 🔥 Предзагружаем эмодзи в фоне
|
}
|
||||||
|
|
||||||
|
// Фиксируем момент, когда список впервые реально отрисовался.
|
||||||
|
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)
|
com.rosetta.messenger.ui.components.EmojiCache.preload(context)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Consume pending forward messages for this chat
|
// Consume pending forward messages for this chat
|
||||||
LaunchedEffect(user.publicKey, forwardTrigger) {
|
LaunchedEffect(user.publicKey, forwardTrigger) {
|
||||||
@@ -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,7 +3811,34 @@ 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(
|
||||||
@@ -4083,6 +4186,41 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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 }
|
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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,6 +1123,13 @@ fun MessageBubble(
|
|||||||
|
|
||||||
// 📎 Attachments (IMAGE, FILE, AVATAR)
|
// 📎 Attachments (IMAGE, FILE, AVATAR)
|
||||||
if (message.attachments.isNotEmpty()) {
|
if (message.attachments.isNotEmpty()) {
|
||||||
|
if (shouldDeferAttachmentRendering) {
|
||||||
|
DeferredAttachmentPlaceholder(
|
||||||
|
attachments = message.attachments,
|
||||||
|
isOutgoing = message.isOutgoing,
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
)
|
||||||
|
} else {
|
||||||
val attachmentDisplayStatus =
|
val attachmentDisplayStatus =
|
||||||
if (isSavedMessages) MessageStatus.READ
|
if (isSavedMessages) MessageStatus.READ
|
||||||
else message.status
|
else message.status
|
||||||
@@ -1095,6 +1162,7 @@ fun MessageBubble(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🖼️ Caption под фото (Telegram-style)
|
// 🖼️ Caption под фото (Telegram-style)
|
||||||
// Отступ от фото = 9dp, слева = 11dp (исходящие) / 13dp
|
// Отступ от фото = 9dp, слева = 11dp (исходящие) / 13dp
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user