Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
393 lines
11 KiB
Dart
393 lines
11 KiB
Dart
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<int> changes = ValueNotifier<int>(0);
|
|
|
|
void _notifyChanged() => changes.value++;
|
|
|
|
Future<bool> isAuthorized() async =>
|
|
(await TokenStorage.readSecure(_tokenKey))?.isNotEmpty ?? false;
|
|
|
|
Future<String?> linkedEndpoint() => TokenStorage.readSecure(_endpointKey);
|
|
|
|
Future<WebPushLinkInfo?> 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<String> 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<MaxWebDevice> 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<WebPushQrTrack> 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<WebPushAuthStep> awaitApproval(WebPushQrTrack track) async {
|
|
final socket = _requireSocket();
|
|
final deadline = DateTime.now().add(track.lifetime);
|
|
|
|
while (DateTime.now().isBefore(deadline)) {
|
|
await Future<void>.delayed(track.pollInterval);
|
|
|
|
final payload = _asMap(
|
|
await socket.request(_opcodeQrStatus, <String, Object?>{
|
|
'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, <String, Object?>{
|
|
'trackId': track.trackId,
|
|
}),
|
|
'завершение входа',
|
|
);
|
|
return _stepFrom(finish);
|
|
}
|
|
|
|
throw const MaxWebException('время подтверждения истекло');
|
|
}
|
|
|
|
Future<WebPushAuthStep> submitPassword(
|
|
String trackId,
|
|
String password,
|
|
) async {
|
|
final socket = _requireSocket();
|
|
final payload = _asMap(
|
|
await socket.request(Opcode.authLoginCheckPassword, <String, Object?>{
|
|
'trackId': trackId,
|
|
'password': password,
|
|
}),
|
|
'проверка пароля',
|
|
);
|
|
return _stepFrom(payload);
|
|
}
|
|
|
|
Future<void> finishAuth(String loginToken) async {
|
|
await TokenStorage.writeSecure(_tokenKey, loginToken);
|
|
await cancelAuth();
|
|
_notifyChanged();
|
|
logger.i('WebPush: WEB-сессия авторизована по QR');
|
|
}
|
|
|
|
Future<void> cancelAuth() async {
|
|
final socket = _authSocket;
|
|
_authSocket = null;
|
|
await socket?.close();
|
|
}
|
|
|
|
Future<void> 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, <String, Object?>{
|
|
'token': token,
|
|
'chatsCount': 0,
|
|
'interactive': false,
|
|
'chatsSync': 0,
|
|
'contactsSync': 0,
|
|
'presenceSync': -1,
|
|
'draftsSync': 0,
|
|
});
|
|
await socket.request(Opcode.config, <String, Object?>{
|
|
'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<void> 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<void> _terminateWebSession(String token, String? endpoint) async {
|
|
final socket = MaxWebSocketSession(device: await device());
|
|
try {
|
|
await socket.connect();
|
|
await socket.request(Opcode.login, <String, Object?>{
|
|
'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, <String, Object?>{
|
|
'subscribe': false,
|
|
'pushToken': endpoint,
|
|
'secretKey': '',
|
|
'publicKey': '',
|
|
});
|
|
} catch (e) {
|
|
logger.w('WebPush: подписку снять не удалось ($e)');
|
|
}
|
|
}
|
|
|
|
await socket.request(Opcode.logout, <String, Object?>{});
|
|
logger.i('WebPush: веб-сессия завершена');
|
|
} finally {
|
|
await socket.close();
|
|
}
|
|
}
|
|
|
|
MaxWebSocketSession _requireSocket() {
|
|
final socket = _authSocket;
|
|
if (socket == null) {
|
|
throw const MaxWebException('сессия входа потеряна, начните заново');
|
|
}
|
|
return socket;
|
|
}
|
|
|
|
WebPushAuthStep _stepFrom(Map<Object?, Object?> 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<Object?, Object?> _asMap(Object? payload, String step) {
|
|
if (payload is Map<Object?, Object?>) return payload;
|
|
throw MaxWebException('$step: неожиданный ответ сервера');
|
|
}
|
|
|
|
Duration _durationFrom(Object? value, Duration fallback) {
|
|
if (value is int && value > 0) return Duration(milliseconds: value);
|
|
return fallback;
|
|
}
|
|
|
|
Future<String> _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';
|
|
}
|
|
}
|