392 lines
19 KiB
Kotlin
392 lines
19 KiB
Kotlin
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
|
||
}
|
||
}
|
||
}
|
||
}
|