package com.rosetta.messenger.database import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase @Database( entities = [ EncryptedAccountEntity::class, MessageEntity::class, MessageSearchIndexEntity::class, DialogEntity::class, BlacklistEntity::class, AvatarCacheEntity::class, AccountSyncTimeEntity::class, GroupEntity::class, PinnedMessageEntity::class], version = 17, exportSchema = false ) abstract class RosettaDatabase : RoomDatabase() { abstract fun accountDao(): AccountDao abstract fun messageDao(): MessageDao abstract fun dialogDao(): DialogDao abstract fun blacklistDao(): BlacklistDao abstract fun avatarDao(): AvatarDao abstract fun syncTimeDao(): SyncTimeDao abstract fun groupDao(): GroupDao abstract fun pinnedMessageDao(): PinnedMessageDao abstract fun messageSearchIndexDao(): MessageSearchIndexDao companion object { @Volatile private var INSTANCE: RosettaDatabase? = null private val MIGRATION_4_5 = object : Migration(4, 5) { override fun migrate(database: SupportSQLiteDatabase) { // Добавляем новые столбцы для индикаторов прочтения database.execSQL( "ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0" ) database.execSQL( "ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0" ) database.execSQL( "ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0" ) } } private val MIGRATION_5_6 = object : Migration(5, 6) { override fun migrate(database: SupportSQLiteDatabase) { // Добавляем поле username в encrypted_accounts database.execSQL("ALTER TABLE encrypted_accounts ADD COLUMN username TEXT") } } private val MIGRATION_6_7 = object : Migration(6, 7) { override fun migrate(database: SupportSQLiteDatabase) { // Создаем таблицу для кэша аватаров database.execSQL( """ CREATE TABLE IF NOT EXISTS avatar_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, public_key TEXT NOT NULL, avatar TEXT NOT NULL, timestamp INTEGER NOT NULL ) """ ) database.execSQL( "CREATE INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp ON avatar_cache (public_key, timestamp)" ) } } private val MIGRATION_7_8 = object : Migration(7, 8) { override fun migrate(database: SupportSQLiteDatabase) { // Удаляем таблицу avatar_delivery (больше не нужна) database.execSQL("DROP TABLE IF EXISTS avatar_delivery") } } /** * 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop) Blob слишком большой для * SQLite CursorWindow (2MB лимит) Просто обнуляем attachments - изображения перескачаются с * CDN */ private val MIGRATION_8_9 = object : Migration(8, 9) { override fun migrate(database: SupportSQLiteDatabase) { // Очищаем все attachments с большими blob'ами // Они будут перескачаны с CDN при открытии database.execSQL( """ UPDATE messages SET attachments = '[]' WHERE length(attachments) > 10000 """ ) } } /** * 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments Для пользователей которые уже * были на версии 9 */ private val MIGRATION_9_10 = object : Migration(9, 10) { override fun migrate(database: SupportSQLiteDatabase) { // Очищаем все attachments с большими blob'ами database.execSQL( """ UPDATE messages SET attachments = '[]' WHERE length(attachments) > 10000 """ ) } } /** * 🚀 МИГРАЦИЯ 10->11: Денормализация — кэш attachments последнего сообщения в dialogs * Устраняет N+1 проблему: ранее для каждого диалога делался отдельный запрос к messages */ private val MIGRATION_10_11 = object : Migration(10, 11) { override fun migrate(database: SupportSQLiteDatabase) { // Добавляем столбец для кэша attachments последнего сообщения database.execSQL( "ALTER TABLE dialogs ADD COLUMN last_message_attachments TEXT NOT NULL DEFAULT '[]'" ) } } private val MIGRATION_11_12 = object : Migration(11, 12) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( """ CREATE TABLE IF NOT EXISTS accounts_sync_times ( account TEXT NOT NULL PRIMARY KEY, last_sync INTEGER NOT NULL ) """ ) } } /** * 📌 МИГРАЦИЯ 12->13: Таблица pinned_messages для закреплённых сообщений (Telegram-style) */ private val MIGRATION_12_13 = object : Migration(12, 13) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( """ CREATE TABLE IF NOT EXISTS pinned_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, account TEXT NOT NULL, dialog_key TEXT NOT NULL, message_id TEXT NOT NULL, pinned_at INTEGER NOT NULL ) """ ) database.execSQL( "CREATE UNIQUE INDEX IF NOT EXISTS index_pinned_messages_account_dialog_key_message_id ON pinned_messages (account, dialog_key, message_id)" ) database.execSQL( "CREATE INDEX IF NOT EXISTS index_pinned_messages_account_dialog_key_pinned_at ON pinned_messages (account, dialog_key, pinned_at)" ) } } /** * 👥 МИГРАЦИЯ 13->14: Таблица groups для групповых диалогов */ private val MIGRATION_13_14 = object : Migration(13, 14) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( """ CREATE TABLE IF NOT EXISTS groups ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, account TEXT NOT NULL, group_id TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL, `key` TEXT NOT NULL ) """ ) database.execSQL( "CREATE UNIQUE INDEX IF NOT EXISTS index_groups_account_group_id ON groups (account, group_id)" ) } } /** * 🧱 МИГРАЦИЯ 14->15: Денормализованный тип вложения для ускорения фильтров (media/files/calls) */ private val MIGRATION_14_15 = object : Migration(14, 15) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( "ALTER TABLE messages ADD COLUMN primary_attachment_type INTEGER NOT NULL DEFAULT -1" ) database.execSQL( "CREATE INDEX IF NOT EXISTS index_messages_account_primary_attachment_type_timestamp ON messages (account, primary_attachment_type, timestamp)" ) // Best-effort backfill для уже сохраненных сообщений. database.execSQL( """ UPDATE messages SET primary_attachment_type = CASE WHEN attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]' THEN -1 WHEN attachments LIKE '%"type":0%' OR attachments LIKE '%"type": 0%' THEN 0 WHEN attachments LIKE '%"type":1%' OR attachments LIKE '%"type": 1%' THEN 1 WHEN attachments LIKE '%"type":2%' OR attachments LIKE '%"type": 2%' THEN 2 WHEN attachments LIKE '%"type":3%' OR attachments LIKE '%"type": 3%' THEN 3 WHEN attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%' THEN 4 ELSE -1 END """ ) } } /** * 🧱 МИГРАЦИЯ 15->16: Денормализованный has_content для быстрых выборок dialogs/requests */ private val MIGRATION_15_16 = object : Migration(15, 16) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( "ALTER TABLE dialogs ADD COLUMN has_content INTEGER NOT NULL DEFAULT 0" ) database.execSQL( "CREATE INDEX IF NOT EXISTS index_dialogs_account_i_have_sent_has_content_last_message_timestamp ON dialogs (account, i_have_sent, has_content, last_message_timestamp)" ) database.execSQL( """ UPDATE dialogs SET has_content = CASE WHEN TRIM(last_message) != '' THEN 1 WHEN last_message_attachments IS NOT NULL AND TRIM(last_message_attachments) != '' AND TRIM(last_message_attachments) != '[]' THEN 1 ELSE 0 END """ ) } } /** * 🧱 МИГРАЦИЯ 16->17: * - dialogs: last_message_attachment_type + last_sender_key * - messages: индекс (account, timestamp) * - локальный message_search_index для поиска без повторной дешифровки */ private val MIGRATION_16_17 = object : Migration(16, 17) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( "ALTER TABLE dialogs ADD COLUMN last_message_attachment_type INTEGER NOT NULL DEFAULT -1" ) database.execSQL( "ALTER TABLE dialogs ADD COLUMN last_sender_key TEXT NOT NULL DEFAULT ''" ) database.execSQL( "CREATE INDEX IF NOT EXISTS index_messages_account_timestamp ON messages (account, timestamp)" ) database.execSQL( """ CREATE TABLE IF NOT EXISTS message_search_index ( account TEXT NOT NULL, message_id TEXT NOT NULL, dialog_key TEXT NOT NULL, opponent_key TEXT NOT NULL, timestamp INTEGER NOT NULL, from_me INTEGER NOT NULL DEFAULT 0, plain_text TEXT NOT NULL, plain_text_normalized TEXT NOT NULL, PRIMARY KEY(account, message_id) ) """ ) database.execSQL( "CREATE INDEX IF NOT EXISTS index_message_search_index_account_timestamp ON message_search_index (account, timestamp)" ) database.execSQL( "CREATE INDEX IF NOT EXISTS index_message_search_index_account_opponent_key_timestamp ON message_search_index (account, opponent_key, timestamp)" ) database.execSQL( """ UPDATE dialogs SET last_message_attachment_type = CASE WHEN last_message_attachments IS NULL OR TRIM(last_message_attachments) = '' OR TRIM(last_message_attachments) = '[]' THEN -1 WHEN last_message_attachments LIKE '%"type":0%' OR last_message_attachments LIKE '%"type": 0%' THEN 0 WHEN last_message_attachments LIKE '%"type":1%' OR last_message_attachments LIKE '%"type": 1%' THEN 1 WHEN last_message_attachments LIKE '%"type":2%' OR last_message_attachments LIKE '%"type": 2%' THEN 2 WHEN last_message_attachments LIKE '%"type":3%' OR last_message_attachments LIKE '%"type": 3%' THEN 3 WHEN last_message_attachments LIKE '%"type":4%' OR last_message_attachments LIKE '%"type": 4%' THEN 4 ELSE -1 END """ ) database.execSQL( """ UPDATE dialogs SET last_sender_key = COALESCE( ( SELECT m.from_public_key FROM messages m WHERE m.account = dialogs.account AND m.dialog_key = CASE WHEN dialogs.opponent_key = dialogs.account THEN dialogs.account WHEN LOWER(dialogs.opponent_key) LIKE '#group:%' OR LOWER(dialogs.opponent_key) LIKE 'group:%' THEN dialogs.opponent_key WHEN dialogs.account < dialogs.opponent_key THEN dialogs.account || ':' || dialogs.opponent_key ELSE dialogs.opponent_key || ':' || dialogs.account END ORDER BY m.timestamp DESC, m.id DESC LIMIT 1 ), '' ) """ ) database.execSQL( """ CREATE TRIGGER IF NOT EXISTS trg_message_search_index_delete AFTER DELETE ON messages BEGIN DELETE FROM message_search_index WHERE account = OLD.account AND message_id = OLD.message_id; END """ ) } } fun getDatabase(context: Context): RosettaDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, RosettaDatabase::class.java, "rosetta_secure.db" ) .setJournalMode( JournalMode.WRITE_AHEAD_LOGGING ) // WAL mode for performance .addMigrations( MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17 ) .fallbackToDestructiveMigration() // Для разработки - только // если миграция не // найдена .build() INSTANCE = instance instance } } } }