fix: щавель виноград, ебырь омайгад

This commit is contained in:
Jganenokk
2026-06-30 16:00:16 +07:00
parent d40cb31b80
commit 4b4aeec92a
10 changed files with 357 additions and 30 deletions
+4
View File
@@ -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) {
+30
View File
@@ -328,6 +328,36 @@ class ChatsModule {
_bump();
}
static Future<int?> 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<String, dynamic>.from(rows.first);
row['unread_count'] = unread;
await AppDatabase.saveChats([row]);
_bump();
}
return unread;
}
static Future<void> applyOutgoing(
int accountId,
int chatId, {
+1 -1
View File
@@ -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;
+9
View File
@@ -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;
@@ -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<CodeConfirmationScreen>
Animation<double>? _routeAnimation;
AnimationStatusListener? _routeAnimationListener;
late String _token;
late int _epoch;
StreamSubscription<SessionState>? _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<CodeConfirmationScreen>
if (_routeAnimationListener != null) {
_routeAnimation?.removeStatusListener(_routeAnimationListener!);
}
_stateSub?.cancel();
_timer?.cancel();
_errorTimer?.cancel();
_shakeController.dispose();
@@ -76,6 +94,62 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
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<void> _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<CodeConfirmationScreen>
}
Future<void> _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<CodeConfirmationScreen>
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<CodeConfirmationScreen>
),
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<CodeConfirmationScreen>
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,
),
),
],
),
+47 -10
View File
@@ -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<LoginScreen> {
Timer? _phoneErrorTimer;
int _logoTapCount = 0;
Timer? _logoTapTimer;
late SessionState _sessionState;
StreamSubscription<SessionState>? _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<LoginScreen> {
@override
void dispose() {
_stateSub?.cancel();
_phoneErrorTimer?.cancel();
_logoTapTimer?.cancel();
_phoneController.dispose();
@@ -458,6 +468,13 @@ class _LoginScreenState extends State<LoginScreen> {
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<LoginScreen> {
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<LoginScreen> {
_showPhoneConfirmationDialog(_phoneController.text);
}
void _notifyConnecting() {
showCustomNotification(context, 'Подключаемся к серверу, секунду…');
}
void _showServerSettingsSheet(BuildContext context) {
final cs = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
@@ -956,21 +982,32 @@ class _LoginScreenState extends State<LoginScreen> {
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,
),
),
),
],
@@ -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<Password2FAScreen> {
bool _isPasswordVisible = false;
bool _isLoading = false;
late int _epoch;
StreamSubscription<SessionState>? _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<void> _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<Password2FAScreen> {
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<Password2FAScreen> {
_isLoading = false;
});
showCustomNotification(context, 'Неверный пароль: $e');
if (!passed && (isSessionStateError(e) || _sessionStale)) {
_recoverStaleSession();
} else {
showCustomNotification(context, 'Неверный пароль: $e');
}
}
}
+22 -1
View File
@@ -503,7 +503,6 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
);
}
Future<void> _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<ChatScreen>
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<List<({int id, String title})>> Function()? loadReportReasons;
final Future<bool> 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,
);
}
@@ -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<DebugMenuScreen> {
),
),
),
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),
@@ -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<void> _markUnread() async {
final onMarkUnread = widget.onMarkUnread;
await _close();
onMarkUnread?.call();
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);