import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import '../api.dart'; import '../../core/config/debug_test.dart'; import '../../core/config/qlyra_settings.dart'; import '../../core/protocol/chat_cache_fingerprint.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/media/media_playback.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/profile_deletion_store.dart'; import '../../core/storage/spoofing_service.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import '../../models/login_info.dart'; import 'banners.dart'; import 'chats.dart'; import 'complaints.dart'; import 'contacts.dart'; import 'folders.dart'; import 'messages.dart'; import 'webapp.dart'; import 'account/account_models.dart'; import 'account/privacy_module.dart'; import 'account/profile_module.dart'; import 'account/sessions_module.dart'; import 'account/two_factor_module.dart'; export 'account/account_models.dart'; // #***! номер к виду который ждёт сервер String _normalizeAuthPhone(String phone) { final digits = phone.replaceAll(RegExp(r'\D'), ''); return '+$digits'; } // #***! маскируем номер, в логе ему делать нечего String _maskPhone(String phone) { if (phone.length <= 5) return '***'; return '${phone.substring(0, 3)}***${phone.substring(phone.length - 2)}'; } // #***! аккаунт целиком, вход регистрация мультиаккаунт class AccountModule { final Api _api; late final SessionsModule _sessions = SessionsModule(_api); late final PrivacyModule _privacy = PrivacyModule(_api); late final ProfileModule _profile = ProfileModule(_api); late final TwoFactorModule _twoFactor = TwoFactorModule(_api, _profile); late final BannersModule banners = BannersModule(_api); final _loginStatusController = StreamController.broadcast(); final _noticeController = StreamController.broadcast(); bool _loggedIn = false; int? _loginEpoch; // #***! тяжёлое в подмодулях, тут только оркестрация AccountModule(this._api) { _api.stateStream.listen((state) { if (state != SessionState.online) _loggedIn = false; }); } Stream get loginStatusStream => _loginStatusController.stream; Stream get noticeStream => _noticeController.stream; /// `true`, только когда сервер считает сессию ONLINE — после успешного /// login (opcode 19), а не просто после хэндшейка (opcode 6). bool get isLoggedIn => _loggedIn && _loginEpoch == _api.sessionEpoch && _api.state == SessionState.online; // #***! дальше обёртки над подмодулями, единая точка для юишки Future getPrivacyConfig() => _privacy.getPrivacyConfig(); Future> getBlockedContacts() => _privacy.getBlockedContacts(); Future updatePrivacyConfig(Map settings) => _privacy.updatePrivacyConfig(settings); Future setSafeMode(bool value) => _privacy.setSafeMode(value); Future setChatsPushNotification(bool value) => _privacy.setChatsPushNotification(value); Future setMessagePreview(bool value) => _privacy.setMessagePreview(value); Future setNotificationSound(bool value) => _privacy.setNotificationSound(value); Future setCallNotifications(bool value) => _privacy.setCallNotifications(value); Future setNewContacts(bool value) => _privacy.setNewContacts(value); Future registerPushToken(String pushToken) => _privacy.registerPushToken(pushToken); Future unregisterPushToken(String pushToken) => _privacy.unregisterPushToken(pushToken); Future updateProfileName(String firstName, String? lastName) => _profile.updateProfileName(firstName, lastName); Future updateProfileAvatar( String photoToken, { String avatarType = 'USER_AVATAR', }) => _profile.updateProfileAvatar(photoToken, avatarType: avatarType); Future getAvatarUploadUrl() => _profile.getAvatarUploadUrl(); Future removeProfilePhoto(int photoId) => _profile.removeProfilePhoto(photoId); Future create2faTrack() => _twoFactor.create2faTrack(); Future set2faPassword(String trackId, String password) => _twoFactor.set2faPassword(trackId, password); Future set2faHint(String trackId, String hint) => _twoFactor.set2faHint(trackId, hint); Future verify2faEmail(String trackId, String email) => _twoFactor.verify2faEmail(trackId, email); Future verify2faCode(String trackId, String code) => _twoFactor.verify2faCode(trackId, code); Future confirm2fa({ required String trackId, required String password, String? hint, bool withEmail = true, }) => _twoFactor.confirm2fa( trackId: trackId, password: password, hint: hint, withEmail: withEmail, ); Future enter2faPanel() => _twoFactor.enter2faPanel(); Future get2faDetails(String trackId) => _twoFactor.get2faDetails(trackId); Future get2faStatus() => _twoFactor.get2faStatus(); Future check2faPassword(String trackId, String password) => _twoFactor.check2faPassword(trackId, password); Future update2faPassword({ required String trackId, required String newPassword, String? hint, }) => _twoFactor.update2faPassword( trackId: trackId, newPassword: newPassword, hint: hint, ); Future commit2faEmailChange(String trackId) => _twoFactor.commit2faEmailChange(trackId); Future remove2fa(String trackId) => _twoFactor.remove2fa(trackId); // #***! запрос кода, первый и повторный отличаются типом Future requestCode( String phone, { String language = 'ru', }) => _requestCodeInternal(phone, AuthRequestType.startAuth, language); Future resendCode( String phone, { String language = 'ru', }) => _requestCodeInternal(phone, AuthRequestType.resend, language); // #***! проверка кода, дальше вход регистрация или 2FA 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); final data = _requireMapPayload(packet, 'verifyCode'); final result = VerifyCodeResult(payload: data.cast()); final sessionToken = result.loginToken; final verifiedProfile = _profileFromVerifyPayload(result.payload); final accountId = result.accountId ?? verifiedProfile?.id; // #***! токен сохраняем сразу и переносим спуф профиль if (sessionToken != null && accountId != null) { if (result.loginToken != null && verifiedProfile != null) { await AppDatabase.saveProfile(verifiedProfile, isActive: true); } await TokenStorage.saveToken(sessionToken, accountId); await TokenStorage.setActiveAccount(accountId); await SpoofingService.commitPendingSpoof(accountId); } return result; } ProfileData? _profileFromVerifyPayload(Map payload) { final profileMap = payload['profile']; if (profileMap is! Map) return null; return ProfileData.fromServerProfile(profileMap.cast()); } // #***! регистрация после кода Future completeRegistration({ required String token, required String firstName, String? lastName, int? photoId, }) async { _ensureOnline(); final payload = { 'token': token, 'tokenType': AuthRequestType.register.value, 'firstName': firstName, }; if (lastName != null && lastName.isNotEmpty) { payload['lastName'] = lastName; } if (photoId != null) { payload['photoId'] = photoId; payload['avatarType'] = 'PRESET_AVATAR'; } logger.i('Завершение регистрации (opcode=${Opcode.authConfirm})'); final packet = await _api.sendRequest(Opcode.authConfirm, payload); final data = _requireMapPayload(packet, 'completeRegistration'); final loginToken = data['token']; if (loginToken is! String || loginToken.trim().isEmpty) { throw const FormatException( 'completeRegistration: отсутствует токен входа в ответе сервера', ); } if (data['tokenType'] != null && data['tokenType'] != 'LOGIN') { throw const FormatException( 'completeRegistration: сервер не выдал токен типа LOGIN', ); } final profileMap = data['profile']; if (profileMap is! Map) { throw Exception('completeRegistration: отсутствует profile в ответе'); } final contact = profileMap['contact']; if (contact is! Map) { throw Exception('completeRegistration: отсутствует profile.contact'); } final accountId = contact['id'] as int?; if (accountId == null) { throw Exception('completeRegistration: отсутствует id аккаунта'); } final profile = ProfileData.fromServerProfile( profileMap.cast(), ); await TokenStorage.saveToken(loginToken, accountId); await AppDatabase.saveProfile(profile, isActive: true); await TokenStorage.setActiveAccount(accountId); await SpoofingService.commitPendingSpoof(accountId); logger.i('Регистрация завершена, accountId=$accountId'); return accountId; } // #***! основной вход, syncParams говорят серверу что у нас есть Future login({ int? accountId, String? token, LoginSyncParams? syncParams, }) async { _ensureOnline(); int? resolvedAccountId = accountId ?? await TokenStorage.getActiveAccountId(); String? authToken = token; if (authToken == null) { if (resolvedAccountId == null) { throw StateError('login: нет активного аккаунта'); } authToken = await TokenStorage.readToken(resolvedAccountId); if (authToken == null) { throw StateError('login: нет токена для аккаунта $resolvedAccountId'); } } if (authToken.trim().isEmpty) { throw StateError('login: пустой токен входа'); } final requestPayload = buildLoginPayload(authToken, sync: syncParams); final loginEpoch = _api.sessionEpoch; _loggedIn = false; _loginStatusController.add(LoginStatus.loading); try { final packet = await _api.sendRequest(Opcode.login, requestPayload); final data = _requireMapPayload(packet, 'login'); final dataMap = data.cast(); // #***! вошли по чужому токену, id узнаём из ответа if (resolvedAccountId == null) { resolvedAccountId = extractAccountId(dataMap); if (resolvedAccountId == null) { logger.e( 'login: accountId не найден в ответе; ' '${describeResponseShape(dataMap)}', ); throw Exception('login: не удалось определить accountId из ответа'); } logger.i('login: accountId=$resolvedAccountId определён из ответа'); await TokenStorage.saveToken(authToken, resolvedAccountId); await TokenStorage.setActiveAccount(resolvedAccountId); await SpoofingService.commitPendingSpoof(resolvedAccountId); } final result = await _processLoginResponse(dataMap, resolvedAccountId); if (_api.sessionEpoch != loginEpoch || _api.state != SessionState.online) { throw StateError('Session changed during login'); } _loginEpoch = loginEpoch; _loggedIn = true; _loginStatusController.add(LoginStatus.success); return result; } catch (e) { _loginStatusController.add(LoginStatus.error); rethrow; } } Future> getSessions() => _sessions.getSessions(); Future terminateOtherSessions() => _sessions.terminateOtherSessions(); Future authorizeWebQrLogin(String qrLink) => _sessions.authorizeWebQrLogin(qrLink); // #***! второй аккаунт, рвём сессию чистим кэши готовим спуф Future beginAddAccount() async { final existing = await AppDatabase.loadAllProfiles(); await SpoofingService.prepareNewAccountSpoof( existing.map((p) => p.id).toList(growable: false), ); try { await _api.disconnect(); } catch (_) {} await TokenStorage.clearActiveAccount(); ContactCache.clear(); TranscriptionCache.clear(); ComplaintsModule.clear(); ContactsModule.clearBlockedCache(); banners.clear(); chats.resetForAccountSwitch(); logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен'); } // #***! вход по чужому токену из дев меню Future loginWithToken(String token) async { await TokenStorage.clearActiveAccount(); try { await _api.disconnect(); } catch (_) {} ContactCache.clear(); TranscriptionCache.clear(); ComplaintsModule.clear(); ContactsModule.clearBlockedCache(); banners.clear(); chats.resetForAccountSwitch(); await _api.connect(); if (_api.state != SessionState.online) { throw StateError('loginWithToken: нет соединения с сервером'); } logger.i('Вход по токену: сессия поднята со спуфом, выполняю login'); return login(token: token); } // #***! переключение аккаунта, реконнект с другим спуфом Future switchAccount(int accountId) async { MediaPlayback.instance.closeAudioFile(); final profile = await AppDatabase.loadProfile(accountId); if (profile == null) { throw StateError('switchAccount: аккаунт $accountId не найден в базе'); } final token = await TokenStorage.readToken(accountId); if (token == null) { throw StateError('switchAccount: нет токена для аккаунта $accountId'); } try { await _api.disconnect(); } catch (_) {} await AppDatabase.setActiveAccount(accountId); await TokenStorage.setActiveAccount(accountId); ContactCache.clear(); TranscriptionCache.clear(); ComplaintsModule.clear(); ContactsModule.clearBlockedCache(); banners.clear(); chats.resetForAccountSwitch(); await ContactsModule.primeCacheFromDb(accountId); try { await _api.connect(); } catch (e) { logger.e( 'switchAccount: ошибка соединения при переключении на $accountId: $e', ); throw StateError('switchAccount: не удалось подключиться к серверу'); } if (_api.state != SessionState.online) { logger.w( 'switchAccount: нет соединения с сервером после переключения на $accountId', ); throw StateError('switchAccount: нет соединения с сервером'); } logger.i('Активный аккаунт переключён на $accountId'); return profile; } Future> listAccounts() async { return AppDatabase.loadAllProfiles(); } // #***! удаляем локально, база токен спуф и заявка Future removeAccount(int accountId) async { await AppDatabase.deleteAccount(accountId); await TokenStorage.deleteAccount(accountId); await SpoofingService.clearAccountSpoof(accountId); await ProfileDeletionStore.clear(accountId); logger.i('Аккаунт $accountId удалён локально'); } Future profileDeletionScheduledAt() async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return null; return ProfileDeletionStore.scheduledAt(accountId); } // #***! заявка на удаление профиля, сервер вернёт дату Future setProfileDeletion(bool delete) async { _ensureOnline(); final packet = await _api.sendRequest(Opcode.profileDelete, { 'delete': delete, 'type': 0, }); final data = _requireMapPayload(packet, 'setProfileDeletion'); final raw = data['timestamp']; final millis = raw is int ? raw : 0; final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { await ProfileDeletionStore.save(accountId, millis); } logger.i( 'Профиль: заявка на удаление ' '${delete ? 'создана' : 'отменена'} (timestamp=$millis)', ); if (millis <= 0) return null; return DateTime.fromMillisecondsSinceEpoch(millis); } // #***! выход, сначала сервер потом чистка Future logout() async { MediaPlayback.instance.closeAudioFile(); final accountId = await TokenStorage.getActiveAccountId(); try { await _logoutOnServer(accountId); } on SessionExpiredException catch (e) { logger.w('logout: сервер отклонил сессию: ${e.message}'); } _loggedIn = false; try { await _api.disconnect(); } catch (_) {} if (accountId != null) { await removeAccount(accountId); } ContactCache.clear(); TranscriptionCache.clear(); ComplaintsModule.clear(); ContactsModule.clearBlockedCache(); banners.clear(); chats.resetForAccountSwitch(); } Future _logoutOnServer(int? accountId) async { await _ensureLogoutSession(accountId); await _api.sendRequestOrThrow(Opcode.logout, {}); } // #***! чтоб выйти нужна живая сессия, при чём логинимся заново Future _ensureLogoutSession(int? accountId) async { if (_api.state == SessionState.disconnected) { await _api.connect(); } if (_api.state != SessionState.online) { await _api.stateStream .firstWhere((state) => state == SessionState.online) .timeout(const Duration(seconds: 20)); } if (_loggedIn) return; if (accountId == null) return; final token = await TokenStorage.readToken(accountId); if (token == null || token.isEmpty) { throw StateError('logout: нет токена для серверного выхода'); } await _api.sendRequestOrThrow( Opcode.login, buildLoginPayload(token, interactive: false), ); _loggedIn = true; } // #***! второй шаг входа при 2FA Future checkPassword({ required String password, required String trackId, }) async { _ensureOnline(); final payload = { 'trackId': trackId, 'password': password, }; logger.i('Проверка 2FA-пароля'); final packet = await _api.sendRequest( Opcode.authLoginCheckPassword, payload, ); final data = _requireMapPayload(packet, 'checkPassword'); if (data['error'] != null) { throw const WrongPasswordException(); } 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: отсутствует токен в ответе'); } final accountId = extractAccountId(data); if (accountId == null) { throw Exception('checkPassword: отсутствует accountId в ответе'); } final profileMap = data['profile']; if (profileMap is Map) { final profile = ProfileData.fromServerProfile( profileMap.cast(), ); await AppDatabase.saveProfile(profile, isActive: true); } await TokenStorage.saveToken(loginToken, accountId); await TokenStorage.setActiveAccount(accountId); await SpoofingService.commitPendingSpoof(accountId); logger.i('2FA пройдена, получен login-токен'); return TwoFactorResult(loginToken: loginToken, accountId: accountId); } // #***! тело login, токен невидимка и маркеры синхры Map buildLoginPayload( String token, { LoginSyncParams? sync, bool? interactive, }) { final payload = { 'token': token, 'interactive': interactive ?? !QlyraSettings.ghostMode.value, // #***! exp это экспериментальные фичи которые просим 'exp': { 'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]), }, }; final callsSeed = _api.callsSeed; final deviceId = _api.deviceId; // #***! без отпечатка сборки сервер не отдаст кэш чатов if (callsSeed != null && deviceId != null) { payload['chatCacheFingerprint'] = ChatCacheFingerprint.compute( callsSeed, deviceId, ); } 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.serverConfigSeen && sync.configHash != null) { payload['configHash'] = sync.configHash; } // #***! нет маркеров, просим всё с нуля } else { payload['presenceSync'] = -1; payload['chatsSync'] = -1; } return payload; } // #***! разбор login, профиль чаты контакты папки баннеры 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); } ProfileData profile; final profileMap = data['profile']; // #***! берсерк ломает профиль специально чтоб проверить восстановление if (!DebugTest.berserk && profileMap is Map && profileMap['contact'] is Map) { profile = ProfileData.fromServerProfile( profileMap.cast(), ); } else { profile = await _resurrectProfile(accountId); } await AppDatabase.saveProfile(profile, isActive: true); await AppDatabase.setActiveAccount(profile.id); await _saveSyncState(data, serverTime, profile.id); await ContactsModule.syncFromLoginPayload(data, profile.id); await chats.syncFromLoginPayload(data, profile.id, profile.id); // #***! остальные страницы чатов в фоне, вход не ждёт unawaited(chats.paginateChats(_api, profile.id, profile.id, data)); // #***! каждый блок в try, упавший кусок не должен ронять вход try { await ContactsModule.syncFromServer(_api, profile.id); } catch (e) { logger.w('Контакты: $e'); } final config = data['config']; if (config is Map) { await FoldersModule.applyFromLoginConfig( profile.id, config.cast(), ); await chats.applyFavorites(profile.id); final userConfig = config['user']; if (userConfig is Map) { await AppDatabase.savePrivacyConfig(profile.id, jsonEncode(userConfig)); } } try { await FoldersModule.syncFromServer(_api, profile.id); } catch (e) { logger.w('Папки чатов: $e'); } await chats.applyFavorites(profile.id); try { await banners.initFromLogin(profile.id, data); } catch (e) { logger.w('Баннеры: $e'); } try { await _saveLoginInfo(data, profile.id); } catch (e) { logger.w('Info: $e'); } return LoginResult( profile: profile, updatedToken: updatedToken, serverTime: serverTime, raw: data, ); } // #***! профиля нет нигде, тянем через контакт иначе заглушка Future _resurrectProfile(int accountId) async { if (DebugTest.berserk) { await AppDatabase.deleteAccount(accountId); logger.w( 'login: [BERSERK] профиль удалён из БД, форсирую регенерацию (id=$accountId)', ); } else { final cached = await AppDatabase.loadProfile(accountId); if (cached != null) return cached; } _noticeController.add(AccountNotice.resurrectingProfile); try { final fetched = await ContactsModule.fetchSelfProfile(_api, accountId); if (fetched != null) { logger.i( 'login: профиль восстановлен через CONTACT_INFO (id=$accountId)', ); return fetched; } } catch (e) { logger.w( 'login: восстановление профиля через CONTACT_INFO не удалось: $e', ); } logger.w('login: профиль недоступен, использую заглушку (id=$accountId)'); return ProfileData.stub(accountId); } // #***! маркеры синхры, считаем что знаем всё до 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); } } // #***! сводка login для отладочного экрана Future _saveLoginInfo(Map data, int accountId) async { final config = data['config'] as Map?; final serverConfig = config?['server'] as Map?; if (serverConfig != null) { await AppDatabase.setSyncValue(accountId, SyncKey.serverConfigSeen, '1'); await AppDatabase.setSyncValue( accountId, SyncKey.profileInviteLink, serverConfig['invite-link']?.toString().trim() ?? '', ); await _persistEntryBannerApps(accountId, serverConfig); } final info = LoginInfo.fromPayload(data); await AppDatabase.saveLoginInfo(accountId, jsonEncode(info)); } // #***! id мини аппок ловим по имени иконки в конфиге Future _persistEntryBannerApps(int accountId, Map serverConfig) async { final banners = serverConfig['settings-entry-banners']; if (banners is! List) return; final resolved = {}; for (final banner in banners) { final items = (banner is Map) ? banner['items'] : null; if (items is! List) continue; for (final item in items) { if (item is! Map) continue; final appId = item['appid']; if (appId is! int) continue; final icon = item['icon']?.toString().toLowerCase() ?? ''; for (final entry in EntryBannerApps.iconMatchers.entries) { if (!resolved.containsKey(entry.key) && icon.contains(entry.value)) { resolved[entry.key] = appId; } } } } for (final entry in resolved.entries) { await AppDatabase.setSyncValue( accountId, entry.key, entry.value.toString(), ); } } // #***! общий запрос кода Future _requestCodeInternal( String phone, AuthRequestType type, String language, ) async { _ensureOnline(); final normalizedPhone = _normalizeAuthPhone(phone); final payload = { 'phone': normalizedPhone, 'type': type.value, 'language': language, }; final callsSeed = _api.callsSeed; final deviceId = _api.deviceId; if (callsSeed != null && deviceId != null) { payload['mode'] = ChatCacheFingerprint.compute(callsSeed, deviceId); } logger.i( 'Запрос OTP-кода: phone=${_maskPhone(normalizedPhone)} type=${type.value}', ); final packet = await _api.sendRequest(Opcode.authRequest, payload); final data = _requireMapPayload(packet, 'requestCode'); 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})', ); } } Map _requireMapPayload(Packet packet, String method) { _checkPacketError(packet, method); final data = packet.payload; if (data is! Map) { throw Exception('$method: неожиданный тип payload: ${data.runtimeType}'); } return data; } void _checkPacketError(Packet packet, String method) { throwIfPacketError(packet); } }