1858 lines
55 KiB
Dart
1858 lines
55 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'message_persistence.dart';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:ikar/core/storage/app_instance.dart';
|
|
import 'package:ikar/core/storage/message_ranges.dart';
|
|
import 'package:ikar/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';
|
|
|
|
// #***! профиль как строка таблицы profile
|
|
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<int>? profileOptions;
|
|
final String? description;
|
|
|
|
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,
|
|
this.description,
|
|
});
|
|
|
|
factory ProfileData.stub(int id) => ProfileData(
|
|
id: id,
|
|
firstName: '',
|
|
phone: 0,
|
|
country: '',
|
|
accountStatus: 0,
|
|
updateTime: 0,
|
|
);
|
|
|
|
factory ProfileData.fromServerProfile(Map<dynamic, dynamic> profile) {
|
|
final contact = profile['contact'];
|
|
if (contact is! Map) {
|
|
throw const FormatException('No contact in profile');
|
|
}
|
|
return ProfileData.fromServerMap(
|
|
contact.cast<dynamic, dynamic>(),
|
|
profileOptions: _parseProfileOptions(profile['profileOptions']),
|
|
);
|
|
}
|
|
|
|
factory ProfileData.fromServerMap(
|
|
Map<dynamic, dynamic> contact, {
|
|
List<int>? 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']),
|
|
description: _nonEmpty(contact['description']),
|
|
);
|
|
}
|
|
static String? _nonEmpty(dynamic raw) {
|
|
if (raw is! String) return null;
|
|
return raw.isEmpty ? null : raw;
|
|
}
|
|
|
|
static List<int>? _parseProfileOptions(dynamic raw) {
|
|
if (raw is! List) return null;
|
|
final options = raw
|
|
.map((e) => e is int ? e : int.tryParse(e.toString()))
|
|
.whereType<int>()
|
|
.toList();
|
|
return options.isEmpty ? null : options;
|
|
}
|
|
|
|
factory ProfileData.fromDbRow(Map<String, dynamic> row) {
|
|
final profileOptionsStr = row['profile_options'] as String?;
|
|
List<int>? 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,
|
|
description: _nonEmpty(row['description']),
|
|
);
|
|
}
|
|
|
|
// #***! обратно в строку для sqflite
|
|
Map<String, dynamic> 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(','),
|
|
'description': description,
|
|
};
|
|
}
|
|
|
|
// #***! ключи sync_state, докуда мы досинхронизировались
|
|
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';
|
|
static const serverConfigSeen = 'server_config_seen';
|
|
static const profileInviteLink = 'profile_invite_link';
|
|
static const welcomeStickerIds = 'welcome_sticker_ids';
|
|
}
|
|
|
|
// #***! вся локальная база, профили чаты контакты сообщения
|
|
class AppDatabase {
|
|
static Database? _db;
|
|
|
|
static String? _mobileDbDir;
|
|
|
|
// #***! зовётся один раз на старте до первого обращения
|
|
static Future<void> init() async {
|
|
if (Platform.isAndroid || Platform.isIOS) {
|
|
_mobileDbDir = await databaseFactorySqflitePlugin.getDatabasesPath();
|
|
}
|
|
sqfliteFfiInit();
|
|
databaseFactory = databaseFactoryFfi;
|
|
}
|
|
|
|
static Completer<Database>? _initCompleter;
|
|
|
|
// #***! ленивое открытие с защитой от гонки чтоб базу не открыли дважды
|
|
static Future<Database> get _instance async {
|
|
if (_db != null) return _db!;
|
|
if (_initCompleter != null) return _initCompleter!.future;
|
|
_initCompleter = Completer<Database>();
|
|
try {
|
|
_db = await _open();
|
|
_initCompleter!.complete(_db!);
|
|
} catch (e) {
|
|
_initCompleter!.completeError(e);
|
|
_initCompleter = null;
|
|
rethrow;
|
|
}
|
|
return _db!;
|
|
}
|
|
|
|
// #***! на десктопе и на iOS в support, на андроиде в системной папке баз.
|
|
// #***! на iOS getDatabasesPath это Documents, а он открыт файловым шерингом
|
|
static Future<String> _databasesDir() async {
|
|
if (Platform.isLinux ||
|
|
Platform.isWindows ||
|
|
Platform.isMacOS ||
|
|
Platform.isIOS) {
|
|
final dir = await getApplicationSupportDirectory();
|
|
return dir.path;
|
|
}
|
|
return _mobileDbDir ??= await databaseFactorySqflitePlugin
|
|
.getDatabasesPath();
|
|
}
|
|
|
|
static String? _legacyExposedDb;
|
|
|
|
// #***! WAL живёт в отдельных файлах, копировать надо все три
|
|
static Future<void> _copyDbFiles(String from, String to) async {
|
|
for (final suffix in const ['', '-wal', '-shm']) {
|
|
final src = File('$from$suffix');
|
|
if (await src.exists()) await src.copy('$to$suffix');
|
|
}
|
|
}
|
|
|
|
static Future<void> _dropLegacyExposedDb() async {
|
|
final legacy = _legacyExposedDb;
|
|
if (legacy == null) return;
|
|
_legacyExposedDb = null;
|
|
for (final suffix in const ['', '-wal', '-shm']) {
|
|
try {
|
|
final file = File('$legacy$suffix');
|
|
if (await file.exists()) await file.delete();
|
|
} catch (e) {
|
|
logger.w('[db] не удалось убрать старую базу: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
// #***! разовый перенос со старого пути
|
|
static Future<void> _migrateLegacyDb(String target) async {
|
|
if (Platform.isIOS) {
|
|
try {
|
|
final legacyDir = await databaseFactorySqflitePlugin.getDatabasesPath();
|
|
final legacy = join(legacyDir, 'qlyra${AppInstance.suffix}.db');
|
|
if (legacy == target || !await File(legacy).exists()) return;
|
|
if (!await File(target).exists()) {
|
|
await _copyDbFiles(legacy, target);
|
|
logger.i('[db] перенёс базу из Documents в Application Support');
|
|
}
|
|
// #***! удаляем только после того, как новая база успешно откроется
|
|
_legacyExposedDb = legacy;
|
|
} catch (e) {
|
|
logger.w('ios db migration failed: $e');
|
|
}
|
|
return;
|
|
}
|
|
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(), 'qlyra.db'));
|
|
if (legacy.path == target) return;
|
|
if (await legacy.exists()) {
|
|
await legacy.copy(target);
|
|
logger.i('[db] перенёс qlyra.db -> $target');
|
|
}
|
|
} catch (e) {
|
|
logger.w('legacy db migration failed: $e');
|
|
}
|
|
}
|
|
|
|
// #***! версия 24, поднял версию дописывай миграцию ниже
|
|
static Future<Database> _open() async {
|
|
final dbPath = await _databasesDir();
|
|
await Directory(dbPath).create(recursive: true);
|
|
final target = join(dbPath, 'qlyra${AppInstance.suffix}.db');
|
|
await _migrateLegacyDb(target);
|
|
final opened = await openDatabase(
|
|
target,
|
|
// #***! каждый if oldVersion < N это шаг миграции, идут по порядку
|
|
version: 29,
|
|
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',
|
|
);
|
|
}
|
|
if (oldVersion < 20) {
|
|
await _addColumnIfMissing(
|
|
db,
|
|
'chats_cache',
|
|
'last_mention_msg_id',
|
|
'INTEGER',
|
|
);
|
|
}
|
|
if (oldVersion < 21) {
|
|
await _addColumnIfMissing(
|
|
db,
|
|
'chats_cache',
|
|
'last_msg_preview',
|
|
'TEXT',
|
|
);
|
|
}
|
|
if (oldVersion < 22) {
|
|
await db.execute(_webAppStorageSchema);
|
|
await db.execute(_webAppBiometrySchema);
|
|
}
|
|
if (oldVersion < 23) {
|
|
await _addColumnIfMissing(
|
|
db,
|
|
'contacts',
|
|
'account_status',
|
|
'INTEGER NOT NULL DEFAULT 0',
|
|
);
|
|
}
|
|
if (oldVersion < 24) {
|
|
await _addColumnIfMissing(db, 'messages', 'text_sealed', 'BLOB');
|
|
await _addColumnIfMissing(
|
|
db,
|
|
'messages',
|
|
'e2ee',
|
|
'INTEGER NOT NULL DEFAULT 0',
|
|
);
|
|
await db.execute(_e2eeSessionsSchema);
|
|
await db.execute(_e2eeOutgoingSchema);
|
|
}
|
|
if (oldVersion < 25) {
|
|
await _addColumnIfMissing(db, 'chats_cache', 'active_call', 'TEXT');
|
|
}
|
|
if (oldVersion < 26) {
|
|
await _addColumnIfMissing(db, 'chats_cache', 'public_link', 'TEXT');
|
|
}
|
|
if (oldVersion < 27) {
|
|
await _addColumnIfMissing(db, 'profile', 'description', 'TEXT');
|
|
}
|
|
if (oldVersion < 28) {
|
|
await db.execute(_messageRangesSchema);
|
|
}
|
|
if (oldVersion < 29) {
|
|
await _addColumnIfMissing(db, 'messages', 'typing_ms', 'INTEGER');
|
|
}
|
|
},
|
|
);
|
|
await _dropLegacyExposedDb();
|
|
return opened;
|
|
}
|
|
|
|
// #***! создание таблиц с нуля для свежей установки
|
|
static Future<void> _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,
|
|
description TEXT
|
|
)
|
|
''');
|
|
await db.execute(_syncStateSchema);
|
|
await db.execute(_chatsCacheSchema);
|
|
await db.execute(_contactsSchema);
|
|
await db.execute(_messagesSchema);
|
|
await db.execute(_chatParticipantsSchema);
|
|
await db.execute(_webAppStorageSchema);
|
|
await db.execute(_webAppBiometrySchema);
|
|
await db.execute(_e2eeSessionsSchema);
|
|
await db.execute(_e2eeOutgoingSchema);
|
|
await db.execute(_messageRangesSchema);
|
|
await _createIndexes(db);
|
|
await _createChatParticipantsIndex(db);
|
|
}
|
|
|
|
// #***! в sqlite нет ADD COLUMN IF NOT EXISTS, делаем сами
|
|
static Future<void> _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<void> _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<void> _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<int> _participantIdsFromRaw(Object? raw) {
|
|
if (raw is! String || raw.isEmpty) return const [];
|
|
try {
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is! Map) return const [];
|
|
final ids = <int>[];
|
|
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<void> _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);
|
|
}
|
|
|
|
// #***! схемы таблиц строками, их же жуют onCreate и миграции
|
|
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,
|
|
account_status INTEGER NOT NULL DEFAULT 0
|
|
)
|
|
''';
|
|
|
|
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_preview 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,
|
|
last_mention_msg_id INTEGER,
|
|
active_call TEXT,
|
|
public_link TEXT,
|
|
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,
|
|
text_sealed BLOB,
|
|
e2ee INTEGER NOT NULL DEFAULT 0,
|
|
typing_ms INTEGER,
|
|
PRIMARY KEY (id, account_id),
|
|
FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE
|
|
)
|
|
''';
|
|
|
|
static const _messageRangesSchema = '''
|
|
CREATE TABLE IF NOT EXISTS message_ranges (
|
|
account_id INTEGER NOT NULL,
|
|
chat_id INTEGER NOT NULL,
|
|
start_time INTEGER NOT NULL,
|
|
end_time INTEGER NOT NULL,
|
|
PRIMARY KEY (account_id, chat_id, start_time),
|
|
FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE
|
|
)
|
|
''';
|
|
|
|
static const _e2eeSessionsSchema = '''
|
|
CREATE TABLE e2ee_sessions (
|
|
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
|
chat_id INTEGER NOT NULL,
|
|
peer_id INTEGER NOT NULL,
|
|
phase TEXT NOT NULL,
|
|
state BLOB,
|
|
peer_public BLOB,
|
|
verified INTEGER NOT NULL DEFAULT 0,
|
|
offer_text TEXT,
|
|
offer_message_id TEXT,
|
|
updated INTEGER NOT NULL,
|
|
PRIMARY KEY (account_id, chat_id)
|
|
)
|
|
''';
|
|
|
|
static const _webAppStorageSchema = '''
|
|
CREATE TABLE webapp_storage (
|
|
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
|
bot_id INTEGER NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
PRIMARY KEY (account_id, bot_id, key)
|
|
)
|
|
''';
|
|
|
|
static const _webAppBiometrySchema = '''
|
|
CREATE TABLE webapp_biometry (
|
|
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
|
bot_id INTEGER NOT NULL,
|
|
access_requested INTEGER NOT NULL DEFAULT 0,
|
|
access_granted INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (account_id, bot_id)
|
|
)
|
|
''';
|
|
|
|
// #***! дальше операции с данными
|
|
static Future<void> 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<ProfileData?> 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<List<ProfileData>> 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<ProfileData?> 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<void> 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<void> deleteAccount(int accountId) async {
|
|
final db = await _instance;
|
|
await db.delete('profile', where: 'id = ?', whereArgs: [accountId]);
|
|
}
|
|
|
|
static Future<void> 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<String?> 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<void> setWelcomeStickerIds(int accountId, List<int> ids) =>
|
|
setSyncValue(accountId, SyncKey.welcomeStickerIds, ids.join(','));
|
|
|
|
static Future<List<int>> getWelcomeStickerIds(int accountId) async {
|
|
final raw = await getSyncValue(accountId, SyncKey.welcomeStickerIds);
|
|
if (raw == null || raw.isEmpty) return const [];
|
|
return raw.split(',').map(int.tryParse).whereType<int>().toList();
|
|
}
|
|
|
|
static Future<Map<String, String>> 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<void> saveE2eeSession(Map<String, dynamic> row) async {
|
|
final db = await _instance;
|
|
await db.insert(
|
|
'e2ee_sessions',
|
|
row,
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
}
|
|
|
|
static const _e2eeOutgoingSchema = '''
|
|
CREATE TABLE e2ee_outgoing (
|
|
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
|
chat_id INTEGER NOT NULL,
|
|
wire_text TEXT NOT NULL,
|
|
sealed BLOB NOT NULL,
|
|
e2ee INTEGER NOT NULL,
|
|
PRIMARY KEY (account_id, chat_id, wire_text)
|
|
)
|
|
''';
|
|
|
|
static Future<void> saveE2eeOutgoing(
|
|
int accountId,
|
|
int chatId,
|
|
String wire,
|
|
Uint8List sealed,
|
|
int e2ee,
|
|
) async {
|
|
final db = await _instance;
|
|
await db.insert('e2ee_outgoing', {
|
|
'account_id': accountId,
|
|
'chat_id': chatId,
|
|
'wire_text': wire,
|
|
'sealed': sealed,
|
|
'e2ee': e2ee,
|
|
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
|
}
|
|
|
|
static Future<Map<String, dynamic>?> loadE2eeOutgoing(
|
|
int accountId,
|
|
int chatId,
|
|
String wire,
|
|
) async {
|
|
final db = await _instance;
|
|
final rows = await db.query(
|
|
'e2ee_outgoing',
|
|
where: 'account_id = ? AND chat_id = ? AND wire_text = ?',
|
|
whereArgs: [accountId, chatId, wire],
|
|
limit: 1,
|
|
);
|
|
return rows.isEmpty ? null : rows.first;
|
|
}
|
|
|
|
static Future<Map<String, dynamic>?> loadE2eeSession(
|
|
int accountId,
|
|
int chatId,
|
|
) async {
|
|
final db = await _instance;
|
|
final rows = await db.query(
|
|
'e2ee_sessions',
|
|
where: 'account_id = ? AND chat_id = ?',
|
|
whereArgs: [accountId, chatId],
|
|
limit: 1,
|
|
);
|
|
if (rows.isEmpty) return null;
|
|
return rows.first;
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> loadE2eeSessions(
|
|
int accountId,
|
|
) async {
|
|
final db = await _instance;
|
|
return db.query(
|
|
'e2ee_sessions',
|
|
where: 'account_id = ?',
|
|
whereArgs: [accountId],
|
|
);
|
|
}
|
|
|
|
static Future<void> deleteE2eeSession(int accountId, int chatId) async {
|
|
final db = await _instance;
|
|
await db.delete(
|
|
'e2ee_sessions',
|
|
where: 'account_id = ? AND chat_id = ?',
|
|
whereArgs: [accountId, chatId],
|
|
);
|
|
}
|
|
|
|
static Future<void> updateMessageSealed(
|
|
int accountId,
|
|
int chatId,
|
|
String messageId, {
|
|
required Uint8List? sealed,
|
|
required int e2ee,
|
|
}) async {
|
|
final db = await _instance;
|
|
await db.update(
|
|
'messages',
|
|
{'text_sealed': sealed, 'e2ee': e2ee},
|
|
where: 'account_id = ? AND chat_id = ? AND id = ?',
|
|
whereArgs: [accountId, chatId, messageId],
|
|
);
|
|
}
|
|
|
|
/// Avoid overwriting edits, deletion state or sealed text during media I/O.
|
|
static Future<void> updateMessageMediaPayload(
|
|
int accountId,
|
|
int chatId,
|
|
String messageId,
|
|
String originalPayload,
|
|
String payload,
|
|
) async {
|
|
final db = await _instance;
|
|
await db.update(
|
|
'messages',
|
|
{'payload': payload},
|
|
where: 'account_id = ? AND chat_id = ? AND id = ? AND payload = ?',
|
|
whereArgs: [accountId, chatId, messageId, originalPayload],
|
|
);
|
|
}
|
|
|
|
static Future<void> saveWebAppValue(
|
|
int accountId,
|
|
int botId,
|
|
String key,
|
|
String value,
|
|
) async {
|
|
final db = await _instance;
|
|
await db.insert('webapp_storage', {
|
|
'account_id': accountId,
|
|
'bot_id': botId,
|
|
'key': key,
|
|
'value': value,
|
|
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
|
}
|
|
|
|
static Future<String?> getWebAppValue(
|
|
int accountId,
|
|
int botId,
|
|
String key,
|
|
) async {
|
|
final db = await _instance;
|
|
final rows = await db.query(
|
|
'webapp_storage',
|
|
where: 'account_id = ? AND bot_id = ? AND key = ?',
|
|
whereArgs: [accountId, botId, key],
|
|
limit: 1,
|
|
);
|
|
if (rows.isEmpty) return null;
|
|
return rows.first['value'] as String;
|
|
}
|
|
|
|
static Future<void> removeWebAppValue(
|
|
int accountId,
|
|
int botId,
|
|
String key,
|
|
) async {
|
|
final db = await _instance;
|
|
await db.delete(
|
|
'webapp_storage',
|
|
where: 'account_id = ? AND bot_id = ? AND key = ?',
|
|
whereArgs: [accountId, botId, key],
|
|
);
|
|
}
|
|
|
|
static Future<void> clearWebAppValues(int accountId, int botId) async {
|
|
final db = await _instance;
|
|
await db.delete(
|
|
'webapp_storage',
|
|
where: 'account_id = ? AND bot_id = ?',
|
|
whereArgs: [accountId, botId],
|
|
);
|
|
}
|
|
|
|
static Future<int> countWebAppValues(int accountId, int botId) async {
|
|
final db = await _instance;
|
|
final rows = await db.rawQuery(
|
|
'SELECT COUNT(*) AS total FROM webapp_storage '
|
|
'WHERE account_id = ? AND bot_id = ?',
|
|
[accountId, botId],
|
|
);
|
|
if (rows.isEmpty) return 0;
|
|
return (rows.first['total'] as num?)?.toInt() ?? 0;
|
|
}
|
|
|
|
static Future<(bool, bool)> getWebAppBiometryAccess(
|
|
int accountId,
|
|
int botId,
|
|
) async {
|
|
final db = await _instance;
|
|
final rows = await db.query(
|
|
'webapp_biometry',
|
|
where: 'account_id = ? AND bot_id = ?',
|
|
whereArgs: [accountId, botId],
|
|
limit: 1,
|
|
);
|
|
if (rows.isEmpty) return (false, false);
|
|
final row = rows.first;
|
|
return (
|
|
(row['access_requested'] as int? ?? 0) != 0,
|
|
(row['access_granted'] as int? ?? 0) != 0,
|
|
);
|
|
}
|
|
|
|
static Future<void> setWebAppBiometryAccess(
|
|
int accountId,
|
|
int botId, {
|
|
required bool requested,
|
|
required bool granted,
|
|
}) async {
|
|
final db = await _instance;
|
|
await db.insert('webapp_biometry', {
|
|
'account_id': accountId,
|
|
'bot_id': botId,
|
|
'access_requested': requested ? 1 : 0,
|
|
'access_granted': granted ? 1 : 0,
|
|
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
|
}
|
|
|
|
static Future<void> 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<String?> 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<void> 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<String?> getLoginInfo(int accountId) async {
|
|
return getSyncValue(accountId, SyncKey.loginInfo);
|
|
}
|
|
|
|
static Future<void> close() async {
|
|
await _db?.close();
|
|
_db = null;
|
|
_initCompleter = null;
|
|
}
|
|
|
|
// Chats cache
|
|
|
|
// #***! чаты пачкой в одной транзакции, по одному было бы на порядок медленнее
|
|
static Future<void> saveChats(List<Map<String, dynamic>> 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<void> repairLastMessageSenders(int accountId) async {
|
|
try {
|
|
final db = await _instance;
|
|
await db.rawUpdate(
|
|
'UPDATE chats_cache SET last_msg_sender = ('
|
|
' SELECT m.sender_id FROM messages m'
|
|
' WHERE m.account_id = chats_cache.account_id'
|
|
' AND m.chat_id = chats_cache.id'
|
|
' AND m.id = CAST(chats_cache.last_msg_id AS TEXT)'
|
|
') '
|
|
'WHERE account_id = ? AND last_msg_sender IS NULL '
|
|
'AND last_msg_id IS NOT NULL',
|
|
[accountId],
|
|
);
|
|
} catch (e) {
|
|
logger.w('Не удалось восстановить отправителей последних сообщений: $e');
|
|
}
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> 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 bool chatRowIsInList(Map<String, dynamic> row) {
|
|
final value = row['in_list'];
|
|
return value is! int || value != 0;
|
|
}
|
|
|
|
static Future<bool> isChatInList(int accountId, int chatId) async {
|
|
final rows = await loadChat(accountId, chatId);
|
|
return rows.isNotEmpty && chatRowIsInList(rows.first);
|
|
}
|
|
|
|
static Future<void> updateChatColumns(
|
|
List<({int accountId, int chatId, Map<String, Object?> values})> updates,
|
|
) async {
|
|
if (updates.isEmpty) return;
|
|
try {
|
|
final db = await _instance;
|
|
final batch = db.batch();
|
|
for (final update in updates) {
|
|
batch.update(
|
|
'chats_cache',
|
|
update.values,
|
|
where: 'account_id = ? AND id = ?',
|
|
whereArgs: [update.accountId, update.chatId],
|
|
);
|
|
}
|
|
await batch.commit(noResult: true);
|
|
} catch (e) {
|
|
logger.e('Ошибка при обновлении чата: $e');
|
|
}
|
|
}
|
|
|
|
static Future<void> setChatListState(
|
|
int accountId,
|
|
int chatId,
|
|
int listState,
|
|
) async {
|
|
final db = await _instance;
|
|
await db.update(
|
|
'chats_cache',
|
|
{'in_list': listState},
|
|
where: 'account_id = ? AND id = ?',
|
|
whereArgs: [accountId, chatId],
|
|
);
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> 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<int> sumUnread(
|
|
int accountId, {
|
|
int? excludeChatId,
|
|
Set<int>? excludeChatIds,
|
|
}) async {
|
|
final db = await _instance;
|
|
final buffer = StringBuffer('account_id = ? AND in_list = 1');
|
|
final args = <Object?>[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<int?> 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<List<Map<String, dynamic>>> loadDialogChats(
|
|
int accountId,
|
|
) async {
|
|
final db = await _instance;
|
|
return db.query(
|
|
'chats_cache',
|
|
where: "account_id = ? AND type = 'DIALOG'",
|
|
whereArgs: [accountId],
|
|
);
|
|
}
|
|
|
|
// #***! экранируем LIKE иначе поиск по % вернёт всё
|
|
static String _escapeLike(String value) => value
|
|
.replaceAll('\\', '\\\\')
|
|
.replaceAll('%', '\\%')
|
|
.replaceAll('_', '\\_');
|
|
|
|
static Future<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> loadChatsByIds(
|
|
int accountId,
|
|
List<int> 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<void> 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<void> clearChatsCache(int accountId) async {
|
|
final db = await _instance;
|
|
await db.delete(
|
|
'chats_cache',
|
|
where: 'account_id = ?',
|
|
whereArgs: [accountId],
|
|
);
|
|
}
|
|
|
|
// #***! контакты пачкой как и чаты
|
|
static Future<void> saveContacts(List<Map<String, dynamic>> 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<List<Map<String, dynamic>>> loadContacts(
|
|
int accountId, {
|
|
bool includeDeleted = false,
|
|
}) async {
|
|
final db = await _instance;
|
|
return db.query(
|
|
'contacts',
|
|
where: includeDeleted
|
|
? 'account_id = ?'
|
|
: 'account_id = ? AND account_status = 0',
|
|
whereArgs: [accountId],
|
|
);
|
|
}
|
|
|
|
static Future<List<int>> loadContactIds(int accountId) async {
|
|
final db = await _instance;
|
|
final rows = await db.query(
|
|
'contacts',
|
|
columns: ['id'],
|
|
where: 'account_id = ?',
|
|
whereArgs: [accountId],
|
|
);
|
|
return [for (final r in rows) r['id'] as int];
|
|
}
|
|
|
|
static Future<Map<String, dynamic>?> loadContact(
|
|
int accountId,
|
|
int id,
|
|
) async {
|
|
final db = await _instance;
|
|
final rows = await db.query(
|
|
'contacts',
|
|
where: 'account_id = ? AND id = ?',
|
|
whereArgs: [accountId, id],
|
|
limit: 1,
|
|
);
|
|
return rows.isEmpty ? null : rows.first;
|
|
}
|
|
|
|
static Future<void> deleteContact(int accountId, int id) async {
|
|
final db = await _instance;
|
|
await db.delete(
|
|
'contacts',
|
|
where: 'account_id = ? AND id = ?',
|
|
whereArgs: [accountId, id],
|
|
);
|
|
}
|
|
|
|
static Future<void> deleteContacts(int accountId, List<int> ids) async {
|
|
if (ids.isEmpty) return;
|
|
final db = await _instance;
|
|
await db.transaction((txn) async {
|
|
final batch = txn.batch();
|
|
for (final id in ids) {
|
|
batch.delete(
|
|
'contacts',
|
|
where: 'account_id = ? AND id = ?',
|
|
whereArgs: [accountId, id],
|
|
);
|
|
}
|
|
await batch.commit(noResult: true);
|
|
});
|
|
}
|
|
|
|
static Future<void> replaceMessage(
|
|
Map<String, dynamic> row, {
|
|
String? removeId,
|
|
}) async {
|
|
await MessagePersistence.replace(await _instance, row, removeId: removeId);
|
|
}
|
|
|
|
static Future<void> saveMessages(List<Map<String, dynamic>> 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);
|
|
});
|
|
}
|
|
|
|
/// A first outgoing message can precede the server's first chat card.
|
|
/// Keep its local parent and message in one transaction so foreign keys stay
|
|
/// enabled and an interrupted write cannot leave half an outgoing item.
|
|
static Future<void> saveOutgoingMessage(
|
|
Map<String, dynamic> row, {
|
|
bool createDialog = false,
|
|
String? dialogTitle,
|
|
String? dialogIconUrl,
|
|
String? removeId,
|
|
}) async {
|
|
final db = await _instance;
|
|
await db.transaction((txn) async {
|
|
if (createDialog) {
|
|
await _insertDraftDialog(
|
|
txn,
|
|
row['account_id'] as int,
|
|
row['chat_id'] as int,
|
|
title: dialogTitle,
|
|
iconUrl: dialogIconUrl,
|
|
);
|
|
}
|
|
await txn.insert(
|
|
'messages',
|
|
row,
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
if (removeId != null && removeId != row['id']) {
|
|
await txn.delete(
|
|
'messages',
|
|
where: 'account_id = ? AND chat_id = ? AND id = ?',
|
|
whereArgs: [row['account_id'], row['chat_id'], removeId],
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
static Future<void> ensureDialogCached(
|
|
int accountId,
|
|
int chatId, {
|
|
String? title,
|
|
String? iconUrl,
|
|
}) async {
|
|
final db = await _instance;
|
|
await db.transaction(
|
|
(txn) => _insertDraftDialog(
|
|
txn,
|
|
accountId,
|
|
chatId,
|
|
title: title,
|
|
iconUrl: iconUrl,
|
|
),
|
|
);
|
|
}
|
|
|
|
static Future<void> _insertDraftDialog(
|
|
DatabaseExecutor txn,
|
|
int accountId,
|
|
int chatId, {
|
|
String? title,
|
|
String? iconUrl,
|
|
}) async {
|
|
final existing = await txn.query(
|
|
'chats_cache',
|
|
columns: ['id'],
|
|
where: 'account_id = ? AND id = ?',
|
|
whereArgs: [accountId, chatId],
|
|
limit: 1,
|
|
);
|
|
if (existing.isNotEmpty) return;
|
|
final peerId = accountId ^ chatId;
|
|
if (accountId <= 0 || peerId <= 0) {
|
|
throw ArgumentError('An account and dialog peer are required');
|
|
}
|
|
final participants = {accountId: 0, peerId: 0};
|
|
await txn.insert('chats_cache', {
|
|
'id': chatId,
|
|
'account_id': accountId,
|
|
'type': 'DIALOG',
|
|
'title': title,
|
|
'icon_url': iconUrl,
|
|
'cached_at': DateTime.now().millisecondsSinceEpoch,
|
|
'participants': jsonEncode(
|
|
participants.map((id, marker) => MapEntry(id.toString(), marker)),
|
|
),
|
|
}, conflictAlgorithm: ConflictAlgorithm.ignore);
|
|
for (final participantId in participants.keys) {
|
|
await txn.insert('chat_participants', {
|
|
'account_id': accountId,
|
|
'chat_id': chatId,
|
|
'participant_id': participantId,
|
|
}, conflictAlgorithm: ConflictAlgorithm.ignore);
|
|
}
|
|
}
|
|
|
|
static Future<bool> updateSendingMessageStatus(
|
|
int accountId,
|
|
int chatId,
|
|
String messageId,
|
|
String status,
|
|
) async {
|
|
final db = await _instance;
|
|
final changed = await db.update(
|
|
'messages',
|
|
{'status': status},
|
|
where:
|
|
'account_id = ? AND chat_id = ? AND id = ? '
|
|
"AND status = 'sending' AND deleted = 0",
|
|
whereArgs: [accountId, chatId, messageId],
|
|
);
|
|
return changed != 0;
|
|
}
|
|
|
|
static Future<bool> replacePendingMessage(
|
|
Map<String, dynamic> row, {
|
|
required String pendingId,
|
|
}) async => MessagePersistence.replacePending(
|
|
await _instance,
|
|
row,
|
|
pendingId: pendingId,
|
|
);
|
|
|
|
// #***! дальше выборки истории, с конца до сообщения между и вокруг
|
|
static Future<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> 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<void> 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<void> markMessagesDeleted(
|
|
int accountId,
|
|
int chatId,
|
|
List<String> 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<void> clearMessages(int accountId, int chatId) async {
|
|
final db = await _instance;
|
|
await db.transaction((txn) async {
|
|
await txn.delete(
|
|
'messages',
|
|
where: 'account_id = ? AND chat_id = ?',
|
|
whereArgs: [accountId, chatId],
|
|
);
|
|
await txn.delete(
|
|
'message_ranges',
|
|
where: 'account_id = ? AND chat_id = ?',
|
|
whereArgs: [accountId, chatId],
|
|
);
|
|
});
|
|
}
|
|
|
|
static Future<MessageRanges> loadMessageRanges(
|
|
int accountId,
|
|
int chatId,
|
|
) async {
|
|
final db = await _instance;
|
|
final rows = await db.query(
|
|
'message_ranges',
|
|
columns: ['start_time', 'end_time'],
|
|
where: 'account_id = ? AND chat_id = ?',
|
|
whereArgs: [accountId, chatId],
|
|
);
|
|
return MessageRanges([
|
|
for (final row in rows)
|
|
MessageRange(row['start_time'] as int, row['end_time'] as int),
|
|
]);
|
|
}
|
|
|
|
static Future<void> addMessageRange(
|
|
int accountId,
|
|
int chatId,
|
|
MessageRange range,
|
|
) async {
|
|
final db = await _instance;
|
|
await db.transaction((txn) async {
|
|
final rows = await txn.query(
|
|
'message_ranges',
|
|
columns: ['start_time', 'end_time'],
|
|
where: 'account_id = ? AND chat_id = ?',
|
|
whereArgs: [accountId, chatId],
|
|
);
|
|
final merged = MessageRanges.merge([
|
|
for (final row in rows)
|
|
MessageRange(row['start_time'] as int, row['end_time'] as int),
|
|
], range);
|
|
await txn.delete(
|
|
'message_ranges',
|
|
where: 'account_id = ? AND chat_id = ?',
|
|
whereArgs: [accountId, chatId],
|
|
);
|
|
final batch = txn.batch();
|
|
for (final r in merged) {
|
|
batch.insert('message_ranges', {
|
|
'account_id': accountId,
|
|
'chat_id': chatId,
|
|
'start_time': r.start,
|
|
'end_time': r.end,
|
|
});
|
|
}
|
|
await batch.commit(noResult: true);
|
|
});
|
|
}
|
|
|
|
static Future<void> extendMessageRange(
|
|
int accountId,
|
|
int chatId, {
|
|
required int previousLastTime,
|
|
required int time,
|
|
}) async {
|
|
if (time < previousLastTime) return;
|
|
final db = await _instance;
|
|
await db.rawUpdate(
|
|
'UPDATE message_ranges SET end_time = ? '
|
|
'WHERE account_id = ? AND chat_id = ? '
|
|
'AND start_time <= ? AND end_time >= ? AND end_time < ?',
|
|
[time, accountId, chatId, previousLastTime, previousLastTime, time],
|
|
);
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> loadMessagesByIds(
|
|
int accountId,
|
|
int chatId,
|
|
List<String> 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<Map<String, dynamic>?> 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<void> deleteSyntheticMessages(
|
|
int accountId,
|
|
int chatId, {
|
|
required String prefix,
|
|
}) async {
|
|
if (prefix != 'sim_') throw ArgumentError.value(prefix, 'prefix');
|
|
final db = await _instance;
|
|
await db.delete(
|
|
'messages',
|
|
where: 'account_id = ? AND chat_id = ? AND substr(id, 1, 4) = ?',
|
|
whereArgs: [accountId, chatId, prefix],
|
|
);
|
|
}
|
|
|
|
static Future<void> 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],
|
|
);
|
|
}
|
|
|
|
// #***! неотправленные, их подхватит outbox при коннекте
|
|
static Future<List<Map<String, dynamic>>> loadPendingMessages(
|
|
int accountId,
|
|
) async {
|
|
final db = await _instance;
|
|
return db.query(
|
|
'messages',
|
|
where: 'account_id = ? AND status = ? AND deleted = 0',
|
|
whereArgs: [accountId, 'pending'],
|
|
orderBy: 'time ASC',
|
|
);
|
|
}
|
|
}
|