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 a277b47..4925429 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -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 ) - // � Обрабатываем AVATAR attachments - сохраняем аватар отправителя + // 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop) + processImageAttachments(packet.attachments, packet.chachaKey, privateKey) + + // 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя processAvatarAttachments(packet.attachments, packet.fromPublicKey, packet.chachaKey, privateKey) // �🔒 Шифруем 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, + 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 типа: 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 bde74f7..73bfc27 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 @@ -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 { if (attachmentsJson.isEmpty() || attachmentsJson == "[]") { @@ -779,6 +781,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { return try { val attachments = JSONArray(attachmentsJson) val result = mutableListOf() + 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) diff --git a/app/src/main/java/com/rosetta/messenger/utils/AttachmentFileManager.kt b/app/src/main/java/com/rosetta/messenger/utils/AttachmentFileManager.kt new file mode 100644 index 0000000..38701ad --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/utils/AttachmentFileManager.kt @@ -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 + } + } +}