diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index cac66ce..d73ec1f 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,6 +1,7 @@ import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; +import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; @@ -36,6 +37,20 @@ class VerifyCodeResult { return c is Map ? c.cast() : null; } + /// trackId из passwordChallenge — передаётся в [AccountModule.checkPassword]. + String? get challengeTrackId => passwordChallenge?['trackId'] as String?; + + /// Подсказка к паролю из passwordChallenge. + String? get challengeHint => passwordChallenge?['hint'] as String?; + + int? get accountId { + final profileData = payload['profile']; + if (profileData is! Map) return null; + final contact = profileData['contact']; + if (contact is! Map) return null; + return contact['id'] as int?; + } + String? _nestedToken(String key) { final attrs = payload['tokenAttrs']; if (attrs is! Map) return null; @@ -45,11 +60,68 @@ class VerifyCodeResult { } } -/// Поток авторизации по номеру телефона: -/// 1. [requestCode] → сервер шлёт SMS, возвращает временный токен. -/// 2. [verifyCode] → клиент отправляет код + токен, получает токен сессии. -/// -/// При необходимости повторной отправки SMS используйте [resendCode]. +class TwoFactorResult { + final String loginToken; + + const TwoFactorResult({required this.loginToken}); +} + +/// При отсутствии [LoginSyncParams] в [AccountModule.login] сервер вернёт +/// полный снимок данных (cold start), иначе только дельту (warm start). +class LoginSyncParams { + final int chatsSync; + final int contactsSync; + final int callsSync; + final int draftsSync; + final int bannersSync; + final int presenceSync; + final int lastLogin; + final String? configHash; + final String? chatCacheFingerprint; + + const LoginSyncParams({ + required this.chatsSync, + required this.contactsSync, + required this.callsSync, + required this.draftsSync, + required this.bannersSync, + required this.presenceSync, + required this.lastLogin, + this.configHash, + this.chatCacheFingerprint, + }); + + static Future fromDatabase(int accountId) async { + final values = await AppDatabase.getAllSyncValues(accountId); + final lastLogin = values[SyncKey.lastLogin]; + if (lastLogin == null) return null; + + return LoginSyncParams( + chatsSync: int.tryParse(values[SyncKey.chatsSync] ?? '') ?? 0, + contactsSync: int.tryParse(values[SyncKey.contactsSync] ?? '') ?? 0, + callsSync: int.tryParse(values[SyncKey.callsSync] ?? '') ?? 0, + draftsSync: int.tryParse(values[SyncKey.draftsSync] ?? '') ?? 0, + bannersSync: int.tryParse(values[SyncKey.bannersSync] ?? '') ?? 0, + presenceSync: int.tryParse(values[SyncKey.presenceSync] ?? '') ?? -1, + lastLogin: int.parse(lastLogin), + configHash: values[SyncKey.configHash], + chatCacheFingerprint: values[SyncKey.chatCacheFingerprint], + ); + } +} + +class LoginResult { + final ProfileData profile; + final String? updatedToken; + final int serverTime; + + const LoginResult({ + required this.profile, + required this.updatedToken, + required this.serverTime, + }); +} + class AccountModule { final Api _api; @@ -90,14 +162,225 @@ class AccountModule { final result = VerifyCodeResult(payload: data.cast()); final sessionToken = result.loginToken ?? result.registerToken; - if (sessionToken != null) { - await TokenStorage.save(sessionToken); - logger.i('Токен сохранён в хранилище'); + final accountId = result.accountId; + + if (sessionToken != null && accountId != null) { + await TokenStorage.saveToken(sessionToken, accountId); + await TokenStorage.setActiveAccount(accountId); + logger.i('Токен аккаунта $accountId сохранён, установлен активным'); } return result; } + Future login({ + int? accountId, + String? token, + LoginSyncParams? syncParams, + }) async { + _ensureOnline(); + + final resolvedAccountId = accountId ?? await TokenStorage.getActiveAccountId(); + if (resolvedAccountId == null) { + throw StateError('login: нет активного аккаунта'); + } + + final authToken = token ?? await TokenStorage.readToken(resolvedAccountId); + if (authToken == null) { + throw StateError('login: нет токена для аккаунта $resolvedAccountId'); + } + + final resolvedSyncParams = + syncParams ?? await LoginSyncParams.fromDatabase(resolvedAccountId); + + final requestPayload = _buildLoginPayload(authToken, resolvedSyncParams); + + logger.i('LOGIN opcode=${Opcode.login} ' + 'account=$resolvedAccountId warm=${resolvedSyncParams != null}'); + + final packet = await _api.sendRequest(Opcode.login, requestPayload); + + _checkPacketError(packet, 'login'); + + final data = packet.payload; + if (data is! Map) { + throw Exception('login: неожиданный тип payload: ${data.runtimeType}'); + } + + return _processLoginResponse( + data.cast(), + resolvedAccountId, + ); + } + + Future switchAccount(int accountId) async { + final profile = await AppDatabase.loadProfile(accountId); + if (profile == null) { + throw StateError('switchAccount: аккаунт $accountId не найден в базе'); + } + + await AppDatabase.setActiveAccount(accountId); + await TokenStorage.setActiveAccount(accountId); + + logger.i('Активный аккаунт переключён на $accountId'); + return profile; + } + + Future removeAccount(int accountId) async { + await AppDatabase.deleteAccount(accountId); + await TokenStorage.deleteAccount(accountId); + logger.i('Аккаунт $accountId удалён локально'); + } + + /// Проверяет 2FA-пароль (opcode 115). + /// + /// [trackId] — из [VerifyCodeResult.challengeTrackId]. + /// [accountId] — из [VerifyCodeResult.accountId]. + /// + /// При неверном пароле бросает [Exception]. + /// При успехе сохраняет токен и устанавливает аккаунт активным. + Future checkPassword({ + required String password, + required String trackId, + required int accountId, + }) async { + _ensureOnline(); + + final payload = { + 'trackId': trackId, + 'password': password, + }; + + logger.i('Проверка 2FA-пароля для аккаунта $accountId'); + + final packet = await _api.sendRequest( + Opcode.authLoginCheckPassword, + payload, + ); + + _checkPacketError(packet, 'checkPassword'); + + final data = packet.payload; + if (data is! Map) { + throw Exception('checkPassword: неожиданный тип payload: ${data.runtimeType}'); + } + + if (data['error'] != null) { + throw Exception('checkPassword: неверный пароль'); + } + + final tokenAttrs = data['tokenAttrs']; + if (tokenAttrs is! Map) { + throw Exception('checkPassword: отсутствует tokenAttrs в ответе'); + } + + final loginEntry = tokenAttrs['LOGIN']; + if (loginEntry is! Map) { + throw Exception('checkPassword: отсутствует tokenAttrs.LOGIN в ответе'); + } + + final loginToken = loginEntry['token'] as String?; + if (loginToken == null || loginToken.isEmpty) { + throw Exception('checkPassword: отсутствует токен в ответе'); + } + + await TokenStorage.saveToken(loginToken, accountId); + await TokenStorage.setActiveAccount(accountId); + logger.i('2FA пройдена, токен аккаунта $accountId сохранён'); + + return TwoFactorResult(loginToken: loginToken); + } + + Map _buildLoginPayload( + String token, + LoginSyncParams? sync, + ) { + final payload = { + 'token': token, + 'interactive': true, + 'exp': {'chatsCountGroups': '0b32'}, + }; + + if (sync != null) { + payload['presenceSync'] = sync.presenceSync; + payload['chatsSync'] = sync.chatsSync; + payload['contactsSync'] = sync.contactsSync; + payload['callsSync'] = sync.callsSync; + payload['draftsSync'] = sync.draftsSync; + payload['bannersSync'] = sync.bannersSync; + payload['lastLogin'] = sync.lastLogin; + if (sync.configHash != null) payload['configHash'] = sync.configHash; + if (sync.chatCacheFingerprint != null) { + payload['chatCacheFingerprint'] = sync.chatCacheFingerprint; + } + } else { + payload['presenceSync'] = 0; + } + + return payload; + } + + Future _processLoginResponse( + Map data, + int accountId, + ) async { + final serverTime = + (data['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch; + + final updatedToken = data['token'] as String?; + if (updatedToken != null) { + await TokenStorage.saveToken(updatedToken, accountId); + logger.i('Обновлённый токен аккаунта $accountId сохранён'); + } + + final profileMap = data['profile']; + if (profileMap is! Map) { + throw Exception('login: отсутствует profile в ответе'); + } + final contact = profileMap['contact']; + if (contact is! Map) { + throw Exception('login: отсутствует profile.contact в ответе'); + } + final profile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(profile); + await AppDatabase.setActiveAccount(profile.id); + logger.i('Профиль сохранён: id=${profile.id}, name=${profile.firstName}'); + + await _saveSyncState(data, serverTime, profile.id); + + return LoginResult( + profile: profile, + updatedToken: updatedToken, + serverTime: serverTime, + ); + } + + Future _saveSyncState( + Map data, + int serverTime, + int accountId, + ) async { + final ts = serverTime.toString(); + + Future set(String key, String value) => + AppDatabase.setSyncValue(accountId, key, value); + + await set(SyncKey.serverTime, ts); + await set(SyncKey.lastLogin, ts); + await set(SyncKey.chatsSync, ts); + await set(SyncKey.contactsSync, ts); + await set(SyncKey.callsSync, ts); + await set(SyncKey.draftsSync, ts); + await set(SyncKey.bannersSync, ts); + await set(SyncKey.presenceSync, '-1'); + + final config = data['config']; + if (config is Map) { + final hash = config['hash'] as String?; + if (hash != null) await set(SyncKey.configHash, hash); + } + } + Future _requestCodeInternal( String phone, AuthRequestType type, diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart new file mode 100644 index 0000000..8542c77 --- /dev/null +++ b/lib/core/storage/app_database.dart @@ -0,0 +1,261 @@ +import 'dart:io'; + +import 'package:path/path.dart'; +import 'package:sqflite/sqflite.dart'; +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; + + 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, + }); + + factory ProfileData.fromServerMap(Map contact) { + 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, + 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, + ); + } + + factory ProfileData.fromDbRow(Map row) { + return ProfileData( + id: row['id'] as int, + firstName: row['first_name'] as String, + lastName: row['last_name'] as String?, + phone: row['phone'] as int, + 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, + updateTime: row['update_time'] as int, + ); + } + + Map toDbRow() => { + '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, + }; +} + +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'; +} + +class AppDatabase { + static Database? _db; + + static Future init() async { + if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + } + } + + static Future get _instance async { + _db ??= await _open(); + return _db!; + } + + static Future _open() async { + final dbPath = await getDatabasesPath(); + return openDatabase( + join(dbPath, 'komet.db'), + version: 2, + onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), + onCreate: (db, _) => _createTables(db), + onUpgrade: (db, oldVersion, newVersion) async { + if (oldVersion < 2) { + await db.execute( + 'ALTER TABLE profile ADD COLUMN is_active INTEGER NOT NULL DEFAULT 0', + ); + await db.execute('DROP TABLE IF EXISTS sync_state'); + await db.execute(_syncStateSchema); + } + }, + ); + } + + 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 + ) + '''); + await db.execute(_syncStateSchema); + } + + 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 Future saveProfile(ProfileData profile) async { + final db = await _instance; + await db.insert( + 'profile', + profile.toDbRow(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + 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 close() async { + await _db?.close(); + _db = null; + } +} diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index a33c3ee..657ae14 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -1,16 +1,42 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class TokenStorage { - static const _key = 'auth_token'; + static const _tokenPrefix = 'auth_token_'; + static const _activeAccountKey = 'active_account_id'; static const _storage = FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), ); - static Future save(String token) => - _storage.write(key: _key, value: token); + static Future saveToken(String token, int accountId) => + _storage.write(key: '$_tokenPrefix$accountId', value: token); - static Future read() => _storage.read(key: _key); + static Future readToken(int accountId) => + _storage.read(key: '$_tokenPrefix$accountId'); - static Future delete() => _storage.delete(key: _key); + static Future deleteToken(int accountId) => + _storage.delete(key: '$_tokenPrefix$accountId'); + + static Future setActiveAccount(int accountId) => + _storage.write(key: _activeAccountKey, value: accountId.toString()); + + static Future getActiveAccountId() async { + final val = await _storage.read(key: _activeAccountKey); + return val != null ? int.tryParse(val) : null; + } + + static Future readActiveToken() async { + final id = await getActiveAccountId(); + if (id == null) return null; + return readToken(id); + } + + /// Удаляет токен аккаунта и, если он был активным, сбрасывает активный аккаунт. + static Future deleteAccount(int accountId) async { + await deleteToken(accountId); + final activeId = await getActiveAccountId(); + if (activeId == accountId) { + await _storage.delete(key: _activeAccountKey); + } + } } diff --git a/lib/main.dart b/lib/main.dart index 429dfbf..edd81ce 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'backend/api.dart'; +import 'core/storage/app_database.dart'; final api = Api(); void main() async { WidgetsFlutterBinding.ensureInitialized(); + await AppDatabase.init(); await api.connect(); runApp(const MyApp()); } diff --git a/pubspec.lock b/pubspec.lock index e7a8ffd..b8e8b82 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -329,7 +329,7 @@ packages: source: hosted version: "9.3.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -421,6 +421,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_common_ffi: + dependency: "direct main" + description: + name: sqflite_common_ffi + sha256: c59fcdc143839a77581f7a7c4de018e53682408903a0a0800b95ef2dc4033eff + url: "https://pub.dev" + source: hosted + version: "2.4.0+2" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: caa693ad15a587a2b4fde093b728131a1827903872171089dedb16f7665d3a91 + url: "https://pub.dev" + source: hosted + version: "3.2.0" stack_trace: dependency: transitive description: @@ -445,6 +501,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5ae7d5e..3159405 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,9 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 flutter_secure_storage: ^10.0.0 + sqflite: ^2.4.2 + sqflite_common_ffi: ^2.4.0+2 + path: ^1.9.1 dev_dependencies: flutter_test: