From 1fd4a10814ed6548fb55b07d84a4edb7060aa1aa Mon Sep 17 00:00:00 2001 From: torvalds Date: Sun, 5 Jul 2026 23:03:46 +0700 Subject: [PATCH 1/3] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82?= =?UTF-8?q?=D1=80=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/contacts.dart | 29 ++ lib/core/utils/media_saver.dart | 60 +++ .../screens/chats/chat_info_screen.dart | 14 +- .../contacts/contact_profile_screen.dart | 37 +- .../screens/profile/settings_tab.dart | 37 +- .../widgets/avatar_history_screen.dart | 350 ++++++++++++++++++ 6 files changed, 503 insertions(+), 24 deletions(-) create mode 100644 lib/core/utils/media_saver.dart create mode 100644 lib/frontend/widgets/avatar_history_screen.dart diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index aed44e5..d59f5e2 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -62,6 +62,15 @@ class PhoneLookupResult { const PhoneLookupResult({required this.id, this.name, this.avatarUrl}); } +class ContactPhotos { + final List urls; + final int total; + + const ContactPhotos({required this.urls, required this.total}); + + static const empty = ContactPhotos(urls: [], total: 0); +} + class ContactsModule { static final ValueNotifier revision = ValueNotifier(0); @@ -194,6 +203,26 @@ class ContactsModule { } } + static Future fetchPhotos( + Api api, + int contactId, { + int from = 0, + int count = 25, + }) async { + final map = await api.sendRequestMap(Opcode.contactPhotos, { + 'contactId': contactId, + 'from': from, + 'count': count, + }); + if (map == null) return ContactPhotos.empty; + final rawUrls = map['urls']; + final urls = rawUrls is List + ? rawUrls.whereType().toList() + : []; + final total = map['total'] is int ? map['total'] as int : urls.length; + return ContactPhotos(urls: urls, total: total); + } + static Future> getContacts(int accountId) async { final rows = await AppDatabase.loadContacts(accountId); return rows.map(CachedContact.fromDbRow).toList(); diff --git a/lib/core/utils/media_saver.dart b/lib/core/utils/media_saver.dart new file mode 100644 index 0000000..39968a2 --- /dev/null +++ b/lib/core/utils/media_saver.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:photo_manager/photo_manager.dart'; + +import 'media_cache.dart'; + +class MediaSaveResult { + final bool ok; + final bool toGallery; + final String? location; + final String? error; + + const MediaSaveResult({ + required this.ok, + this.toGallery = false, + this.location, + this.error, + }); +} + +Future saveImageFromUrl(String url) async { + if (url.isEmpty) { + return const MediaSaveResult(ok: false, error: 'нет ссылки'); + } + try { + final cacheName = 'avatar_${url.hashCode & 0x7fffffff}.jpg'; + final file = await MediaCache.getOrDownload(cacheName, url); + if (file == null) { + return const MediaSaveResult(ok: false, error: 'не удалось загрузить'); + } + final saveName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg'; + + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { + final state = await PhotoManager.requestPermissionExtend(); + if (!state.isAuth && !state.hasAccess) { + return const MediaSaveResult(ok: false, error: 'нет доступа к галерее'); + } + final bytes = await file.readAsBytes(); + await PhotoManager.editor.saveImage(bytes, filename: saveName); + return const MediaSaveResult(ok: true, toGallery: true); + } + + final dir = await _targetDirectory(); + final target = File('${dir.path}${Platform.pathSeparator}$saveName'); + await file.copy(target.path); + return MediaSaveResult(ok: true, location: target.path); + } catch (e) { + return MediaSaveResult(ok: false, error: e.toString()); + } +} + +Future _targetDirectory() async { + try { + final downloads = await getDownloadsDirectory(); + if (downloads != null) return downloads; + } catch (_) {} + return getApplicationDocumentsDirectory(); +} diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 7627c01..94eaea4 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -10,6 +10,7 @@ import '../../../core/utils/format.dart'; import '../../../l10n/app_localizations.dart'; import '../../../models/chat_info.dart'; import '../../../models/contact_info.dart'; +import '../../widgets/avatar_history_screen.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/komet_avatar.dart'; @@ -1130,12 +1131,23 @@ class _ChatInfoScreenState extends State { } Widget _avatar() { - return KometAvatar( + final avatar = KometAvatar( name: widget.name, imageUrl: widget.imageUrl, size: 96, fontSize: 36, ); + final peerId = widget.chatType == 'DIALOG' ? _otherId : null; + if (peerId == null || widget.imageUrl.isEmpty) return avatar; + return GestureDetector( + onTap: () => AvatarHistoryScreen.open( + context, + contactId: peerId, + name: widget.name, + currentAvatarUrl: widget.imageUrl, + ), + child: avatar, + ); } Widget _buildShimmer(ColorScheme cs) { diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart index 67001da..609397c 100644 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -7,6 +7,7 @@ import '../../../core/storage/token_storage.dart'; import '../../../core/utils/format.dart'; import '../../../l10n/app_localizations.dart'; import '../../../models/contact_info.dart'; +import '../../widgets/avatar_history_screen.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/komet_avatar.dart'; @@ -146,11 +147,19 @@ class _ContactProfileScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ - KometAvatar( - name: _displayName(), - imageUrl: _avatarUrl(), - size: 96, - fontSize: 36, + GestureDetector( + onTap: () => AvatarHistoryScreen.open( + context, + contactId: widget.contactId, + name: _displayName(), + currentAvatarUrl: _avatarUrl(), + ), + child: KometAvatar( + name: _displayName(), + imageUrl: _avatarUrl(), + size: 96, + fontSize: 36, + ), ), const SizedBox(height: 14), _buildNameRow(cs), @@ -206,7 +215,11 @@ class _ContactProfileScreenState extends State { label: l10n.contactProfileActionChat, onTap: _openChat, ), - (icon: Symbols.notifications, label: l10n.contactProfileActionSound, onTap: null), + ( + icon: Symbols.notifications, + label: l10n.contactProfileActionSound, + onTap: null, + ), if (!_isBot) (icon: Symbols.call, label: l10n.contactProfileActionCall, onTap: null), ]; @@ -250,17 +263,23 @@ class _ContactProfileScreenState extends State { final phoneStr = formatPhone(c.raw['phone']); if (phoneStr != null) { - rows.add(_infoRow(cs, Symbols.phone, l10n.contactProfileInfoPhone, phoneStr)); + rows.add( + _infoRow(cs, Symbols.phone, l10n.contactProfileInfoPhone, phoneStr), + ); } final country = c.raw['country'] as String?; if (country != null && country.isNotEmpty) { - rows.add(_infoRow(cs, Symbols.public, l10n.contactProfileInfoCountry, country)); + rows.add( + _infoRow(cs, Symbols.public, l10n.contactProfileInfoCountry, country), + ); } final genderStr = formatGender(c.raw['gender']); if (genderStr != null) { - rows.add(_infoRow(cs, Symbols.wc, l10n.contactProfileInfoGender, genderStr)); + rows.add( + _infoRow(cs, Symbols.wc, l10n.contactProfileInfoGender, genderStr), + ); } final regTime = c.raw['registrationTime'] as int?; diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 24c3448..5691271 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -11,6 +11,7 @@ import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; +import '../../widgets/avatar_history_screen.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/info_action_sheet.dart'; import '../../widgets/komet_avatar.dart'; @@ -557,21 +558,29 @@ class _SettingsTabState extends State { ], ), const SizedBox(height: 8), - Container( - width: 88, - height: 88, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: cs.primary.withValues(alpha: 0.5), - width: 2.5, - ), - ), - child: KometAvatar( + GestureDetector( + onTap: () => AvatarHistoryScreen.open( + context, + contactId: _profile?.id ?? 0, name: name, - imageUrl: _profile?.baseUrl, - size: 88, - fontSize: 32, + currentAvatarUrl: _profile?.baseUrl, + ), + child: Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.5), + width: 2.5, + ), + ), + child: KometAvatar( + name: name, + imageUrl: _profile?.baseUrl, + size: 88, + fontSize: 32, + ), ), ), const SizedBox(height: 8), diff --git a/lib/frontend/widgets/avatar_history_screen.dart b/lib/frontend/widgets/avatar_history_screen.dart new file mode 100644 index 0000000..1f57b89 --- /dev/null +++ b/lib/frontend/widgets/avatar_history_screen.dart @@ -0,0 +1,350 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../backend/modules/contacts.dart'; +import '../../core/utils/media_saver.dart'; +import '../../main.dart'; +import 'custom_notification.dart'; + +class AvatarHistoryScreen extends StatefulWidget { + final int contactId; + final String? name; + final String? currentAvatarUrl; + + const AvatarHistoryScreen({ + super.key, + required this.contactId, + this.name, + this.currentAvatarUrl, + }); + + static Future open( + BuildContext context, { + required int contactId, + String? name, + String? currentAvatarUrl, + }) { + final url = currentAvatarUrl; + if (url == null || url.isEmpty) return Future.value(); + return Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => AvatarHistoryScreen( + contactId: contactId, + name: name, + currentAvatarUrl: url, + ), + ), + ); + } + + @override + State createState() => _AvatarHistoryScreenState(); +} + +class _AvatarHistoryScreenState extends State { + static const int _pageSize = 50; + static const int _maxDots = 10; + + final PageController _pageController = PageController(); + String? _current; + final List _history = []; + List _pages = const []; + int _historyTotal = 0; + int _index = 0; + bool _loading = true; + bool _loadingMore = false; + bool _saving = false; + + @override + void initState() { + super.initState(); + final current = widget.currentAvatarUrl; + _current = (current != null && current.isNotEmpty) ? current : null; + _rebuildPages(); + _load(); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _rebuildPages() { + _pages = _current == null ? List.of(_history) : [_current!, ..._history]; + } + + void _addHistory(List urls) { + for (final url in urls) { + if (url == _current || _history.contains(url)) continue; + _history.add(url); + } + } + + Future _load() async { + final photos = await ContactsModule.fetchPhotos( + api, + widget.contactId, + count: _pageSize, + ); + if (!mounted) return; + setState(() { + _addHistory(photos.urls); + _historyTotal = photos.total; + _rebuildPages(); + _loading = false; + }); + } + + Future _loadMore() async { + if (_loadingMore || _history.length >= _historyTotal) return; + _loadingMore = true; + final photos = await ContactsModule.fetchPhotos( + api, + widget.contactId, + from: _history.length, + count: _pageSize, + ); + if (!mounted) { + _loadingMore = false; + return; + } + setState(() { + _addHistory(photos.urls); + if (photos.total > _historyTotal) _historyTotal = photos.total; + _rebuildPages(); + }); + _loadingMore = false; + } + + void _onPageChanged(int index) { + setState(() => _index = index); + if (index >= _pages.length - 2) _loadMore(); + } + + void _prev() { + if (_index <= 0) return; + _pageController.previousPage( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + + void _next() { + if (_index >= _pages.length - 1) return; + _pageController.nextPage( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + + Future _save() async { + if (_saving || _index >= _pages.length) return; + setState(() => _saving = true); + final result = await saveImageFromUrl(_pages[_index]); + if (!mounted) return; + setState(() => _saving = false); + final message = result.ok + ? (result.toGallery + ? 'Сохранено в галерею' + : 'Сохранено: ${result.location}') + : 'Не удалось сохранить: ${result.error}'; + showCustomNotification(context, message); + } + + int get _count { + final total = _historyTotal + (_current != null ? 1 : 0); + return total > _pages.length ? total : _pages.length; + } + + @override + Widget build(BuildContext context) { + final topPad = MediaQuery.of(context).padding.top; + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned.fill(child: _buildBody()), + if (_pages.length > 1 && _index > 0) + _navButton( + alignLeft: true, + icon: Symbols.chevron_left, + onTap: _prev, + ), + if (_pages.length > 1 && _index < _pages.length - 1) + _navButton( + alignLeft: false, + icon: Symbols.chevron_right, + onTap: _next, + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + height: topPad + 76, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.55), + Colors.transparent, + ], + ), + ), + ), + ), + Positioned( + top: topPad + 4, + left: 4, + right: 4, + child: Row( + children: [ + IconButton( + icon: const Icon(Symbols.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + Expanded(child: _buildCounter()), + IconButton( + icon: _saving + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : const Icon(Symbols.download, color: Colors.white), + onPressed: _pages.isEmpty || _saving ? null : _save, + ), + ], + ), + ), + if (_pages.length > 1 && _pages.length <= _maxDots) + Positioned( + bottom: MediaQuery.of(context).padding.bottom + 18, + left: 0, + right: 0, + child: _buildDots(), + ), + ], + ), + ); + } + + Widget _buildCounter() { + final hasName = widget.name != null && widget.name!.isNotEmpty; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_pages.length > 1) + Text( + '${_index + 1} из $_count', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ) + else if (hasName) + Text( + widget.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + if (_pages.length > 1 && hasName) + Text( + widget.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ); + } + + Widget _buildBody() { + if (_pages.isEmpty) { + return Center( + child: _loading + ? const CircularProgressIndicator(color: Colors.white) + : const Text( + 'Нет фотографий', + style: TextStyle(color: Colors.white54, fontSize: 15), + ), + ); + } + return PageView.builder( + controller: _pageController, + onPageChanged: _onPageChanged, + itemCount: _pages.length, + itemBuilder: (context, i) => Center( + child: CachedNetworkImage( + imageUrl: _pages[i], + fit: BoxFit.contain, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + errorWidget: (_, _, _) => + const Icon(Symbols.broken_image, color: Colors.white54, size: 64), + ), + ), + ); + } + + Widget _navButton({ + required bool alignLeft, + required IconData icon, + required VoidCallback onTap, + }) { + return Positioned( + top: 0, + bottom: 0, + left: alignLeft ? 8 : null, + right: alignLeft ? null : 8, + child: Center( + child: Material( + color: Colors.black.withValues(alpha: 0.35), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(icon, color: Colors.white, size: 30), + ), + ), + ), + ), + ); + } + + Widget _buildDots() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i = 0; i < _pages.length; i++) + AnimatedContainer( + duration: const Duration(milliseconds: 180), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: i == _index ? 8 : 6, + height: i == _index ? 8 : 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: i == _index ? Colors.white : Colors.white38, + ), + ), + ], + ); + } +} From 496b7a40ab9edb115821e5afac5561224ecfd772 Mon Sep 17 00:00:00 2001 From: torvalds Date: Sun, 5 Jul 2026 23:43:15 +0700 Subject: [PATCH 2/3] =?UTF-8?q?=D0=BD=D0=B0=D0=B4=D0=B5=D1=8E=D1=81=D1=8C?= =?UTF-8?q?=20=D0=B1=D1=83=D0=B4=D0=B5=D1=82=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=8C=20=D1=81=D1=83=D0=BAi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 232 ++++++++++++------ lib/backend/modules/account.dart | 13 +- .../modules/account/account_models.dart | 72 +++++- lib/core/config/proxy_config.dart | 45 +++- lib/core/transport/connection.dart | 48 +++- lib/core/utils/debug_session_log.dart | 70 +++++- lib/core/utils/logger.dart | 13 +- lib/frontend/debug/quick_actions_section.dart | 2 +- .../screens/profile/debug_menu_screen.dart | 2 +- lib/main.dart | 39 +++ 10 files changed, 419 insertions(+), 117 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 8852943..0be7e66 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -70,10 +70,16 @@ class Api { StreamSubscription? _socketStateSubscription; Timer? _pingTimer; Timer? _reconnectTimer; + Timer? _connectWatchdog; + int _connectGen = 0; int _reconnectAttempts = 0; bool _autoReconnect = false; int _sessionEpoch = 0; + static const Duration _connectWatchdogTimeout = Duration(seconds: 75); + static const Duration _shouldArmTimeout = Duration(seconds: 5); + static const Duration _endpointTimeout = Duration(seconds: 5); + int get sessionEpoch => _sessionEpoch; /// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля. @@ -83,87 +89,168 @@ class Api { /// Подключается к серверу, шлёт хэндшейк, запускает пинг. Future connect() async { - if (_sessionState != SessionState.disconnected) return; - // Ставим автоматический реконнект и статус подключения - _autoReconnect = true; - _setSessionState(SessionState.connecting); - - _dataSubscription = _connection.dataStream.listen(_onDataReceived); - _socketStateSubscription = _connection.stateStream.listen((socketState) { - if (socketState == SocketState.disconnected && - _sessionState != SessionState.disconnected) { - _onDisconnected(); - } - }); - - final bypassArmed = await VpnBypassService.instance.shouldArm(); - if (!bypassArmed) _bypassActive = false; - final useBypass = _bypassActive && bypassArmed; - // Попытку через VPN ограничиваем по времени, чтобы быстро понять, - // что туннель не пропускает, и переключиться на обход. - final attemptTimeout = bypassArmed && !useBypass - ? const Duration(seconds: 8) - : null; - - try { - final endpoint = await ServerConfig.loadEndpoint(); - await _connection.connect( - endpoint.host, - endpoint.port, - bypassVpn: useBypass, - timeout: attemptTimeout, - ); - } catch (e) { - await _handleConnectFailure( - e, - phase: 'Не удалось подключиться', - bypassArmed: bypassArmed, - useBypass: useBypass, - bypassWhy: 'подключение не удалось', - ); + if (_sessionState != SessionState.disconnected) { + logger.i('connect пропущен: состояние ${_sessionState.name}'); return; } - - _setSessionState(SessionState.connected); - _reconnectAttempts = 0; + _autoReconnect = true; + final gen = ++_connectGen; + _setSessionState(SessionState.connecting); + logger.i('connect: старт (поколение $gen)'); + _armConnectWatchdog(gen); try { - final response = await sendHandshake(); - if (response.isOk) { - _callsSeed = response.payload['callsSeed'] as int?; - _registrationCountries = _parseRegistrationCountries(response.payload); - _sessionState = SessionState.online; - _sessionEpoch++; - _startPinging(); - logger.i('Сессия онлайн, хэндшейк ок'); - if (_onReconnectCallback != null) { - try { - await _onReconnectCallback!(); - } catch (e) { - logger.w('Авто-логин при хэндшейке не удался: $e'); - } + _dataSubscription = _connection.dataStream.listen(_onDataReceived); + _socketStateSubscription = _connection.stateStream.listen((socketState) { + if (socketState == SocketState.disconnected && + _sessionState != SessionState.disconnected) { + _onDisconnected(); } - if (_sessionState == SessionState.online) { - _stateController.add(SessionState.online); - _handshakeSuccessController.add( - response.payload['device_name'] as String? ?? 'Unknown', + }); + + bool bypassArmed; + try { + bypassArmed = await VpnBypassService.instance.shouldArm().timeout( + _shouldArmTimeout, + ); + } catch (e) { + logger.w('connect: shouldArm завис/упал ($e) — без обхода VPN'); + bypassArmed = false; + } + if (gen != _connectGen) return; + + if (!bypassArmed) _bypassActive = false; + final useBypass = _bypassActive && bypassArmed; + final attemptTimeout = bypassArmed && !useBypass + ? const Duration(seconds: 8) + : null; + + ({String host, int port}) endpoint; + try { + endpoint = await ServerConfig.loadEndpoint().timeout(_endpointTimeout); + } catch (e) { + logger.w('connect: loadEndpoint завис/упал ($e) — дефолтный endpoint'); + endpoint = ( + host: ServerConfig.defaultHost, + port: ServerConfig.defaultPort, + ); + } + if (gen != _connectGen) return; + + logger.i( + 'connect: endpoint ${endpoint.host}:${endpoint.port}, bypass=$useBypass', + ); + try { + await _connection.connect( + endpoint.host, + endpoint.port, + bypassVpn: useBypass, + timeout: attemptTimeout, + ); + } catch (e) { + if (gen != _connectGen) return; + await _handleConnectFailure( + e, + phase: 'Не удалось подключиться', + bypassArmed: bypassArmed, + useBypass: useBypass, + bypassWhy: 'подключение не удалось', + ); + return; + } + if (gen != _connectGen) return; + + _setSessionState(SessionState.connected); + _reconnectAttempts = 0; + + try { + logger.i('connect: сокет готов, отправляю хэндшейк'); + final response = await sendHandshake(); + if (gen != _connectGen) return; + if (response.isOk) { + _callsSeed = response.payload['callsSeed'] as int?; + _registrationCountries = _parseRegistrationCountries( + response.payload, + ); + _sessionState = SessionState.online; + _sessionEpoch++; + _cancelConnectWatchdog(); + _startPinging(); + logger.i('Сессия онлайн, хэндшейк ок'); + if (_onReconnectCallback != null) { + try { + await _onReconnectCallback!(); + } catch (e) { + logger.w('Авто-логин при хэндшейке не удался: $e'); + } + } + if (_sessionState == SessionState.online) { + _stateController.add(SessionState.online); + _handshakeSuccessController.add( + response.payload['device_name'] as String? ?? 'Unknown', + ); + } + } else { + logger.e('Хэндшейк отклонён: ${response.payload}'); + await _handleConnectFailure( + StateError('хэндшейк отклонён сервером'), + phase: 'Хэндшейк отклонён', + bypassArmed: bypassArmed, + useBypass: useBypass, + bypassWhy: 'хэндшейк отклонён', + disconnectSocket: true, ); } - } else { - logger.e('Хэндшейк отклонён: ${response.payload}'); + } catch (e) { + if (gen != _connectGen) return; + await _handleConnectFailure( + e, + phase: 'Ошибка хэндшейка', + bypassArmed: bypassArmed, + useBypass: useBypass, + bypassWhy: 'хэндшейк не прошёл', + disconnectSocket: true, + ); } - } catch (e) { - await _handleConnectFailure( - e, - phase: 'Ошибка хэндшейка', - bypassArmed: bypassArmed, - useBypass: useBypass, - bypassWhy: 'хэндшейк не прошёл', - disconnectSocket: true, - ); + } catch (e, st) { + logger.e('connect: непредвиденная ошибка: $e\n$st'); + if (gen == _connectGen) await _resetStuckConnect(gen); } } + void _armConnectWatchdog(int gen) { + _connectWatchdog?.cancel(); + _connectWatchdog = Timer(_connectWatchdogTimeout, () { + if (gen != _connectGen) return; + if (_sessionState == SessionState.online || + _sessionState == SessionState.disconnected) { + return; + } + logger.e( + 'connect: watchdog ${_connectWatchdogTimeout.inSeconds}с — застряли в ' + '${_sessionState.name}, принудительный сброс', + ); + unawaited(_resetStuckConnect(gen)); + }); + } + + void _cancelConnectWatchdog() { + _connectWatchdog?.cancel(); + _connectWatchdog = null; + } + + Future _resetStuckConnect(int gen) async { + if (gen != _connectGen) return; + _connectGen++; + _cancelConnectWatchdog(); + _cleanup(); + try { + await _connection.disconnect(); + } catch (_) {} + _setSessionState(SessionState.disconnected); + if (_autoReconnect) _scheduleReconnect(); + } + Future _handleConnectFailure( Object error, { required String phase, @@ -173,6 +260,7 @@ class Api { bool disconnectSocket = false, }) async { logger.e('$phase: $error'); + _cancelConnectWatchdog(); if (_sessionState != SessionState.disconnected) { _cleanup(); if (disconnectSocket) await _connection.disconnect(); @@ -193,6 +281,7 @@ class Api { Future disconnect() async { _autoReconnect = false; _bypassActive = false; + _connectGen++; _reconnectTimer?.cancel(); _cleanup(); await _connection.disconnect(); @@ -441,6 +530,7 @@ class Api { } void _onDisconnected() { + _connectGen++; _cleanup(); _setSessionState(SessionState.disconnected); if (_autoReconnect) _scheduleReconnect(); @@ -463,6 +553,7 @@ class Api { } Future _forceReconnect() async { + _connectGen++; _cleanup(); await _connection.disconnect(); _reconnectAttempts = 0; @@ -472,6 +563,7 @@ class Api { } void _cleanup() { + _cancelConnectWatchdog(); _pingTimer?.cancel(); _dataSubscription?.cancel(); _socketStateSubscription?.cancel(); diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index be7507a..5112b4e 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -269,16 +269,15 @@ class AccountModule { final dataMap = data.cast(); if (resolvedAccountId == null) { - final profileMap = dataMap['profile']; - if (profileMap is Map) { - final contact = profileMap['contact']; - if (contact is Map) { - resolvedAccountId = contact['id'] as int?; - } - } + 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); diff --git a/lib/backend/modules/account/account_models.dart b/lib/backend/modules/account/account_models.dart index 989a4f2..844e6b8 100644 --- a/lib/backend/modules/account/account_models.dart +++ b/lib/backend/modules/account/account_models.dart @@ -2,6 +2,70 @@ import 'dart:convert'; import '../../../core/storage/app_database.dart'; +int? _coerceAccountId(dynamic value) { + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value.trim()); + return null; +} + +int? extractAccountId(dynamic response) { + if (response is! Map) return null; + + final profile = response['profile']; + if (profile is Map) { + final contact = profile['contact']; + if (contact is Map) { + final cid = + _coerceAccountId(contact['id']) ?? + _coerceAccountId(contact['contactId']) ?? + _coerceAccountId(contact['accountId']); + if (cid != null) return cid; + } + final pid = + _coerceAccountId(profile['id']) ?? + _coerceAccountId(profile['accountId']); + if (pid != null) return pid; + } + + final contact = response['contact']; + if (contact is Map) { + final cid = + _coerceAccountId(contact['id']) ?? + _coerceAccountId(contact['contactId']); + if (cid != null) return cid; + } + + final account = response['account']; + if (account is Map) { + final aid = + _coerceAccountId(account['id']) ?? + _coerceAccountId(account['accountId']); + if (aid != null) return aid; + } + + return _coerceAccountId(response['accountId']) ?? + _coerceAccountId(response['account_id']); +} + +String describeResponseShape(dynamic response) { + if (response is! Map) return 'не-Map (${response.runtimeType})'; + final sb = StringBuffer('keys=${response.keys.toList()}'); + final profile = response['profile']; + if (profile is Map) { + sb.write(' profile.keys=${profile.keys.toList()}'); + final contact = profile['contact']; + if (contact is Map) { + sb.write(' contact.keys=${contact.keys.toList()}'); + } else { + sb.write(' profile.contact=${contact.runtimeType}'); + } + } else { + sb.write(' profile=${profile.runtimeType}'); + } + return sb.toString(); +} + class PrivacyConfig { final String searchByPhone; final String incomingCall; @@ -295,13 +359,7 @@ class VerifyCodeResult { 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?; - } + int? get accountId => extractAccountId(payload); String? _nestedToken(String key) { final attrs = payload['tokenAttrs']; diff --git a/lib/core/config/proxy_config.dart b/lib/core/config/proxy_config.dart index ba5cc40..55d4c70 100644 --- a/lib/core/config/proxy_config.dart +++ b/lib/core/config/proxy_config.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:shared_preferences/shared_preferences.dart'; import '../storage/token_storage.dart'; +import '../utils/logger.dart'; enum ProxyType { none, socks5, httpConnect } @@ -35,26 +38,52 @@ abstract class ProxyConfig { static const String _prefUsername = 'proxy_username'; static const String _prefPassword = 'proxy_password'; + static const Duration _secureReadTimeout = Duration(seconds: 5); + + static Future _readSecureSafe(String key) async { + try { + return await TokenStorage.readSecure(key).timeout(_secureReadTimeout); + } catch (e) { + logger.w('ProxyConfig: чтение secure "$key" не удалось/зависло: $e'); + return null; + } + } + + static Future _migrateLegacySecure(String key, String value) async { + try { + await TokenStorage.writeSecure(key, value).timeout(_secureReadTimeout); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(key); + } catch (e) { + logger.w('ProxyConfig: миграция legacy "$key" не удалась: $e'); + } + } + static Future load() async { final prefs = await SharedPreferences.getInstance(); final typeIndex = prefs.getInt(_prefType) ?? 0; final host = prefs.getString(_prefHost) ?? ''; final port = prefs.getInt(_prefPort) ?? 1080; - var username = await TokenStorage.readSecure(_prefUsername); - var password = await TokenStorage.readSecure(_prefPassword); + if (ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)] == + ProxyType.none || + host.isEmpty) { + return ProxySettings( + type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)], + host: host, + port: port, + ); + } + var username = await _readSecureSafe(_prefUsername); + var password = await _readSecureSafe(_prefPassword); final legacyUsername = prefs.getString(_prefUsername); final legacyPassword = prefs.getString(_prefPassword); if (username == null && legacyUsername != null) { username = legacyUsername; - await TokenStorage.writeSecure(_prefUsername, legacyUsername); + unawaited(_migrateLegacySecure(_prefUsername, legacyUsername)); } if (password == null && legacyPassword != null) { password = legacyPassword; - await TokenStorage.writeSecure(_prefPassword, legacyPassword); - } - if (legacyUsername != null || legacyPassword != null) { - await prefs.remove(_prefUsername); - await prefs.remove(_prefPassword); + unawaited(_migrateLegacySecure(_prefPassword, legacyPassword)); } return ProxySettings( type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)], diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index ac7ee8e..dff5aed 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -15,6 +15,8 @@ enum SocketState { disconnected, connecting, connected } /// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver]. class Connection { static const Duration _defaultConnectTimeout = Duration(seconds: 15); + static const Duration _proxyLoadTimeout = Duration(seconds: 8); + static const Duration _vpnCallTimeout = Duration(seconds: 5); SecureSocket? _socket; StreamSubscription? _subscription; @@ -40,20 +42,41 @@ class Connection { bool bypassVpn = false, Duration? timeout, }) async { - if (_state != SocketState.disconnected) return; + if (_state != SocketState.disconnected) { + logger.w('Connection.connect пропущен: state=$_state (уже $_state)'); + return; + } _setState(SocketState.connecting); try { - final proxySettings = await ProxyConfig.load(); - - // Решение «обходить VPN или нет» принимает вызывающий (Api): - // первая попытка идёт через VPN, при её провале — мимо туннеля. - if (bypassVpn) { - await VpnBypassService.instance.bind(); - } else { - await VpnBypassService.instance.restoreDefault(); + logger.i('Connection: загрузка прокси-конфига'); + ProxySettings proxySettings; + try { + proxySettings = await ProxyConfig.load().timeout(_proxyLoadTimeout); + } catch (e) { + logger.w('Connection: ProxyConfig.load завис/упал ($e) — без прокси'); + proxySettings = const ProxySettings(); } + logger.i( + 'Connection: VPN ${bypassVpn ? 'bind (обход)' : 'restoreDefault'}', + ); + try { + if (bypassVpn) { + await VpnBypassService.instance.bind().timeout(_vpnCallTimeout); + } else { + await VpnBypassService.instance + .restoreDefault() + .timeout(_vpnCallTimeout); + } + } catch (e) { + logger.w('Connection: VPN-вызов завис/упал ($e) — продолжаю'); + } + + logger.i( + 'Connection: открываю сокет $host:$port ' + '(прокси: ${proxySettings.isEnabled ? proxySettings.type.name : 'нет'})', + ); final socket = await _openSecureSocket( host, port, @@ -109,7 +132,9 @@ class Connection { socket = await connector.connect(host, port).timeout(connectTimeout); logger.i('Подключено через прокси ${proxySettings.type.name}'); } else { + logger.i('Connection: TCP connect $host:$port (лимит ${connectTimeout.inSeconds}с)'); socket = await Socket.connect(host, port, timeout: connectTimeout); + logger.i('Connection: TCP установлен, начинаю TLS'); } final allowInsecure = await TlsConfig.isInsecureAllowed(); if (allowInsecure) { @@ -121,8 +146,11 @@ class Connection { ? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true) : SecureSocket.secure(socket, host: host); try { - return await secured.timeout(connectTimeout); + final result = await secured.timeout(connectTimeout); + logger.i('Connection: TLS-handshake завершён'); + return result; } on TimeoutException { + logger.w('Connection: TLS-handshake таймаут ${connectTimeout.inSeconds}с'); socket.destroy(); rethrow; } diff --git a/lib/core/utils/debug_session_log.dart b/lib/core/utils/debug_session_log.dart index 11bf6f8..02cb02f 100644 --- a/lib/core/utils/debug_session_log.dart +++ b/lib/core/utils/debug_session_log.dart @@ -57,11 +57,15 @@ class _SessionData { final DateTime startedAt; final List<_LogEntry> entries; final bool truncated; + final List logLines; + final bool logsTruncated; _SessionData({ required this.startedAt, required this.entries, this.truncated = false, + this.logLines = const [], + this.logsTruncated = false, }); static _SessionData fromJson(Map data) { @@ -72,37 +76,43 @@ class _SessionData { if (e is Map) entries.add(_LogEntry.fromJson(e)); } } + final rawLogs = data['logs']; + final logLines = []; + if (rawLogs is List) { + for (final l in rawLogs) { + logLines.add(l.toString()); + } + } return _SessionData( startedAt: DateTime.tryParse(data['startedAt']?.toString() ?? '') ?? DateTime.fromMillisecondsSinceEpoch(0), entries: entries, truncated: data['truncated'] == true, + logLines: logLines, + logsTruncated: data['logsTruncated'] == true, ); } } -/// Персистентный лог запросов для кнопки «Отладочный лог». -/// -/// Хранит ВСЕ запросы (с payload) за последние [_maxSessions] заходов в -/// приложение: каждый запуск — отдельная сессия-файл, старые ротируются. -/// Пинги идут мимо [Api.sendRequest], поэтому в лог не попадают. -/// -/// Payload сохраняется уже отредактированным — токен скрыт целиком, от номера -/// остаются первые 3 символа, остальное видно. Поэтому секреты не лежат на диске. class DebugSessionLog { DebugSessionLog._(); static final DebugSessionLog instance = DebugSessionLog._(); static const int _maxSessions = 3; static const int _maxEntriesPerSession = 2000; + static const int _maxLogLinesPerSession = 5000; static const Duration _flushDebounce = Duration(seconds: 3); + static final RegExp _ansiEscape = RegExp(r'\x1B\[[0-9;]*m'); + Directory? _dir; File? _currentFile; DateTime? _currentStart; final List<_LogEntry> _entries = []; + final List _logLines = []; bool _truncated = false; + bool _logsTruncated = false; bool _initialized = false; bool _dirty = false; Timer? _flushTimer; @@ -120,12 +130,23 @@ class DebugSessionLog { _currentFile = File( '${dir.path}/session_${_currentStart!.millisecondsSinceEpoch}.json', ); + if (_dirty) _scheduleFlush(); } catch (_) { _dir = null; _currentFile = null; } } + void recordLogLine(String line) { + final clean = line.replaceAll(_ansiEscape, ''); + _logLines.add(clean); + if (_logLines.length > _maxLogLinesPerSession) { + _logLines.removeRange(0, _logLines.length - _maxLogLinesPerSession); + _logsTruncated = true; + } + _scheduleFlush(); + } + void recordRequest(int opcode, int seq, dynamic payload) { _entries.add( _LogEntry( @@ -189,6 +210,8 @@ class DebugSessionLog { 'startedAt': _currentStart?.toIso8601String(), 'truncated': _truncated, 'entries': _entries.map((e) => e.toJson()).toList(), + 'logsTruncated': _logsTruncated, + 'logs': List.of(_logLines), }); await file.writeAsString(data); } catch (_) {} @@ -249,6 +272,8 @@ class DebugSessionLog { startedAt: _currentStart ?? DateTime.now(), entries: List.of(_entries), truncated: _truncated, + logLines: List.of(_logLines), + logsTruncated: _logsTruncated, ), ); sessions.sort((a, b) => a.startedAt.compareTo(b.startedAt)); @@ -256,7 +281,8 @@ class DebugSessionLog { ? sessions.sublist(sessions.length - _maxSessions) : sessions; final totalEntries = lastN.fold(0, (sum, s) => sum + s.entries.length); - if (totalEntries == 0) return null; + final totalLogs = lastN.fold(0, (sum, s) => sum + s.logLines.length); + if (totalEntries == 0 && totalLogs == 0) return null; final buffer = StringBuffer(); buffer.writeln('Komet — отладочный лог'); @@ -264,6 +290,7 @@ class DebugSessionLog { buffer.writeln('Экспортирован: ${DateTime.now().toIso8601String()}'); buffer.writeln('Заходов в приложение: ${lastN.length}'); buffer.writeln('Всего запросов: $totalEntries'); + buffer.writeln('Всего строк лога: $totalLogs'); buffer.writeln('Скрыто: токен полностью, номер кроме первых 3 символов'); buffer.writeln(); @@ -275,12 +302,31 @@ class DebugSessionLog { ); buffer.writeln( 'запросов: ${session.entries.length}' - '${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}', + '${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}' + ' · строк лога: ${session.logLines.length}' + '${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}', ); buffer.writeln('=================================================='); buffer.writeln(); - for (var i = 0; i < session.entries.length; i++) { - _writeEntry(buffer, i + 1, session.entries[i]); + + buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----'); + if (session.logLines.isEmpty) { + buffer.writeln('(пусто)'); + } else { + for (final line in session.logLines) { + buffer.writeln(line); + } + } + buffer.writeln(); + + buffer.writeln('----- ЗАПРОСЫ -----'); + if (session.entries.isEmpty) { + buffer.writeln('(пусто)'); + buffer.writeln(); + } else { + for (var i = 0; i < session.entries.length; i++) { + _writeEntry(buffer, i + 1, session.entries[i]); + } } } return buffer.toString(); diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index 1460e91..66de238 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; +import 'debug_session_log.dart'; + Level _minimumLogLevel() { const raw = String.fromEnvironment('KOMET_LOG_LEVEL', defaultValue: ''); switch (raw.toLowerCase()) { @@ -41,9 +43,18 @@ final logger = Logger( filter: _logFilter(), level: _minimumLogLevel(), printer: KometLogPrinter(), - output: ConsoleOutput(), + output: MultiOutput([ConsoleOutput(), DebugSessionLogOutput()]), ); +class DebugSessionLogOutput extends LogOutput { + @override + void output(OutputEvent event) { + for (final line in event.lines) { + DebugSessionLog.instance.recordLogLine(line); + } + } +} + int _importanceSortKey(Level level) { final v = level.value; if (v >= 5999) { diff --git a/lib/frontend/debug/quick_actions_section.dart b/lib/frontend/debug/quick_actions_section.dart index 5758d1a..0df3867 100644 --- a/lib/frontend/debug/quick_actions_section.dart +++ b/lib/frontend/debug/quick_actions_section.dart @@ -49,7 +49,7 @@ class DebugQuickActionsSection extends StatelessWidget { ), const SizedBox(height: 2), Text( - 'Все запросы за последние 3 захода в приложение', + 'Все логи и запросы за последние 3 захода в приложение', style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index d48cd57..81fa819 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -73,7 +73,7 @@ class _DebugMenuScreenState extends State { endpoint: TrafficMonitor.instance.activeEndpoint, ); if (content == null) { - if (mounted) showCustomNotification(context, 'Нет запросов для лога'); + if (mounted) showCustomNotification(context, 'Лог пуст'); return; } final bytes = Uint8List.fromList(utf8.encode(content)); diff --git a/lib/main.dart b/lib/main.dart index dc82e4b..1dfb8e1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -105,8 +105,47 @@ Future _loadInitialLocale() async { return const Locale('ru'); } +void _installLogCapture() { + final previousDebugPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) { + final t = DateTime.now(); + final stamp = + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}.${t.millisecond.toString().padLeft(3, '0')}'; + DebugSessionLog.instance.recordLogLine(' |$stamp P $message'); + } + previousDebugPrint(message, wrapWidth: wrapWidth); + }; + + final previousFlutterOnError = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + DebugSessionLog.instance.recordLogLine( + ' | FlutterError: ${details.exceptionAsString()}', + ); + if (details.stack != null) { + DebugSessionLog.instance.recordLogLine(details.stack.toString()); + } + if (previousFlutterOnError != null) { + previousFlutterOnError(details); + } else { + FlutterError.presentError(details); + } + }; + + final previousPlatformOnError = ui.PlatformDispatcher.instance.onError; + ui.PlatformDispatcher.instance.onError = (Object error, StackTrace stack) { + DebugSessionLog.instance.recordLogLine(' | Uncaught: $error'); + DebugSessionLog.instance.recordLogLine(stack.toString()); + if (previousPlatformOnError != null) { + return previousPlatformOnError(error, stack); + } + return false; + }; +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); + _installLogCapture(); VideoPlayerMediaKit.ensureInitialized( windows: true, linux: true, From 992deb60ec84e697b7b2a83efda622864f873eea Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 5 Jul 2026 23:06:16 +0300 Subject: [PATCH 3/3] Fix password login profile handling --- lib/backend/modules/account.dart | 47 +++++++++++++++---- .../modules/account/account_models.dart | 3 +- .../modules/account/profile_module.dart | 6 +-- lib/core/storage/app_database.dart | 34 ++++++++++---- .../screens/auth/password_2fa_screen.dart | 5 +- pubspec.yaml | 2 +- 6 files changed, 73 insertions(+), 24 deletions(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 5112b4e..395e0c3 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -228,7 +228,9 @@ class AccountModule { throw Exception('completeRegistration: отсутствует id аккаунта'); } - final profile = ProfileData.fromServerMap(contact.cast()); + final profile = ProfileData.fromServerProfile( + profileMap.cast(), + ); await AppDatabase.saveProfile(profile, isActive: true); await TokenStorage.setActiveAccount(accountId); await SpoofingService.commitPendingSpoof(accountId); @@ -446,8 +448,25 @@ class AccountModule { 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); + return TwoFactorResult(loginToken: loginToken, accountId: accountId); } Map buildLoginPayload( @@ -500,16 +519,24 @@ class AccountModule { await TokenStorage.saveToken(updatedToken, accountId); } + ProfileData profile; final profileMap = data['profile']; - if (profileMap is! Map) { - throw Exception('login: отсутствует profile в ответе'); + if (profileMap is Map) { + final contact = profileMap['contact']; + if (contact is! Map) { + throw Exception('login: отсутствует profile.contact в ответе'); + } + profile = ProfileData.fromServerProfile( + profileMap.cast(), + ); + await AppDatabase.saveProfile(profile, isActive: true); + } else { + final cachedProfile = await AppDatabase.loadProfile(accountId); + if (cachedProfile == null) { + throw Exception('login: отсутствует profile в ответе'); + } + profile = cachedProfile; } - final contact = profileMap['contact']; - if (contact is! Map) { - throw Exception('login: отсутствует profile.contact в ответе'); - } - final profile = ProfileData.fromServerMap(contact.cast()); - await AppDatabase.saveProfile(profile, isActive: true); await AppDatabase.setActiveAccount(profile.id); await _saveSyncState(data, serverTime, profile.id); diff --git a/lib/backend/modules/account/account_models.dart b/lib/backend/modules/account/account_models.dart index 844e6b8..a5b2d47 100644 --- a/lib/backend/modules/account/account_models.dart +++ b/lib/backend/modules/account/account_models.dart @@ -372,8 +372,9 @@ class VerifyCodeResult { class TwoFactorResult { final String loginToken; + final int accountId; - const TwoFactorResult({required this.loginToken}); + const TwoFactorResult({required this.loginToken, required this.accountId}); } class LoginSyncParams { diff --git a/lib/backend/modules/account/profile_module.dart b/lib/backend/modules/account/profile_module.dart index 28deb0b..87fe5a2 100644 --- a/lib/backend/modules/account/profile_module.dart +++ b/lib/backend/modules/account/profile_module.dart @@ -18,8 +18,8 @@ class ProfileModule extends AccountApiBase { if (profile == null) throw Exception('No profile in response'); final contact = profile['contact'] as Map?; if (contact == null) throw Exception('No contact in response'); - final newProfile = ProfileData.fromServerMap( - contact.cast(), + final newProfile = ProfileData.fromServerProfile( + profile.cast(), ); await AppDatabase.saveProfile(newProfile, isActive: true); return newProfile; @@ -88,7 +88,7 @@ class ProfileModule extends AccountApiBase { final contact = profile['contact']; if (contact is! Map) return; completer.complete( - ProfileData.fromServerMap(contact.cast()), + ProfileData.fromServerProfile(profile.cast()), ); }); final timer = Timer(const Duration(seconds: 15), () { diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 287b7e2..52731c2 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -36,7 +36,21 @@ class ProfileData { this.profileOptions, }); - factory ProfileData.fromServerMap(Map contact) { + factory ProfileData.fromServerProfile(Map profile) { + final contact = profile['contact']; + if (contact is! Map) { + throw const FormatException('No contact in profile'); + } + return ProfileData.fromServerMap( + contact.cast(), + profileOptions: _parseProfileOptions(profile['profileOptions']), + ); + } + + factory ProfileData.fromServerMap( + Map contact, { + List? profileOptions, + }) { final names = contact['names']; String firstName = ''; String? lastName; @@ -52,12 +66,6 @@ class ProfileData { lastName = name['lastName'] as String?; } - final profileOptionsRaw = contact['profileOptions']; - List? profileOptions; - if (profileOptionsRaw is List) { - profileOptions = profileOptionsRaw.map((e) => e as int).toList(); - } - return ProfileData( id: contact['id'] as int, firstName: firstName, @@ -69,10 +77,20 @@ class ProfileData { country: (contact['country'] as String?) ?? '', accountStatus: (contact['accountStatus'] as int?) ?? 0, updateTime: (contact['updateTime'] as int?) ?? 0, - profileOptions: profileOptions, + profileOptions: + profileOptions ?? _parseProfileOptions(contact['profileOptions']), ); } + static List? _parseProfileOptions(dynamic raw) { + if (raw is! List) return null; + final options = raw + .map((e) => e is int ? e : int.tryParse(e.toString())) + .whereType() + .toList(); + return options.isEmpty ? null : options; + } + factory ProfileData.fromDbRow(Map row) { final profileOptionsStr = row['profile_options'] as String?; List? profileOptions; diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index 8ab80c5..dae30dd 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -67,7 +67,10 @@ class _Password2FAScreenState extends State if (!mounted) return; - final loginResult = await accountModule.login(token: result.loginToken); + final loginResult = await accountModule.login( + accountId: result.accountId, + token: result.loginToken, + ); if (!mounted) return; diff --git a/pubspec.yaml b/pubspec.yaml index fd2f641..4f29a8c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.5.0+13 +version: 0.5.0+14 environment: sdk: ^3.10.4