Фикс синхронизации Android ↔ iOS: retry механизм и нормализация sync-курсора

- Добавлен retry для исходящих сообщений (iOS parity): 4с интервал, 3 попытки, 80с таймаут
- Нормализация sync timestamp в миллисекунды (предотвращает расхождение курсора)
- resolveOutgoingRetry при получении delivery ACK (0x08)
- cancelAllOutgoingRetries при дисконнекте
- Обновлены release notes для 1.2.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 23:36:35 +07:00
parent d5b6ca3a7e
commit f72138a8a2
3 changed files with 155 additions and 6 deletions

View File

@@ -389,8 +389,21 @@ class MessageRepository private constructor(private val context: Context) {
suspend fun updateLastSyncTimestamp(timestamp: Long) {
val account = currentAccount ?: return
// Desktop parity: store raw cursor value from sync/update events.
syncTimeDao.upsert(AccountSyncTimeEntity(account = account, lastSync = timestamp))
// iOS parity: normalize timestamp to milliseconds before storing.
// Server may send seconds or milliseconds depending on the packet.
val normalized = normalizeSyncTimestamp(timestamp)
if (normalized <= 0L) return
val existing = syncTimeDao.getLastSync(account) ?: 0L
if (normalized <= existing) return
syncTimeDao.upsert(AccountSyncTimeEntity(account = account, lastSync = normalized))
}
/**
* iOS parity: normalize sync timestamp to milliseconds.
* Values below 1_000_000_000_000 are assumed to be in seconds.
*/
private fun normalizeSyncTimestamp(raw: Long): Long {
return if (raw < 1_000_000_000_000L) raw * 1000L else raw
}
/** Получить поток сообщений для диалога */
@@ -589,7 +602,8 @@ class MessageRepository private constructor(private val context: Context) {
// 📝 LOG: Отправка пакета
MessageLogger.logPacketSend(messageId, toPublicKey, timestamp)
ProtocolManager.send(packet)
// iOS parity: send with automatic retry (4s interval, 3 attempts, 80s timeout)
ProtocolManager.sendMessageWithRetry(packet)
// 📝 LOG: Успешная отправка
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
@@ -1135,7 +1149,8 @@ class MessageRepository private constructor(private val context: Context) {
this.attachments = emptyList() // Attachments already uploaded, tags are in content
}
ProtocolManager.send(packet)
// iOS parity: use retry mechanism for reconnect-resent messages too
ProtocolManager.sendMessageWithRetry(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}")
@@ -1158,7 +1173,7 @@ class MessageRepository private constructor(private val context: Context) {
* Получить ключ диалога для группировки сообщений 📁 SAVED MESSAGES: Для saved messages
* (account == opponentKey) возвращает просто account
*/
private fun getDialogKey(opponentKey: String): String {
fun getDialogKey(opponentKey: String): String {
val account = currentAccount ?: return opponentKey
if (isGroupDialogKey(opponentKey)) {
return opponentKey.trim()
@@ -1238,6 +1253,16 @@ class MessageRepository private constructor(private val context: Context) {
}
}
/**
* Public API for ProtocolManager to update delivery status (e.g., marking as ERROR on retry timeout).
*/
suspend fun updateMessageDeliveryStatus(dialogKey: String, messageId: String, status: DeliveryStatus) {
val account = currentAccount ?: return
messageDao.updateDeliveryStatus(account, messageId, status.value)
updateMessageStatus(dialogKey, messageId, status)
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, messageId, status))
}
private fun updateMessageStatus(dialogKey: String, messageId: String, status: DeliveryStatus) {
messageCache[dialogKey]?.let { flow ->
flow.value =