Files
mobile-android/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt

392 lines
19 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}
}