feat: implement delivery status updates and enhance file size limit for uploads
This commit is contained in:
@@ -75,6 +75,20 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
)
|
)
|
||||||
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>()
|
||||||
|
|
||||||
@@ -512,6 +526,11 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -545,6 +564,11 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔔 Уведомляем UI о прочтении (пустой messageId = все исходящие сообщения)
|
||||||
|
_deliveryStatusEvents.tryEmit(
|
||||||
|
DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ)
|
||||||
|
)
|
||||||
|
|
||||||
// 📝 LOG: Статус прочтения
|
// 📝 LOG: Статус прочтения
|
||||||
MessageLogger.logReadStatus(
|
MessageLogger.logReadStatus(
|
||||||
fromPublicKey = packet.fromPublicKey,
|
fromPublicKey = packet.fromPublicKey,
|
||||||
|
|||||||
@@ -249,7 +249,8 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user