feat: Implement AttachmentFileManager for handling image attachments; add methods for saving, reading, and managing attachment files

This commit is contained in:
k1ngsterr1
2026-01-26 17:43:23 +05:00
parent 44c0151294
commit 0f652bea86
3 changed files with 265 additions and 5 deletions

View File

@@ -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 типа:

View File

@@ -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)

View File

@@ -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
}
}
}