feat: Implement AttachmentFileManager for handling image attachments; add methods for saving, reading, and managing attachment files
This commit is contained in:
@@ -6,6 +6,7 @@ import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.database.*
|
||||
import com.rosetta.messenger.network.*
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
@@ -331,7 +332,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
privateKey
|
||||
)
|
||||
|
||||
// <EFBFBD> Обрабатываем AVATAR attachments - сохраняем аватар отправителя
|
||||
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
|
||||
processImageAttachments(packet.attachments, packet.chachaKey, privateKey)
|
||||
|
||||
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
|
||||
processAvatarAttachments(packet.attachments, packet.fromPublicKey, packet.chachaKey, privateKey)
|
||||
|
||||
// <20>🔒 Шифруем plainMessage с использованием приватного ключа
|
||||
@@ -810,6 +814,56 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🖼️ Обработка IMAGE attachments - сохранение в файл (как в desktop)
|
||||
* Desktop сохраняет: writeFile(`m/${md5(attachment.id + publicKey)}`, encryptedBlob)
|
||||
* Файлы (FILE тип) НЕ сохраняются - они слишком большие, загружаются с CDN
|
||||
*/
|
||||
private fun processImageAttachments(
|
||||
attachments: List<MessageAttachment>,
|
||||
encryptedKey: String,
|
||||
privateKey: String
|
||||
) {
|
||||
val publicKey = currentAccount ?: return
|
||||
|
||||
for (attachment in attachments) {
|
||||
// Сохраняем только IMAGE, не FILE (файлы загружаются с CDN при необходимости)
|
||||
if (attachment.type == AttachmentType.IMAGE && attachment.blob.isNotEmpty()) {
|
||||
try {
|
||||
Log.d("MessageRepository", "🖼️ Processing IMAGE attachment: ${attachment.id}")
|
||||
|
||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||
val decryptedBlob = MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Сохраняем в файл (как в desktop)
|
||||
val saved = AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = decryptedBlob,
|
||||
attachmentId = attachment.id,
|
||||
publicKey = publicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
|
||||
if (saved) {
|
||||
Log.d("MessageRepository", "🖼️ ✅ Image saved to file: ${attachment.id}")
|
||||
} else {
|
||||
Log.w("MessageRepository", "🖼️ ⚠️ Failed to save image to file")
|
||||
}
|
||||
} else {
|
||||
Log.w("MessageRepository", "🖼️ ⚠️ Decryption returned null for image")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MessageRepository", "🖼️ ❌ Failed to process image attachment", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сериализация attachments в JSON с RE-ENCRYPTION для хранения в БД
|
||||
* Для MESSAGES типа:
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.rosetta.messenger.database.MessageEntity
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.*
|
||||
import com.rosetta.messenger.ui.chats.models.*
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.json.JSONArray
|
||||
@@ -770,6 +771,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
/**
|
||||
* Парсинг всех attachments из JSON (кроме MESSAGES который обрабатывается отдельно)
|
||||
* 💾 Для IMAGE - загружает blob из файловой системы если пустой в БД
|
||||
*/
|
||||
private fun parseAllAttachments(attachmentsJson: String): List<MessageAttachment> {
|
||||
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
|
||||
@@ -779,6 +781,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return try {
|
||||
val attachments = JSONArray(attachmentsJson)
|
||||
val result = mutableListOf<MessageAttachment>()
|
||||
val publicKey = myPublicKey ?: ""
|
||||
val privateKey = myPrivateKey ?: ""
|
||||
|
||||
for (i in 0 until attachments.length()) {
|
||||
val attachment = attachments.getJSONObject(i)
|
||||
@@ -787,11 +791,28 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Пропускаем MESSAGES (1) - это reply, обрабатывается отдельно
|
||||
if (type == 1) continue
|
||||
|
||||
var blob = attachment.optString("blob", "")
|
||||
val attachmentId = attachment.optString("id", "")
|
||||
val attachmentType = AttachmentType.fromInt(type)
|
||||
|
||||
// 💾 Для IMAGE - пробуем загрузить blob из файла если пустой
|
||||
if (attachmentType == AttachmentType.IMAGE && blob.isEmpty() && attachmentId.isNotEmpty()) {
|
||||
val fileBlob = AttachmentFileManager.readAttachment(
|
||||
context = getApplication(),
|
||||
attachmentId = attachmentId,
|
||||
publicKey = publicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
if (fileBlob != null) {
|
||||
blob = fileBlob
|
||||
}
|
||||
}
|
||||
|
||||
result.add(
|
||||
MessageAttachment(
|
||||
id = attachment.optString("id", ""),
|
||||
blob = attachment.optString("blob", ""),
|
||||
type = AttachmentType.fromInt(type),
|
||||
id = attachmentId,
|
||||
blob = blob,
|
||||
type = attachmentType,
|
||||
preview = attachment.optString("preview", ""),
|
||||
width = attachment.optInt("width", 0),
|
||||
height = attachment.optInt("height", 0)
|
||||
@@ -1404,8 +1425,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
|
||||
// 💾 Сохраняем изображение в файл (как в desktop)
|
||||
// Файлы НЕ сохраняем - они слишком большие, загружаются с CDN
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = getApplication(),
|
||||
blob = imageBase64,
|
||||
attachmentId = imageAttachment.id,
|
||||
publicKey = sender,
|
||||
privateKey = privateKey
|
||||
)
|
||||
|
||||
// ⚠️ НЕ сохраняем blob в БД - он слишком большой (SQLite CursorWindow 2MB limit)
|
||||
// Изображение должно храниться в файловой системе или загружаться с сервера при необходимости
|
||||
// Изображение хранится в файловой системе
|
||||
val attachmentsJson = JSONArray().apply {
|
||||
put(JSONObject().apply {
|
||||
put("id", imageAttachment.id)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.rosetta.messenger.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Менеджер для работы с файлами вложений (изображения)
|
||||
* Совместимо с desktop версией:
|
||||
* - Все файлы зашифрованы приватным ключом пользователя
|
||||
* - Формат пути: "m/md5hash" (без расширения)
|
||||
* - MD5 генерируется из (attachmentId + publicKey)
|
||||
*
|
||||
* ВАЖНО: Файлы (FILE тип) НЕ сохраняются локально - они слишком большие
|
||||
* Они загружаются с CDN при необходимости
|
||||
*/
|
||||
object AttachmentFileManager {
|
||||
|
||||
private const val TAG = "AttachmentFileManager"
|
||||
private const val ATTACHMENTS_DIR = "attachments"
|
||||
|
||||
/**
|
||||
* Генерирует MD5-хэш для пути к файлу (как в desktop)
|
||||
* @param attachmentId ID вложения
|
||||
* @param publicKey Публичный ключ пользователя
|
||||
* @return Путь формата "m/md5hash"
|
||||
*/
|
||||
fun generatePath(attachmentId: String, publicKey: String): String {
|
||||
val input = attachmentId + publicKey
|
||||
val md5 = MessageDigest.getInstance("MD5")
|
||||
.digest(input.toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
return "m/$md5"
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить вложение (изображение) в файловую систему
|
||||
* @param context Android context
|
||||
* @param blob Base64-encoded данные изображения
|
||||
* @param attachmentId ID вложения
|
||||
* @param publicKey Публичный ключ пользователя
|
||||
* @param privateKey Приватный ключ для шифрования
|
||||
* @return true если успешно сохранено
|
||||
*/
|
||||
fun saveAttachment(
|
||||
context: Context,
|
||||
blob: String,
|
||||
attachmentId: String,
|
||||
publicKey: String,
|
||||
privateKey: String
|
||||
): Boolean {
|
||||
return try {
|
||||
if (blob.isEmpty()) {
|
||||
Log.w(TAG, "💾 Empty blob, skipping save")
|
||||
return false
|
||||
}
|
||||
|
||||
val filePath = generatePath(attachmentId, publicKey)
|
||||
Log.d(TAG, "💾 Saving attachment: $attachmentId -> $filePath")
|
||||
|
||||
// Шифруем данные приватным ключом (как в desktop)
|
||||
val encrypted = CryptoManager.encryptWithPassword(blob, privateKey)
|
||||
|
||||
// Создаем директории
|
||||
val dir = File(context.filesDir, ATTACHMENTS_DIR)
|
||||
dir.mkdirs()
|
||||
|
||||
val parts = filePath.split("/")
|
||||
if (parts.size == 2) {
|
||||
val subDir = File(dir, parts[0])
|
||||
subDir.mkdirs()
|
||||
val file = File(subDir, parts[1])
|
||||
file.writeText(encrypted)
|
||||
Log.d(TAG, "💾 Saved to: ${file.absolutePath} (${encrypted.length} bytes)")
|
||||
} else {
|
||||
val file = File(dir, filePath)
|
||||
file.writeText(encrypted)
|
||||
}
|
||||
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Error saving attachment", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Прочитать и расшифровать вложение
|
||||
* @param context Android context
|
||||
* @param attachmentId ID вложения
|
||||
* @param publicKey Публичный ключ пользователя
|
||||
* @param privateKey Приватный ключ для расшифровки
|
||||
* @return Base64-encoded данные или null если не найдено
|
||||
*/
|
||||
fun readAttachment(
|
||||
context: Context,
|
||||
attachmentId: String,
|
||||
publicKey: String,
|
||||
privateKey: String
|
||||
): String? {
|
||||
return try {
|
||||
val filePath = generatePath(attachmentId, publicKey)
|
||||
val dir = File(context.filesDir, ATTACHMENTS_DIR)
|
||||
val file = File(dir, filePath)
|
||||
|
||||
if (!file.exists()) {
|
||||
Log.d(TAG, "📖 File not found: $filePath")
|
||||
return null
|
||||
}
|
||||
|
||||
val encrypted = file.readText()
|
||||
val decrypted = CryptoManager.decryptWithPassword(encrypted, privateKey)
|
||||
|
||||
Log.d(TAG, "📖 Read attachment: $attachmentId (${decrypted?.length ?: 0} bytes)")
|
||||
decrypted
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Error reading attachment", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить существует ли вложение в кэше
|
||||
*/
|
||||
fun hasAttachment(
|
||||
context: Context,
|
||||
attachmentId: String,
|
||||
publicKey: String
|
||||
): Boolean {
|
||||
val filePath = generatePath(attachmentId, publicKey)
|
||||
val dir = File(context.filesDir, ATTACHMENTS_DIR)
|
||||
val file = File(dir, filePath)
|
||||
return file.exists()
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить вложение из кэша
|
||||
*/
|
||||
fun deleteAttachment(
|
||||
context: Context,
|
||||
attachmentId: String,
|
||||
publicKey: String
|
||||
): Boolean {
|
||||
return try {
|
||||
val filePath = generatePath(attachmentId, publicKey)
|
||||
val dir = File(context.filesDir, ATTACHMENTS_DIR)
|
||||
val file = File(dir, filePath)
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Error deleting attachment", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить весь кэш вложений
|
||||
*/
|
||||
fun clearCache(context: Context): Boolean {
|
||||
return try {
|
||||
val dir = File(context.filesDir, ATTACHMENTS_DIR)
|
||||
dir.deleteRecursively()
|
||||
Log.d(TAG, "🗑️ Cache cleared")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Error clearing cache", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user