907 lines
30 KiB
Dart
907 lines
30 KiB
Dart
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/crypto/e2ee_service.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<LoginStatus>.broadcast();
|
||
final _noticeController = StreamController<AccountNotice>.broadcast();
|
||
bool _loggedIn = false;
|
||
int? _loginEpoch;
|
||
|
||
// #***! тяжёлое в подмодулях, тут только оркестрация
|
||
AccountModule(this._api) {
|
||
_api.stateStream.listen((state) {
|
||
if (state != SessionState.online) _loggedIn = false;
|
||
});
|
||
}
|
||
|
||
Stream<LoginStatus> get loginStatusStream => _loginStatusController.stream;
|
||
|
||
Stream<AccountNotice> get noticeStream => _noticeController.stream;
|
||
|
||
/// `true`, только когда сервер считает сессию ONLINE — после успешного
|
||
/// login (opcode 19), а не просто после хэндшейка (opcode 6).
|
||
bool get isLoggedIn =>
|
||
_loggedIn &&
|
||
_loginEpoch == _api.sessionEpoch &&
|
||
_api.state == SessionState.online;
|
||
|
||
// #***! дальше обёртки над подмодулями, единая точка для юишки
|
||
Future<PrivacyConfig> getPrivacyConfig() => _privacy.getPrivacyConfig();
|
||
|
||
Future<List<BlockedContact>> getBlockedContacts() =>
|
||
_privacy.getBlockedContacts();
|
||
|
||
Future<PrivacyConfig> updatePrivacyConfig(Map<String, dynamic> settings) =>
|
||
_privacy.updatePrivacyConfig(settings);
|
||
|
||
Future<PrivacyConfig> setSafeMode(bool value) => _privacy.setSafeMode(value);
|
||
|
||
Future<PrivacyConfig> setChatsPushNotification(bool value) =>
|
||
_privacy.setChatsPushNotification(value);
|
||
|
||
Future<PrivacyConfig> setMessagePreview(bool value) =>
|
||
_privacy.setMessagePreview(value);
|
||
|
||
Future<PrivacyConfig> setNotificationSound(bool value) =>
|
||
_privacy.setNotificationSound(value);
|
||
|
||
Future<PrivacyConfig> setCallNotifications(bool value) =>
|
||
_privacy.setCallNotifications(value);
|
||
|
||
Future<PrivacyConfig> setNewContacts(bool value) =>
|
||
_privacy.setNewContacts(value);
|
||
|
||
Future<void> registerPushToken(String pushToken) =>
|
||
_privacy.registerPushToken(pushToken);
|
||
|
||
Future<void> unregisterPushToken(String pushToken) =>
|
||
_privacy.unregisterPushToken(pushToken);
|
||
|
||
Future<ProfileData> updateProfile(
|
||
String firstName,
|
||
String? lastName, {
|
||
String? description,
|
||
}) => _profile.updateProfile(firstName, lastName, description: description);
|
||
|
||
Future<ProfileData> updateProfileAvatar(
|
||
String photoToken, {
|
||
String avatarType = 'USER_AVATAR',
|
||
}) => _profile.updateProfileAvatar(photoToken, avatarType: avatarType);
|
||
|
||
Future<String> getAvatarUploadUrl() => _profile.getAvatarUploadUrl();
|
||
|
||
Future<ProfileData> removeProfilePhoto(int photoId) =>
|
||
_profile.removeProfilePhoto(photoId);
|
||
|
||
Future<String> create2faTrack() => _twoFactor.create2faTrack();
|
||
|
||
Future<void> set2faPassword(String trackId, String password) =>
|
||
_twoFactor.set2faPassword(trackId, password);
|
||
|
||
Future<void> set2faHint(String trackId, String hint) =>
|
||
_twoFactor.set2faHint(trackId, hint);
|
||
|
||
Future<int> verify2faEmail(String trackId, String email) =>
|
||
_twoFactor.verify2faEmail(trackId, email);
|
||
|
||
Future<String> verify2faCode(String trackId, String code) =>
|
||
_twoFactor.verify2faCode(trackId, code);
|
||
|
||
Future<ProfileData> confirm2fa({
|
||
required String trackId,
|
||
required String password,
|
||
String? hint,
|
||
bool withEmail = true,
|
||
}) => _twoFactor.confirm2fa(
|
||
trackId: trackId,
|
||
password: password,
|
||
hint: hint,
|
||
withEmail: withEmail,
|
||
);
|
||
|
||
Future<String> enter2faPanel() => _twoFactor.enter2faPanel();
|
||
|
||
Future<TwoFactorDetails> get2faDetails(String trackId) =>
|
||
_twoFactor.get2faDetails(trackId);
|
||
|
||
Future<TwoFactorDetails> get2faStatus() => _twoFactor.get2faStatus();
|
||
|
||
Future<void> check2faPassword(String trackId, String password) =>
|
||
_twoFactor.check2faPassword(trackId, password);
|
||
|
||
Future<ProfileData> update2faPassword({
|
||
required String trackId,
|
||
required String newPassword,
|
||
String? hint,
|
||
}) => _twoFactor.update2faPassword(
|
||
trackId: trackId,
|
||
newPassword: newPassword,
|
||
hint: hint,
|
||
);
|
||
|
||
Future<ProfileData> commit2faEmailChange(String trackId) =>
|
||
_twoFactor.commit2faEmailChange(trackId);
|
||
|
||
Future<ProfileData> remove2fa(String trackId) =>
|
||
_twoFactor.remove2fa(trackId);
|
||
|
||
// #***! запрос кода, первый и повторный отличаются типом
|
||
Future<RequestCodeResult> requestCode(
|
||
String phone, {
|
||
String language = 'ru',
|
||
}) => _requestCodeInternal(phone, AuthRequestType.startAuth, language);
|
||
|
||
Future<RequestCodeResult> resendCode(
|
||
String phone, {
|
||
String language = 'ru',
|
||
}) => _requestCodeInternal(phone, AuthRequestType.resend, language);
|
||
|
||
// #***! проверка кода, дальше вход регистрация или 2FA
|
||
Future<VerifyCodeResult> verifyCode(String code, String token) async {
|
||
_ensureOnline();
|
||
|
||
final payload = <dynamic, dynamic>{
|
||
'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<dynamic, dynamic>());
|
||
|
||
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<dynamic, dynamic> payload) {
|
||
final profileMap = payload['profile'];
|
||
if (profileMap is! Map) return null;
|
||
return ProfileData.fromServerProfile(profileMap.cast<dynamic, dynamic>());
|
||
}
|
||
|
||
// #***! регистрация после кода
|
||
Future<int> completeRegistration({
|
||
required String token,
|
||
required String firstName,
|
||
String? lastName,
|
||
int? photoId,
|
||
}) async {
|
||
_ensureOnline();
|
||
|
||
final payload = <dynamic, dynamic>{
|
||
'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<dynamic, dynamic>(),
|
||
);
|
||
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<LoginResult> 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<dynamic, dynamic>();
|
||
|
||
// #***! вошли по чужому токену, 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<List<SessionInfo>> getSessions() => _sessions.getSessions();
|
||
|
||
Future<void> terminateOtherSessions() => _sessions.terminateOtherSessions();
|
||
|
||
Future<void> authorizeWebQrLogin(String qrLink) =>
|
||
_sessions.authorizeWebQrLogin(qrLink);
|
||
|
||
// #***! второй аккаунт, рвём сессию чистим кэши готовим спуф
|
||
Future<void> 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<LoginResult> 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<ProfileData> switchAccount(int accountId) async {
|
||
final profile = await AppDatabase.loadProfile(accountId);
|
||
final token = await TokenStorage.tryReadToken(accountId);
|
||
if (profile == null || token == null) {
|
||
logger.w(
|
||
'switchAccount: у аккаунта $accountId нет сохранённой сессии '
|
||
'(профиль: ${profile != null}, токен: ${token != null})',
|
||
);
|
||
throw AccountSessionLostException(accountId);
|
||
}
|
||
MediaPlayback.instance.closeAudioFile();
|
||
|
||
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 переключён без связи, сессия поднимется '
|
||
'при переподключении',
|
||
);
|
||
}
|
||
|
||
logger.i('Активный аккаунт переключён на $accountId');
|
||
return profile;
|
||
}
|
||
|
||
Future<List<ProfileData>> listAccounts() async {
|
||
return AppDatabase.loadAllProfiles();
|
||
}
|
||
|
||
Future<bool> hasStoredSession(int accountId) async =>
|
||
await TokenStorage.tryReadToken(accountId) != null;
|
||
|
||
// #***! удаляем локально, база токен спуф и заявка
|
||
Future<void> removeAccount(int accountId) async {
|
||
await E2eeService.instance.eraseAccount(accountId);
|
||
await AppDatabase.deleteAccount(accountId);
|
||
await TokenStorage.deleteAccount(accountId);
|
||
await SpoofingService.clearAccountSpoof(accountId);
|
||
await ProfileDeletionStore.clear(accountId);
|
||
logger.i('Аккаунт $accountId удалён локально');
|
||
}
|
||
|
||
Future<DateTime?> profileDeletionScheduledAt() async {
|
||
final accountId = await TokenStorage.getActiveAccountId();
|
||
if (accountId == null) return null;
|
||
return ProfileDeletionStore.scheduledAt(accountId);
|
||
}
|
||
|
||
// #***! заявка на удаление профиля, сервер вернёт дату
|
||
Future<DateTime?> 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<void> 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<void> _logoutOnServer(int? accountId) async {
|
||
await _ensureLogoutSession(accountId);
|
||
await _api.sendRequestOrThrow(Opcode.logout, <dynamic, dynamic>{});
|
||
}
|
||
|
||
// #***! чтоб выйти нужна живая сессия, при чём логинимся заново
|
||
Future<void> _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<TwoFactorResult> checkPassword({
|
||
required String password,
|
||
required String trackId,
|
||
}) async {
|
||
_ensureOnline();
|
||
|
||
final payload = <dynamic, dynamic>{
|
||
'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<dynamic, dynamic>(),
|
||
);
|
||
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<dynamic, dynamic> buildLoginPayload(
|
||
String token, {
|
||
LoginSyncParams? sync,
|
||
bool? interactive,
|
||
}) {
|
||
final payload = <dynamic, dynamic>{
|
||
'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,
|
||
arch: _api.architecture,
|
||
);
|
||
}
|
||
|
||
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<LoginResult> _processLoginResponse(
|
||
Map<dynamic, dynamic> 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<dynamic, dynamic>(),
|
||
);
|
||
} 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<dynamic, dynamic>(),
|
||
);
|
||
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<ProfileData> _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<void> _saveSyncState(
|
||
Map<dynamic, dynamic> data,
|
||
int serverTime,
|
||
int accountId,
|
||
) async {
|
||
final ts = serverTime.toString();
|
||
|
||
Future<void> 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<void> _saveLoginInfo(Map<dynamic, dynamic> 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));
|
||
}
|
||
|
||
Future<void> _persistEntryBannerApps(int accountId, Map serverConfig) async {
|
||
final resolved = EntryBannerApps.appIdsFrom(serverConfig);
|
||
for (final entry in resolved.entries) {
|
||
await AppDatabase.setSyncValue(
|
||
accountId,
|
||
entry.key,
|
||
entry.value.toString(),
|
||
);
|
||
}
|
||
}
|
||
|
||
// #***! общий запрос кода
|
||
Future<RequestCodeResult> _requestCodeInternal(
|
||
String phone,
|
||
AuthRequestType type,
|
||
String language,
|
||
) async {
|
||
_ensureOnline();
|
||
|
||
final normalizedPhone = _normalizeAuthPhone(phone);
|
||
|
||
final payload = <dynamic, dynamic>{
|
||
'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,
|
||
arch: _api.architecture,
|
||
);
|
||
}
|
||
|
||
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,
|
||
deviceKey: data['authMethod'] == 'DEVICE_KEY',
|
||
);
|
||
}
|
||
|
||
// #***! дальше три помощника
|
||
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);
|
||
}
|
||
}
|