feat: implement delivery status updates and enhance file size limit for uploads

This commit is contained in:
2026-02-06 03:17:22 +05:00
parent 0bd8cb39ab
commit dcc719ec56
7 changed files with 115 additions and 23 deletions

View File

@@ -74,7 +74,21 @@ class MessageRepository private constructor(private val context: Context) {
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
) )
val newMessageEvents: SharedFlow<String> = _newMessageEvents.asSharedFlow() val newMessageEvents: SharedFlow<String> = _newMessageEvents.asSharedFlow()
// 🔔 События обновления статуса доставки для UI
data class DeliveryStatusUpdate(
val dialogKey: String,
val messageId: String,
val status: DeliveryStatus
)
private val _deliveryStatusEvents = MutableSharedFlow<DeliveryStatusUpdate>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
)
val deliveryStatusEvents: SharedFlow<DeliveryStatusUpdate> = _deliveryStatusEvents.asSharedFlow()
// 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы // 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы
private val requestedUserInfoKeys = mutableSetOf<String>() private val requestedUserInfoKeys = mutableSetOf<String>()
@@ -511,11 +525,16 @@ class MessageRepository private constructor(private val context: Context) {
// Обновляем кэш // Обновляем кэш
val dialogKey = getDialogKey(packet.toPublicKey) val dialogKey = getDialogKey(packet.toPublicKey)
updateMessageStatus(dialogKey, packet.messageId, DeliveryStatus.DELIVERED) updateMessageStatus(dialogKey, packet.messageId, DeliveryStatus.DELIVERED)
// 🔔 Уведомляем UI о смене статуса доставки
_deliveryStatusEvents.tryEmit(
DeliveryStatusUpdate(dialogKey, packet.messageId, DeliveryStatus.DELIVERED)
)
// 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageDelivered обновился // 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageDelivered обновился
dialogDao.updateDialogFromMessages(account, packet.toPublicKey) dialogDao.updateDialogFromMessages(account, packet.toPublicKey)
} }
/** /**
* Обработка прочтения * Обработка прочтения
* В Desktop PacketRead сообщает что собеседник прочитал наши сообщения * В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
@@ -544,7 +563,12 @@ class MessageRepository private constructor(private val context: Context) {
else msg else msg
} }
} }
// 🔔 Уведомляем UI о прочтении (пустой messageId = все исходящие сообщения)
_deliveryStatusEvents.tryEmit(
DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ)
)
// 📝 LOG: Статус прочтения // 📝 LOG: Статус прочтения
MessageLogger.logReadStatus( MessageLogger.logReadStatus(
fromPublicKey = packet.fromPublicKey, fromPublicKey = packet.fromPublicKey,

View File

@@ -249,8 +249,9 @@ enum class AttachmentType(val value: Int) {
enum class DeliveryStatus(val value: Int) { enum class DeliveryStatus(val value: Int) {
WAITING(0), // Ожидает отправки WAITING(0), // Ожидает отправки
DELIVERED(1), // Доставлено DELIVERED(1), // Доставлено
ERROR(2); // Ошибка ERROR(2), // Ошибка
READ(3); // Прочитано
companion object { companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: WAITING fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: WAITING
} }

View File

@@ -42,9 +42,9 @@ object TransportManager {
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow() val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS) .readTimeout(5, TimeUnit.MINUTES) // 5 минут для больших файлов
.writeTimeout(60, TimeUnit.SECONDS) .writeTimeout(5, TimeUnit.MINUTES) // 5 минут для больших файлов
.build() .build()
/** /**
@@ -76,7 +76,7 @@ object TransportManager {
} }
/** /**
* Загрузить файл на транспортный сервер * Загрузить файл на транспортный сервер с отслеживанием прогресса
* @param id Уникальный ID файла * @param id Уникальный ID файла
* @param content Содержимое файла (base64 или бинарные данные) * @param content Содержимое файла (base64 или бинарные данные)
* @return Tag для скачивания файла * @return Tag для скачивания файла
@@ -84,21 +84,43 @@ object TransportManager {
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) { suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
val server = getActiveServer() val server = getActiveServer()
// Добавляем в список загрузок // Добавляем в список загрузок
_uploading.value = _uploading.value + TransportState(id, 0) _uploading.value = _uploading.value + TransportState(id, 0)
try { try {
// 🔥 КРИТИЧНО: Преобразуем строку в байты (как desktop делает new Blob([content])) // 🔥 КРИТИЧНО: Преобразуем строку в байты (как desktop делает new Blob([content]))
val contentBytes = content.toByteArray(Charsets.UTF_8) 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() val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
.addFormDataPart( .addFormDataPart("file", id, progressRequestBody)
"file",
id,
contentBytes.toRequestBody("application/octet-stream".toMediaType())
)
.build() .build()
val request = Request.Builder() val request = Request.Builder()
@@ -126,11 +148,9 @@ object TransportManager {
val responseBody = response.body?.string() val responseBody = response.body?.string()
?: throw IOException("Empty response") ?: throw IOException("Empty response")
// Parse JSON response to get tag // Parse JSON response to get tag
val tag = org.json.JSONObject(responseBody).getString("t") val tag = org.json.JSONObject(responseBody).getString("t")
// Обновляем прогресс до 100% // Обновляем прогресс до 100%
_uploading.value = _uploading.value.map { _uploading.value = _uploading.value.map {
if (it.id == id) it.copy(progress = 100) else it if (it.id == id) it.copy(progress = 100) else it

View File

@@ -255,7 +255,11 @@ fun ChatDetailScreen(
// Проверяем размер файла // Проверяем размер файла
if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) { 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 return@launch
} }

View File

@@ -189,6 +189,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
init { init {
setupPacketListeners() setupPacketListeners()
setupNewMessageListener() setupNewMessageListener()
setupDeliveryStatusListener()
} }
// 🔥 Debounce для защиты от спама входящих сообщений // 🔥 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() { private fun setupPacketListeners() {
// ✅ Обработчики 0x06, 0x07, 0x08 удалены - они обрабатываются ТОЛЬКО в ProtocolManager // ✅ Обработчики 0x06, 0x07, 0x08 обрабатываются в ProtocolManager → MessageRepository
// Это предотвращает дублирование сообщений и статусов при навигации между чатами // ChatViewModel получает обновления через deliveryStatusEvents SharedFlow
// ChatViewModel получает обновления через messageCache Flow из MessageRepository
// Typing - нужен здесь для UI текущего чата // Typing - нужен здесь для UI текущего чата
ProtocolManager.waitPacket(0x0B, typingPacketHandler) ProtocolManager.waitPacket(0x0B, typingPacketHandler)

View File

@@ -66,5 +66,6 @@ fun Message.toChatMessage() = ChatMessage(
DeliveryStatus.WAITING -> MessageStatus.SENDING DeliveryStatus.WAITING -> MessageStatus.SENDING
DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED
DeliveryStatus.ERROR -> MessageStatus.SENT DeliveryStatus.ERROR -> MessageStatus.SENT
DeliveryStatus.READ -> MessageStatus.READ
} }
) )

View File

@@ -26,7 +26,9 @@ object MediaUtils {
private const val IMAGE_QUALITY = 85 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 * Конвертировать изображение из Uri в Base64 PNG