feat: берсерк. Регенерация profile если сервер его не отдал

This commit is contained in:
Jganenokk
2026-07-15 19:33:25 +07:00
parent 1e2fc42b35
commit 0ba39b3575
12 changed files with 164 additions and 40 deletions
+34 -11
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import '../api.dart';
import '../../core/config/debug_test.dart';
import '../../core/config/komet_settings.dart';
import '../../core/protocol/chat_cache_fingerprint.dart';
import '../../core/protocol/opcode_map.dart';
@@ -41,6 +42,7 @@ class AccountModule {
late final ProfileModule _profile = ProfileModule(_api);
late final TwoFactorModule _twoFactor = TwoFactorModule(_api, _profile);
final _loginStatusController = StreamController<LoginStatus>.broadcast();
final _noticeController = StreamController<AccountNotice>.broadcast();
bool _loggedIn = false;
AccountModule(this._api) {
@@ -51,6 +53,8 @@ class AccountModule {
Stream<LoginStatus> get loginStatusStream => _loginStatusController.stream;
Stream<AccountNotice> get noticeStream => _noticeController.stream;
/// `true`, только когда сервер считает сессию ONLINE — после успешного
/// login (opcode 19), а не просто после хэндшейка (opcode 6).
bool get isLoggedIn => _loggedIn;
@@ -565,22 +569,16 @@ class AccountModule {
ProfileData profile;
final profileMap = data['profile'];
if (profileMap is Map) {
final contact = profileMap['contact'];
if (contact is! Map) {
throw Exception('login: отсутствует profile.contact в ответе');
}
if (!DebugTest.berserk &&
profileMap is Map &&
profileMap['contact'] is Map) {
profile = ProfileData.fromServerProfile(
profileMap.cast<dynamic, dynamic>(),
);
await AppDatabase.saveProfile(profile, isActive: true);
} else {
final cachedProfile = await AppDatabase.loadProfile(accountId);
if (cachedProfile == null) {
throw Exception('login: отсутствует profile в ответе');
}
profile = cachedProfile;
profile = await _resurrectProfile(accountId);
}
await AppDatabase.saveProfile(profile, isActive: true);
await AppDatabase.setActiveAccount(profile.id);
await _saveSyncState(data, serverTime, profile.id);
@@ -625,6 +623,31 @@ class AccountModule {
);
}
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);
}
Future<void> _saveSyncState(
Map<dynamic, dynamic> data,
int serverTime,
@@ -282,6 +282,8 @@ enum AuthRequestType {
enum LoginStatus { idle, loading, success, error }
enum AccountNotice { resurrectingProfile }
class WrongDeviceTokenException implements Exception {
const WrongDeviceTokenException();
@override
+15
View File
@@ -191,6 +191,21 @@ class ContactsModule {
await syncFromLoginPayload(map.cast<dynamic, dynamic>(), accountId);
}
static Future<ProfileData?> fetchSelfProfile(Api api, int accountId) async {
final map = await api.sendRequestMap(Opcode.contactInfo, {
'contactIds': [accountId],
});
final contacts = map?['contacts'];
if (contacts is! List) return null;
for (final raw in contacts.whereType<Map>()) {
if (raw['id'] != accountId) continue;
final contact = raw.cast<dynamic, dynamic>();
_primeContactCache(contact);
return ProfileData.fromServerMap(contact);
}
return null;
}
static void _primeContactCache(Map<dynamic, dynamic> contact) {
final id = contact['id'];
if (id is! int) return;
+6
View File
@@ -1,20 +1,24 @@
class DebugTest {
static bool enabled = false;
static int contactCount = 0;
static bool berserk = false;
static const int debugAccountId = -424242;
static const bool _envEnabled = bool.fromEnvironment('DEBUG_TEST');
static const bool _envBerserk = bool.fromEnvironment('BERSERK');
static const int _envContacts = int.fromEnvironment(
'DEBUG_CONTACTS',
defaultValue: -1,
);
static const String _flag = '--debug-test';
static const String _berserkFlag = '--berserk';
static const String _contactsFlag = '--contacts';
static void parse(List<String> args) {
if (_envEnabled) enabled = true;
if (_envBerserk) berserk = true;
if (_envContacts >= 0) {
enabled = true;
contactCount = _envContacts;
@@ -24,6 +28,8 @@ class DebugTest {
final arg = args[i];
if (arg == _flag) {
enabled = true;
} else if (arg == _berserkFlag) {
berserk = true;
} else if (arg.startsWith('$_contactsFlag=')) {
enabled = true;
contactCount =
+10 -1
View File
@@ -36,6 +36,15 @@ class ProfileData {
this.profileOptions,
});
factory ProfileData.stub(int id) => ProfileData(
id: id,
firstName: '',
phone: 0,
country: '',
accountStatus: 0,
updateTime: 0,
);
factory ProfileData.fromServerProfile(Map<dynamic, dynamic> profile) {
final contact = profile['contact'];
if (contact is! Map) {
@@ -70,7 +79,7 @@ class ProfileData {
id: contact['id'] as int,
firstName: firstName,
lastName: lastName,
phone: contact['phone'] as int,
phone: (contact['phone'] as int?) ?? 0,
photoId: contact['photoId'] as int?,
baseUrl: contact['baseUrl'] as String?,
baseRawUrl: contact['baseRawUrl'] as String?,
+49 -26
View File
@@ -340,7 +340,9 @@ class _SettingsTabState extends State<SettingsTab> {
final String fullName =
'${_profile!.firstName}${_profile!.lastName != null ? ' ${_profile!.lastName}' : ''}';
final String phone = '+${_profile!.phone}';
final String phone = _profile!.phone == 0
? l10n.profilePhoneRegenFailed
: '+${_profile!.phone}';
final size = MediaQuery.sizeOf(context);
final topPad = MediaQuery.paddingOf(context).top;
@@ -672,6 +674,7 @@ class _SettingsTabState extends State<SettingsTab> {
) {
final topPad = MediaQuery.paddingOf(context).top;
final hasPhoto = (_profile?.baseUrl ?? '').isNotEmpty;
final phoneMissing = (_profile?.phone ?? 0) == 0;
final pt = hasPhoto ? t : 0.0;
if (pt > 0) _headerEverExpanded = true;
@@ -826,37 +829,57 @@ class _SettingsTabState extends State<SettingsTab> {
const SizedBox(height: 6),
_headerAligned(
pt,
Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => setState(
() => _isPhoneVisible = !_isPhoneVisible,
),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: _PhoneSpoiler(
text: phone,
isVisible: _isPhoneVisible,
phoneMissing
? Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
child: Text(
phone,
textAlign: TextAlign.center,
style: TextStyle(
color: subColor,
fontSize: 14,
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.5,
height: 1.3,
),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => setState(
() => _isPhoneVisible = !_isPhoneVisible,
),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: _PhoneSpoiler(
text: phone,
isVisible: _isPhoneVisible,
style: TextStyle(
color: subColor,
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.5,
),
),
),
),
const SizedBox(width: 4),
Icon(
_isPhoneVisible
? Symbols.visibility
: Symbols.visibility_off,
size: 14,
color: Color.lerp(
cs.mutedText,
Colors.white70,
pt,
),
),
],
),
),
const SizedBox(width: 4),
Icon(
_isPhoneVisible
? Symbols.visibility
: Symbols.visibility_off,
size: 14,
color: Color.lerp(cs.mutedText, Colors.white70, pt),
),
],
),
),
],
),
+3 -1
View File
@@ -907,5 +907,7 @@
"updateCheck": "Check for updates",
"updateChecking": "Checking for updates…",
"updateUpToDate": "You have the latest version",
"updateCheckFailed": "Couldn't check for updates. Try again later"
"updateCheckFailed": "Couldn't check for updates. Try again later",
"profileResurrecting": "Oops! The server didn't send your profile. Trying to regenerate…",
"profilePhoneRegenFailed": "Couldn't regenerate your phone number. Please sign in again and report the issue to the developers"
}
+12
View File
@@ -4045,6 +4045,18 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Couldn\'t check for updates. Try again later'**
String get updateCheckFailed;
/// No description provided for @profileResurrecting.
///
/// In en, this message translates to:
/// **'Oops! The server didn\'t send your profile. Trying to regenerate…'**
String get profileResurrecting;
/// No description provided for @profilePhoneRegenFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t regenerate your phone number. Please sign in again and report the issue to the developers'**
String get profilePhoneRegenFailed;
}
class _AppLocalizationsDelegate
+8
View File
@@ -2097,4 +2097,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get updateCheckFailed =>
'Couldn\'t check for updates. Try again later';
@override
String get profileResurrecting =>
'Oops! The server didn\'t send your profile. Trying to regenerate…';
@override
String get profilePhoneRegenFailed =>
'Couldn\'t regenerate your phone number. Please sign in again and report the issue to the developers';
}
+8
View File
@@ -2108,4 +2108,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get updateCheckFailed =>
'Не удалось проверить обновления. Повторите позже';
@override
String get profileResurrecting =>
'Упс! Сервер не прислал profile. Попробую регенерировать…';
@override
String get profilePhoneRegenFailed =>
'Не удалось регенерировать данные об номере. Перезайдите и сообщите об проблеме разработчикам';
}
+3 -1
View File
@@ -689,5 +689,7 @@
"updateCheck": "Проверить обновление",
"updateChecking": "Проверяем обновления…",
"updateUpToDate": "Установлена актуальная версия",
"updateCheckFailed": "Не удалось проверить обновления. Повторите позже"
"updateCheckFailed": "Не удалось проверить обновления. Повторите позже",
"profileResurrecting": "Упс! Сервер не прислал profile. Попробую регенерировать…",
"profilePhoneRegenFailed": "Не удалось регенерировать данные об номере. Перезайдите и сообщите об проблеме разработчикам"
}
+14
View File
@@ -339,6 +339,7 @@ class KometAppState extends State<KometApp>
StreamSubscription<VpnBypassResult>? _vpnBypassSub;
StreamSubscription<IncomingCall>? _callIncomingSub;
StreamSubscription<String>? _serverErrorSub;
StreamSubscription<AccountNotice>? _accountNoticeSub;
Timer? _scheduleTimer;
String? _lastVpnNotice;
DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0);
@@ -477,6 +478,18 @@ class KometAppState extends State<KometApp>
showCustomNotificationOnOverlay(overlay, msg);
}
});
_accountNoticeSub = accountModule.noticeStream.listen((notice) {
final overlay = KometApp.navigatorKey.currentState?.overlay;
final ctx = KometApp.navigatorKey.currentContext;
if (overlay == null || ctx == null || !ctx.mounted) return;
final l10n = AppLocalizations.of(ctx);
if (l10n == null) return;
final message = switch (notice) {
AccountNotice.resurrectingProfile => l10n.profileResurrecting,
};
showCustomNotificationOnOverlay(overlay, message);
});
}
Future<void> _ensureFullScreenIntentPermission() async {
@@ -534,6 +547,7 @@ class KometAppState extends State<KometApp>
_vpnBypassSub?.cancel();
_callIncomingSub?.cancel();
_serverErrorSub?.cancel();
_accountNoticeSub?.cancel();
_scheduleTimer?.cancel();
AppThemeModeConfig.current.removeListener(_onThemeModeChanged);
AppAmoled.current.removeListener(_onAmoledChanged);