From ece0592889f4eda5ee7bea8386457bde35758a37 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 29 Mar 2026 10:39:02 +0300 Subject: [PATCH 1/4] =?UTF-8?q?=D1=81=D1=83=D0=BA=D0=B0=20=D0=B6=D0=B8?= =?UTF-8?q?=D0=B6=D0=B0=20=D0=BF=D0=B8=D0=B4=D0=BE=D1=80=D0=B0=D1=81=20?= =?UTF-8?q?=D0=B2=D0=BE=D1=82=20=D1=82=D0=B5=D0=B1=D0=B5=20=D0=BF=D0=B5?= =?UTF-8?q?=D0=B9=D0=BB=D0=BE=D0=B0=D0=B4=D1=8B=20=D1=81=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=BC=20=D0=BD=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 141 +++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index e69de29..1baf5e8 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -0,0 +1,141 @@ +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/utils/logger.dart'; + +enum AuthRequestType { + startAuth('START_AUTH'), + resend('RESEND'), + checkCode('CHECK_CODE'), + register('REGISTER'); + + const AuthRequestType(this.value); + final String value; +} + +class RequestCodeResult { + final String token; + + const RequestCodeResult({required this.token}); +} + +class VerifyCodeResult { + final Map payload; + + const VerifyCodeResult({required this.payload}); + + String? get loginToken => _nestedToken('LOGIN'); + + String? get registerToken => _nestedToken('REGISTER'); + + bool get requiresPassword => payload['passwordChallenge'] != null; + + Map? get passwordChallenge { + final c = payload['passwordChallenge']; + return c is Map ? c.cast() : null; + } + + String? _nestedToken(String key) { + final attrs = payload['tokenAttrs']; + if (attrs is! Map) return null; + final entry = attrs[key]; + if (entry is! Map) return null; + return entry['token'] as String?; + } +} + +/// Поток авторизации по номеру телефона: +/// 1. [requestCode] → сервер шлёт SMS, возвращает временный токен. +/// 2. [verifyCode] → клиент отправляет код + токен, получает токен сессии. +/// +/// При необходимости повторной отправки SMS используйте [resendCode]. +class AccountModule { + final Api _api; + + AccountModule(this._api); + + Future requestCode( + String phone, { + String language = 'ru', + }) => + _requestCodeInternal(phone, AuthRequestType.startAuth, language); + + Future resendCode( + String phone, { + String language = 'ru', + }) => + _requestCodeInternal(phone, AuthRequestType.resend, language); + + Future verifyCode(String code, String token) async { + _ensureOnline(); + + final payload = { + 'token': token, + 'verifyCode': code, + 'authTokenType': AuthRequestType.checkCode.value, + }; + + logger.i('Отправка OTP-кода (opcode=${Opcode.auth})'); + + final packet = await _api.sendRequest(Opcode.auth, payload); + + _checkPacketError(packet, 'verifyCode'); + + final data = packet.payload; + if (data is! Map) { + throw Exception('verifyCode: неожиданный тип payload: ${data.runtimeType}'); + } + + return VerifyCodeResult(payload: data.cast()); + } + + Future _requestCodeInternal( + String phone, + AuthRequestType type, + String language, + ) async { + _ensureOnline(); + + final payload = { + 'phone': phone, + 'type': type.value, + 'language': language, + }; + + logger.i('Запрос OTP-кода: phone=$phone type=${type.value}'); + + final packet = await _api.sendRequest(Opcode.authRequest, payload); + + _checkPacketError(packet, 'requestCode'); + + final data = packet.payload; + if (data is! Map) { + throw Exception('requestCode: неожиданный тип payload: ${data.runtimeType}'); + } + + final token = data['token']; + if (token is! String || token.isEmpty) { + throw Exception('requestCode: отсутствует token в ответе сервера'); + } + + logger.i('OTP-код запрошен, получен временный токен'); + return RequestCodeResult(token: token); + } + + void _ensureOnline() { + if (_api.state != SessionState.online) { + throw StateError( + 'AccountModule: сессия не онлайн (текущее состояние: ${_api.state.name})', + ); + } + } + + void _checkPacketError(Packet packet, String method) { + if (packet.isError) { + final errMsg = packet.payload is Map + ? (packet.payload as Map)['message'] ?? packet.payload.toString() + : packet.payload?.toString() ?? 'unknown error'; + throw Exception('$method: ошибка от сервера — $errMsg'); + } + } +} From 73eaff142b8086d33f2675de5f7172ab513f6216 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 29 Mar 2026 10:48:52 +0300 Subject: [PATCH 2/4] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=B0=20=D0=B2=20?= =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89=D0=B5=20=D0=9E?= =?UTF-8?q?=D0=A1=20=D0=BD=D0=B0=20Android=20=D1=8D=D1=82=D0=BE=20=D0=B2?= =?UTF-8?q?=20EncryptedSharedPreferences=20=D0=B2=20=D1=8F=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=BE=20=D0=BF=D0=BE=D0=B4=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=8D=D1=82=D0=BE=20Keychain=20=D1=81=20=D0=B2?= =?UTF-8?q?=D0=B8=D0=BD=D0=B4=D0=BE=D0=B9=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=BD=D0=BE=20=D1=8F=20=D1=85=D0=B7=20?= =?UTF-8?q?=D1=87=D0=B5=20=D1=82=D0=B0=D0=BC=20=D0=B7=D0=B0=20=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89=D0=B5=20=D1=81=20=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D1=83=D1=85=D0=BE=D0=BC=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=B5=D1=82,=20=D0=BD=D0=BE=20=D0=BD=D1=83=D0=B6?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BF=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B4=D0=BE=D0=BF.=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Debian / Ubuntu sudo apt install libsecret-1-dev # Fedora / RHEL sudo dnf install libsecret-devel # Arch sudo pacman -S libsecret --- lib/backend/modules/account.dart | 11 +- lib/core/storage/token_storage.dart | 16 +++ linux/CMakeLists.txt | 1 + pubspec.lock | 186 +++++++++++++++++++++++++++- pubspec.yaml | 1 + 5 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 lib/core/storage/token_storage.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 1baf5e8..cac66ce 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/token_storage.dart'; import '../../core/utils/logger.dart'; enum AuthRequestType { @@ -86,7 +87,15 @@ class AccountModule { throw Exception('verifyCode: неожиданный тип payload: ${data.runtimeType}'); } - return VerifyCodeResult(payload: data.cast()); + final result = VerifyCodeResult(payload: data.cast()); + + final sessionToken = result.loginToken ?? result.registerToken; + if (sessionToken != null) { + await TokenStorage.save(sessionToken); + logger.i('Токен сохранён в хранилище'); + } + + return result; } Future _requestCodeInternal( diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart new file mode 100644 index 0000000..a33c3ee --- /dev/null +++ b/lib/core/storage/token_storage.dart @@ -0,0 +1,16 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class TokenStorage { + static const _key = 'auth_token'; + + static const _storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); + + static Future save(String token) => + _storage.write(key: _key, value: token); + + static Future read() => _storage.read(key: _key); + + static Future delete() => _storage.delete(key: _key); +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 74a50c9..ed7975d 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -53,6 +53,7 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) # System-level dependencies. find_package(PkgConfig REQUIRED) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(SECRET REQUIRED IMPORTED_TARGET libsecret-1) # Application build; see runner/CMakeLists.txt. add_subdirectory("runner") diff --git a/pubspec.lock b/pubspec.lock index 6bb2187..e7a8ffd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -41,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -118,6 +134,54 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 + url: "https://pub.dev" + source: hosted + version: "10.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -136,6 +200,22 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" http: dependency: transitive description: @@ -192,6 +272,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -224,6 +312,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" path: dependency: transitive description: @@ -232,6 +336,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + url: "https://pub.dev" + source: hosted + version: "2.2.23" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -240,6 +400,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" sky_engine: dependency: transitive description: flutter @@ -349,6 +517,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.10.4 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 252d74e..5ae7d5e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: device_info_plus: ^12.3.0 flutter_timezone: ^5.0.1 timezone: ^0.11.0 + flutter_secure_storage: ^10.0.0 dev_dependencies: flutter_test: From c3e825ec186bf651bb125265d33e065ca06b1b2d Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 29 Mar 2026 11:33:13 +0300 Subject: [PATCH 3/4] feat: add phone auth with 2FA, multi-account storage --- lib/backend/modules/account.dart | 299 +++++++++++++++++++++++++++- lib/core/storage/app_database.dart | 261 ++++++++++++++++++++++++ lib/core/storage/token_storage.dart | 36 +++- lib/main.dart | 2 + pubspec.lock | 66 +++++- pubspec.yaml | 3 + 6 files changed, 653 insertions(+), 14 deletions(-) create mode 100644 lib/core/storage/app_database.dart 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: From 610527b0932f7e09688ef784bfbd964035f361bd Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 29 Mar 2026 11:53:23 +0300 Subject: [PATCH 4/4] feat: implement chat caching and synchronization from login payload --- lib/backend/modules/account.dart | 2 + lib/backend/modules/chats.dart | 204 +++++++++++++++++++++++++++++ lib/core/storage/app_database.dart | 50 ++++++- 3 files changed, 255 insertions(+), 1 deletion(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index d73ec1f..a2ce112 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -4,6 +4,7 @@ import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; +import 'chats.dart'; enum AuthRequestType { startAuth('START_AUTH'), @@ -347,6 +348,7 @@ class AccountModule { logger.i('Профиль сохранён: id=${profile.id}, name=${profile.firstName}'); await _saveSyncState(data, serverTime, profile.id); + await ChatsModule.syncFromLoginPayload(data, profile.id, profile.id); return LoginResult( profile: profile, diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index e69de29..d9c7d91 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -0,0 +1,204 @@ +import '../../core/storage/app_database.dart'; + +class CachedChat { + final int id; + final int accountId; + final String type; + final String? title; + final String? iconUrl; + final int? lastMsgId; + final int? lastMsgTime; + final String? lastMsgText; + final int? lastMsgSenderId; + final int unreadCount; + final int lastEventTime; + final int cachedAt; + + const CachedChat({ + required this.id, + required this.accountId, + required this.type, + this.title, + this.iconUrl, + this.lastMsgId, + this.lastMsgTime, + this.lastMsgText, + this.lastMsgSenderId, + required this.unreadCount, + required this.lastEventTime, + required this.cachedAt, + }); + + factory CachedChat.fromDbRow(Map row) => CachedChat( + id: row['id'] as int, + accountId: row['account_id'] as int, + type: row['type'] as String, + title: row['title'] as String?, + iconUrl: row['icon_url'] as String?, + lastMsgId: row['last_msg_id'] as int?, + lastMsgTime: row['last_msg_time'] as int?, + lastMsgText: row['last_msg_text'] as String?, + lastMsgSenderId: row['last_msg_sender'] as int?, + unreadCount: row['unread_count'] as int, + lastEventTime: row['last_event_time'] as int, + cachedAt: row['cached_at'] as int, + ); + + Map toDbRow() => { + 'id': id, + 'account_id': accountId, + 'type': type, + 'title': title, + 'icon_url': iconUrl, + 'last_msg_id': lastMsgId, + 'last_msg_time': lastMsgTime, + 'last_msg_text': lastMsgText, + 'last_msg_sender': lastMsgSenderId, + 'unread_count': unreadCount, + 'last_event_time': lastEventTime, + 'cached_at': cachedAt, + }; +} + +class ChatsModule { + /// Парсит и кэширует чаты из payload opcode 19. + /// + /// Для диалогов разрезолвит имя и аватар из списка [contacts] того же + /// ответа. На warm start контакты не приходят — используется существующий + /// кэш. + static Future syncFromLoginPayload( + Map data, + int accountId, + int currentUserId, + ) async { + final chats = data['chats']; + if (chats is! List || chats.isEmpty) return; + + final contactsMap = _buildContactsMap(data['contacts']); + final cachedAt = DateTime.now().millisecondsSinceEpoch; + + final existingRows = await AppDatabase.loadChats(accountId); + final existing = { + for (final row in existingRows) row['id'] as int: CachedChat.fromDbRow(row), + }; + + final rows = chats + .whereType() + .map((c) => _parseChat( + c.cast(), + accountId, + currentUserId, + contactsMap, + existing, + cachedAt, + )) + .whereType() + .map((c) => c.toDbRow()) + .toList(); + + if (rows.isNotEmpty) { + await AppDatabase.saveChats(rows); + } + } + + static Future> getChats(int accountId) async { + final rows = await AppDatabase.loadChats(accountId); + return rows.map(CachedChat.fromDbRow).toList(); + } + + static Future clearCache(int accountId) => + AppDatabase.clearChatsCache(accountId); + + // internal + + static Map> _buildContactsMap(dynamic contacts) { + if (contacts is! List) return {}; + final result = >{}; + for (final c in contacts.whereType()) { + final id = c['id']; + if (id is int) result[id] = c.cast(); + } + return result; + } + + static CachedChat? _parseChat( + Map chat, + int accountId, + int currentUserId, + Map> contactsMap, + Map existing, + int cachedAt, + ) { + final id = chat['id']; + if (id is! int) return null; + + final type = (chat['type'] as String?) ?? 'DIALOG'; + + String? title; + String? iconUrl; + + if (type == 'DIALOG') { + final otherId = _otherParticipantId(chat['participants'], currentUserId); + final contact = otherId != null ? contactsMap[otherId] : null; + + if (contact != null) { + title = _nameFromContact(contact); + iconUrl = contact['baseUrl'] as String?; + } else { + // Warm start: контакты не пришли — берём из кэша + title = existing[id]?.title; + iconUrl = existing[id]?.iconUrl; + } + } else { + title = chat['title'] as String?; + iconUrl = chat['baseIconUrl'] as String?; + } + + final lastMsg = chat['lastMessage']; + int? lastMsgId; + int? lastMsgTime; + String? lastMsgText; + int? lastMsgSenderId; + + if (lastMsg is Map) { + lastMsgId = lastMsg['id'] as int?; + lastMsgTime = lastMsg['time'] as int?; + lastMsgText = lastMsg['text'] as String?; + lastMsgSenderId = lastMsg['sender'] as int?; + } + + return CachedChat( + id: id, + accountId: accountId, + type: type, + title: title, + iconUrl: iconUrl, + lastMsgId: lastMsgId, + lastMsgTime: lastMsgTime, + lastMsgText: lastMsgText, + lastMsgSenderId: lastMsgSenderId, + unreadCount: (chat['newMessages'] as int?) ?? 0, + lastEventTime: (chat['lastEventTime'] as int?) ?? 0, + cachedAt: cachedAt, + ); + } + + static int? _otherParticipantId(dynamic participants, int currentUserId) { + if (participants is! Map) return null; + for (final key in participants.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null && id != currentUserId) return id; + } + return null; + } + + static String? _nameFromContact(Map contact) { + final names = contact['names']; + if (names is! List || names.isEmpty) return null; + final name = names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.first, + ) as Map; + return name['name'] as String?; + } +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 8542c77..3c0409a 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -118,7 +118,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 2, + version: 3, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -129,6 +129,9 @@ class AppDatabase { await db.execute('DROP TABLE IF EXISTS sync_state'); await db.execute(_syncStateSchema); } + if (oldVersion < 3) { + await db.execute(_chatsCacheSchema); + } }, ); } @@ -150,6 +153,7 @@ class AppDatabase { ) '''); await db.execute(_syncStateSchema); + await db.execute(_chatsCacheSchema); } static const _syncStateSchema = ''' @@ -161,6 +165,24 @@ class AppDatabase { ) '''; + 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_sender INTEGER, + unread_count INTEGER NOT NULL DEFAULT 0, + last_event_time INTEGER NOT NULL DEFAULT 0, + cached_at INTEGER NOT NULL, + PRIMARY KEY (id, account_id) + ) + '''; + static Future saveProfile(ProfileData profile) async { final db = await _instance; await db.insert( @@ -258,4 +280,30 @@ class AppDatabase { await _db?.close(); _db = null; } + + // Chats cache + + static Future saveChats(List> rows) async { + final db = await _instance; + final batch = db.batch(); + for (final row in rows) { + batch.insert('chats_cache', row, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + static Future>> loadChats(int accountId) async { + final db = await _instance; + return db.query( + 'chats_cache', + where: 'account_id = ?', + whereArgs: [accountId], + orderBy: 'last_event_time DESC', + ); + } + + static Future clearChatsCache(int accountId) async { + final db = await _instance; + await db.delete('chats_cache', where: 'account_id = ?', whereArgs: [accountId]); + } }