From dcc719ec56210121c4c5dbfb2aa7c0e6560f2da3 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 6 Feb 2026 03:17:22 +0500 Subject: [PATCH] feat: implement delivery status updates and enhance file size limit for uploads --- .../messenger/data/MessageRepository.kt | 32 +++++++++++-- .../com/rosetta/messenger/network/Packets.kt | 5 +- .../messenger/network/TransportManager.kt | 44 +++++++++++++----- .../messenger/ui/chats/ChatDetailScreen.kt | 6 ++- .../messenger/ui/chats/ChatViewModel.kt | 46 +++++++++++++++++-- .../ui/chats/models/ChatDetailModels.kt | 1 + .../com/rosetta/messenger/utils/MediaUtils.kt | 4 +- 7 files changed, 115 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 7e5482c..3bd787b 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -74,7 +74,21 @@ class MessageRepository private constructor(private val context: Context) { onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST ) val newMessageEvents: SharedFlow = _newMessageEvents.asSharedFlow() - + + // 🔔 События обновления статуса доставки для UI + data class DeliveryStatusUpdate( + val dialogKey: String, + val messageId: String, + val status: DeliveryStatus + ) + + private val _deliveryStatusEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST + ) + val deliveryStatusEvents: SharedFlow = _deliveryStatusEvents.asSharedFlow() + // 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы private val requestedUserInfoKeys = mutableSetOf() @@ -511,11 +525,16 @@ class MessageRepository private constructor(private val context: Context) { // Обновляем кэш val dialogKey = getDialogKey(packet.toPublicKey) updateMessageStatus(dialogKey, packet.messageId, DeliveryStatus.DELIVERED) - + + // 🔔 Уведомляем UI о смене статуса доставки + _deliveryStatusEvents.tryEmit( + DeliveryStatusUpdate(dialogKey, packet.messageId, DeliveryStatus.DELIVERED) + ) + // 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageDelivered обновился dialogDao.updateDialogFromMessages(account, packet.toPublicKey) } - + /** * Обработка прочтения * В Desktop PacketRead сообщает что собеседник прочитал наши сообщения @@ -544,7 +563,12 @@ class MessageRepository private constructor(private val context: Context) { else msg } } - + + // 🔔 Уведомляем UI о прочтении (пустой messageId = все исходящие сообщения) + _deliveryStatusEvents.tryEmit( + DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ) + ) + // 📝 LOG: Статус прочтения MessageLogger.logReadStatus( fromPublicKey = packet.fromPublicKey, diff --git a/app/src/main/java/com/rosetta/messenger/network/Packets.kt b/app/src/main/java/com/rosetta/messenger/network/Packets.kt index f91c8e1..1fc445a 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Packets.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Packets.kt @@ -249,8 +249,9 @@ enum class AttachmentType(val value: Int) { enum class DeliveryStatus(val value: Int) { WAITING(0), // Ожидает отправки DELIVERED(1), // Доставлено - ERROR(2); // Ошибка - + ERROR(2), // Ошибка + READ(3); // Прочитано + companion object { fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: WAITING } diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt index 69880dc..fb59b1e 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -42,9 +42,9 @@ object TransportManager { val downloading: StateFlow> = _downloading.asStateFlow() private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.MINUTES) // 5 минут для больших файлов + .writeTimeout(5, TimeUnit.MINUTES) // 5 минут для больших файлов .build() /** @@ -76,7 +76,7 @@ object TransportManager { } /** - * Загрузить файл на транспортный сервер + * Загрузить файл на транспортный сервер с отслеживанием прогресса * @param id Уникальный ID файла * @param content Содержимое файла (base64 или бинарные данные) * @return Tag для скачивания файла @@ -84,21 +84,43 @@ object TransportManager { suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) { val server = getActiveServer() - // Добавляем в список загрузок _uploading.value = _uploading.value + TransportState(id, 0) try { // 🔥 КРИТИЧНО: Преобразуем строку в байты (как desktop делает new Blob([content])) val contentBytes = content.toByteArray(Charsets.UTF_8) + val totalSize = contentBytes.size.toLong() + + // 🔥 RequestBody с отслеживанием прогресса загрузки + val progressRequestBody = object : RequestBody() { + override fun contentType() = "application/octet-stream".toMediaType() + override fun contentLength() = totalSize + + override fun writeTo(sink: okio.BufferedSink) { + val source = okio.Buffer().write(contentBytes) + var uploaded = 0L + val bufferSize = 8 * 1024L // 8 KB chunks + + while (true) { + val read = source.read(sink.buffer, bufferSize) + if (read == -1L) break + + uploaded += read + sink.flush() + + // Обновляем прогресс + val progress = ((uploaded * 100) / totalSize).toInt() + _uploading.value = _uploading.value.map { + if (it.id == id) it.copy(progress = progress) else it + } + } + } + } val requestBody = MultipartBody.Builder() .setType(MultipartBody.FORM) - .addFormDataPart( - "file", - id, - contentBytes.toRequestBody("application/octet-stream".toMediaType()) - ) + .addFormDataPart("file", id, progressRequestBody) .build() val request = Request.Builder() @@ -126,11 +148,9 @@ object TransportManager { val responseBody = response.body?.string() ?: throw IOException("Empty response") - // Parse JSON response to get tag val tag = org.json.JSONObject(responseBody).getString("t") - // Обновляем прогресс до 100% _uploading.value = _uploading.value.map { if (it.id == id) it.copy(progress = 100) else it diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 06400d8..b3b9405 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -255,7 +255,11 @@ fun ChatDetailScreen( // Проверяем размер файла if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) { - // TODO: Показать ошибку + android.widget.Toast.makeText( + context, + "Файл слишком большой (макс. ${MediaUtils.MAX_FILE_SIZE_MB} МБ)", + android.widget.Toast.LENGTH_LONG + ).show() return@launch } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index ff6c264..99a42f0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -189,6 +189,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { init { setupPacketListeners() setupNewMessageListener() + setupDeliveryStatusListener() } // 🔥 Debounce для защиты от спама входящих сообщений @@ -316,10 +317,49 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } + /** + * 🔔 Подписка на события изменения статуса доставки + * Обновляет UI когда сообщение доставлено или прочитано + */ + private fun setupDeliveryStatusListener() { + viewModelScope.launch { + messageRepository.deliveryStatusEvents.collect { update -> + val account = myPublicKey ?: return@collect + val opponent = opponentKey ?: return@collect + val currentDialogKey = getDialogKey(account, opponent) + + if (update.dialogKey == currentDialogKey) { + when (update.status) { + DeliveryStatus.DELIVERED -> { + // Обновляем конкретное сообщение + updateMessageStatus(update.messageId, MessageStatus.DELIVERED) + } + DeliveryStatus.READ -> { + // Помечаем все исходящие как прочитанные + markAllOutgoingAsRead() + } + else -> {} + } + } + } + } + } + + /** + * Помечает все исходящие сообщения как прочитанные + */ + private fun markAllOutgoingAsRead() { + _messages.value = _messages.value.map { msg -> + if (msg.isOutgoing && msg.status != MessageStatus.READ) { + msg.copy(status = MessageStatus.READ) + } else msg + } + updateCacheFromCurrentMessages() + } + private fun setupPacketListeners() { - // ✅ Обработчики 0x06, 0x07, 0x08 удалены - они обрабатываются ТОЛЬКО в ProtocolManager - // Это предотвращает дублирование сообщений и статусов при навигации между чатами - // ChatViewModel получает обновления через messageCache Flow из MessageRepository + // ✅ Обработчики 0x06, 0x07, 0x08 обрабатываются в ProtocolManager → MessageRepository + // ChatViewModel получает обновления через deliveryStatusEvents SharedFlow // Typing - нужен здесь для UI текущего чата ProtocolManager.waitPacket(0x0B, typingPacketHandler) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt index 68927b6..5062715 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt @@ -66,5 +66,6 @@ fun Message.toChatMessage() = ChatMessage( DeliveryStatus.WAITING -> MessageStatus.SENDING DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED DeliveryStatus.ERROR -> MessageStatus.SENT + DeliveryStatus.READ -> MessageStatus.READ } ) diff --git a/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt b/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt index e193218..5584fdc 100644 --- a/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt +++ b/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt @@ -26,7 +26,9 @@ object MediaUtils { private const val IMAGE_QUALITY = 85 // Максимальный размер файла в МБ - const val MAX_FILE_SIZE_MB = 15 + // Android ограничение: файл + base64 + шифрование = ~3x памяти + // 20 МБ файл = ~60 МБ RAM, безопасно для большинства устройств + const val MAX_FILE_SIZE_MB = 20 /** * Конвертировать изображение из Uri в Base64 PNG