feat: Bump version to 1.0.7, enhance message delivery handling, and add connection logs screen

This commit is contained in:
2026-02-25 11:16:31 +05:00
parent 75810a0696
commit aed685ee73
11 changed files with 618 additions and 59 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.0.6" val rosettaVersionName = "1.0.7"
val rosettaVersionCode = 6 // Increment on each release val rosettaVersionCode = 7 // Increment on each release
android { android {
namespace = "com.rosetta.messenger" namespace = "com.rosetta.messenger"

View File

@@ -96,6 +96,9 @@ class MessageRepository private constructor(private val context: Context) {
companion object { companion object {
@Volatile private var INSTANCE: MessageRepository? = null @Volatile private var INSTANCE: MessageRepository? = null
/** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */
private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L
const val SYSTEM_SAFE_PUBLIC_KEY = "0x000000000000000000000000000000000000000002" const val SYSTEM_SAFE_PUBLIC_KEY = "0x000000000000000000000000000000000000000002"
const val SYSTEM_SAFE_TITLE = "Safe" const val SYSTEM_SAFE_TITLE = "Safe"
const val SYSTEM_SAFE_USERNAME = "safe" const val SYSTEM_SAFE_USERNAME = "safe"
@@ -773,6 +776,9 @@ class MessageRepository private constructor(private val context: Context) {
) )
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа // 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
// Desktop parity: always re-fetch on incoming message so renamed contacts
// get their new name/username updated in the chat list.
requestedUserInfoKeys.remove(dialogOpponentKey)
requestUserInfo(dialogOpponentKey) requestUserInfo(dialogOpponentKey)
// Обновляем кэш только если сообщение новое // Обновляем кэш только если сообщение новое
@@ -810,11 +816,22 @@ class MessageRepository private constructor(private val context: Context) {
status = "DELIVERED" status = "DELIVERED"
) )
messageDao.updateDeliveryStatus(account, packet.messageId, DeliveryStatus.DELIVERED.value) // Desktop parity: update both delivery status AND timestamp on delivery confirmation.
// Desktop sets timestamp = Date.now() when PacketDelivery arrives (useSynchronize.ts).
val deliveryTimestamp = System.currentTimeMillis()
messageDao.updateDeliveryStatusAndTimestamp(
account, packet.messageId, DeliveryStatus.DELIVERED.value, deliveryTimestamp
)
// Обновляем кэш // Обновляем кэш (status + timestamp)
val dialogKey = getDialogKey(packet.toPublicKey) val dialogKey = getDialogKey(packet.toPublicKey)
updateMessageStatus(dialogKey, packet.messageId, DeliveryStatus.DELIVERED) messageCache[dialogKey]?.let { flow ->
flow.value = flow.value.map { msg ->
if (msg.messageId == packet.messageId)
msg.copy(deliveryStatus = DeliveryStatus.DELIVERED, timestamp = deliveryTimestamp)
else msg
}
}
// 🔔 Уведомляем UI о смене статуса доставки // 🔔 Уведомляем UI о смене статуса доставки
_deliveryStatusEvents.tryEmit( _deliveryStatusEvents.tryEmit(
@@ -950,6 +967,86 @@ class MessageRepository private constructor(private val context: Context) {
} }
} }
// ===============================
// Retry WAITING messages on reconnect
// ===============================
/**
* Desktop parity: resend messages stuck in WAITING status.
*
* On Android, if the app is killed while a message is being sent, it stays in DB
* with delivered=WAITING forever. Desktop has _packetQueue (in-memory) but desktop
* apps are rarely force-killed. On Android this is critical.
*
* Messages older than MESSAGE_MAX_TIME_TO_DELIVERED_MS are marked as ERROR instead
* of being retried (desktop: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80s).
*/
suspend fun retryWaitingMessages() {
val account = currentAccount ?: return
val privateKey = currentPrivateKey ?: return
val now = System.currentTimeMillis()
// Mark expired messages as ERROR (older than 80 seconds)
val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
if (expiredCount > 0) {
android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR")
}
// Get remaining WAITING messages (younger than 80s)
val waitingMessages = messageDao.getWaitingMessages(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
if (waitingMessages.isEmpty()) return
android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages")
for (entity in waitingMessages) {
// Skip saved messages (should not happen, but guard)
if (entity.fromPublicKey == entity.toPublicKey) continue
try {
// The message is already saved in DB with encrypted content and chachaKey.
// We can re-send the PacketMessage directly using stored fields.
val aesChachaKeyValue = if (entity.chachaKey.startsWith("sync:")) {
entity.chachaKey.removePrefix("sync:")
} else {
// Re-generate aesChachaKey from the stored chachaKey + privateKey.
// The chachaKey in DB is the ECC-encrypted key for the recipient.
// We need to decrypt it, then re-encrypt with our private key for self-sync.
try {
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(entity.chachaKey, privateKey)
CryptoManager.encryptWithPassword(
String(plainKeyAndNonce, Charsets.ISO_8859_1),
privateKey
)
} catch (e: Exception) {
android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it")
""
}
}
val packet = PacketMessage().apply {
this.fromPublicKey = account
this.toPublicKey = entity.toPublicKey
this.content = entity.content
this.chachaKey = if (entity.chachaKey.startsWith("sync:")) "" else entity.chachaKey
this.aesChachaKey = aesChachaKeyValue
this.timestamp = entity.timestamp
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
this.messageId = entity.messageId
this.attachments = emptyList() // Attachments already uploaded, tags are in content
}
ProtocolManager.send(packet)
android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
} catch (e: Exception) {
android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}")
// Mark as ERROR if retry fails
messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value)
val dialogKey = getDialogKey(entity.toPublicKey)
updateMessageStatus(dialogKey, entity.messageId, DeliveryStatus.ERROR)
}
}
}
// =============================== // ===============================
// Private helpers // Private helpers
// =============================== // ===============================
@@ -1100,6 +1197,53 @@ class MessageRepository private constructor(private val context: Context) {
messageCache.remove(dialogKey) messageCache.remove(dialogKey)
} }
/**
* Desktop parity: clear the one-shot guard so that names can be re-requested
* after reconnect / sync. Desktop's useUserInformation re-fires on every render;
* on Android we clear the guard after each sync cycle instead.
*/
fun clearUserInfoRequestCache() {
requestedUserInfoKeys.clear()
}
/**
* Desktop parity: after sync, resolve names for ALL dialogs that still have
* an empty / placeholder title. Desktop does this per-component via useUserInformation;
* we batch it here for efficiency.
*/
suspend fun requestMissingUserInfo() {
val account = currentAccount ?: return
val privateKey = currentPrivateKey ?: return
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
// Query dialogs with empty or placeholder titles
val dialogs = dialogDao.getDialogsWithEmptyTitle(account)
for (dialog in dialogs) {
// Skip self (Saved Messages)
if (dialog.opponentKey == account) continue
// Skip if already requested in this cycle
if (requestedUserInfoKeys.contains(dialog.opponentKey)) continue
requestedUserInfoKeys.add(dialog.opponentKey)
val packet = PacketSearch().apply {
this.privateKey = privateKeyHash
this.search = dialog.opponentKey
}
ProtocolManager.send(packet)
// Small delay to avoid flooding the server with search requests
kotlinx.coroutines.delay(50)
}
}
/**
* Force-request user info, bypassing the one-shot guard.
* Use when opening a dialog to ensure the name/username is fresh.
*/
fun forceRequestUserInfo(publicKey: String) {
requestedUserInfoKeys.remove(publicKey)
requestUserInfo(publicKey)
}
/** /**
* Запросить информацию о пользователе с сервера 🔥 Защита от бесконечных запросов - каждый ключ * Запросить информацию о пользователе с сервера 🔥 Защита от бесконечных запросов - каждый ключ
* запрашивается только один раз * запрашивается только один раз

View File

@@ -17,15 +17,28 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
- Исправлена критическая ошибка синхронизации сообщений между ПК и мобильным устройством Синхронизация
- Подтверждение доставки теперь отправляется только после успешной обработки - Исправлена рассинхронизация сообщений между ПК и Android
- Автоматическая синхронизация при возврате из фона - Зависшие сообщения автоматически переотправляются после реконнекта
- Анимация сайдбара в стиле Telegram - Уведомления больше не дублируются во время синхронизации
- Исправлены артефакты на разделителях при анимации - Время доставки сообщений теперь корректно обновляется
- Улучшено качество блюра аватара на экранах профиля
- Устранены артефакты по краям изображения Контакты
- Обновлён цвет шапки и сайдбара в светлой теме - Имена и юзернеймы загружаются автоматически при первом запуске
- Белая галочка верификации на экранах профиля - Имя контакта обновляется при открытии чата и входящем сообщении
Соединение
- Исправлен баг с зависанием WebSocket-соединения
- Автореконнект теперь срабатывает корректно при разрыве связи
Обновления
- Система автообновлений — проверка, загрузка и установка APK
- Новый экран обновлений с детальной информацией
Интерфейс
- Бейдж непрочитанных на стрелке назад в чате
- Новый значок верификации
- Печатает = всегда онлайн
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -414,6 +414,50 @@ interface MessageDao {
""" """
) )
suspend fun getLastMessageAttachments(account: String, opponent: String): String? suspend fun getLastMessageAttachments(account: String, opponent: String): String?
/**
* Get all outgoing messages stuck in WAITING status (delivered = 0).
* Used to retry sending on reconnect (desktop parity: _packetQueue flush).
* Only returns messages younger than minTimestamp to avoid retrying stale messages.
*/
@Query(
"""
SELECT * FROM messages
WHERE account = :account
AND from_me = 1
AND delivered = 0
AND timestamp >= :minTimestamp
ORDER BY timestamp ASC
"""
)
suspend fun getWaitingMessages(account: String, minTimestamp: Long): List<MessageEntity>
/**
* Mark old WAITING messages as ERROR (delivery timeout expired).
* Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80s.
*/
@Query(
"""
UPDATE messages SET delivered = 2
WHERE account = :account
AND from_me = 1
AND delivered = 0
AND timestamp < :maxTimestamp
"""
)
suspend fun markExpiredWaitingAsError(account: String, maxTimestamp: Long): Int
/**
* Update delivery status AND timestamp on delivery confirmation.
* Desktop parity: useDialogFiber.ts sets timestamp = Date.now() on PacketDelivery.
*/
@Query(
"""
UPDATE messages SET delivered = :status, timestamp = :timestamp
WHERE account = :account AND message_id = :messageId
"""
)
suspend fun updateDeliveryStatusAndTimestamp(account: String, messageId: String, status: Int, timestamp: Long)
} }
/** DAO для работы с диалогами */ /** DAO для работы с диалогами */
@@ -480,6 +524,23 @@ interface DialogDao {
) )
fun getRequestsCountFlow(account: String): Flow<Int> fun getRequestsCountFlow(account: String): Flow<Int>
/**
* Desktop parity: get all dialogs where opponent_title is empty or equals the raw
* public key (or its prefix). Used by requestMissingUserInfo() to batch-resolve names
* after sync, like Desktop's useUserInformation per-component hook.
*/
@Query("""
SELECT * FROM dialogs
WHERE account = :account
AND last_message_timestamp > 0
AND (
opponent_title = ''
OR opponent_title = opponent_key
OR LENGTH(opponent_title) <= 8
)
""")
suspend fun getDialogsWithEmptyTitle(account: String): List<DialogEntity>
/** Получить диалог */ /** Получить диалог */
@Query("SELECT * FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1") @Query("SELECT * FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1")
suspend fun getDialog(account: String, opponentKey: String): DialogEntity? suspend fun getDialog(account: String, opponentKey: String): DialogEntity?

View File

@@ -97,8 +97,8 @@ class Protocol(
// 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, MutableList<(Packet) -> Unit>>()
// Packet queue for packets sent before handshake complete // Packet queue for packets sent before handshake complete (thread-safe)
private val packetQueue = mutableListOf<Packet>() private val packetQueue = java.util.Collections.synchronizedList(mutableListOf<Packet>())
// Last used credentials for reconnection // Last used credentials for reconnection
private var lastPublicKey: String? = null private var lastPublicKey: String? = null
@@ -314,6 +314,15 @@ class Protocol(
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
log("⚠️ WebSocket CLOSING: code=$code reason='$reason' state=${_state.value}") log("⚠️ WebSocket CLOSING: code=$code reason='$reason' state=${_state.value}")
// Must respond with close() so OkHttp transitions to onClosed.
// Without this, the socket stays in a half-closed "zombie" state —
// heartbeat keeps running but no data flows, and handleDisconnect
// is never called (it only fires from onClosed/onFailure).
try {
webSocket.close(code, reason)
} catch (e: Exception) {
log("⚠️ Error responding to CLOSING: ${e.message}")
}
} }
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
@@ -470,9 +479,12 @@ class Protocol(
} }
private fun flushPacketQueue() { private fun flushPacketQueue() {
log("📬 Flushing ${packetQueue.size} queued packets") val packets: List<Packet>
val packets = packetQueue.toList() synchronized(packetQueue) {
packetQueue.clear() packets = packetQueue.toList()
packetQueue.clear()
}
log("📬 Flushing ${packets.size} queued packets")
packets.forEach { sendPacketDirect(it) } packets.forEach { sendPacketDirect(it) }
} }

View File

@@ -6,11 +6,10 @@ import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.data.isPlaceholderAccountName
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
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.sync.withLock
import java.security.SecureRandom import java.security.SecureRandom
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -70,10 +69,14 @@ object ProtocolManager {
private val _syncInProgress = MutableStateFlow(false) private val _syncInProgress = MutableStateFlow(false)
val syncInProgress: StateFlow<Boolean> = _syncInProgress.asStateFlow() val syncInProgress: StateFlow<Boolean> = _syncInProgress.asStateFlow()
@Volatile private var resyncRequiredAfterAccountInit = false @Volatile private var resyncRequiredAfterAccountInit = false
// Desktop parity: sequential task queue with Job-based completion tracking // Desktop parity: sequential task queue matching dialogQueue.ts (promise chain).
// (replaces AtomicInteger polling with 15s timeout that could lose messages) // Uses Channel to guarantee strict FIFO ordering (Mutex+lastInboundJob had a race
private val inboundPacketMutex = Mutex() // condition: Dispatchers.IO doesn't guarantee FIFO, so the last-launched job could
@Volatile private var lastInboundJob: Job = Job().also { it.complete() } // finish before earlier ones, causing whenInboundTasksFinish to return prematurely
// and BATCH_END to advance the sync timestamp while messages were still processing).
private val inboundTaskChannel = Channel<suspend () -> Unit>(Channel.UNLIMITED)
// Tracks the tail of the sequential processing chain (like desktop's `tail` promise)
@Volatile private var inboundQueueDrainJob: Job? = null
private fun setSyncInProgress(value: Boolean) { private fun setSyncInProgress(value: Boolean) {
syncBatchInProgress = value syncBatchInProgress = value
@@ -86,10 +89,8 @@ object ProtocolManager {
val timestamp = dateFormat.format(Date()) val timestamp = dateFormat.format(Date())
val logLine = "[$timestamp] $message" val logLine = "[$timestamp] $message"
// UI логи отключены по умолчанию - вызывали ANR из-за перекомпозиций // Always keep logs in memory for the Logs screen (capped at 500)
if (uiLogsEnabled) { _debugLogs.value = (_debugLogs.value + logLine).takeLast(500)
_debugLogs.value = (_debugLogs.value + logLine).takeLast(50)
}
} }
fun enableUILogs(enabled: Boolean) { fun enableUILogs(enabled: Boolean) {
@@ -184,6 +185,7 @@ object ProtocolManager {
} }
// Обработчик доставки (0x08) // Обработчик доставки (0x08)
// Desktop parity: useDialogFiber.ts updates sync time on delivery (await updateSyncTime(Date.now()))
waitPacket(0x08) { packet -> waitPacket(0x08) { packet ->
val deliveryPacket = packet as PacketDelivery val deliveryPacket = packet as PacketDelivery
@@ -194,6 +196,9 @@ object ProtocolManager {
return@launchInboundPacketTask return@launchInboundPacketTask
} }
repository.handleDelivery(deliveryPacket) repository.handleDelivery(deliveryPacket)
if (!syncBatchInProgress) {
repository.updateLastSyncTimestamp(System.currentTimeMillis())
}
} }
} }
@@ -331,22 +336,32 @@ object ProtocolManager {
} }
/** /**
* Desktop parity: sequential task queue (like dialogQueue.ts runTaskInQueue). * Desktop parity: sequential task queue (like dialogQueue.ts runTaskInQueue / whenFinish).
* All inbound packet tasks are serialized via mutex. *
* lastInboundJob tracks the last submitted job so BATCH_END can await completion * Desktop uses a promise chain: `tail = tail.then(fn).catch(...)` which guarantees
* without an arbitrary timeout (desktop uses `await whenFinish()`). * strict FIFO ordering and `whenFinish = () => tail` returns a promise that resolves
* only after ALL queued tasks complete.
*
* We reproduce this with a Channel<suspend () -> Unit> (UNLIMITED buffer) consumed
* by a single coroutine. Tasks are executed strictly in the order they were submitted,
* and `whenInboundTasksFinish()` waits for the queue to drain completely.
*/ */
private fun launchInboundPacketTask(block: suspend () -> Unit) { private fun ensureInboundQueueDrainRunning() {
val job = scope.launch { if (inboundQueueDrainJob?.isActive == true) return
try { inboundQueueDrainJob = scope.launch {
inboundPacketMutex.withLock { for (task in inboundTaskChannel) {
block() try {
task()
} catch (e: Exception) {
android.util.Log.e(TAG, "Inbound packet task error", e)
} }
} catch (e: Exception) {
android.util.Log.e(TAG, "Inbound packet task error", e)
} }
} }
lastInboundJob = job }
private fun launchInboundPacketTask(block: suspend () -> Unit) {
ensureInboundQueueDrainRunning()
inboundTaskChannel.trySend(block)
} }
private fun requireResyncAfterAccountInit(reason: String) { private fun requireResyncAfterAccountInit(reason: String) {
@@ -358,12 +373,14 @@ object ProtocolManager {
/** /**
* Desktop parity: equivalent of `await whenFinish()` in useSynchronize.ts. * Desktop parity: equivalent of `await whenFinish()` in useSynchronize.ts.
* Waits for all currently queued inbound packet tasks to complete. * Sends a sentinel task into the sequential queue and suspends until it executes.
* Since tasks are serialized via mutex, awaiting the last job * Since the queue is strictly FIFO, when the sentinel runs, all previously
* guarantees all previous jobs have finished. * submitted tasks are guaranteed to have completed.
*/ */
private suspend fun whenInboundTasksFinish() { private suspend fun whenInboundTasksFinish() {
lastInboundJob.join() val done = CompletableDeferred<Unit>()
launchInboundPacketTask { done.complete(Unit) }
done.await()
} }
private fun onAuthenticated() { private fun onAuthenticated() {
@@ -393,13 +410,21 @@ object ProtocolManager {
} }
private fun requestSynchronize() { private fun requestSynchronize() {
// Desktop parity: set syncBatchInProgress=true BEFORE sending the sync request.
// This closes the race window between AUTHENTICATED → BATCH_START where real-time
// messages could arrive and update lastSync, potentially advancing the cursor past
// messages the server hasn't delivered yet.
setSyncInProgress(true)
addLog("🔄 SYNC requested — fetching last sync timestamp...")
scope.launch { scope.launch {
val repository = messageRepository val repository = messageRepository
if (repository == null || !repository.isInitialized()) { if (repository == null || !repository.isInitialized()) {
setSyncInProgress(false)
requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized") requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized")
return@launch return@launch
} }
val lastSync = repository.getLastSyncTimestamp() val lastSync = repository.getLastSyncTimestamp()
addLog("🔄 SYNC sending request with lastSync=$lastSync")
sendSynchronize(lastSync) sendSynchronize(lastSync)
} }
} }
@@ -414,29 +439,89 @@ object ProtocolManager {
/** /**
* Desktop parity: useSynchronize.ts usePacket(25, ...) * Desktop parity: useSynchronize.ts usePacket(25, ...)
* BATCH_START → mark sync in progress * BATCH_START → mark sync in progress (synchronous — no scope.launch)
* BATCH_END → wait for ALL message tasks to finish, save timestamp, request next batch * BATCH_END → wait for ALL message tasks to finish, save timestamp, request next batch
* NOT_NEEDED → sync complete, save timestamp, mark connected * NOT_NEEDED → sync complete, mark connected (synchronous — no scope.launch)
*
* CRITICAL: BATCH_START and NOT_NEEDED are handled synchronously in the WebSocket
* callback thread. This prevents a race condition where 0x06 message packets arrive
* and check syncBatchInProgress BEFORE the scope.launch coroutine for BATCH_START
* has been scheduled on Dispatchers.IO.
*/ */
private fun handleSyncPacket(packet: PacketSync) { private fun handleSyncPacket(packet: PacketSync) {
scope.launch { when (packet.status) {
when (packet.status) { SyncStatus.BATCH_START -> {
SyncStatus.BATCH_START -> { addLog("🔄 SYNC BATCH_START — incoming message batch")
setSyncInProgress(true) // Synchronous — guarantees syncBatchInProgress=true before any
} // subsequent 0x06 packets are dispatched by OkHttp's sequential callback.
SyncStatus.BATCH_END -> { setSyncInProgress(true)
}
SyncStatus.BATCH_END -> {
addLog("🔄 SYNC BATCH_END — waiting for tasks to finish (ts=${packet.timestamp})")
// BATCH_END requires suspend (whenInboundTasksFinish), so we launch a coroutine.
// syncBatchInProgress stays true until NOT_NEEDED arrives.
scope.launch {
setSyncInProgress(true) setSyncInProgress(true)
// Desktop: await whenFinish() — wait for ALL queued tasks without timeout. // Desktop: await whenFinish() — wait for ALL queued tasks without timeout.
// Old code used 15s polling timeout which could advance the sync timestamp // Old code used 15s polling timeout which could advance the sync timestamp
// before all messages were processed, causing message loss on app crash. // before all messages were processed, causing message loss on app crash.
whenInboundTasksFinish() whenInboundTasksFinish()
addLog("🔄 SYNC tasks done — saving timestamp ${packet.timestamp}, requesting next batch")
messageRepository?.updateLastSyncTimestamp(packet.timestamp) messageRepository?.updateLastSyncTimestamp(packet.timestamp)
sendSynchronize(packet.timestamp) sendSynchronize(packet.timestamp)
} }
SyncStatus.NOT_NEEDED -> { }
setSyncInProgress(false) SyncStatus.NOT_NEEDED -> {
messageRepository?.updateLastSyncTimestamp(packet.timestamp) addLog("✅ SYNC COMPLETE — no more messages to sync")
} // Synchronous — immediately marks sync as complete.
// Desktop parity: NOT_NEEDED just sets state to CONNECTED,
// does NOT update last_sync timestamp (unnecessary since client
// was already up to date).
setSyncInProgress(false)
// Retry any messages stuck in WAITING status from previous sessions.
retryWaitingMessages()
// Desktop parity: resolve names for all dialogs with empty titles.
// Desktop does this per-component via useUserInformation hook;
// we batch it after sync for efficiency.
requestMissingUserInfo()
}
}
}
/**
* Retry messages stuck in WAITING status on reconnect.
* Desktop has in-memory _packetQueue that flushes on handshake, but desktop apps are
* rarely force-killed. On Android, the app can be killed mid-send, leaving messages
* in WAITING status in the DB. This method resends them after sync completes.
*
* Messages older than 80s (MESSAGE_MAX_TIME_TO_DELEVERED_S) are marked ERROR.
*/
private fun retryWaitingMessages() {
scope.launch {
val repository = messageRepository
if (repository == null || !repository.isInitialized()) return@launch
try {
repository.retryWaitingMessages()
} catch (e: Exception) {
android.util.Log.e(TAG, "retryWaitingMessages failed", e)
}
}
}
/**
* Desktop parity: after sync completes, resolve names/usernames for all dialogs
* that still have empty titles. Clears the one-shot guard first so that previously
* failed requests can be retried.
*/
private fun requestMissingUserInfo() {
scope.launch {
val repository = messageRepository
if (repository == null || !repository.isInitialized()) return@launch
try {
repository.clearUserInfoRequestCache()
repository.requestMissingUserInfo()
} catch (e: Exception) {
android.util.Log.e(TAG, "requestMissingUserInfo failed", e)
} }
} }
} }

View File

@@ -155,6 +155,11 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
if (isAppInForeground || !areNotificationsEnabled()) { if (isAppInForeground || !areNotificationsEnabled()) {
return return
} }
// Desktop parity: suppress notifications during sync (useDialogFiber.ts checks
// protocolState != ProtocolState.SYNCHRONIZATION before calling notify()).
if (ProtocolManager.syncInProgress.value) {
return
}
val senderKey = senderPublicKey?.trim().orEmpty() val senderKey = senderPublicKey?.trim().orEmpty()
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) { if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
return return

View File

@@ -425,7 +425,9 @@ fun ChatDetailScreen(
val inputText by viewModel.inputText.collectAsState() val inputText by viewModel.inputText.collectAsState()
val isTyping by viewModel.opponentTyping.collectAsState() val isTyping by viewModel.opponentTyping.collectAsState()
val isLoadingMore by viewModel.isLoadingMore.collectAsState() val isLoadingMore by viewModel.isLoadingMore.collectAsState()
val isOnline by viewModel.opponentOnline.collectAsState() val rawIsOnline by viewModel.opponentOnline.collectAsState()
// If typing, the user is obviously online — never show "offline" while typing
val isOnline = rawIsOnline || isTyping
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
// <20>🔥 Reply/Forward state // <20>🔥 Reply/Forward state
@@ -905,7 +907,7 @@ fun ChatDetailScreen(
) )
.background( .background(
Color( Color(
0xFFFF3B30 0xFF3B82F6
) )
), ),
contentAlignment = contentAlignment =

View File

@@ -584,6 +584,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
isDialogActive = true // 🔥 Диалог активен! isDialogActive = true // 🔥 Диалог активен!
// Desktop parity: refresh opponent name/username from server on dialog open,
// so renamed contacts get their new name displayed immediately.
messageRepository?.forceRequestUserInfo(publicKey)
// 📝 Восстанавливаем черновик для этого диалога (draft, как в Telegram) // 📝 Восстанавливаем черновик для этого диалога (draft, как в Telegram)
val draft = com.rosetta.messenger.data.DraftManager.getDraft(publicKey) val draft = com.rosetta.messenger.data.DraftManager.getDraft(publicKey)
_inputText.value = draft ?: "" _inputText.value = draft ?: ""

View File

@@ -189,6 +189,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialog.opponentTitle == dialog.opponentTitle ==
dialog.opponentKey.take( dialog.opponentKey.take(
7 7
) ||
dialog.opponentTitle ==
dialog.opponentKey.take(
8
)) ))
) { ) {
loadUserInfoForDialog(dialog.opponentKey) loadUserInfoForDialog(dialog.opponentKey)
@@ -371,6 +375,20 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.collect { blockedSet -> _blockedUsers.value = blockedSet } .collect { blockedSet -> _blockedUsers.value = blockedSet }
} }
// Desktop parity: when sync finishes (syncInProgress transitions true → false),
// clear the one-shot requestedUserInfoKeys guard so the dialog-list .map{} block
// can re-trigger loadUserInfoForDialog() on the next Room emission for any
// dialogs that still have empty titles.
launch {
var wasSyncing = false
ProtocolManager.syncInProgress.collect { syncing ->
if (wasSyncing && !syncing) {
requestedUserInfoKeys.clear()
}
wasSyncing = syncing
}
}
} // end accountSubscriptionsJob } // end accountSubscriptionsJob
} }

View File

@@ -0,0 +1,215 @@
package com.rosetta.messenger.ui.chats
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import kotlinx.coroutines.launch
/**
* Full-screen connection logs viewer.
* Shows all protocol/WebSocket logs from ProtocolManager.debugLogs.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectionLogsScreen(
isDarkTheme: Boolean,
onBack: () -> Unit
) {
val logs by ProtocolManager.debugLogs.collectAsState()
val protocolState by ProtocolManager.getProtocol().state.collectAsState()
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val bgColor = if (isDarkTheme) Color(0xFF0E0E0E) else Color(0xFFF5F5F5)
val cardColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color.White
val textColor = if (isDarkTheme) Color(0xFFE0E0E0) else Color(0xFF1A1A1A)
val headerColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFF228BE6)
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
// Auto-scroll to bottom when new logs arrive
LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(bgColor)
.statusBarsPadding()
) {
// Header
Box(
modifier = Modifier
.fillMaxWidth()
.background(headerColor)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
imageVector = TablerIcons.ArrowLeft,
contentDescription = "Back",
tint = Color.White
)
}
Text(
text = "Connection Logs",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f)
)
// Clear button
IconButton(onClick = { ProtocolManager.clearLogs() }) {
Icon(
imageVector = TablerIcons.Trash,
contentDescription = "Clear logs",
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.size(22.dp)
)
}
// Scroll to bottom
IconButton(onClick = {
scope.launch {
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1)
}
}) {
Icon(
imageVector = TablerIcons.ArrowDown,
contentDescription = "Scroll to bottom",
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.size(22.dp)
)
}
}
}
// Status bar
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (isDarkTheme) Color(0xFF252525) else Color(0xFFE8E8E8))
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
val stateColor = when (protocolState) {
ProtocolState.AUTHENTICATED -> Color(0xFF4CAF50)
ProtocolState.CONNECTING, ProtocolState.HANDSHAKING -> Color(0xFFFFA726)
ProtocolState.DISCONNECTED -> Color(0xFFEF5350)
else -> Color(0xFF9E9E9E)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.background(stateColor, RoundedCornerShape(4.dp))
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = protocolState.name,
color = textColor,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace
)
}
if (syncInProgress) {
Text(
text = "SYNCING…",
color = Color(0xFFFFA726),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
Text(
text = "${logs.size} logs",
color = textColor.copy(alpha = 0.5f),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
// Logs list
if (logs.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No logs yet.\nConnect to see protocol activity.",
color = textColor.copy(alpha = 0.4f),
fontSize = 14.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
} else {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(logs, key = { it.hashCode().toString() + logs.indexOf(it) }) { log ->
val logColor = when {
"" in log || "FAILED" in log || "Error" in log || "error" in log -> Color(0xFFEF5350)
"" in log || "COMPLETE" in log || "SUCCESS" in log -> Color(0xFF4CAF50)
"⚠️" in log || "WARNING" in log -> Color(0xFFFFA726)
"🔄" in log || "RECONNECT" in log || "SYNC" in log -> Color(0xFF42A5F5)
"💓" in log || "Heartbeat" in log -> Color(0xFF9E9E9E)
"📤" in log || "Sending" in log -> Color(0xFF7E57C2)
"📥" in log || "onMessage" in log -> Color(0xFF26A69A)
"🤝" in log || "HANDSHAKE" in log -> Color(0xFFFFCA28)
else -> textColor.copy(alpha = 0.85f)
}
Text(
text = log,
color = logColor,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
lineHeight = 15.sp,
modifier = Modifier
.fillMaxWidth()
.background(
if ("" in log) Color.Red.copy(alpha = 0.08f)
else Color.Transparent,
RoundedCornerShape(4.dp)
)
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
}