Files

907 lines
30 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}