From 101262897fbbb2c30e5b3cbf77830d7bddeaf4f2 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 22 Aug 2026 13:52:00 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=B5=D0=B1-=D1=81=D0=B5?= =?UTF-8?q?=D1=81=D1=81=D0=B8=D0=B8=20=D0=B8=20=D0=BF=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/webpush/web_push_service.dart | 93 +++++++++++++++++- .../screens/profile/web_push_screen.dart | 94 +++++++++++++++++-- lib/l10n/app_en.arb | 8 +- lib/l10n/app_localizations.dart | 36 +++++++ lib/l10n/app_localizations_en.dart | 19 ++++ lib/l10n/app_localizations_ru.dart | 19 ++++ lib/l10n/app_ru.arb | 8 +- 7 files changed, 264 insertions(+), 13 deletions(-) diff --git a/lib/core/webpush/web_push_service.dart b/lib/core/webpush/web_push_service.dart index 75a6544..c4a28dc 100644 --- a/lib/core/webpush/web_push_service.dart +++ b/lib/core/webpush/web_push_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io' show Platform; -import 'dart:ui' show PlatformDispatcher; + +import 'package:flutter/foundation.dart'; import 'package:device_info_plus/device_info_plus.dart'; @@ -22,6 +23,26 @@ class WebPushSubscription { }); } +class WebPushLinkInfo { + final String endpoint; + final DateTime? linkedAt; + final String deviceId; + + const WebPushLinkInfo({ + required this.endpoint, + required this.linkedAt, + required this.deviceId, + }); + + String get shortEndpoint { + final token = endpoint.split('/').last; + if (token.length <= 24) return token; + return '${token.substring(0, 12)}…${token.substring(token.length - 8)}'; + } + + String get host => Uri.tryParse(endpoint)?.host ?? endpoint; +} + class WebPushQrTrack { final String trackId; final String qrLink; @@ -64,6 +85,7 @@ class WebPushService { static const String _tokenKey = 'webpush_login_token'; static const String _deviceIdKey = 'webpush_device_id'; static const String _endpointKey = 'webpush_endpoint'; + static const String _linkedAtKey = 'webpush_linked_at'; static const String _appVersion = '26.8.8'; static const Duration _defaultPoll = Duration(seconds: 5); @@ -72,11 +94,27 @@ class WebPushService { MaxWebSocketSession? _authSocket; MaxWebDevice? _device; + final ValueNotifier changes = ValueNotifier(0); + + void _notifyChanged() => changes.value++; + Future isAuthorized() async => (await TokenStorage.readSecure(_tokenKey))?.isNotEmpty ?? false; Future linkedEndpoint() => TokenStorage.readSecure(_endpointKey); + Future linkInfo() async { + final endpoint = await TokenStorage.readSecure(_endpointKey); + if (endpoint == null || endpoint.isEmpty) return null; + + final stamp = await TokenStorage.readSecure(_linkedAtKey); + return WebPushLinkInfo( + endpoint: endpoint, + linkedAt: stamp == null ? null : DateTime.tryParse(stamp), + deviceId: await deviceId(), + ); + } + Future deviceId() async { final saved = await TokenStorage.readSecure(_deviceIdKey); if (saved != null && saved.isNotEmpty) return saved; @@ -171,6 +209,7 @@ class WebPushService { Future finishAuth(String loginToken) async { await TokenStorage.writeSecure(_tokenKey, loginToken); await cancelAuth(); + _notifyChanged(); logger.i('WebPush: WEB-сессия авторизована по QR'); } @@ -205,6 +244,11 @@ class WebPushService { 'publicKey': subscription.publicKey, }); await TokenStorage.writeSecure(_endpointKey, subscription.endpoint); + await TokenStorage.writeSecure( + _linkedAtKey, + DateTime.now().toIso8601String(), + ); + _notifyChanged(); logger.i('WebPush: подписка зарегистрирована'); } finally { await socket.close(); @@ -213,8 +257,55 @@ class WebPushService { Future signOut() async { await cancelAuth(); + + final token = await TokenStorage.readSecure(_tokenKey); + final endpoint = await TokenStorage.readSecure(_endpointKey); + if (token != null && token.isNotEmpty) { + try { + await _terminateWebSession(token, endpoint); + } catch (e) { + logger.w('WebPush: веб-сессию завершить не удалось ($e)'); + } + } + await TokenStorage.deleteSecure(_tokenKey); await TokenStorage.deleteSecure(_endpointKey); + await TokenStorage.deleteSecure(_linkedAtKey); + _notifyChanged(); + } + + Future _terminateWebSession(String token, String? endpoint) async { + final socket = MaxWebSocketSession(device: await device()); + try { + await socket.connect(); + await socket.request(Opcode.login, { + 'token': token, + 'chatsCount': 0, + 'interactive': false, + 'chatsSync': 0, + 'contactsSync': 0, + 'presenceSync': -1, + 'draftsSync': 0, + }); + + if (endpoint != null && endpoint.isNotEmpty) { + try { + await socket.request(Opcode.config, { + 'subscribe': false, + 'pushToken': endpoint, + 'secretKey': '', + 'publicKey': '', + }); + } catch (e) { + logger.w('WebPush: подписку снять не удалось ($e)'); + } + } + + await socket.request(Opcode.logout, {}); + logger.i('WebPush: веб-сессия завершена'); + } finally { + await socket.close(); + } } MaxWebSocketSession _requireSocket() { diff --git a/lib/frontend/screens/profile/web_push_screen.dart b/lib/frontend/screens/profile/web_push_screen.dart index 4bd6431..724c9b8 100644 --- a/lib/frontend/screens/profile/web_push_screen.dart +++ b/lib/frontend/screens/profile/web_push_screen.dart @@ -3,12 +3,14 @@ import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/api.dart'; +import '../../../core/utils/format.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/utils/link_opener.dart'; import '../../../core/webpush/max_web_socket.dart'; import '../../../core/webpush/web_push_service.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show accountModule, api; +import '../../widgets/confirm_dialog.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/section_header.dart'; @@ -36,19 +38,26 @@ class _WebPushScreenState extends State { _Stage _stage = _Stage.loading; bool _busy = false; bool _linked = false; + WebPushLinkInfo? _link; String? _trackId; String? _passwordHint; @override void initState() { super.initState(); + WebPushService.instance.changes.addListener(_onServiceChanged); _reload(); } + void _onServiceChanged() { + if (mounted) _reload(); + } + @override void dispose() { final listener = _routeAnimationListener; if (listener != null) _routeAnimation?.removeStatusListener(listener); + WebPushService.instance.changes.removeListener(_onServiceChanged); _passwordController.dispose(); _passwordFocus.dispose(); WebPushService.instance.cancelAuth(); @@ -58,10 +67,11 @@ class _WebPushScreenState extends State { Future _reload() async { final service = WebPushService.instance; final authorized = await service.isAuthorized(); - final endpoint = await service.linkedEndpoint(); + final link = await service.linkInfo(); if (!mounted) return; setState(() { - _linked = endpoint != null && endpoint.isNotEmpty; + _link = link; + _linked = link != null; _stage = authorized ? _Stage.ready : _Stage.intro; }); } @@ -152,15 +162,28 @@ class _WebPushScreenState extends State { setState(() => _stage = _Stage.ready); } - Future _signOut() => _run(() async { - await WebPushService.instance.signOut(); - if (!mounted) return; - setState(() { - _linked = false; - _trackId = null; - _stage = _Stage.intro; + Future _signOut() async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showConfirmDialog( + context, + title: l10n.webPushSignOut, + message: l10n.webPushSignOutConfirm, + confirmLabel: l10n.webPushSignOutAction, + destructive: true, + ); + if (!confirmed || !mounted) return; + + await _run(() async { + await WebPushService.instance.signOut(); + if (!mounted) return; + setState(() { + _linked = false; + _link = null; + _trackId = null; + _stage = _Stage.intro; + }); }); - }); + } @override Widget build(BuildContext context) { @@ -243,6 +266,10 @@ class _WebPushScreenState extends State { fontSize: 14, ), _explainer(cs, _linked ? l10n.webPushLinkedBody : l10n.webPushInstallBody), + if (_link != null) ...[ + const SizedBox(height: 12), + _linkDetails(cs, l10n, _link!), + ], const SizedBox(height: 20), _primary(l10n.webPushOpenSite, () { Haptics.tap(); @@ -263,6 +290,53 @@ class _WebPushScreenState extends State { ], }; + Widget _detailRow(ColorScheme cs, String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 104, + child: Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 13, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ); + + Widget _linkDetails( + ColorScheme cs, + AppLocalizations l10n, + WebPushLinkInfo link, + ) => SettingsPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _detailRow(cs, l10n.webPushStatusService, link.host), + _detailRow(cs, l10n.webPushStatusToken, link.shortEndpoint), + if (link.linkedAt != null) + _detailRow( + cs, + l10n.webPushStatusLinkedAt, + formatDateTimeWords(link.linkedAt!), + ), + _detailRow(cs, l10n.webPushStatusDevice, link.deviceId), + ], + ), + ); + Widget _explainer(ColorScheme cs, String text) => SettingsPanel( child: Text( text, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b6c0d76..d747053 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1209,5 +1209,11 @@ "webPushNotAuthorized": "Sign in under \"Notifications via PWA\" first", "webPushConnect": "Connect notifications", "webPushWaitingBody": "Komet is approving the web session from this device. This usually takes a few seconds.", - "webPushNeedsOnline": "No connection to the server. Wait for it and try again." + "webPushNeedsOnline": "No connection to the server. Wait for it and try again.", + "webPushSignOutConfirm": "The web session will be terminated and disappear from your device list. To get notifications back you will have to connect again.", + "webPushSignOutAction": "Disconnect", + "webPushStatusService": "Service", + "webPushStatusToken": "Token", + "webPushStatusLinkedAt": "Linked", + "webPushStatusDevice": "Device" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 28b5f18..70a88cb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5317,6 +5317,42 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'No connection to the server. Wait for it and try again.'** String get webPushNeedsOnline; + + /// No description provided for @webPushSignOutConfirm. + /// + /// In en, this message translates to: + /// **'The web session will be terminated and disappear from your device list. To get notifications back you will have to connect again.'** + String get webPushSignOutConfirm; + + /// No description provided for @webPushSignOutAction. + /// + /// In en, this message translates to: + /// **'Disconnect'** + String get webPushSignOutAction; + + /// No description provided for @webPushStatusService. + /// + /// In en, this message translates to: + /// **'Service'** + String get webPushStatusService; + + /// No description provided for @webPushStatusToken. + /// + /// In en, this message translates to: + /// **'Token'** + String get webPushStatusToken; + + /// No description provided for @webPushStatusLinkedAt. + /// + /// In en, this message translates to: + /// **'Linked'** + String get webPushStatusLinkedAt; + + /// No description provided for @webPushStatusDevice. + /// + /// In en, this message translates to: + /// **'Device'** + String get webPushStatusDevice; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4d897a6..b9231ca 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2806,4 +2806,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get webPushNeedsOnline => 'No connection to the server. Wait for it and try again.'; + + @override + String get webPushSignOutConfirm => + 'The web session will be terminated and disappear from your device list. To get notifications back you will have to connect again.'; + + @override + String get webPushSignOutAction => 'Disconnect'; + + @override + String get webPushStatusService => 'Service'; + + @override + String get webPushStatusToken => 'Token'; + + @override + String get webPushStatusLinkedAt => 'Linked'; + + @override + String get webPushStatusDevice => 'Device'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index f6fb0d8..11b065d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2818,4 +2818,23 @@ class AppLocalizationsRu extends AppLocalizations { @override String get webPushNeedsOnline => 'Нет связи с сервером. Дождитесь подключения и попробуйте снова.'; + + @override + String get webPushSignOutConfirm => + 'Веб-сессия будет завершена и исчезнет из списка устройств. Чтобы вернуть уведомления, подключение придётся пройти заново.'; + + @override + String get webPushSignOutAction => 'Отключить'; + + @override + String get webPushStatusService => 'Сервис'; + + @override + String get webPushStatusToken => 'Токен'; + + @override + String get webPushStatusLinkedAt => 'Привязан'; + + @override + String get webPushStatusDevice => 'Устройство'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 913432b..bbf613b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -953,5 +953,11 @@ "webPushNotAuthorized": "Сначала войдите в разделе «Уведомления через PWA»", "webPushConnect": "Подключить уведомления", "webPushWaitingBody": "Комет подтверждает вход веб-сессии с этого устройства. Обычно занимает несколько секунд.", - "webPushNeedsOnline": "Нет связи с сервером. Дождитесь подключения и попробуйте снова." + "webPushNeedsOnline": "Нет связи с сервером. Дождитесь подключения и попробуйте снова.", + "webPushSignOutConfirm": "Веб-сессия будет завершена и исчезнет из списка устройств. Чтобы вернуть уведомления, подключение придётся пройти заново.", + "webPushSignOutAction": "Отключить", + "webPushStatusService": "Сервис", + "webPushStatusToken": "Токен", + "webPushStatusLinkedAt": "Привязан", + "webPushStatusDevice": "Устройство" }