Исправлены ложные галочки и синхронизация статусов сообщений
Some checks failed
Android Kernel Build / build (push) Failing after 44m56s

This commit is contained in:
2026-03-20 19:20:06 +05:00
parent e5a68439f8
commit 58455cf32a
6 changed files with 270 additions and 62 deletions

View File

@@ -806,6 +806,15 @@ class MessageRepository private constructor(private val context: Context) {
packet.chachaKey packet.chachaKey
} }
val isSelfDialog = packet.toPublicKey.trim() == account
// Для исходящих сообщений статус доставки меняется ТОЛЬКО по PacketDelivery.
val initialDeliveredStatus =
if (isOwnMessage && !isSelfDialog) {
DeliveryStatus.WAITING.value
} else {
DeliveryStatus.DELIVERED.value
}
// Создаем entity для кэша и возможной вставки // Создаем entity для кэша и возможной вставки
val entity = val entity =
MessageEntity( MessageEntity(
@@ -817,7 +826,7 @@ class MessageRepository private constructor(private val context: Context) {
chachaKey = storedChachaKey, chachaKey = storedChachaKey,
read = 0, read = 0,
fromMe = if (isOwnMessage) 1 else 0, fromMe = if (isOwnMessage) 1 else 0,
delivered = DeliveryStatus.DELIVERED.value, delivered = initialDeliveredStatus,
messageId = messageId, // 🔥 Используем сгенерированный messageId! messageId = messageId, // 🔥 Используем сгенерированный messageId!
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
attachments = attachmentsJson, attachments = attachmentsJson,
@@ -946,18 +955,34 @@ class MessageRepository private constructor(private val context: Context) {
// Desktop parity (group): from=groupMember, to=groupId -> mark own group messages as read. // Desktop parity (group): from=groupMember, to=groupId -> mark own group messages as read.
if (!isOwnReadSync && isGroupDialogKey(toPublicKey)) { if (!isOwnReadSync && isGroupDialogKey(toPublicKey)) {
val dialogKey = getDialogKey(toPublicKey) val dialogKey = getDialogKey(toPublicKey)
messageDao.markAllAsRead(account, toPublicKey) val updatedRows = messageDao.markAllAsRead(account, toPublicKey)
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0 val readCount =
messageCache[dialogKey]?.value?.count {
it.isFromMe &&
!it.isRead &&
(it.deliveryStatus == DeliveryStatus.DELIVERED ||
it.deliveryStatus == DeliveryStatus.READ)
} ?: 0
messageCache[dialogKey]?.let { flow -> messageCache[dialogKey]?.let { flow ->
flow.value = flow.value =
flow.value.map { msg -> flow.value.map { msg ->
if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true) else msg if (msg.isFromMe &&
!msg.isRead &&
(msg.deliveryStatus == DeliveryStatus.DELIVERED ||
msg.deliveryStatus == DeliveryStatus.READ)
) {
msg.copy(isRead = true, deliveryStatus = DeliveryStatus.READ)
} else {
msg
}
} }
} }
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ)) if (updatedRows > 0) {
MessageLogger.logReadStatus(fromPublicKey = toPublicKey, messagesCount = readCount) _deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
}
MessageLogger.logReadStatus(fromPublicKey = toPublicKey, messagesCount = minOf(readCount, updatedRows))
dialogDao.updateDialogFromMessages(account, toPublicKey) dialogDao.updateDialogFromMessages(account, toPublicKey)
return return
} }
@@ -981,20 +1006,36 @@ class MessageRepository private constructor(private val context: Context) {
} }
// Opponent read our outgoing messages. // Opponent read our outgoing messages.
messageDao.markAllAsRead(account, opponentKey) val updatedRows = messageDao.markAllAsRead(account, opponentKey)
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0 val readCount =
messageCache[dialogKey]?.value?.count {
it.isFromMe &&
!it.isRead &&
(it.deliveryStatus == DeliveryStatus.DELIVERED ||
it.deliveryStatus == DeliveryStatus.READ)
} ?: 0
messageCache[dialogKey]?.let { flow -> messageCache[dialogKey]?.let { flow ->
flow.value = flow.value =
flow.value.map { msg -> flow.value.map { msg ->
if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true) else msg if (msg.isFromMe &&
!msg.isRead &&
(msg.deliveryStatus == DeliveryStatus.DELIVERED ||
msg.deliveryStatus == DeliveryStatus.READ)
) {
msg.copy(isRead = true, deliveryStatus = DeliveryStatus.READ)
} else {
msg
}
} }
} }
// Notify current dialog UI: all outgoing messages are now read. // Notify current dialog UI only when there are real DB read updates.
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ)) if (updatedRows > 0) {
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
}
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = readCount) MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = minOf(readCount, updatedRows))
dialogDao.updateDialogFromMessages(account, opponentKey) dialogDao.updateDialogFromMessages(account, opponentKey)
} }

View File

@@ -400,10 +400,13 @@ interface MessageDao {
@Query( @Query(
""" """
UPDATE messages SET read = 1 UPDATE messages SET read = 1
WHERE account = :account AND to_public_key = :opponent AND from_me = 1 WHERE account = :account
AND to_public_key = :opponent
AND from_me = 1
AND delivered IN (1, 3)
""" """
) )
suspend fun markAllAsRead(account: String, opponent: String) suspend fun markAllAsRead(account: String, opponent: String): Int
/** 🔥 DEBUG: Получить последнее сообщение в диалоге для отладки */ /** 🔥 DEBUG: Получить последнее сообщение в диалоге для отладки */
@Query( @Query(

View File

@@ -159,21 +159,68 @@ private data class IncomingRunAvatarUiState(
val overlays: List<IncomingRunAvatarOverlay> val overlays: List<IncomingRunAvatarOverlay>
) )
private val copiedTimeRegex = Regex("^\\d{1,2}:\\d{2}(?:\\s?[AaPp][Mm])?$")
private fun sanitizeCopyText(rawText: String, senderName: String): String {
if (rawText.isBlank()) return ""
val normalizedSender = senderName.trim()
val lines =
rawText
.replace("\r\n", "\n")
.split('\n')
.map { it.trim() }
.filter { it.isNotEmpty() }
.toMutableList()
while (lines.isNotEmpty()) {
val first = lines.first()
val isSenderLine =
normalizedSender.isNotBlank() &&
first.equals(normalizedSender, ignoreCase = true)
val isForwardHeader = first.startsWith("Forwarded from ", ignoreCase = true)
if (!isSenderLine && !isForwardHeader) break
lines.removeAt(0)
}
while (lines.isNotEmpty() && copiedTimeRegex.matches(lines.last())) {
lines.removeAt(lines.lastIndex)
}
return lines.joinToString("\n").trim()
}
private fun extractCopyableMessageText(message: ChatMessage): String { private fun extractCopyableMessageText(message: ChatMessage): String {
val directText = message.text.trim() val directText = sanitizeCopyText(message.text, message.senderName)
if (directText.isNotEmpty()) { if (directText.isNotEmpty()) {
return directText return directText
} }
val forwardedText = val forwardedText =
message.forwardedMessages message.forwardedMessages
.mapNotNull { forwarded -> forwarded.text.trim().takeIf { it.isNotEmpty() } } .mapNotNull { forwarded ->
sanitizeCopyText(
rawText = forwarded.text,
senderName =
forwarded.forwardedFromName
.ifBlank { forwarded.senderName }
)
.takeIf { it.isNotEmpty() }
}
.joinToString("\n\n") .joinToString("\n\n")
if (forwardedText.isNotEmpty()) { if (forwardedText.isNotEmpty()) {
return forwardedText return forwardedText
} }
val replyText = message.replyData?.text?.trim().orEmpty() val replyText =
message.replyData
?.let { reply ->
sanitizeCopyText(
rawText = reply.text,
senderName = reply.forwardedFromName.ifBlank { reply.senderName }
)
}
.orEmpty()
if (replyText.isNotEmpty()) { if (replyText.isNotEmpty()) {
return replyText return replyText
} }
@@ -181,6 +228,48 @@ private fun extractCopyableMessageText(message: ChatMessage): String {
return "" return ""
} }
@Composable
private fun GroupRejoinRequiredState(
isDarkTheme: Boolean,
hasChatWallpaper: Boolean,
modifier: Modifier = Modifier
) {
val titleColor = if (isDarkTheme || hasChatWallpaper) Color.White else Color(0xFF1C1C1E)
val subtitleColor = titleColor.copy(alpha = 0.72f)
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.speech))
val progress by
animateLottieCompositionAsState(
composition = composition,
iterations = LottieConstants.IterateForever
)
Column(
modifier = modifier.fillMaxSize().padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LottieAnimation(
composition = composition,
progress = { progress },
modifier = Modifier.size(120.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Join Group Again",
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
color = titleColor
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Group sync key is missing. Rejoin this group to load messages.",
fontSize = 14.sp,
color = subtitleColor
)
}
}
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
androidx.compose.foundation.ExperimentalFoundationApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class,
@@ -636,6 +725,7 @@ 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 groupRequiresRejoin by viewModel.groupRequiresRejoin.collectAsState()
val showMessageSkeleton by val showMessageSkeleton by
produceState(initialValue = false, key1 = isLoading) { produceState(initialValue = false, key1 = isLoading) {
if (!isLoading) { if (!isLoading) {
@@ -2402,6 +2492,13 @@ fun ChatDetailScreen(
label = "listBottomPadding" label = "listBottomPadding"
) )
when { when {
groupRequiresRejoin -> {
GroupRejoinRequiredState(
isDarkTheme = isDarkTheme,
hasChatWallpaper = hasChatWallpaper,
modifier = Modifier.fillMaxSize()
)
}
// 🔥 СКЕЛЕТОН - показываем пока загружаются // 🔥 СКЕЛЕТОН - показываем пока загружаются
// сообщения // сообщения
showMessageSkeleton -> { showMessageSkeleton -> {

View File

@@ -166,6 +166,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _isLoadingMore = MutableStateFlow(false) private val _isLoadingMore = MutableStateFlow(false)
val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow() val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow()
private val _groupRequiresRejoin = MutableStateFlow(false)
val groupRequiresRejoin: StateFlow<Boolean> = _groupRequiresRejoin.asStateFlow()
private val _opponentTyping = MutableStateFlow(false) private val _opponentTyping = MutableStateFlow(false)
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow() val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
private var typingTimeoutJob: kotlinx.coroutines.Job? = null private var typingTimeoutJob: kotlinx.coroutines.Job? = null
@@ -448,10 +451,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val nextStatus = val nextStatus =
when (msg.status) { when (msg.status) {
// Read event can promote only already-sent/delivered messages. // Read event can promote only messages that were already delivery-confirmed.
MessageStatus.SENT,
MessageStatus.DELIVERED, MessageStatus.DELIVERED,
MessageStatus.READ -> MessageStatus.READ MessageStatus.READ -> MessageStatus.READ
MessageStatus.SENT,
MessageStatus.SENDING, MessageStatus.SENDING,
MessageStatus.ERROR -> msg.status MessageStatus.ERROR -> msg.status
} }
@@ -609,6 +612,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (!isGroupDialogKey(publicKey)) { if (!isGroupDialogKey(publicKey)) {
groupKeyCache.remove(publicKey) groupKeyCache.remove(publicKey)
} }
_groupRequiresRejoin.value = false
groupSenderNameCache.clear() groupSenderNameCache.clear()
groupSenderResolveRequested.clear() groupSenderResolveRequested.clear()
@@ -735,6 +739,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
loadingJob = loadingJob =
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
if (isGroupDialogKey(opponent)) {
val hasGroupKey = resolveGroupKeyForDialog(opponent) != null
withContext(Dispatchers.Main.immediate) {
_groupRequiresRejoin.value = !hasGroupKey
if (!hasGroupKey) {
_messages.value = emptyList()
_isLoading.value = false
}
}
if (!hasGroupKey) {
hasMoreMessages = false
currentOffset = 0
isLoadingMessages = false
return@launch
}
} else {
_groupRequiresRejoin.value = false
}
// 🔥 МГНОВЕННАЯ загрузка из кэша если есть! // 🔥 МГНОВЕННАЯ загрузка из кэша если есть!
val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)] val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
@@ -2574,9 +2597,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
// 3. 🎯 UI обновление в Main потоке // 3. 🎯 UI обновление в Main потоке
// Для обычных диалогов статус остаётся SENDING до PacketDelivery(messageId).
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// 📁 Для Saved Messages - сразу SENT, для обычных - ждём delivery if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT) updateMessageStatus(messageId, MessageStatus.SENT)
}
} }
// 4. 💾 Сохранение в БД с attachments // 4. 💾 Сохранение в БД с attachments
@@ -2923,13 +2948,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
updateMessageStatusAndAttachmentsInDb( updateMessageStatusAndAttachmentsInDb(
messageId = messageId, messageId = messageId,
delivered = 1, delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = finalAttachmentsJson attachmentsJson = finalAttachmentsJson
) )
if (isCurrentDialogTarget) { if (isCurrentDialogTarget) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT) if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
} }
} }
refreshTargetDialog() refreshTargetDialog()
@@ -3275,19 +3302,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// (сообщение уже существует в БД от optimistic UI, поэтому просто обновляем) // (сообщение уже существует в БД от optimistic UI, поэтому просто обновляем)
val finalAttachmentsJson = attachmentsJson // Уже без localUri val finalAttachmentsJson = attachmentsJson // Уже без localUri
if (!isSavedMessages) { val deliveryStatus = if (isSavedMessages) 1 else 0
updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson) updateMessageStatusAndAttachmentsInDb(messageId, deliveryStatus, finalAttachmentsJson)
} else {
updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson)
}
logPhotoPipeline(messageId, "db status+attachments updated") logPhotoPipeline(messageId, "db status+attachments updated")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT) if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
// Также очищаем localUri в UI // Также очищаем localUri в UI
updateMessageAttachments(messageId, null) updateMessageAttachments(messageId, null)
} }
logPhotoPipeline(messageId, "ui status switched to SENT") logPhotoPipeline(
messageId,
if (isSavedMessages) "ui status switched to SENT"
else "ui status kept at SENDING until delivery ACK"
)
saveDialog( saveDialog(
lastMessage = if (caption.isNotEmpty()) caption else "photo", lastMessage = if (caption.isNotEmpty()) caption else "photo",
@@ -3468,13 +3498,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient opponentPublicKey = recipient
) )
// 🔥 После успешной отправки обновляем статус на DELIVERED (1) в БД и UI // Для обычных диалогов остаёмся в SENDING до PacketDelivery(messageId).
if (!isSavedMessages) { withContext(Dispatchers.Main) {
updateMessageStatusInDb(messageId, 1) // DELIVERED if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
} }
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
saveDialog( saveDialog(
lastMessage = if (text.isNotEmpty()) text else "photo", lastMessage = if (text.isNotEmpty()) text else "photo",
timestamp = timestamp, timestamp = timestamp,
@@ -3739,7 +3769,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
updateMessageStatusAndAttachmentsInDb( updateMessageStatusAndAttachmentsInDb(
messageId = messageId, messageId = messageId,
delivered = 1, delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = finalDbAttachments.toString() attachmentsJson = finalDbAttachments.toString()
) )
@@ -3748,7 +3778,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_messages.value.map { msg -> _messages.value.map { msg ->
if (msg.id != messageId) return@map msg if (msg.id != messageId) return@map msg
msg.copy( msg.copy(
status = MessageStatus.SENT, status =
if (isSavedMessages) MessageStatus.SENT
else MessageStatus.SENDING,
attachments = attachments =
msg.attachments.map { current -> msg.attachments.map { current ->
val final = finalAttachmentsById[current.id] val final = finalAttachmentsById[current.id]
@@ -3974,14 +4006,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient opponentPublicKey = recipient
) )
// 🔥 Обновляем статус в БД после отправки // Обновляем UI: для обычных чатов оставляем SENDING до PacketDelivery(messageId).
if (!isSavedMessages) { withContext(Dispatchers.Main) {
updateMessageStatusInDb(messageId, 1) // DELIVERED if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
} }
// Обновляем UI
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
saveDialog( saveDialog(
lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos", lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos",
timestamp = timestamp, timestamp = timestamp,
@@ -4164,13 +4195,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
attachmentsJson = attachmentsJson attachmentsJson = attachmentsJson
) )
// 🔥 После успешной отправки обновляем статус на DELIVERED (1) в БД и UI // Для обычных чатов статус подтверждаем только по PacketDelivery(messageId).
if (!isSavedMessages) { withContext(Dispatchers.Main) {
updateMessageStatusInDb(messageId, 1) // DELIVERED if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
} }
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
saveDialog(if (text.isNotEmpty()) text else "file", timestamp) saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
} catch (e: Exception) { } catch (e: Exception) {
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
@@ -4394,14 +4425,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
attachmentsJson = attachmentsJson attachmentsJson = attachmentsJson
) )
// 🔥 Обновляем статус в БД после отправки // Обновляем UI: для обычных чатов остаёмся в SENDING до PacketDelivery(messageId).
if (!isSavedMessages) { withContext(Dispatchers.Main) {
updateMessageStatusInDb(messageId, 1) // DELIVERED if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
} }
// Обновляем UI
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
saveDialog("\$a=Avatar", timestamp) saveDialog("\$a=Avatar", timestamp)
} catch (e: Exception) { } catch (e: Exception) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {

View File

@@ -2465,6 +2465,8 @@ fun ChatsListScreen(
dialog.opponentKey, dialog.opponentKey,
isSelected = isSelected =
isSelectedDialog, isSelectedDialog,
syncInProgress =
syncInProgress,
onSwipeStarted = { onSwipeStarted = {
swipedItemKey = swipedItemKey =
dialog.opponentKey dialog.opponentKey
@@ -3501,6 +3503,7 @@ fun SwipeableDialogItem(
isDrawerOpen: Boolean = false, isDrawerOpen: Boolean = false,
isSwipedOpen: Boolean = false, isSwipedOpen: Boolean = false,
isSelected: Boolean = false, isSelected: Boolean = false,
syncInProgress: Boolean = false,
onSwipeStarted: () -> Unit = {}, onSwipeStarted: () -> Unit = {},
onSwipeClosed: () -> Unit = {}, onSwipeClosed: () -> Unit = {},
onClick: () -> Unit, onClick: () -> Unit,
@@ -3901,6 +3904,7 @@ fun SwipeableDialogItem(
isPinned = isPinned, isPinned = isPinned,
isBlocked = isBlocked, isBlocked = isBlocked,
isMuted = isMuted, isMuted = isMuted,
syncInProgress = syncInProgress,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
onClick = null // Tap handled by parent pointerInput onClick = null // Tap handled by parent pointerInput
) )
@@ -3917,6 +3921,7 @@ fun DialogItemContent(
isPinned: Boolean = false, isPinned: Boolean = false,
isBlocked: Boolean = false, isBlocked: Boolean = false,
isMuted: Boolean = false, isMuted: Boolean = false,
syncInProgress: Boolean = false,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
onClick: (() -> Unit)? = null onClick: (() -> Unit)? = null
) { ) {
@@ -4148,8 +4153,8 @@ fun DialogItemContent(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.End
) { ) {
// <20> Скрываем статус доставки когда есть черновик (как в Telegram) // Скрываем статус доставки при черновике и во время синхронизации.
if (dialog.draftText.isNullOrEmpty()) { if (dialog.draftText.isNullOrEmpty() && !syncInProgress) {
// <20>📁 Для Saved Messages ВСЕГДА показываем синие двойные // <20>📁 Для Saved Messages ВСЕГДА показываем синие двойные
// галочки (прочитано) // галочки (прочитано)
if (dialog.isSavedMessages) { if (dialog.isSavedMessages) {
@@ -4284,7 +4289,7 @@ fun DialogItemContent(
} }
} }
} }
} // 📝 end if (draftText.isNullOrEmpty) — скрываем статус при наличии черновика } // 📝 end if (draftText.isNullOrEmpty && !syncInProgress)
val formattedTime = val formattedTime =
remember(dialog.lastMessageTimestamp) { remember(dialog.lastMessageTimestamp) {

View File

@@ -338,8 +338,12 @@ object UpdateManager {
persistState(ctx) persistState(ctx)
} }
val version = latestUpdateInfo?.version val availableVersion = resolveAvailableUpdateVersion()
_updateState.value = if (version != null) UpdateState.UpdateAvailable(version) else UpdateState.Idle _updateState.value = if (availableVersion != null) {
UpdateState.UpdateAvailable(availableVersion)
} else {
UpdateState.Idle
}
_downloadProgress.value = 0 _downloadProgress.value = 0
} }
@@ -366,7 +370,7 @@ object UpdateManager {
} }
private fun setUpdateAvailableOrIdleState() { private fun setUpdateAvailableOrIdleState() {
val version = latestUpdateInfo?.version ?: activeApkVersion val version = resolveAvailableUpdateVersion()
_updateState.value = if (!version.isNullOrBlank()) { _updateState.value = if (!version.isNullOrBlank()) {
UpdateState.UpdateAvailable(version) UpdateState.UpdateAvailable(version)
} else { } else {
@@ -374,6 +378,29 @@ object UpdateManager {
} }
} }
private fun resolveAvailableUpdateVersion(): String? {
val latestVersion =
latestUpdateInfo
?.takeIf {
!it.servicePackUrl.isNullOrBlank() &&
it.version.isNotBlank() &&
compareVersions(it.version, appVersion) > 0
}
?.version
if (!latestVersion.isNullOrBlank()) {
return latestVersion
}
val persistedVersion = activeApkVersion
return if (!persistedVersion.isNullOrBlank() &&
compareVersions(persistedVersion, appVersion) > 0
) {
persistedVersion
} else {
null
}
}
private fun restorePersistedState(context: Context) { private fun restorePersistedState(context: Context) {
val preferences = prefs(context) val preferences = prefs(context)
val restoredId = preferences.getLong(KEY_DOWNLOAD_ID, -1L).takeIf { it >= 0L } val restoredId = preferences.getLong(KEY_DOWNLOAD_ID, -1L).takeIf { it >= 0L }
@@ -529,12 +556,17 @@ object UpdateManager {
if (items == null || items.length() == 0) { if (items == null || items.length() == 0) {
sduLog("No items in response, trying legacy format...") sduLog("No items in response, trying legacy format...")
// Fallback на старый формат /updates/get // Fallback на старый формат /updates/get
val version = obj.optString("version", "")
val rawServicePackUrl =
obj.optString("service_pack_url", "").takeIf { it.isNotEmpty() && it != "null" }
val hasNewerVersion =
version.isNotBlank() && compareVersions(version, appVersion) > 0
return UpdateInfo( return UpdateInfo(
version = obj.optString("version", ""), version = version,
platform = obj.optString("platform", ""), platform = obj.optString("platform", ""),
arch = obj.optString("arch", ""), arch = obj.optString("arch", ""),
kernelUpdateRequired = obj.optBoolean("kernel_update_required", false), kernelUpdateRequired = obj.optBoolean("kernel_update_required", false),
servicePackUrl = obj.optString("service_pack_url", "").takeIf { it.isNotEmpty() && it != "null" }, servicePackUrl = rawServicePackUrl?.takeIf { hasNewerVersion },
kernelUrl = obj.optString("kernel_url", "").takeIf { it.isNotEmpty() && it != "null" } kernelUrl = obj.optString("kernel_url", "").takeIf { it.isNotEmpty() && it != "null" }
) )
} }