From 4b4aeec92aee96b3350833b400434d0c1c074f48 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Tue, 30 Jun 2026 16:00:16 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=89=D0=B0=D0=B2=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=BD=D0=BE=D0=B3=D1=80=D0=B0=D0=B4,=20=D0=B5?= =?UTF-8?q?=D0=B1=D1=8B=D1=80=D1=8C=20=D0=BE=D0=BC=D0=B0=D0=B9=D0=B3=D0=B0?= =?UTF-8?q?=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 4 + lib/backend/modules/chats.dart | 30 +++++ lib/core/config/config.dart | 2 +- lib/core/protocol/packet.dart | 9 ++ .../auth/code_confirmation_screen.dart | 127 ++++++++++++++++-- lib/frontend/screens/auth/login_screen.dart | 57 ++++++-- .../screens/auth/password_2fa_screen.dart | 60 ++++++++- lib/frontend/screens/chats/chat_screen.dart | 23 +++- .../screens/profile/debug_menu_screen.dart | 63 +++++++++ .../widgets/message_actions_overlay.dart | 12 ++ 10 files changed, 357 insertions(+), 30 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 9b940d5..dad59b8 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -68,6 +68,9 @@ class Api { Timer? _reconnectTimer; int _reconnectAttempts = 0; bool _autoReconnect = false; + int _sessionEpoch = 0; + + int get sessionEpoch => _sessionEpoch; /// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля. bool _bypassActive = false; @@ -125,6 +128,7 @@ class Api { _callsSeed = response.payload['callsSeed'] as int?; _registrationCountries = _parseRegistrationCountries(response.payload); _sessionState = SessionState.online; + _sessionEpoch++; _startPinging(); logger.i('Сессия онлайн, хэндшейк ок'); if (_onReconnectCallback != null) { diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 27a9344..74f433b 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -328,6 +328,36 @@ class ChatsModule { _bump(); } + static Future markUnread( + Api api, + int accountId, + int chatId, + int mark, + ) async { + int? unread; + try { + final resp = await api.sendRequest(Opcode.chatMark, { + 'type': 'SET_AS_UNREAD', + 'chatId': chatId, + 'mark': mark, + }); + final payload = resp.payload; + if (payload is Map) unread = payload['unread'] as int?; + } catch (_) { + return null; + } + if (unread == null) return null; + + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isNotEmpty) { + final row = Map.from(rows.first); + row['unread_count'] = unread; + await AppDatabase.saveChats([row]); + _bump(); + } + return unread; + } + static Future applyOutgoing( int accountId, int chatId, { diff --git a/lib/core/config/config.dart b/lib/core/config/config.dart index c0c97cc..40d09e6 100644 --- a/lib/core/config/config.dart +++ b/lib/core/config/config.dart @@ -5,7 +5,7 @@ abstract class ServerConfig { static const int defaultPort = 443; static const String prefHostKey = 'server_host_override'; static const String prefPortKey = 'server_port_override'; - static const Duration pingInterval = Duration(seconds: 30); + static const Duration pingInterval = Duration(seconds: 10); static const Duration requestTimeout = Duration(seconds: 30); static const int maxReconnectAttempts = 50; diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 90dec0c..38f001a 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -83,6 +83,15 @@ String messageFromErrorPayload(dynamic payload) { return s.isNotEmpty ? s : 'Неизвестная ошибка'; } +bool isSessionStateError(Object error) { + if (error is SessionExpiredException) return true; + final text = error.toString().toLowerCase(); + return text.contains('состояние сессии') || + text.contains('сессия не найдена') || + text.contains('авторизационная сессия') || + text.contains('сессия не онлайн'); +} + /// Payload меньше этого размера отправляется без сжатия (как в оригинале). const int _compressionThreshold = 32; diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index fe64747..49f9c64 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -4,17 +4,21 @@ import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'password_2fa_screen.dart'; import 'registration_screen.dart'; +import '../../../backend/api.dart'; +import '../../../core/protocol/packet.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; class CodeConfirmationScreen extends StatefulWidget { final String phoneNumber; + final String rawPhone; final String token; const CodeConfirmationScreen({ super.key, required this.phoneNumber, + required this.rawPhone, required this.token, }); @@ -38,9 +42,22 @@ class _CodeConfirmationScreenState extends State Animation? _routeAnimation; AnimationStatusListener? _routeAnimationListener; + late String _token; + late int _epoch; + StreamSubscription? _stateSub; + bool _recovering = false; + bool _verifying = false; + bool _dropNotified = false; + + bool get _sessionStale => + api.sessionEpoch != _epoch || api.state != SessionState.online; + @override void initState() { super.initState(); + _token = widget.token; + _epoch = api.sessionEpoch; + _stateSub = api.stateStream.listen(_onSessionState); _startTimer(); _shakeController = AnimationController( @@ -68,6 +85,7 @@ class _CodeConfirmationScreenState extends State if (_routeAnimationListener != null) { _routeAnimation?.removeStatusListener(_routeAnimationListener!); } + _stateSub?.cancel(); _timer?.cancel(); _errorTimer?.cancel(); _shakeController.dispose(); @@ -76,6 +94,62 @@ class _CodeConfirmationScreenState extends State super.dispose(); } + void _onSessionState(SessionState state) { + if (!mounted) return; + if (state != SessionState.online) { + if (!_dropNotified) { + _dropNotified = true; + showCustomNotification( + context, + 'Соединение прервалось, восстанавливаем…', + ); + } + return; + } + if (api.sessionEpoch != _epoch) _recoverStaleSession(); + } + + Future _recoverStaleSession() async { + if (_recovering) return; + setState(() => _recovering = true); + try { + if (api.state != SessionState.online) { + final back = await api.stateStream + .firstWhere((s) => s == SessionState.online) + .timeout( + const Duration(seconds: 12), + onTimeout: () => SessionState.disconnected, + ); + if (back != SessionState.online) { + if (mounted) { + showCustomNotification(context, 'Нет соединения с сервером'); + } + return; + } + } + final fresh = await accountModule.requestCode(widget.rawPhone); + if (!mounted) return; + setState(() { + _token = fresh.token; + _epoch = api.sessionEpoch; + _dropNotified = false; + _codeController.clear(); + _errorMessage = null; + }); + _startTimer(); + showCustomNotification( + context, + 'Соединение восстановлено — выслали новый код', + ); + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Не удалось обновить код: $e'); + } + } finally { + if (mounted) setState(() => _recovering = false); + } + } + void _scheduleKeyboardOpen() { if (_keyboardScheduled) return; _keyboardScheduled = true; @@ -136,13 +210,21 @@ class _CodeConfirmationScreenState extends State } Future _verifyCode() async { - if (_codeController.text.length != 6) return; + if (_codeController.text.length != 6 || _recovering || _verifying) return; + if (_sessionStale) { + _recoverStaleSession(); + return; + } + + setState(() => _verifying = true); + var verified = false; try { final result = await accountModule.verifyCode( _codeController.text, - widget.token, + _token, ); + verified = true; if (!mounted) return; @@ -196,15 +278,21 @@ class _CodeConfirmationScreenState extends State context, PageRouteBuilder( transitionDuration: const Duration(milliseconds: 240), - pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), - transitionsBuilder: (_, animation, __, child) => + pageBuilder: (_, _, _) => LoginSuccessScreen(avatar: avatar), + transitionsBuilder: (_, animation, _, child) => FadeTransition(opacity: animation, child: child), ), (route) => false, ); } catch (e) { if (!mounted) return; - _showError(e.toString()); + if (!verified && (isSessionStateError(e) || _sessionStale)) { + _recoverStaleSession(); + } else { + _showError(e.toString()); + } + } finally { + if (mounted) setState(() => _verifying = false); } } @@ -419,9 +507,11 @@ class _CodeConfirmationScreenState extends State ), const SizedBox(width: 16), FloatingActionButton( - onPressed: () { - if (_codeController.text.length == 6) _verifyCode(); - }, + onPressed: (_recovering || _verifying) + ? null + : () { + if (_codeController.text.length == 6) _verifyCode(); + }, backgroundColor: _codeController.text.length == 6 ? cs.primaryContainer : cs.surfaceContainerHighest, @@ -429,12 +519,21 @@ class _CodeConfirmationScreenState extends State shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(50), ), - child: Icon( - Icons.arrow_forward, - color: _codeController.text.length == 6 - ? cs.onPrimaryContainer - : cs.onSurfaceVariant, - ), + child: _recovering + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimaryContainer, + ), + ) + : Icon( + Icons.arrow_forward, + color: _codeController.text.length == 6 + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + ), ), ], ), diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 4dd45b9..8c268a5 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -19,6 +19,7 @@ import '../../widgets/custom_notification.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/sheet_helpers.dart'; import '../../../backend/api.dart'; +import '../../../core/protocol/packet.dart'; import '../../../main.dart'; class LoginScreen extends StatefulWidget { @@ -39,13 +40,21 @@ class _LoginScreenState extends State { Timer? _phoneErrorTimer; int _logoTapCount = 0; Timer? _logoTapTimer; + late SessionState _sessionState; + StreamSubscription? _stateSub; + + bool get _isOnline => _sessionState == SessionState.online; @override void initState() { super.initState(); + _sessionState = api.state; if (api.state == SessionState.disconnected) { unawaited(api.connect()); } + _stateSub = api.stateStream.listen((state) { + if (mounted) setState(() => _sessionState = state); + }); _selectedCountry = countriesByCode['RU'] ?? allCountries.first; _clampCountryToAllowed(); _checkTOS(); @@ -79,6 +88,7 @@ class _LoginScreenState extends State { @override void dispose() { + _stateSub?.cancel(); _phoneErrorTimer?.cancel(); _logoTapTimer?.cancel(); _phoneController.dispose(); @@ -458,6 +468,13 @@ class _LoginScreenState extends State { final fullPhone = '${_selectedCountry.phoneCode}${_phoneController.text}'; + if (!_isOnline) { + _showPhoneError( + 'Нет соединения с сервером. Подождите подключения.', + ); + return; + } + try { final result = await accountModule.requestCode( fullPhone, @@ -470,13 +487,18 @@ class _LoginScreenState extends State { builder: (context) => CodeConfirmationScreen( phoneNumber: '${_selectedCountry.phoneCode} $formattedPhone', + rawPhone: fullPhone, token: result.token, ), ), ); } catch (e) { if (!screenContext.mounted) return; - _showPhoneError(e.toString()); + _showPhoneError( + isSessionStateError(e) + ? 'Нет соединения с сервером. Попробуйте ещё раз.' + : e.toString(), + ); } }, child: Text( @@ -510,6 +532,10 @@ class _LoginScreenState extends State { _showPhoneConfirmationDialog(_phoneController.text); } + void _notifyConnecting() { + showCustomNotification(context, 'Подключаемся к серверу, секунду…'); + } + void _showServerSettingsSheet(BuildContext context) { final cs = Theme.of(context).colorScheme; showModalBottomSheet( @@ -956,21 +982,32 @@ class _LoginScreenState extends State { Padding( padding: const EdgeInsets.only(bottom: 16.0), child: FloatingActionButton( - onPressed: _isPhoneValid - ? _validateAndSubmit - : null, + onPressed: !_isPhoneValid + ? null + : (_isOnline + ? _validateAndSubmit + : _notifyConnecting), backgroundColor: _isPhoneValid ? cs.primaryContainer : cs.surfaceContainerHighest, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(50), ), - child: Icon( - Icons.arrow_forward, - color: _isPhoneValid - ? cs.onPrimaryContainer - : cs.onSurfaceVariant, - ), + child: _isPhoneValid && !_isOnline + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimaryContainer, + ), + ) + : Icon( + Icons.arrow_forward, + color: _isPhoneValid + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + ), ), ), ], diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index 6e1d1d6..89cf530 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -1,4 +1,7 @@ +import 'dart:async'; import 'package:flutter/material.dart'; +import '../../../backend/api.dart'; +import '../../../core/protocol/packet.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; @@ -18,24 +21,69 @@ class _Password2FAScreenState extends State { bool _isPasswordVisible = false; bool _isLoading = false; + late int _epoch; + StreamSubscription? _stateSub; + bool _recovering = false; + bool _dropNotified = false; + + bool get _sessionStale => + api.sessionEpoch != _epoch || api.state != SessionState.online; + + @override + void initState() { + super.initState(); + _epoch = api.sessionEpoch; + _stateSub = api.stateStream.listen(_onSessionState); + } + @override void dispose() { + _stateSub?.cancel(); _passwordController.dispose(); super.dispose(); } + void _onSessionState(SessionState state) { + if (!mounted) return; + if (state != SessionState.online) { + if (!_dropNotified) { + _dropNotified = true; + showCustomNotification(context, 'Соединение прервалось…'); + } + return; + } + if (api.sessionEpoch != _epoch) _recoverStaleSession(); + } + + void _recoverStaleSession() { + if (_recovering || !mounted) return; + _recovering = true; + showCustomNotification( + context, + 'Соединение прервалось — войдите заново', + ); + Navigator.of(context).pop(); + } + Future _checkPassword() async { - if (_passwordController.text.isEmpty) return; + if (_passwordController.text.isEmpty || _isLoading || _recovering) return; + + if (_sessionStale) { + _recoverStaleSession(); + return; + } setState(() { _isLoading = true; }); + var passed = false; try { final result = await accountModule.checkPassword( password: _passwordController.text, trackId: widget.trackId, ); + passed = true; if (!mounted) return; @@ -54,8 +102,8 @@ class _Password2FAScreenState extends State { context, PageRouteBuilder( transitionDuration: const Duration(milliseconds: 240), - pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), - transitionsBuilder: (_, animation, __, child) => + pageBuilder: (_, _, _) => LoginSuccessScreen(avatar: avatar), + transitionsBuilder: (_, animation, _, child) => FadeTransition(opacity: animation, child: child), ), (route) => false, @@ -67,7 +115,11 @@ class _Password2FAScreenState extends State { _isLoading = false; }); - showCustomNotification(context, 'Неверный пароль: $e'); + if (!passed && (isSessionStateError(e) || _sessionStale)) { + _recoverStaleSession(); + } else { + showCustomNotification(context, 'Неверный пароль: $e'); + } } } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 2529985..89015e5 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -503,7 +503,6 @@ class _ChatScreenState extends State void _markRead() { if (_myId == 0 || _messages.isEmpty) return; final newest = _messages.last; - if (newest.senderId == _myId) return; if (newest.id == _lastMarkedId) return; _lastMarkedId = newest.id; unawaited( @@ -511,6 +510,21 @@ class _ChatScreenState extends State ); } + Future _markMessageUnread(CachedMessage message) async { + final unread = await ChatsModule.markUnread( + api, + _myId, + widget.chatId, + message.time, + ); + if (!mounted) return; + if (unread == null) { + showCustomNotification(context, 'Не удалось пометить непрочитанным'); + return; + } + Navigator.of(context).pop(); + } + bool _badgeRefreshing = false; bool _badgeRefreshQueued = false; @@ -3449,6 +3463,9 @@ class _ChatScreenState extends State onForward: message.isControl ? null : () => _forwardMessages([message]), + onMarkUnread: message.isControl + ? null + : () => _markMessageUnread(message), loadReportReasons: canReport ? () => _loadReportReasons(reportTypeId) : null, @@ -5890,6 +5907,7 @@ class _SelectableMessageRow extends StatefulWidget { final VoidCallback? onEdit; final VoidCallback? onReply; final VoidCallback? onForward; + final VoidCallback? onMarkUnread; final Future> Function()? loadReportReasons; final Future Function(int reasonId)? onReport; @@ -5906,6 +5924,7 @@ class _SelectableMessageRow extends StatefulWidget { this.onEdit, this.onReply, this.onForward, + this.onMarkUnread, this.loadReportReasons, this.onReport, }); @@ -5958,6 +5977,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { onEdit: widget.onEdit, onReply: widget.onReply, onForward: widget.onForward, + onMarkUnread: widget.onMarkUnread, onDispose: controller.dispose, ); } @@ -5988,6 +6008,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { onEdit: widget.onEdit, onReply: widget.onReply, onForward: widget.onForward, + onMarkUnread: widget.onMarkUnread, onDispose: controller.dispose, ); } diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 5e9a860..ec9ba11 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -28,6 +28,7 @@ import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/login_success_screen.dart'; +import '../auth/login_screen.dart'; import '../calls/call_screen.dart'; import '../../../core/calls/call_controller.dart'; import '../../widgets/connection_status.dart'; @@ -351,6 +352,68 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LoginScreen()), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.dialpad, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Экран ввода номера', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Открыть без выхода из аккаунта и обрыва сессии', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Icon( + Symbols.chevron_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index a95b86c..1c04519 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -83,6 +83,7 @@ void showMessageActions({ VoidCallback? onEdit, VoidCallback? onReply, VoidCallback? onForward, + VoidCallback? onMarkUnread, MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease, }) { final overlay = Overlay.of(context, rootOverlay: true); @@ -104,6 +105,7 @@ void showMessageActions({ onEdit: onEdit, onReply: onReply, onForward: onForward, + onMarkUnread: onMarkUnread, onDismiss: () { if (entry.mounted) entry.remove(); onDispose(); @@ -130,6 +132,7 @@ class _MessageActionsLayer extends StatefulWidget { final VoidCallback? onEdit; final VoidCallback? onReply; final VoidCallback? onForward; + final VoidCallback? onMarkUnread; const _MessageActionsLayer({ required this.snapshot, @@ -148,6 +151,7 @@ class _MessageActionsLayer extends StatefulWidget { this.onEdit, this.onReply, this.onForward, + this.onMarkUnread, }); @override @@ -380,6 +384,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> _Action(Symbols.reply, 'Ответить', _reply), if (widget.onForward != null) _Action(Symbols.forward, 'Переслать', _forward), + if (widget.onMarkUnread != null) + _Action(Symbols.mark_chat_unread, 'Непрочитанное', _markUnread), if (widget.editHistory != null && widget.editHistory!.isNotEmpty) _Action(Symbols.history, 'История изменений', _showHistoryView), if (widget.onReport != null && widget.loadReportReasons != null) @@ -517,6 +523,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> onForward?.call(); } + Future _markUnread() async { + final onMarkUnread = widget.onMarkUnread; + await _close(); + onMarkUnread?.call(); + } + @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context);