import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:komet/core/storage/app_instance.dart'; import 'package:komet/core/utils/logger.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart' show databaseFactorySqflitePlugin; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; class ProfileData { final int id; final String firstName; final String? lastName; final int phone; final int? photoId; final String? baseUrl; final String? baseRawUrl; final String country; final int accountStatus; final int updateTime; final List? profileOptions; ProfileData({ required this.id, required this.firstName, this.lastName, required this.phone, this.photoId, this.baseUrl, this.baseRawUrl, required this.country, required this.accountStatus, required this.updateTime, this.profileOptions, }); factory ProfileData.stub(int id) => ProfileData( id: id, firstName: '', phone: 0, country: '', accountStatus: 0, updateTime: 0, ); factory ProfileData.fromServerProfile(Map profile) { final contact = profile['contact']; if (contact is! Map) { throw const FormatException('No contact in profile'); } return ProfileData.fromServerMap( contact.cast(), profileOptions: _parseProfileOptions(profile['profileOptions']), ); } factory ProfileData.fromServerMap( Map contact, { List? profileOptions, }) { final names = contact['names']; String firstName = ''; String? lastName; if (names is List && names.isNotEmpty) { final name = names.firstWhere( (n) => n is Map && n['type'] == 'ONEME', orElse: () => names.first, ) as Map; firstName = (name['firstName'] as String?) ?? ''; lastName = name['lastName'] as String?; } return ProfileData( id: contact['id'] as int, firstName: firstName, lastName: lastName, phone: (contact['phone'] as int?) ?? 0, photoId: contact['photoId'] as int?, baseUrl: contact['baseUrl'] as String?, baseRawUrl: contact['baseRawUrl'] as String?, country: (contact['country'] as String?) ?? '', accountStatus: (contact['accountStatus'] as int?) ?? 0, updateTime: (contact['updateTime'] as int?) ?? 0, profileOptions: profileOptions ?? _parseProfileOptions(contact['profileOptions']), ); } static List? _parseProfileOptions(dynamic raw) { if (raw is! List) return null; final options = raw .map((e) => e is int ? e : int.tryParse(e.toString())) .whereType() .toList(); return options.isEmpty ? null : options; } factory ProfileData.fromDbRow(Map row) { final profileOptionsStr = row['profile_options'] as String?; List? profileOptions; if (profileOptionsStr != null && profileOptionsStr.isNotEmpty) { try { profileOptions = profileOptionsStr .split(',') .where((e) => e.trim().isNotEmpty) .map((e) => int.parse(e.trim())) .toList(); } catch (_) { profileOptions = null; } } return ProfileData( id: row['id'] as int, firstName: (row['first_name'] as String?) ?? '', lastName: row['last_name'] as String?, phone: (row['phone'] as int?) ?? 0, photoId: row['photo_id'] as int?, baseUrl: row['base_url'] as String?, baseRawUrl: row['base_raw_url'] as String?, country: (row['country'] as String?) ?? '', accountStatus: (row['account_status'] as int?) ?? 0, updateTime: (row['update_time'] as int?) ?? 0, profileOptions: profileOptions, ); } Map toDbRow({bool isActive = false}) => { 'id': id, 'first_name': firstName, 'last_name': lastName, 'phone': phone, 'photo_id': photoId, 'base_url': baseUrl, 'base_raw_url': baseRawUrl, 'country': country, 'account_status': accountStatus, 'update_time': updateTime, 'is_active': isActive ? 1 : 0, 'profile_options': profileOptions?.join(','), }; } abstract class SyncKey { static const chatsSync = 'chats_sync'; static const contactsSync = 'contacts_sync'; static const callsSync = 'calls_sync'; static const draftsSync = 'drafts_sync'; static const bannersSync = 'banners_sync'; static const presenceSync = 'presence_sync'; static const lastLogin = 'last_login'; static const configHash = 'config_hash'; static const chatCacheFingerprint = 'chat_cache_fingerprint'; static const serverTime = 'server_time'; static const loginInfo = 'login_info'; } class AppDatabase { static Database? _db; static String? _mobileDbDir; static Future init() async { if (Platform.isAndroid || Platform.isIOS) { _mobileDbDir = await databaseFactorySqflitePlugin.getDatabasesPath(); } sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; } static Completer? _initCompleter; static Future get _instance async { if (_db != null) return _db!; if (_initCompleter != null) return _initCompleter!.future; _initCompleter = Completer(); try { _db = await _open(); _initCompleter!.complete(_db!); } catch (e) { _initCompleter!.completeError(e); _initCompleter = null; rethrow; } return _db!; } static Future _databasesDir() async { if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { final dir = await getApplicationSupportDirectory(); return dir.path; } return _mobileDbDir ??= await databaseFactorySqflitePlugin .getDatabasesPath(); } static Future _migrateLegacyDb(String target) async { if (AppInstance.isNamed) return; if (!(Platform.isLinux || Platform.isWindows || Platform.isMacOS)) return; try { if (await File(target).exists()) return; final legacy = File(join(await getDatabasesPath(), 'komet.db')); if (legacy.path == target) return; if (await legacy.exists()) { await legacy.copy(target); logger.i('[db] перенёс komet.db -> $target'); } } catch (e) { logger.w('legacy db migration failed: $e'); } } static Future _open() async { final dbPath = await _databasesDir(); await Directory(dbPath).create(recursive: true); final target = join(dbPath, 'komet${AppInstance.suffix}.db'); await _migrateLegacyDb(target); return openDatabase( target, version: 19, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { if (oldVersion < 2) { await _addColumnIfMissing( db, 'profile', 'is_active', 'INTEGER NOT NULL DEFAULT 0', ); await db.execute('DROP TABLE IF EXISTS sync_state'); await db.execute(_syncStateSchema); } if (oldVersion < 3) { await db.execute(_chatsCacheSchema); } if (oldVersion < 4) { await db.execute(_contactsSchema); } if (oldVersion < 5) { await db.execute('DROP TABLE IF EXISTS chats_cache'); await db.execute(_chatsCacheSchema); } if (oldVersion < 6) { await db.execute(_messagesSchema); } if (oldVersion < 7) { await _addColumnIfMissing(db, 'profile', 'profile_options', 'TEXT'); } if (oldVersion < 8) { await _addColumnIfMissing(db, 'chats_cache', 'participants', 'TEXT'); } if (oldVersion < 9) { await _addColumnIfMissing(db, 'contacts', 'options', 'TEXT'); await _addColumnIfMissing(db, 'chats_cache', 'options', 'TEXT'); } if (oldVersion < 10) { await _addColumnIfMissing(db, 'chats_cache', 'owner', 'INTEGER'); await _addColumnIfMissing(db, 'chats_cache', 'admins', 'TEXT'); } if (oldVersion < 11) { await _createIndexes(db); } if (oldVersion < 12) { await _addColumnIfMissing( db, 'chats_cache', 'last_msg_status', 'TEXT', ); } if (oldVersion < 13) { await _addColumnIfMissing( db, 'messages', 'deleted', 'INTEGER NOT NULL DEFAULT 0', ); } if (oldVersion < 14) { await _addColumnIfMissing( db, 'chats_cache', 'in_list', 'INTEGER NOT NULL DEFAULT 1', ); } if (oldVersion < 15) { await _addColumnIfMissing(db, 'messages', 'edit_history', 'TEXT'); } if (oldVersion < 16) { await _addColumnIfMissing( db, 'chats_cache', 'last_msg_elements', 'TEXT', ); } if (oldVersion < 17) { await db.execute(_chatParticipantsSchema); await _createChatParticipantsIndex(db); await _backfillChatParticipants(db); } if (oldVersion < 18) { await _addColumnIfMissing( db, 'chats_cache', 'pinned_msg_id', 'INTEGER', ); await _addColumnIfMissing( db, 'chats_cache', 'pinned_msg_text', 'TEXT', ); await _addColumnIfMissing( db, 'chats_cache', 'pinned_msg_time', 'INTEGER', ); } if (oldVersion < 19) { await _addColumnIfMissing( db, 'chats_cache', 'pinned_msg_is_preview', 'INTEGER NOT NULL DEFAULT 0', ); } }, ); } static Future _createTables(Database db) async { await db.execute(''' CREATE TABLE profile ( id INTEGER PRIMARY KEY, first_name TEXT NOT NULL, last_name TEXT, phone INTEGER NOT NULL, photo_id INTEGER, base_url TEXT, base_raw_url TEXT, country TEXT NOT NULL DEFAULT '', account_status INTEGER NOT NULL DEFAULT 0, update_time INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 0, profile_options TEXT ) '''); await db.execute(_syncStateSchema); await db.execute(_chatsCacheSchema); await db.execute(_contactsSchema); await db.execute(_messagesSchema); await db.execute(_chatParticipantsSchema); await _createIndexes(db); await _createChatParticipantsIndex(db); } static Future _addColumnIfMissing( Database db, String table, String column, String definition, ) async { final info = await db.rawQuery('PRAGMA table_info($table)'); final exists = info.any((row) => row['name'] == column); if (exists) return; await db.execute('ALTER TABLE $table ADD COLUMN $column $definition'); } static Future _createIndexes(Database db) async { await db.execute( 'CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(account_id, chat_id, time DESC)', ); await db.execute( 'CREATE INDEX IF NOT EXISTS idx_chats_account ON chats_cache(account_id, last_event_time DESC)', ); await db.execute( 'CREATE INDEX IF NOT EXISTS idx_contacts_account ON contacts(account_id)', ); } static Future _createChatParticipantsIndex(Database db) async { await db.execute( 'CREATE INDEX IF NOT EXISTS idx_chat_participants_lookup ' 'ON chat_participants(account_id, participant_id, chat_id)', ); } static List _participantIdsFromRaw(Object? raw) { if (raw is! String || raw.isEmpty) return const []; try { final decoded = jsonDecode(raw); if (decoded is! Map) return const []; final ids = []; for (final key in decoded.keys) { final id = key is int ? key : int.tryParse(key.toString()); if (id != null) ids.add(id); } return ids; } catch (_) { return const []; } } static Future _backfillChatParticipants(Database db) async { final chats = await db.query( 'chats_cache', columns: ['id', 'account_id', 'participants'], where: "type = 'DIALOG'", ); final batch = db.batch(); for (final chat in chats) { final accountId = chat['account_id']; final chatId = chat['id']; if (accountId is! int || chatId is! int) continue; for (final pid in _participantIdsFromRaw(chat['participants'])) { batch.insert('chat_participants', { 'account_id': accountId, 'chat_id': chatId, 'participant_id': pid, }, conflictAlgorithm: ConflictAlgorithm.ignore); } } await batch.commit(noResult: true); } static const _contactsSchema = ''' CREATE TABLE contacts ( id INTEGER PRIMARY KEY, account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, first_name TEXT NOT NULL, last_name TEXT, phone INTEGER NOT NULL, photo_id INTEGER, base_url TEXT, base_raw_url TEXT, update_time INTEGER NOT NULL DEFAULT 0, options TEXT ) '''; static const _syncStateSchema = ''' CREATE TABLE sync_state ( account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (account_id, key) ) '''; static const _chatsCacheSchema = ''' CREATE TABLE chats_cache ( id INTEGER NOT NULL, account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, type TEXT NOT NULL, title TEXT, icon_url TEXT, last_msg_id INTEGER, last_msg_time INTEGER, last_msg_text TEXT, last_msg_elements TEXT, last_msg_sender INTEGER, last_msg_status TEXT, unread_count INTEGER NOT NULL DEFAULT 0, last_event_time INTEGER NOT NULL DEFAULT 0, cached_at INTEGER NOT NULL, fav_index INTEGER, dont_disturb_until INTEGER NOT NULL DEFAULT 0, is_online INTEGER NOT NULL DEFAULT 0, seen_time INTEGER NOT NULL DEFAULT 0, participants TEXT NOT NULL DEFAULT "", options TEXT, owner INTEGER, admins TEXT, in_list INTEGER NOT NULL DEFAULT 1, pinned_msg_id INTEGER, pinned_msg_text TEXT, pinned_msg_time INTEGER, pinned_msg_is_preview INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (id, account_id) ) '''; static const _chatParticipantsSchema = ''' CREATE TABLE chat_participants ( account_id INTEGER NOT NULL, chat_id INTEGER NOT NULL, participant_id INTEGER NOT NULL, PRIMARY KEY (account_id, chat_id, participant_id), FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE ) '''; static const _messagesSchema = ''' CREATE TABLE messages ( id TEXT NOT NULL, account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, chat_id INTEGER NOT NULL, sender_id INTEGER NOT NULL, text TEXT, time INTEGER NOT NULL, status TEXT, payload TEXT, deleted INTEGER NOT NULL DEFAULT 0, edit_history TEXT, PRIMARY KEY (id, account_id), FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE ) '''; static Future saveProfile( ProfileData profile, { bool isActive = true, }) async { final db = await _instance; final row = profile.toDbRow(isActive: isActive); final cols = row.keys.toList(); final placeholders = List.filled(cols.length, '?').join(', '); final updates = cols .where((c) => c != 'id') .map((c) => '$c = excluded.$c') .join(', '); await db.rawInsert( 'INSERT INTO profile (${cols.join(', ')}) VALUES ($placeholders) ' 'ON CONFLICT(id) DO UPDATE SET $updates', cols.map((c) => row[c]).toList(), ); } static Future loadProfile(int accountId) async { final db = await _instance; final rows = await db.query( 'profile', where: 'id = ?', whereArgs: [accountId], limit: 1, ); if (rows.isEmpty) return null; return ProfileData.fromDbRow(rows.first); } static Future> loadAllProfiles() async { final db = await _instance; final rows = await db.query('profile', orderBy: 'is_active DESC, id ASC'); return rows.map(ProfileData.fromDbRow).toList(); } static Future loadActiveProfile() async { final db = await _instance; final rows = await db.query('profile', where: 'is_active = 1', limit: 1); if (rows.isEmpty) return null; return ProfileData.fromDbRow(rows.first); } static Future setActiveAccount(int accountId) async { final db = await _instance; await db.transaction((txn) async { await txn.update('profile', {'is_active': 0}); await txn.update( 'profile', {'is_active': 1}, where: 'id = ?', whereArgs: [accountId], ); }); } static Future deleteAccount(int accountId) async { final db = await _instance; await db.delete('profile', where: 'id = ?', whereArgs: [accountId]); } static Future setSyncValue( int accountId, String key, String value, ) async { final db = await _instance; await db.insert('sync_state', { 'account_id': accountId, 'key': key, 'value': value, }, conflictAlgorithm: ConflictAlgorithm.replace); } static Future getSyncValue(int accountId, String key) async { final db = await _instance; final rows = await db.query( 'sync_state', where: 'account_id = ? AND key = ?', whereArgs: [accountId, key], limit: 1, ); if (rows.isEmpty) return null; return rows.first['value'] as String; } static Future> getAllSyncValues(int accountId) async { final db = await _instance; final rows = await db.query( 'sync_state', where: 'account_id = ?', whereArgs: [accountId], ); return { for (final row in rows) row['key'] as String: row['value'] as String, }; } static Future savePrivacyConfig( int accountId, String jsonConfig, ) async { final db = await _instance; await db.insert('sync_state', { 'account_id': accountId, 'key': 'privacy_config', 'value': jsonConfig, }, conflictAlgorithm: ConflictAlgorithm.replace); } static Future getPrivacyConfig(int accountId) async { final db = await _instance; final rows = await db.query( 'sync_state', where: 'account_id = ? AND key = ?', whereArgs: [accountId, 'privacy_config'], limit: 1, ); if (rows.isEmpty) return null; return rows.first['value'] as String; } static Future saveLoginInfo(int accountId, String jsonInfo) async { final db = await _instance; await db.insert('sync_state', { 'account_id': accountId, 'key': SyncKey.loginInfo, 'value': jsonInfo, }, conflictAlgorithm: ConflictAlgorithm.replace); } static Future getLoginInfo(int accountId) async { return getSyncValue(accountId, SyncKey.loginInfo); } static Future close() async { await _db?.close(); _db = null; } // Chats cache static Future saveChats(List> rows) async { if (rows.isEmpty) return; try { final db = await _instance; final cols = rows.first.keys.toList(); final placeholders = List.filled(cols.length, '?').join(', '); final updates = cols .where((c) => c != 'id' && c != 'account_id') .map((c) => '$c = excluded.$c') .join(', '); final sql = 'INSERT INTO chats_cache (${cols.join(', ')}) ' 'VALUES ($placeholders) ' 'ON CONFLICT(id, account_id) DO UPDATE SET $updates'; await db.transaction((txn) async { final batch = txn.batch(); for (final row in rows) { batch.rawInsert(sql, cols.map((c) => row[c]).toList()); } await batch.commit(noResult: true); for (final row in rows) { if (!row.containsKey('participants')) continue; if (row['type'] != 'DIALOG') continue; final accountId = row['account_id']; final chatId = row['id']; if (accountId is! int || chatId is! int) continue; await txn.delete( 'chat_participants', where: 'account_id = ? AND chat_id = ?', whereArgs: [accountId, chatId], ); for (final pid in _participantIdsFromRaw(row['participants'])) { await txn.insert('chat_participants', { 'account_id': accountId, 'chat_id': chatId, 'participant_id': pid, }, conflictAlgorithm: ConflictAlgorithm.ignore); } } }); } catch (e) { logger.e("Ошибка при сохранении чата: $e"); } } static Future>> loadChat( int accountId, int chatId, ) async { final db = await _instance; return db.query( 'chats_cache', where: 'account_id = ? AND id = ?', whereArgs: [accountId, chatId], orderBy: 'last_event_time DESC', ); } static Future>> loadChats( int accountId, { bool includeHidden = false, }) async { final db = await _instance; return db.query( 'chats_cache', where: includeHidden ? 'account_id = ? AND in_list IN (1, 2)' : 'account_id = ? AND in_list = 1', whereArgs: [accountId], orderBy: 'last_event_time DESC', ); } static Future sumUnread( int accountId, { int? excludeChatId, Set? excludeChatIds, }) async { final db = await _instance; final buffer = StringBuffer('account_id = ? AND in_list = 1'); final args = [accountId]; if (excludeChatId != null) { buffer.write(' AND id != ?'); args.add(excludeChatId); } if (excludeChatIds != null && excludeChatIds.isNotEmpty) { final placeholders = List.filled(excludeChatIds.length, '?').join(', '); buffer.write(' AND id NOT IN ($placeholders)'); args.addAll(excludeChatIds); } final result = await db.rawQuery( 'SELECT COALESCE(SUM(unread_count), 0) AS total ' 'FROM chats_cache WHERE $buffer', args, ); return (result.first['total'] as int?) ?? 0; } static Future findDialogChatByParticipant( int accountId, int contactId, ) async { final db = await _instance; final rows = await db.rawQuery( 'SELECT p.chat_id AS id FROM chat_participants p ' 'JOIN chats_cache c ON c.id = p.chat_id AND c.account_id = p.account_id ' "WHERE p.account_id = ? AND p.participant_id = ? AND c.type = 'DIALOG' " 'LIMIT 1', [accountId, contactId], ); if (rows.isEmpty) return null; return rows.first['id'] as int?; } static Future>> loadDialogChats( int accountId, ) async { final db = await _instance; return db.query( 'chats_cache', where: "account_id = ? AND type = 'DIALOG'", whereArgs: [accountId], ); } static String _escapeLike(String value) => value .replaceAll('\\', '\\\\') .replaceAll('%', '\\%') .replaceAll('_', '\\_'); static Future>> searchContacts( int accountId, String query, { int limit = 30, }) async { final term = query.trim(); if (term.isEmpty) return const []; final db = await _instance; final like = '%${_escapeLike(term)}%'; return db.query( 'contacts', where: 'account_id = ? AND ' "(first_name LIKE ? ESCAPE '\\' OR last_name LIKE ? ESCAPE '\\' " "OR CAST(phone AS TEXT) LIKE ? ESCAPE '\\')", whereArgs: [accountId, like, like, like], orderBy: 'first_name ASC, last_name ASC', limit: limit, ); } static Future>> searchChatsByTitle( int accountId, String query, { int limit = 30, }) async { final term = query.trim(); if (term.isEmpty) return const []; final db = await _instance; final like = '%${_escapeLike(term)}%'; return db.query( 'chats_cache', where: "account_id = ? AND title LIKE ? ESCAPE '\\'", whereArgs: [accountId, like], orderBy: 'last_event_time DESC', limit: limit, ); } static Future>> loadChatsByIds( int accountId, List ids, ) async { if (ids.isEmpty) return const []; final db = await _instance; final placeholders = List.filled(ids.length, '?').join(','); return db.query( 'chats_cache', where: 'account_id = ? AND id IN ($placeholders)', whereArgs: [accountId, ...ids], ); } static Future deleteChat(int chatId, int accountId) async { final db = await _instance; await db.delete( 'chats_cache', where: 'id = ? AND account_id = ?', whereArgs: [chatId, accountId], ); } static Future clearChatsCache(int accountId) async { final db = await _instance; await db.delete( 'chats_cache', where: 'account_id = ?', whereArgs: [accountId], ); } static Future saveContacts(List> rows) async { final db = await _instance; final batch = db.batch(); for (final row in rows) { batch.insert( 'contacts', row, conflictAlgorithm: ConflictAlgorithm.replace, ); } await batch.commit(noResult: true); } static Future>> loadContacts(int accountId) async { final db = await _instance; return db.query( 'contacts', where: 'account_id = ?', whereArgs: [accountId], ); } static Future saveMessages(List> rows) async { final db = await _instance; await db.transaction((txn) async { final batch = txn.batch(); for (final row in rows) { batch.insert( 'messages', row, conflictAlgorithm: ConflictAlgorithm.replace, ); } await batch.commit(noResult: true); }); } static Future>> loadMessages( int accountId, int chatId, { int? limit, int? offset, bool onlyVisible = false, }) async { final db = await _instance; return db.query( 'messages', where: onlyVisible ? 'account_id = ? AND chat_id = ? AND deleted = 0' : 'account_id = ? AND chat_id = ?', whereArgs: [accountId, chatId], orderBy: 'time DESC', limit: limit, offset: offset, ); } static Future>> loadMessagesBefore( int accountId, int chatId, { required int beforeTime, int limit = 30, bool onlyVisible = false, }) async { final db = await _instance; return db.query( 'messages', where: onlyVisible ? 'account_id = ? AND chat_id = ? AND deleted = 0 AND time < ?' : 'account_id = ? AND chat_id = ? AND time < ?', whereArgs: [accountId, chatId, beforeTime], orderBy: 'time DESC', limit: limit, ); } static Future>> loadMessagesBetween( int accountId, int chatId, { required int afterTime, required int beforeTime, int limit = 60, bool onlyVisible = false, }) async { final db = await _instance; return db.query( 'messages', where: onlyVisible ? 'account_id = ? AND chat_id = ? AND deleted = 0 ' 'AND time > ? AND time < ?' : 'account_id = ? AND chat_id = ? AND time > ? AND time < ?', whereArgs: [accountId, chatId, afterTime, beforeTime], orderBy: 'time ASC', limit: limit, ); } static Future>> loadMessagesAround( int accountId, int chatId, { required int centerTime, int before = 40, int after = 20, bool onlyVisible = false, }) async { final db = await _instance; final base = onlyVisible ? 'account_id = ? AND chat_id = ? AND deleted = 0' : 'account_id = ? AND chat_id = ?'; final older = await db.query( 'messages', where: '$base AND time <= ?', whereArgs: [accountId, chatId, centerTime], orderBy: 'time DESC', limit: before, ); final newer = await db.query( 'messages', where: '$base AND time > ?', whereArgs: [accountId, chatId, centerTime], orderBy: 'time ASC', limit: after, ); return [...newer.reversed, ...older]; } static Future markMessageDeleted( int accountId, int chatId, String messageId, ) async { final db = await _instance; await db.update( 'messages', {'deleted': 1}, where: 'account_id = ? AND chat_id = ? AND id = ?', whereArgs: [accountId, chatId, messageId], ); } static Future markMessagesDeleted( int accountId, int chatId, List messageIds, ) async { if (messageIds.isEmpty) return; final db = await _instance; await db.transaction((txn) async { final batch = txn.batch(); for (final id in messageIds) { batch.update( 'messages', {'deleted': 1}, where: 'account_id = ? AND chat_id = ? AND id = ?', whereArgs: [accountId, chatId, id], ); } await batch.commit(noResult: true); }); } static Future clearMessages(int accountId, int chatId) async { final db = await _instance; await db.delete( 'messages', where: 'account_id = ? AND chat_id = ?', whereArgs: [accountId, chatId], ); } static Future>> loadMessagesByIds( int accountId, int chatId, List messageIds, ) async { if (messageIds.isEmpty) return const []; final db = await _instance; final placeholders = List.filled(messageIds.length, '?').join(','); return db.query( 'messages', where: 'account_id = ? AND chat_id = ? AND id IN ($placeholders)', whereArgs: [accountId, chatId, ...messageIds], ); } static Future?> loadMessage( int accountId, int chatId, String messageId, ) async { final db = await _instance; final rows = await db.query( 'messages', where: 'account_id = ? AND chat_id = ? AND id = ?', whereArgs: [accountId, chatId, messageId], limit: 1, ); if (rows.isEmpty) return null; return rows.first; } static Future deleteMessage( int accountId, int chatId, String messageId, ) async { final db = await _instance; await db.delete( 'messages', where: 'account_id = ? AND chat_id = ? AND id = ?', whereArgs: [accountId, chatId, messageId], ); } static Future>> loadPendingMessages( int accountId, ) async { final db = await _instance; return db.query( 'messages', where: 'account_id = ? AND status = ?', whereArgs: [accountId, 'pending'], orderBy: 'time ASC', ); } }