import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:device_info_plus/device_info_plus.dart'; import '../protocol/opcode_map.dart'; import '../storage/token_storage.dart'; import '../utils/ids.dart'; import '../utils/logger.dart'; import 'max_web_socket.dart'; class WebPushSubscription { final String endpoint; final String publicKey; final String authKey; const WebPushSubscription({ required this.endpoint, required this.publicKey, required this.authKey, }); } 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; final Duration pollInterval; final Duration lifetime; const WebPushQrTrack({ required this.trackId, required this.qrLink, required this.pollInterval, required this.lifetime, }); } class WebPushPasswordChallenge { final String trackId; final String? hint; const WebPushPasswordChallenge({required this.trackId, this.hint}); } class WebPushAuthStep { final String? loginToken; final WebPushPasswordChallenge? passwordChallenge; const WebPushAuthStep({this.loginToken, this.passwordChallenge}); bool get needsPassword => loginToken == null && passwordChallenge != null; } class WebPushService { WebPushService._(); static final WebPushService instance = WebPushService._(); static const int _opcodeQrCreate = 288; static const int _opcodeQrStatus = 289; static const int _opcodeQrFinish = 291; 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); static const Duration _defaultLifetime = Duration(minutes: 2); 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; final generated = uuidV4(); await TokenStorage.writeSecure(_deviceIdKey, generated); return generated; } Future device() async { final cached = _device; if (cached != null) return cached; final built = MaxWebDevice( deviceId: await deviceId(), appVersion: _appVersion, userAgent: await _safariUserAgent(), screen: _browserScreen(), ); _device = built; return built; } Future startQrAuth() async { await cancelAuth(); final socket = MaxWebSocketSession(device: await device()); await socket.connect(); _authSocket = socket; final payload = _asMap( await socket.request(_opcodeQrCreate, null), 'создание QR', ); final trackId = payload['trackId']; final qrLink = payload['qrLink']; if (trackId is! String || qrLink is! String) { throw const MaxWebException('сервер не вернул ссылку для входа'); } return WebPushQrTrack( trackId: trackId, qrLink: qrLink, pollInterval: _durationFrom(payload['pollingInterval'], _defaultPoll), lifetime: _durationFrom(payload['ttl'], _defaultLifetime), ); } Future awaitApproval(WebPushQrTrack track) async { final socket = _requireSocket(); final deadline = DateTime.now().add(track.lifetime); while (DateTime.now().isBefore(deadline)) { await Future.delayed(track.pollInterval); final payload = _asMap( await socket.request(_opcodeQrStatus, { 'trackId': track.trackId, }), 'опрос входа', ); final status = payload['status']; final available = status is Map ? status['loginAvailable'] : null; if (available != true) continue; final finish = _asMap( await socket.request(_opcodeQrFinish, { 'trackId': track.trackId, }), 'завершение входа', ); return _stepFrom(finish); } throw const MaxWebException('время подтверждения истекло'); } Future submitPassword( String trackId, String password, ) async { final socket = _requireSocket(); final payload = _asMap( await socket.request(Opcode.authLoginCheckPassword, { 'trackId': trackId, 'password': password, }), 'проверка пароля', ); return _stepFrom(payload); } Future finishAuth(String loginToken) async { await TokenStorage.writeSecure(_tokenKey, loginToken); await cancelAuth(); _notifyChanged(); logger.i('WebPush: WEB-сессия авторизована по QR'); } Future cancelAuth() async { final socket = _authSocket; _authSocket = null; await socket?.close(); } Future registerSubscription(WebPushSubscription subscription) async { final token = await TokenStorage.readSecure(_tokenKey); if (token == null || token.isEmpty) { throw const MaxWebException( 'сначала подключите уведомления в настройках', ); } 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, }); await socket.request(Opcode.config, { 'subscribe': true, 'pushToken': subscription.endpoint, 'secretKey': subscription.authKey, 'publicKey': subscription.publicKey, }); await TokenStorage.writeSecure(_endpointKey, subscription.endpoint); await TokenStorage.writeSecure( _linkedAtKey, DateTime.now().toIso8601String(), ); _notifyChanged(); logger.i('WebPush: подписка зарегистрирована'); } finally { await socket.close(); } } 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() { final socket = _authSocket; if (socket == null) { throw const MaxWebException('сессия входа потеряна, начните заново'); } return socket; } WebPushAuthStep _stepFrom(Map payload) { final attrs = payload['tokenAttrs']; if (attrs is Map) { final login = attrs['LOGIN']; if (login is Map) { final token = login['token']; if (token is String && token.isNotEmpty) { return WebPushAuthStep(loginToken: token); } } } final challenge = payload['passwordChallenge']; if (challenge is Map) { final trackId = challenge['trackId']; if (trackId is String && trackId.isNotEmpty) { final hint = challenge['hint']; return WebPushAuthStep( passwordChallenge: WebPushPasswordChallenge( trackId: trackId, hint: hint is String && hint.isNotEmpty ? hint : null, ), ); } } throw const MaxWebException('сервер не вернул ни токен, ни запрос пароля'); } Map _asMap(Object? payload, String step) { if (payload is Map) return payload; throw MaxWebException('$step: неожиданный ответ сервера'); } Duration _durationFrom(Object? value, Duration fallback) { if (value is int && value > 0) return Duration(milliseconds: value); return fallback; } Future _safariUserAgent() async { var release = '18_5'; var version = '18.5'; if (Platform.isIOS) { try { final info = await DeviceInfoPlugin().iosInfo; final systemVersion = info.systemVersion; if (systemVersion.isNotEmpty) { version = systemVersion; release = systemVersion.replaceAll('.', '_'); } } catch (e) { logger.w('WebPush: не удалось прочитать версию iOS ($e)'); } } return 'Mozilla/5.0 (iPhone; CPU iPhone OS $release like Mac OS X) ' 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/$version ' 'Mobile/15E148 Safari/604.1'; } String _browserScreen() { final view = PlatformDispatcher.instance.views.first; final ratio = view.devicePixelRatio; final height = (view.physicalSize.height / ratio).round(); final width = (view.physicalSize.width / ratio).round(); return '${height}x$width ${ratio.toStringAsFixed(1)}x'; } }