feat: уведомления на iOS через PWA

This commit is contained in:
klockky
2026-08-21 21:49:52 +03:00
parent 6ce3ddce30
commit 5ac407fb76
14 changed files with 1730 additions and 38 deletions
+80
View File
@@ -1,8 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform;
import 'package:app_links/app_links.dart'; import 'package:app_links/app_links.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '../../l10n/app_localizations.dart';
import '../../backend/api.dart'; import '../../backend/api.dart';
import '../../frontend/debug/log_export.dart'; import '../../frontend/debug/log_export.dart';
import '../../frontend/screens/digital_id/digital_id_web_screen.dart'; import '../../frontend/screens/digital_id/digital_id_web_screen.dart';
@@ -10,6 +13,8 @@ import '../../frontend/widgets/custom_notification.dart';
import '../../frontend/widgets/max_link_handler.dart'; import '../../frontend/widgets/max_link_handler.dart';
import '../../frontend/widgets/swipe_route.dart'; import '../../frontend/widgets/swipe_route.dart';
import '../../main.dart'; import '../../main.dart';
import '../webpush/max_web_socket.dart';
import '../webpush/web_push_service.dart';
import 'desktop_url_scheme.dart'; import 'desktop_url_scheme.dart';
import 'max_link.dart'; import 'max_link.dart';
@@ -24,8 +29,10 @@ class DeepLinkService {
String? _pending; String? _pending;
bool _pendingLogExport = false; bool _pendingLogExport = false;
String? _pendingExternalCallback; String? _pendingExternalCallback;
WebPushSubscription? _pendingWebPush;
String? _lastExternalCallback; String? _lastExternalCallback;
Timer? _externalCallbackRetry; Timer? _externalCallbackRetry;
Timer? _webPushRetry;
Timer? _logExportRetry; Timer? _logExportRetry;
bool _ready = false; bool _ready = false;
bool _started = false; bool _started = false;
@@ -58,6 +65,12 @@ class DeepLinkService {
_flushPending(); _flushPending();
return; return;
} }
final webPush = _parseWebPushLink(uri);
if (webPush != null) {
_pendingWebPush = webPush;
_flushPending();
return;
}
if (_isExternalCallback(uri)) { if (_isExternalCallback(uri)) {
final callbackUrl = uri.toString(); final callbackUrl = uri.toString();
if (callbackUrl == _lastExternalCallback) return; if (callbackUrl == _lastExternalCallback) return;
@@ -100,6 +113,19 @@ class DeepLinkService {
} }
} }
if (_pendingWebPush != null) {
if (context == null) {
_webPushRetry ??= Timer(const Duration(milliseconds: 300), () {
_webPushRetry = null;
_flushPending();
});
} else {
final subscription = _pendingWebPush!;
_pendingWebPush = null;
_handleWebPush(context, subscription);
}
}
if (!_ready || context == null) return; if (!_ready || context == null) return;
final pending = _pending; final pending = _pending;
if (pending == null) return; if (pending == null) return;
@@ -132,6 +158,58 @@ class DeepLinkService {
} }
} }
WebPushSubscription? _parseWebPushLink(Uri uri) {
if (!Platform.isIOS) return null;
if (uri.scheme.toLowerCase() != 'komet') return null;
final segments = <String>[
if (uri.host.isNotEmpty) uri.host,
...uri.pathSegments,
].where((s) => s.isNotEmpty).toList();
if (segments.length != 1 || segments.first != 'webpush') return null;
final endpoint = uri.queryParameters['endpoint'] ?? '';
final publicKey = uri.queryParameters['p256dh'] ?? '';
final authKey = uri.queryParameters['auth'] ?? '';
if (endpoint.isEmpty || publicKey.isEmpty || authKey.isEmpty) return null;
if (Uri.tryParse(endpoint)?.isScheme('https') != true) return null;
return WebPushSubscription(
endpoint: endpoint,
publicKey: publicKey,
authKey: authKey,
);
}
Future<void> _handleWebPush(
BuildContext context,
WebPushSubscription subscription,
) async {
final l10n = AppLocalizations.of(context)!;
if (!await WebPushService.instance.isAuthorized()) {
if (context.mounted) {
showCustomNotification(context, l10n.webPushNotAuthorized);
}
return;
}
try {
await WebPushService.instance.registerSubscription(subscription);
if (context.mounted) {
showCustomNotification(context, l10n.webPushLinked);
}
} on MaxWebException catch (e) {
if (context.mounted) {
showCustomNotification(context, l10n.webPushLinkFailed(e.message));
}
} catch (e) {
if (context.mounted) {
showCustomNotification(context, l10n.webPushLinkFailed('$e'));
}
}
}
bool _isLogExportLink(Uri uri) { bool _isLogExportLink(Uri uri) {
final scheme = uri.scheme.toLowerCase(); final scheme = uri.scheme.toLowerCase();
final host = uri.host.toLowerCase(); final host = uri.host.toLowerCase();
@@ -176,6 +254,8 @@ class DeepLinkService {
void dispose() { void dispose() {
_logExportRetry?.cancel(); _logExportRetry?.cancel();
_logExportRetry = null; _logExportRetry = null;
_webPushRetry?.cancel();
_webPushRetry = null;
_sub?.cancel(); _sub?.cancel();
_sub = null; _sub = null;
_stateSub?.cancel(); _stateSub?.cancel();
+453
View File
@@ -0,0 +1,453 @@
import 'dart:convert';
import 'dart:typed_data';
class MaxWebFrame {
final int cmd;
final int seq;
final int opcode;
final Object? payload;
const MaxWebFrame({
required this.cmd,
required this.seq,
required this.opcode,
this.payload,
});
bool get isOk => cmd == MaxWebCmd.ok;
bool get isError => cmd != MaxWebCmd.ok && cmd != MaxWebCmd.request;
}
abstract class MaxWebCmd {
static const int request = 0;
static const int ok = 1;
static const int notFound = 2;
static const int error = 3;
}
abstract class MaxWebFraming {
static const int protocolVersion = 10;
static const int headerSize = 10;
static Uint8List encode({
required int cmd,
required int seq,
required int opcode,
Object? payload,
}) {
final body = payload == null
? Uint8List(0)
: MaxMsgpack.encode(payload);
final frame = Uint8List(headerSize + body.length);
final view = ByteData.view(frame.buffer);
view.setUint8(0, protocolVersion);
view.setUint8(1, cmd);
view.setInt16(2, seq);
view.setInt16(4, opcode);
view.setUint8(6, 0);
view.setUint8(7, (body.length >> 16) & 0xFF);
view.setUint8(8, (body.length >> 8) & 0xFF);
view.setUint8(9, body.length & 0xFF);
frame.setRange(headerSize, frame.length, body);
return frame;
}
static MaxWebFrame decode(Uint8List frame) {
if (frame.length < headerSize) {
throw const FormatException('MaxWebFraming: кадр короче заголовка');
}
final view = ByteData.view(frame.buffer, frame.offsetInBytes, frame.length);
final cmd = view.getUint8(1);
final seq = view.getInt16(2);
final opcode = view.getInt16(4);
final compressionRatio = view.getUint8(6);
final length =
(view.getUint8(7) << 16) | (view.getUint8(8) << 8) | view.getUint8(9);
if (length <= 0) {
return MaxWebFrame(cmd: cmd, seq: seq, opcode: opcode);
}
var body = Uint8List.sublistView(frame, headerSize, headerSize + length);
if (compressionRatio > 0) {
body = Lz4Block.decompress(body, length * compressionRatio * 16);
}
return MaxWebFrame(
cmd: cmd,
seq: seq,
opcode: opcode,
payload: MaxMsgpack.decode(body),
);
}
}
abstract class Lz4Block {
static Uint8List decompress(Uint8List source, int maxOutputSize) {
final output = Uint8List(maxOutputSize);
var input = 0;
var written = 0;
while (input < source.length) {
final token = source[input++];
var literalLength = token >> 4;
if (literalLength == 15) {
literalLength += _readLengthExtension(source, () => input, (v) => input = v);
}
if (written + literalLength > output.length) {
throw const FormatException('Lz4Block: литералы не помещаются');
}
output.setRange(written, written + literalLength,
Uint8List.sublistView(source, input, input + literalLength));
written += literalLength;
input += literalLength;
if (input >= source.length) break;
final offset = source[input] | (source[input + 1] << 8);
input += 2;
if (offset == 0 || offset > written) {
throw const FormatException('Lz4Block: неверное смещение совпадения');
}
var matchLength = token & 0x0F;
if (matchLength == 15) {
matchLength += _readLengthExtension(source, () => input, (v) => input = v);
}
matchLength += 4;
if (written + matchLength > output.length) {
throw const FormatException('Lz4Block: совпадение не помещается');
}
var from = written - offset;
for (var i = 0; i < matchLength; i++) {
output[written++] = output[from++];
}
}
return Uint8List.sublistView(output, 0, written);
}
static int _readLengthExtension(
Uint8List source,
int Function() get,
void Function(int) set,
) {
var cursor = get();
var extra = 0;
while (true) {
if (cursor >= source.length) {
throw const FormatException('Lz4Block: обрыв в расширении длины');
}
final byte = source[cursor++];
extra += byte;
if (byte != 255) break;
}
set(cursor);
return extra;
}
}
abstract class MaxMsgpack {
static Uint8List encode(Object? value) {
final sink = BytesBuilder(copy: false);
_write(sink, value);
return sink.takeBytes();
}
static Object? decode(Uint8List bytes) => _Reader(bytes).read();
static void _write(BytesBuilder sink, Object? value) {
if (value == null) {
sink.addByte(0xC0);
} else if (value is bool) {
sink.addByte(value ? 0xC3 : 0xC2);
} else if (value is int) {
_writeInt(sink, value);
} else if (value is double) {
final buffer = ByteData(9)
..setUint8(0, 0xCB)
..setFloat64(1, value);
sink.add(buffer.buffer.asUint8List());
} else if (value is String) {
_writeString(sink, value);
} else if (value is Uint8List) {
_writeBinary(sink, value);
} else if (value is List) {
_writePrefix(sink, value.length, 0x90, 0xDC, 0xDD);
for (final item in value) {
_write(sink, item);
}
} else if (value is Map) {
_writePrefix(sink, value.length, 0x80, 0xDE, 0xDF);
value.forEach((key, item) {
_write(sink, key);
_write(sink, item);
});
} else {
throw ArgumentError('MaxMsgpack: неподдерживаемый тип ${value.runtimeType}');
}
}
static void _writePrefix(
BytesBuilder sink,
int length,
int fixBase,
int wide16,
int wide32,
) {
if (length < 16) {
sink.addByte(fixBase | length);
} else if (length < 0x10000) {
sink.addByte(wide16);
sink.add([(length >> 8) & 0xFF, length & 0xFF]);
} else {
sink.addByte(wide32);
sink.add([
(length >> 24) & 0xFF,
(length >> 16) & 0xFF,
(length >> 8) & 0xFF,
length & 0xFF,
]);
}
}
static void _writeString(BytesBuilder sink, String value) {
final utf8Bytes = utf8.encode(value);
final length = utf8Bytes.length;
if (length < 32) {
sink.addByte(0xA0 | length);
} else if (length < 0x100) {
sink.add([0xD9, length]);
} else if (length < 0x10000) {
sink.add([0xDA, (length >> 8) & 0xFF, length & 0xFF]);
} else {
sink.add([
0xDB,
(length >> 24) & 0xFF,
(length >> 16) & 0xFF,
(length >> 8) & 0xFF,
length & 0xFF,
]);
}
sink.add(utf8Bytes);
}
static void _writeBinary(BytesBuilder sink, Uint8List value) {
final length = value.length;
if (length < 0x100) {
sink.add([0xC4, length]);
} else if (length < 0x10000) {
sink.add([0xC5, (length >> 8) & 0xFF, length & 0xFF]);
} else {
sink.add([
0xC6,
(length >> 24) & 0xFF,
(length >> 16) & 0xFF,
(length >> 8) & 0xFF,
length & 0xFF,
]);
}
sink.add(value);
}
static void _writeInt(BytesBuilder sink, int value) {
if (value >= 0) {
if (value < 0x80) {
sink.addByte(value);
} else if (value < 0x100) {
sink.add([0xCC, value]);
} else if (value < 0x10000) {
sink.add([0xCD, (value >> 8) & 0xFF, value & 0xFF]);
} else if (value < 0x100000000) {
sink.add([
0xCE,
(value >> 24) & 0xFF,
(value >> 16) & 0xFF,
(value >> 8) & 0xFF,
value & 0xFF,
]);
} else {
final buffer = ByteData(9)
..setUint8(0, 0xCF)
..setUint64(1, value);
sink.add(buffer.buffer.asUint8List());
}
} else if (value >= -32) {
sink.addByte(0xE0 | (value + 32));
} else if (value >= -128) {
final buffer = ByteData(2)
..setUint8(0, 0xD0)
..setInt8(1, value);
sink.add(buffer.buffer.asUint8List());
} else if (value >= -32768) {
final buffer = ByteData(3)
..setUint8(0, 0xD1)
..setInt16(1, value);
sink.add(buffer.buffer.asUint8List());
} else if (value >= -2147483648) {
final buffer = ByteData(5)
..setUint8(0, 0xD2)
..setInt32(1, value);
sink.add(buffer.buffer.asUint8List());
} else {
final buffer = ByteData(9)
..setUint8(0, 0xD3)
..setInt64(1, value);
sink.add(buffer.buffer.asUint8List());
}
}
}
class _Reader {
_Reader(this._bytes) : _view = ByteData.view(
_bytes.buffer,
_bytes.offsetInBytes,
_bytes.length,
);
final Uint8List _bytes;
final ByteData _view;
int _cursor = 0;
Object? read() {
final byte = _u8();
if (byte <= 0x7F) return byte;
if (byte >= 0xE0) return byte - 256;
if (byte >= 0x80 && byte <= 0x8F) return _map(byte & 0x0F);
if (byte >= 0x90 && byte <= 0x9F) return _list(byte & 0x0F);
if (byte >= 0xA0 && byte <= 0xBF) return _string(byte & 0x1F);
switch (byte) {
case 0xC0:
return null;
case 0xC2:
return false;
case 0xC3:
return true;
case 0xC4:
return _binary(_u8());
case 0xC5:
return _binary(_u16());
case 0xC6:
return _binary(_u32());
case 0xCA:
final value = _view.getFloat32(_cursor);
_cursor += 4;
return value;
case 0xCB:
final value = _view.getFloat64(_cursor);
_cursor += 8;
return value;
case 0xCC:
return _u8();
case 0xCD:
return _u16();
case 0xCE:
return _u32();
case 0xCF:
final value = _view.getUint64(_cursor);
_cursor += 8;
return value;
case 0xD0:
final value = _view.getInt8(_cursor);
_cursor += 1;
return value;
case 0xD1:
final value = _view.getInt16(_cursor);
_cursor += 2;
return value;
case 0xD2:
final value = _view.getInt32(_cursor);
_cursor += 4;
return value;
case 0xD3:
final value = _view.getInt64(_cursor);
_cursor += 8;
return value;
case 0xD9:
return _string(_u8());
case 0xDA:
return _string(_u16());
case 0xDB:
return _string(_u32());
case 0xDC:
return _list(_u16());
case 0xDD:
return _list(_u32());
case 0xDE:
return _map(_u16());
case 0xDF:
return _map(_u32());
}
if (byte >= 0xD4 && byte <= 0xD8) return _ext(1 << (byte - 0xD4));
if (byte == 0xC7) return _ext(_u8());
if (byte == 0xC8) return _ext(_u16());
if (byte == 0xC9) return _ext(_u32());
throw FormatException('MaxMsgpack: неизвестный маркер 0x${byte.toRadixString(16)}');
}
static const int _numberExtType = 1;
Object? _ext(int length) {
final type = _u8();
final data = Uint8List.fromList(
Uint8List.sublistView(_bytes, _cursor, _cursor + length),
);
_cursor += length;
if (type != _numberExtType) return null;
return _Reader(data).read();
}
int _u8() => _bytes[_cursor++];
int _u16() {
final value = _view.getUint16(_cursor);
_cursor += 2;
return value;
}
int _u32() {
final value = _view.getUint32(_cursor);
_cursor += 4;
return value;
}
String _string(int length) {
final value = utf8.decode(
Uint8List.sublistView(_bytes, _cursor, _cursor + length),
allowMalformed: true,
);
_cursor += length;
return value;
}
Uint8List _binary(int length) {
final value = Uint8List.fromList(
Uint8List.sublistView(_bytes, _cursor, _cursor + length),
);
_cursor += length;
return value;
}
List<Object?> _list(int length) =>
List<Object?>.generate(length, (_) => read(), growable: false);
Map<Object?, Object?> _map(int length) {
final map = <Object?, Object?>{};
for (var i = 0; i < length; i++) {
final key = read();
map[key] = read();
}
return map;
}
}
+190
View File
@@ -0,0 +1,190 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import '../utils/logger.dart';
import 'max_web_protocol.dart';
class MaxWebException implements Exception {
final String message;
final String? code;
const MaxWebException(this.message, {this.code});
@override
String toString() => code == null ? message : '$code: $message';
}
class MaxWebDevice {
final String deviceId;
final String appVersion;
final String osVersion;
final String deviceName;
final String screen;
final String timezone;
final String locale;
final String userAgent;
const MaxWebDevice({
required this.deviceId,
this.appVersion = '26.8.8',
this.osVersion = 'iOS',
this.deviceName = 'Safari',
required this.screen,
this.timezone = 'Europe/Moscow',
this.locale = 'ru',
required this.userAgent,
});
Map<String, Object?> toHandshakeUserAgent() => <String, Object?>{
'deviceType': 'WEB',
'pushDeviceType': 'WEBPUSH',
'locale': locale,
'deviceLocale': locale,
'osVersion': osVersion,
'deviceName': deviceName,
'headerUserAgent': userAgent,
'isPwa': true,
'appVersion': appVersion,
'screen': screen,
'timezone': timezone,
};
}
class MaxWebSocketSession {
static const String endpoint = 'wss://api.oneme.ru/websocket';
static const String origin = 'https://web.max.ru';
static const Duration requestTimeout = Duration(seconds: 30);
static const int _opcodePing = 1;
static const int _opcodeSessionInit = 6;
final MaxWebDevice device;
WebSocket? _socket;
StreamSubscription<dynamic>? _subscription;
final Map<int, Completer<Object?>> _pending = <int, Completer<Object?>>{};
int _seq = 0;
bool _closed = false;
MaxWebSocketSession({required this.device});
Future<Object?> connect() async {
final socket = await WebSocket.connect(
endpoint,
headers: <String, Object>{
'Origin': origin,
'User-Agent': device.userAgent,
},
);
_socket = socket;
_subscription = socket.listen(
_onFrame,
onError: (Object error) => _failAll(MaxWebException('сокет: $error')),
onDone: () => _failAll(const MaxWebException('соединение закрыто сервером')),
cancelOnError: true,
);
return request(_opcodeSessionInit, <String, Object?>{
'userAgent': device.toHandshakeUserAgent(),
'deviceId': device.deviceId,
});
}
Future<Object?> request(int opcode, Object? payload) {
final socket = _socket;
if (socket == null || _closed) {
return Future<Object?>.error(
const MaxWebException('сессия не подключена'),
);
}
_seq += 1;
final seq = _seq;
final completer = Completer<Object?>();
_pending[seq] = completer;
socket.add(
MaxWebFraming.encode(
cmd: MaxWebCmd.request,
seq: seq,
opcode: opcode,
payload: payload,
),
);
return completer.future.timeout(
requestTimeout,
onTimeout: () {
_pending.remove(seq);
throw MaxWebException('опкод $opcode: сервер не ответил');
},
);
}
void _onFrame(dynamic raw) {
if (raw is! List<int>) return;
final MaxWebFrame frame;
try {
frame = MaxWebFraming.decode(Uint8List.fromList(raw));
} catch (e) {
logger.w('WebPush: не разобрал кадр ($e)');
return;
}
if (frame.cmd == MaxWebCmd.request) {
if (frame.opcode == _opcodePing) {
_socket?.add(
MaxWebFraming.encode(
cmd: MaxWebCmd.ok,
seq: frame.seq,
opcode: _opcodePing,
),
);
}
return;
}
final completer = _pending.remove(frame.seq);
if (completer == null || completer.isCompleted) return;
if (frame.isError) {
completer.completeError(_errorFrom(frame));
return;
}
completer.complete(frame.payload);
}
MaxWebException _errorFrom(MaxWebFrame frame) {
final payload = frame.payload;
if (payload is Map) {
final message = payload['localizedMessage'] ?? payload['message'];
final code = payload['error'];
if (message is String && message.isNotEmpty) {
return MaxWebException(message, code: code is String ? code : null);
}
if (code is String && code.isNotEmpty) {
return MaxWebException(code, code: code);
}
}
return MaxWebException('опкод ${frame.opcode}: ошибка сервера (cmd=${frame.cmd})');
}
void _failAll(MaxWebException error) {
for (final completer in _pending.values) {
if (!completer.isCompleted) completer.completeError(error);
}
_pending.clear();
}
Future<void> close() async {
if (_closed) return;
_closed = true;
_failAll(const MaxWebException('сессия закрыта'));
await _subscription?.cancel();
_subscription = null;
await _socket?.close();
_socket = null;
}
}
+296
View File
@@ -0,0 +1,296 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:ui' show PlatformDispatcher;
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 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 _appVersion = '26.8.8';
static const Duration _defaultPoll = Duration(seconds: 5);
static const Duration _defaultLifetime = Duration(minutes: 2);
MaxWebSocketSession? _authSocket;
MaxWebDevice? _device;
Future<bool> isAuthorized() async =>
(await TokenStorage.readSecure(_tokenKey))?.isNotEmpty ?? false;
Future<String?> linkedEndpoint() => TokenStorage.readSecure(_endpointKey);
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();
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);
logger.i('WebPush: подписка зарегистрирована');
} finally {
await socket.close();
}
}
Future<void> signOut() async {
await cancelAuth();
await TokenStorage.deleteSecure(_tokenKey);
await TokenStorage.deleteSecure(_endpointKey);
}
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';
}
}
@@ -15,6 +15,7 @@ import '../../widgets/custom_notification.dart';
import '../../widgets/section_header.dart'; import '../../widgets/section_header.dart';
import '../../widgets/settings_card.dart'; import '../../widgets/settings_card.dart';
import '../../widgets/small_spinner.dart'; import '../../widgets/small_spinner.dart';
import 'web_push_screen.dart';
class NotificationsScreen extends StatefulWidget { class NotificationsScreen extends StatefulWidget {
const NotificationsScreen({super.key}); const NotificationsScreen({super.key});
@@ -89,6 +90,12 @@ class _NotificationsScreenState extends State<NotificationsScreen>
if (mounted) setState(() => _hapticsEnabled = value); if (mounted) setState(() => _hapticsEnabled = value);
} }
void _openWebPush() {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const WebPushScreen()),
);
}
Future<void> _onFkmChanged(bool value) async { Future<void> _onFkmChanged(bool value) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
if (!FkmController.instance.isSupported) { if (!FkmController.instance.isSupported) {
@@ -159,6 +166,19 @@ class _NotificationsScreenState extends State<NotificationsScreen>
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
children: [ children: [
if (Platform.isIOS) ...[
SettingsCard(
children: [
SettingsNavTile(
icon: Symbols.install_mobile,
label: l10n.webPushTitle,
onTap: _openWebPush,
isLast: true,
),
],
),
const SizedBox(height: 20),
],
SectionHeader( SectionHeader(
l10n.notificationsFkmSectionTitle, l10n.notificationsFkmSectionTitle,
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
@@ -0,0 +1,285 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/api.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/connection_status.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/section_header.dart';
import '../../widgets/settings_card.dart';
import '../../widgets/small_spinner.dart';
const String kWebPushSiteUrl = 'https://push.komet.pw';
enum _Stage { loading, intro, waiting, password, ready }
class WebPushScreen extends StatefulWidget {
const WebPushScreen({super.key});
@override
State<WebPushScreen> createState() => _WebPushScreenState();
}
class _WebPushScreenState extends State<WebPushScreen> {
final _passwordController = TextEditingController();
final _passwordFocus = FocusNode();
Animation<double>? _routeAnimation;
void Function(AnimationStatus)? _routeAnimationListener;
_Stage _stage = _Stage.loading;
bool _busy = false;
bool _linked = false;
String? _trackId;
String? _passwordHint;
@override
void initState() {
super.initState();
_reload();
}
@override
void dispose() {
final listener = _routeAnimationListener;
if (listener != null) _routeAnimation?.removeStatusListener(listener);
_passwordController.dispose();
_passwordFocus.dispose();
WebPushService.instance.cancelAuth();
super.dispose();
}
Future<void> _reload() async {
final service = WebPushService.instance;
final authorized = await service.isAuthorized();
final endpoint = await service.linkedEndpoint();
if (!mounted) return;
setState(() {
_linked = endpoint != null && endpoint.isNotEmpty;
_stage = authorized ? _Stage.ready : _Stage.intro;
});
}
void _scheduleKeyboard() {
final animation = ModalRoute.of(context)?.animation;
if (animation == null || animation.status == AnimationStatus.completed) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _openKeyboard();
});
return;
}
final previous = _routeAnimationListener;
if (previous != null) _routeAnimation?.removeStatusListener(previous);
_routeAnimation = animation;
_routeAnimationListener = (status) {
if (status != AnimationStatus.completed) return;
final listener = _routeAnimationListener;
if (listener != null) animation.removeStatusListener(listener);
_routeAnimationListener = null;
if (mounted) _openKeyboard();
};
animation.addStatusListener(_routeAnimationListener!);
}
void _openKeyboard() {
if (!_passwordFocus.hasFocus) _passwordFocus.requestFocus();
SystemChannels.textInput.invokeMethod<void>('TextInput.show');
}
Future<void> _run(Future<void> Function() action) async {
if (_busy) return;
setState(() => _busy = true);
try {
await action();
} on MaxWebException catch (e) {
if (mounted) showCustomNotification(context, e.message);
} catch (e) {
if (mounted) showCustomNotification(context, '$e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _connect() => _run(() async {
final l10n = AppLocalizations.of(context)!;
if (api.state != SessionState.online) {
showCustomNotification(context, l10n.webPushNeedsOnline);
return;
}
final service = WebPushService.instance;
final track = await service.startQrAuth();
if (!mounted) return;
setState(() => _stage = _Stage.waiting);
await accountModule.authorizeWebQrLogin(track.qrLink);
final step = await service.awaitApproval(track);
await _applyStep(step);
});
Future<void> _submitPassword() => _run(() async {
final trackId = _trackId;
if (trackId == null) return;
final step = await WebPushService.instance.submitPassword(
trackId,
_passwordController.text,
);
await _applyStep(step);
});
Future<void> _applyStep(WebPushAuthStep step) async {
if (step.needsPassword) {
if (!mounted) return;
_trackId = step.passwordChallenge!.trackId;
_passwordHint = step.passwordChallenge!.hint;
setState(() => _stage = _Stage.password);
_scheduleKeyboard();
return;
}
await WebPushService.instance.finishAuth(step.loginToken!);
if (!mounted) return;
_passwordController.clear();
setState(() => _stage = _Stage.ready);
}
Future<void> _signOut() => _run(() async {
await WebPushService.instance.signOut();
if (!mounted) return;
setState(() {
_linked = false;
_trackId = null;
_stage = _Stage.intro;
});
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: cs.surface,
appBar: ConnectionTitleBar(
titleText: l10n.webPushTitle,
backgroundColor: cs.surface,
),
body: SafeArea(
top: false,
child: _stage == _Stage.loading
? const Center(child: SmallSpinner(size: 36))
: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
children: _sections(context, cs, l10n),
),
),
);
}
List<Widget> _sections(
BuildContext context,
ColorScheme cs,
AppLocalizations l10n,
) => switch (_stage) {
_Stage.loading => const [],
_Stage.intro => [
_explainer(cs, l10n.webPushIntro),
const SizedBox(height: 20),
_primary(l10n.webPushConnect, _connect),
],
_Stage.waiting => [
_explainer(cs, l10n.webPushWaitingBody),
const SizedBox(height: 24),
const Center(child: SmallSpinner(size: 32)),
],
_Stage.password => [
_explainer(cs, l10n.webPushPasswordExplainer),
if (_passwordHint != null) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
l10n.webPushPasswordHintLabel(_passwordHint!),
style: TextStyle(color: cs.tertiary, fontSize: 14),
),
),
],
const SizedBox(height: 20),
TextField(
controller: _passwordController,
focusNode: _passwordFocus,
enabled: !_busy,
autofocus: true,
obscureText: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submitPassword(),
decoration: InputDecoration(
hintText: l10n.webPushPasswordHint,
filled: true,
fillColor: cs.surfaceContainerHigh,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 16),
_primary(l10n.webPushConfirm, _submitPassword),
],
_Stage.ready => [
SectionHeader(
_linked ? l10n.webPushLinkedTitle : l10n.webPushInstallTitle,
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
fontSize: 14,
),
_explainer(cs, _linked ? l10n.webPushLinkedBody : l10n.webPushInstallBody),
const SizedBox(height: 20),
_primary(l10n.webPushOpenSite, () {
Haptics.tap();
openExternalUrl(context, kWebPushSiteUrl);
}),
const SizedBox(height: 24),
SettingsCard(
children: [
SettingsNavTile(
icon: Symbols.logout,
label: l10n.webPushSignOut,
tintColor: cs.error,
onTap: _busy ? null : _signOut,
isLast: true,
),
],
),
],
};
Widget _explainer(ColorScheme cs, String text) => SettingsPanel(
child: Text(
text,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14, height: 1.5),
),
);
Widget _primary(String label, VoidCallback? onPressed) => SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _busy ? null : onPressed,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _busy
? const SmallSpinner(size: 20)
: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
),
);
}
+33 -12
View File
@@ -50,7 +50,6 @@
"codeResendSms": "Resend code via SMS", "codeResendSms": "Resend code via SMS",
"codeError2faMissing": "Error: missing data for 2FA", "codeError2faMissing": "Error: missing data for 2FA",
"codeConfirmation2faWarning": "MAX may require 2FA on your account to sign in. If you didn't receive the code, set up 2FA from a client where you're already signed in.", "codeConfirmation2faWarning": "MAX may require 2FA on your account to sign in. If you didn't receive the code, set up 2FA from a client where you're already signed in.",
"proxySettingsTitle": "Proxy", "proxySettingsTitle": "Proxy",
"proxyTypeNone": "Disabled", "proxyTypeNone": "Disabled",
"proxyTypeSocks5": "SOCKS5", "proxyTypeSocks5": "SOCKS5",
@@ -63,7 +62,6 @@
"proxyDisable": "Disable proxy", "proxyDisable": "Disable proxy",
"proxySettingsSaved": "Proxy settings applied", "proxySettingsSaved": "Proxy settings applied",
"proxyInvalidHostOrPort": "Enter a valid proxy host and port (165535)", "proxyInvalidHostOrPort": "Enter a valid proxy host and port (165535)",
"spoofScreenTitle": "Session spoofing", "spoofScreenTitle": "Session spoofing",
"spoofEnableTitle": "Device spoofing", "spoofEnableTitle": "Device spoofing",
"spoofEnableSubtitleOn": "Enabled for this account", "spoofEnableSubtitleOn": "Enabled for this account",
@@ -207,7 +205,6 @@
"chatInfoHasBots": "has bots:", "chatInfoHasBots": "has bots:",
"chatInfoBlockedCount": "blocked in group:", "chatInfoBlockedCount": "blocked in group:",
"chatInfoOfficialStatus": "official status:", "chatInfoOfficialStatus": "official status:",
"chatInfoLastChanged": "last changed:",
"chatInfoJoined": "joined:", "chatInfoJoined": "joined:",
"chatInfoGroupCreated": "group created:", "chatInfoGroupCreated": "group created:",
"chatInfoGroupOwner": "group owner:", "chatInfoGroupOwner": "group owner:",
@@ -375,7 +372,6 @@
"appearancePreviewHowIsIt": "How do you like it?", "appearancePreviewHowIsIt": "How do you like it?",
"appearancePreviewHmm": "hmm...", "appearancePreviewHmm": "hmm...",
"appearancePreviewNotBad": "Not bad at all!", "appearancePreviewNotBad": "Not bad at all!",
"callKometDetectedNotification": "This person uses Komet! :3", "callKometDetectedNotification": "This person uses Komet! :3",
"callStatusConnecting": "Connecting", "callStatusConnecting": "Connecting",
"callGroupConnecting": "Connecting…", "callGroupConnecting": "Connecting…",
@@ -445,7 +441,6 @@
"callBadgeNoiseSuppression": "Noise suppression", "callBadgeNoiseSuppression": "Noise suppression",
"callBadgeAnimoji": "Animoji", "callBadgeAnimoji": "Animoji",
"callInfoNoDataYet": "Data will appear after connecting…", "callInfoNoDataYet": "Data will appear after connecting…",
"hubTitleMenu": "Komet", "hubTitleMenu": "Komet",
"hubChatPageTitle": "Anonymous chat", "hubChatPageTitle": "Anonymous chat",
"hubGamesTitle": "Games", "hubGamesTitle": "Games",
@@ -466,7 +461,6 @@
"hubCheckersLost": "You lost", "hubCheckersLost": "You lost",
"hubCheckersYourMove": "Your move", "hubCheckersYourMove": "Your move",
"hubCheckersOpponentMove": "Opponent's move…", "hubCheckersOpponentMove": "Opponent's move…",
"scheduledPickTimeTitle": "When to send", "scheduledPickTimeTitle": "When to send",
"scheduledEditTitle": "Edit", "scheduledEditTitle": "Edit",
"scheduledMessageTextHint": "Message text", "scheduledMessageTextHint": "Message text",
@@ -485,7 +479,6 @@
"scheduledAttachLocation": "Location", "scheduledAttachLocation": "Location",
"scheduledAttachForwarded": "Forwarded", "scheduledAttachForwarded": "Forwarded",
"scheduledAttachGeneric": "Attachment", "scheduledAttachGeneric": "Attachment",
"contactProfileLoadError": "Error: {error}", "contactProfileLoadError": "Error: {error}",
"@contactProfileLoadError": { "@contactProfileLoadError": {
"placeholders": { "placeholders": {
@@ -510,7 +503,6 @@
"contactProfileInfoDescription": "Description", "contactProfileInfoDescription": "Description",
"contactProfileInfoLink": "Link", "contactProfileInfoLink": "Link",
"contactProfileInfoFlags": "Flags", "contactProfileInfoFlags": "Flags",
"nfcPeerNameFallback": "Contact #{id}", "nfcPeerNameFallback": "Contact #{id}",
"@nfcPeerNameFallback": { "@nfcPeerNameFallback": {
"placeholders": { "placeholders": {
@@ -549,7 +541,6 @@
}, },
"nfcAdded": "Added", "nfcAdded": "Added",
"nfcAddContact": "Add contact", "nfcAddContact": "Add contact",
"chatInfoTabGeneralChats": "Common chats", "chatInfoTabGeneralChats": "Common chats",
"chatInfoTabMedia": "Media", "chatInfoTabMedia": "Media",
"chatInfoTabFiles": "Files", "chatInfoTabFiles": "Files",
@@ -747,7 +738,6 @@
"chatInfoRowComments": "Comments", "chatInfoRowComments": "Comments",
"chatInfoRowRkn": "Roskomnadzor approved", "chatInfoRowRkn": "Roskomnadzor approved",
"chatInfoRowOnlyAdmin": "Admins only", "chatInfoRowOnlyAdmin": "Admins only",
"securityTitle": "Security", "securityTitle": "Security",
"securityLoadError": "Loading error: {error}", "securityLoadError": "Loading error: {error}",
"@securityLoadError": { "@securityLoadError": {
@@ -801,7 +791,6 @@
} }
} }
}, },
"passwordEntryWrongPassword": "Wrong password", "passwordEntryWrongPassword": "Wrong password",
"passwordEntryConfirmTitle": "Confirm password", "passwordEntryConfirmTitle": "Confirm password",
"passwordEntryCurrentPasswordHint": "Current password", "passwordEntryCurrentPasswordHint": "Current password",
@@ -1178,5 +1167,37 @@
"videoEditorProcessing": "Processing video…", "videoEditorProcessing": "Processing video…",
"videoEditorExportFailed": "Failed to process the video", "videoEditorExportFailed": "Failed to process the video",
"videoEditorFrameFailed": "Failed to grab a frame", "videoEditorFrameFailed": "Failed to grab a frame",
"videoEditorQualityTooltip": "Quality" "videoEditorQualityTooltip": "Quality",
"webPushTitle": "Notifications on iOS",
"webPushIntro": "Komet has no ordinary push on iOS: Apple issues a notification token only to apps signed with a developer certificate, and a sideloaded build never gets one.\n\nThe way around it is a web app on the Home Screen. MAX's own server sends the notifications through Apple, and a separate icon displays them.\n\nThat needs a web session. Komet creates one and approves it itself, from this very device — no phone number or code required.",
"webPushConfirm": "Continue",
"webPushPasswordExplainer": "Two-factor protection is enabled on this account.",
"webPushPasswordHintLabel": "Hint: {hint}",
"@webPushPasswordHintLabel": {
"placeholders": {
"hint": {
"type": "String"
}
}
},
"webPushPasswordHint": "Password",
"webPushInstallTitle": "Install the web app",
"webPushInstallBody": "Open push.komet.pw in Safari, add it to the Home Screen and launch the icon that appears. Notifications do not work from a browser tab — that is how iOS works.\n\nIn the app, allow notifications, create a subscription and tap \"Open Komet\". The subscription registers itself from there.",
"webPushLinkedTitle": "Notifications connected",
"webPushLinkedBody": "The subscription is registered on the server. Do not delete the Home Screen icon — the notifications go with it.\n\nIf push stops arriving, open the web app and link again: Apple sometimes rotates the subscription address.",
"webPushOpenSite": "Open push.komet.pw",
"webPushSignOut": "Disconnect notifications",
"webPushLinked": "Notifications connected",
"webPushLinkFailed": "Could not connect notifications: {error}",
"@webPushLinkFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"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."
} }
+108
View File
@@ -5149,6 +5149,114 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Quality'** /// **'Quality'**
String get videoEditorQualityTooltip; String get videoEditorQualityTooltip;
/// No description provided for @webPushTitle.
///
/// In en, this message translates to:
/// **'Notifications on iOS'**
String get webPushTitle;
/// No description provided for @webPushIntro.
///
/// In en, this message translates to:
/// **'Komet has no ordinary push on iOS: Apple issues a notification token only to apps signed with a developer certificate, and a sideloaded build never gets one.\n\nThe way around it is a web app on the Home Screen. MAX\'s own server sends the notifications through Apple, and a separate icon displays them.\n\nThat needs a web session. Komet creates one and approves it itself, from this very device — no phone number or code required.'**
String get webPushIntro;
/// No description provided for @webPushConfirm.
///
/// In en, this message translates to:
/// **'Continue'**
String get webPushConfirm;
/// No description provided for @webPushPasswordExplainer.
///
/// In en, this message translates to:
/// **'Two-factor protection is enabled on this account.'**
String get webPushPasswordExplainer;
/// No description provided for @webPushPasswordHintLabel.
///
/// In en, this message translates to:
/// **'Hint: {hint}'**
String webPushPasswordHintLabel(String hint);
/// No description provided for @webPushPasswordHint.
///
/// In en, this message translates to:
/// **'Password'**
String get webPushPasswordHint;
/// No description provided for @webPushInstallTitle.
///
/// In en, this message translates to:
/// **'Install the web app'**
String get webPushInstallTitle;
/// No description provided for @webPushInstallBody.
///
/// In en, this message translates to:
/// **'Open push.komet.pw in Safari, add it to the Home Screen and launch the icon that appears. Notifications do not work from a browser tab — that is how iOS works.\n\nIn the app, allow notifications, create a subscription and tap \"Open Komet\". The subscription registers itself from there.'**
String get webPushInstallBody;
/// No description provided for @webPushLinkedTitle.
///
/// In en, this message translates to:
/// **'Notifications connected'**
String get webPushLinkedTitle;
/// No description provided for @webPushLinkedBody.
///
/// In en, this message translates to:
/// **'The subscription is registered on the server. Do not delete the Home Screen icon — the notifications go with it.\n\nIf push stops arriving, open the web app and link again: Apple sometimes rotates the subscription address.'**
String get webPushLinkedBody;
/// No description provided for @webPushOpenSite.
///
/// In en, this message translates to:
/// **'Open push.komet.pw'**
String get webPushOpenSite;
/// No description provided for @webPushSignOut.
///
/// In en, this message translates to:
/// **'Disconnect notifications'**
String get webPushSignOut;
/// No description provided for @webPushLinked.
///
/// In en, this message translates to:
/// **'Notifications connected'**
String get webPushLinked;
/// No description provided for @webPushLinkFailed.
///
/// In en, this message translates to:
/// **'Could not connect notifications: {error}'**
String webPushLinkFailed(String error);
/// No description provided for @webPushNotAuthorized.
///
/// In en, this message translates to:
/// **'Sign in under \"Notifications via PWA\" first'**
String get webPushNotAuthorized;
/// No description provided for @webPushConnect.
///
/// In en, this message translates to:
/// **'Connect notifications'**
String get webPushConnect;
/// No description provided for @webPushWaitingBody.
///
/// In en, this message translates to:
/// **'Komet is approving the web session from this device. This usually takes a few seconds.'**
String get webPushWaitingBody;
/// No description provided for @webPushNeedsOnline.
///
/// In en, this message translates to:
/// **'No connection to the server. Wait for it and try again.'**
String get webPushNeedsOnline;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+65
View File
@@ -2706,4 +2706,69 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get videoEditorQualityTooltip => 'Quality'; String get videoEditorQualityTooltip => 'Quality';
@override
String get webPushTitle => 'Notifications on iOS';
@override
String get webPushIntro =>
'Komet has no ordinary push on iOS: Apple issues a notification token only to apps signed with a developer certificate, and a sideloaded build never gets one.\n\nThe way around it is a web app on the Home Screen. MAX\'s own server sends the notifications through Apple, and a separate icon displays them.\n\nThat needs a web session. Komet creates one and approves it itself, from this very device — no phone number or code required.';
@override
String get webPushConfirm => 'Continue';
@override
String get webPushPasswordExplainer =>
'Two-factor protection is enabled on this account.';
@override
String webPushPasswordHintLabel(String hint) {
return 'Hint: $hint';
}
@override
String get webPushPasswordHint => 'Password';
@override
String get webPushInstallTitle => 'Install the web app';
@override
String get webPushInstallBody =>
'Open push.komet.pw in Safari, add it to the Home Screen and launch the icon that appears. Notifications do not work from a browser tab — that is how iOS works.\n\nIn the app, allow notifications, create a subscription and tap \"Open Komet\". The subscription registers itself from there.';
@override
String get webPushLinkedTitle => 'Notifications connected';
@override
String get webPushLinkedBody =>
'The subscription is registered on the server. Do not delete the Home Screen icon — the notifications go with it.\n\nIf push stops arriving, open the web app and link again: Apple sometimes rotates the subscription address.';
@override
String get webPushOpenSite => 'Open push.komet.pw';
@override
String get webPushSignOut => 'Disconnect notifications';
@override
String get webPushLinked => 'Notifications connected';
@override
String webPushLinkFailed(String error) {
return 'Could not connect notifications: $error';
}
@override
String get webPushNotAuthorized =>
'Sign in under \"Notifications via PWA\" first';
@override
String get webPushConnect => 'Connect notifications';
@override
String get webPushWaitingBody =>
'Komet is approving the web session from this device. This usually takes a few seconds.';
@override
String get webPushNeedsOnline =>
'No connection to the server. Wait for it and try again.';
} }
+65
View File
@@ -2718,4 +2718,69 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get videoEditorQualityTooltip => 'Качество'; String get videoEditorQualityTooltip => 'Качество';
@override
String get webPushTitle => 'Уведомления на iOS';
@override
String get webPushIntro =>
'На iOS у Комета нет обычных пушей: Apple выдаёт токен уведомлений только приложениям, подписанным сертификатом разработчика, а sideload-сборка такого не получает.\n\nОбход — веб-приложение на экране «Домой». Уведомления шлёт сам сервер MAX через Apple, а показывает их отдельная иконка.\n\nДля этого нужна веб-сессия. Комет создаст её и подтвердит сам, с этого же устройства — вводить номер и код не придётся.';
@override
String get webPushConfirm => 'Продолжить';
@override
String get webPushPasswordExplainer =>
'На аккаунте включена двухфакторная защита.';
@override
String webPushPasswordHintLabel(String hint) {
return 'Подсказка: $hint';
}
@override
String get webPushPasswordHint => 'Пароль';
@override
String get webPushInstallTitle => 'Установите приложение';
@override
String get webPushInstallBody =>
'Откройте push.komet.pw в Safari, добавьте на экран «Домой» и запустите появившуюся иконку. Из вкладки браузера уведомления не работают — так устроена iOS.\n\nВ приложении разрешите уведомления, создайте подписку и нажмите «Открыть Комет». Дальше подписка зарегистрируется сама.';
@override
String get webPushLinkedTitle => 'Уведомления подключены';
@override
String get webPushLinkedBody =>
'Подписка зарегистрирована на сервере. Не удаляйте иконку с экрана «Домой» — вместе с ней пропадут уведомления.\n\nЕсли пуши перестанут приходить, откройте приложение и свяжите заново: Apple иногда меняет адрес подписки.';
@override
String get webPushOpenSite => 'Открыть push.komet.pw';
@override
String get webPushSignOut => 'Отключить уведомления';
@override
String get webPushLinked => 'Уведомления подключены';
@override
String webPushLinkFailed(String error) {
return 'Не удалось подключить уведомления: $error';
}
@override
String get webPushNotAuthorized =>
'Сначала войдите в разделе «Уведомления через PWA»';
@override
String get webPushConnect => 'Подключить уведомления';
@override
String get webPushWaitingBody =>
'Комет подтверждает вход веб-сессии с этого устройства. Обычно занимает несколько секунд.';
@override
String get webPushNeedsOnline =>
'Нет связи с сервером. Дождитесь подключения и попробуйте снова.';
} }
+33 -12
View File
@@ -50,7 +50,6 @@
"codeResendSms": "Отправить код по SMS", "codeResendSms": "Отправить код по SMS",
"codeError2faMissing": "Ошибка: отсутствуют данные для 2FA", "codeError2faMissing": "Ошибка: отсутствуют данные для 2FA",
"codeConfirmation2faWarning": "По умолчанию код приходит в МАХ. Если код не приходит по SMS - не заходите в Komet/MAX 30 минут, и попробуйте заново.", "codeConfirmation2faWarning": "По умолчанию код приходит в МАХ. Если код не приходит по SMS - не заходите в Komet/MAX 30 минут, и попробуйте заново.",
"proxySettingsTitle": "Прокси", "proxySettingsTitle": "Прокси",
"proxyTypeNone": "Выключен", "proxyTypeNone": "Выключен",
"proxyTypeSocks5": "SOCKS5", "proxyTypeSocks5": "SOCKS5",
@@ -63,7 +62,6 @@
"proxyDisable": "Отключить прокси", "proxyDisable": "Отключить прокси",
"proxySettingsSaved": "Настройки прокси применены", "proxySettingsSaved": "Настройки прокси применены",
"proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)", "proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)",
"spoofScreenTitle": "Подмена данных сессии", "spoofScreenTitle": "Подмена данных сессии",
"spoofEnableTitle": "Подмена устройства", "spoofEnableTitle": "Подмена устройства",
"spoofEnableSubtitleOn": "Включена для этого аккаунта", "spoofEnableSubtitleOn": "Включена для этого аккаунта",
@@ -200,7 +198,6 @@
"chatInfoHasBots": "Есть боты:", "chatInfoHasBots": "Есть боты:",
"chatInfoBlockedCount": "в ЧС группы:", "chatInfoBlockedCount": "в ЧС группы:",
"chatInfoOfficialStatus": "Официальный статус:", "chatInfoOfficialStatus": "Официальный статус:",
"chatInfoLastChanged": "последнее изменение:",
"chatInfoJoined": "Зашли в:", "chatInfoJoined": "Зашли в:",
"chatInfoGroupCreated": "Группа создана в:", "chatInfoGroupCreated": "Группа создана в:",
"chatInfoGroupOwner": "Создатель группы:", "chatInfoGroupOwner": "Создатель группы:",
@@ -333,7 +330,6 @@
"appearancePreviewHowIsIt": "Как тебе?", "appearancePreviewHowIsIt": "Как тебе?",
"appearancePreviewHmm": "хм...", "appearancePreviewHmm": "хм...",
"appearancePreviewNotBad": "Вполне неплохо!", "appearancePreviewNotBad": "Вполне неплохо!",
"callKometDetectedNotification": "Этот человек использует Komet! :3", "callKometDetectedNotification": "Этот человек использует Komet! :3",
"callStatusConnecting": "Соединение...", "callStatusConnecting": "Соединение...",
"callGroupConnecting": "Соединение...", "callGroupConnecting": "Соединение...",
@@ -396,7 +392,6 @@
"callBadgeNoiseSuppression": "Шумоподавление", "callBadgeNoiseSuppression": "Шумоподавление",
"callBadgeAnimoji": "Анимодзи", "callBadgeAnimoji": "Анимодзи",
"callInfoNoDataYet": "Данные появятся после соединения…", "callInfoNoDataYet": "Данные появятся после соединения…",
"hubTitleMenu": "Komet", "hubTitleMenu": "Komet",
"hubChatPageTitle": "Анонимный чат", "hubChatPageTitle": "Анонимный чат",
"hubGamesTitle": "Игры", "hubGamesTitle": "Игры",
@@ -417,7 +412,6 @@
"hubCheckersLost": "Вы проиграли", "hubCheckersLost": "Вы проиграли",
"hubCheckersYourMove": "Ваш ход", "hubCheckersYourMove": "Ваш ход",
"hubCheckersOpponentMove": "Ход соперника…", "hubCheckersOpponentMove": "Ход соперника…",
"scheduledPickTimeTitle": "Когда отправить", "scheduledPickTimeTitle": "Когда отправить",
"scheduledEditTitle": "Изменить", "scheduledEditTitle": "Изменить",
"scheduledMessageTextHint": "Текст сообщения", "scheduledMessageTextHint": "Текст сообщения",
@@ -436,7 +430,6 @@
"scheduledAttachLocation": "Геопозиция", "scheduledAttachLocation": "Геопозиция",
"scheduledAttachForwarded": "Переслано", "scheduledAttachForwarded": "Переслано",
"scheduledAttachGeneric": "Вложение", "scheduledAttachGeneric": "Вложение",
"contactProfileLoadError": "Ошибка: {error}", "contactProfileLoadError": "Ошибка: {error}",
"contactProfileBot": "Бот", "contactProfileBot": "Бот",
"contactProfileOnline": "В сети", "contactProfileOnline": "В сети",
@@ -454,7 +447,6 @@
"contactProfileInfoDescription": "Описание", "contactProfileInfoDescription": "Описание",
"contactProfileInfoLink": "Ссылка", "contactProfileInfoLink": "Ссылка",
"contactProfileInfoFlags": "Флаги", "contactProfileInfoFlags": "Флаги",
"nfcPeerNameFallback": "Контакт #{id}", "nfcPeerNameFallback": "Контакт #{id}",
"nfcPeerFirstNameFallback": "Контакт", "nfcPeerFirstNameFallback": "Контакт",
"nfcContactAdded": "Контакт добавлен", "nfcContactAdded": "Контакт добавлен",
@@ -472,7 +464,6 @@
"nfcPeerIdFallback": "ID {id}", "nfcPeerIdFallback": "ID {id}",
"nfcAdded": "Добавлено", "nfcAdded": "Добавлено",
"nfcAddContact": "Добавить контакт", "nfcAddContact": "Добавить контакт",
"chatInfoTabGeneralChats": "Общие чаты", "chatInfoTabGeneralChats": "Общие чаты",
"chatInfoTabMedia": "Медиа", "chatInfoTabMedia": "Медиа",
"chatInfoTabFiles": "Файлы", "chatInfoTabFiles": "Файлы",
@@ -589,7 +580,6 @@
"chatInfoRowComments": "Комментарии", "chatInfoRowComments": "Комментарии",
"chatInfoRowRkn": "РКН", "chatInfoRowRkn": "РКН",
"chatInfoRowOnlyAdmin": "Только адм.", "chatInfoRowOnlyAdmin": "Только адм.",
"securityTitle": "Безопасность", "securityTitle": "Безопасность",
"securityLoadError": "Ошибка загрузки: {error}", "securityLoadError": "Ошибка загрузки: {error}",
"securitySaveError": "Ошибка сохранения: {error}", "securitySaveError": "Ошибка сохранения: {error}",
@@ -622,7 +612,6 @@
"securityAudioTranscription": "Транскрибация аудио", "securityAudioTranscription": "Транскрибация аудио",
"securityBlacklistTitle": "Чёрный список", "securityBlacklistTitle": "Чёрный список",
"securityBlacklistNotification": "Чёрный список: {count} контактов", "securityBlacklistNotification": "Чёрный список: {count} контактов",
"passwordEntryWrongPassword": "Неверный пароль", "passwordEntryWrongPassword": "Неверный пароль",
"passwordEntryConfirmTitle": "Подтвердите пароль", "passwordEntryConfirmTitle": "Подтвердите пароль",
"passwordEntryCurrentPasswordHint": "Текущий пароль", "passwordEntryCurrentPasswordHint": "Текущий пароль",
@@ -922,5 +911,37 @@
"videoEditorProcessing": "Обработка видео…", "videoEditorProcessing": "Обработка видео…",
"videoEditorExportFailed": "Не удалось обработать видео", "videoEditorExportFailed": "Не удалось обработать видео",
"videoEditorFrameFailed": "Не удалось получить кадр", "videoEditorFrameFailed": "Не удалось получить кадр",
"videoEditorQualityTooltip": "Качество" "videoEditorQualityTooltip": "Качество",
"webPushTitle": "Уведомления на iOS",
"webPushIntro": "На iOS у Комета нет обычных пушей: Apple выдаёт токен уведомлений только приложениям, подписанным сертификатом разработчика, а sideload-сборка такого не получает.\n\nОбход — веб-приложение на экране «Домой». Уведомления шлёт сам сервер MAX через Apple, а показывает их отдельная иконка.\n\nДля этого нужна веб-сессия. Комет создаст её и подтвердит сам, с этого же устройства — вводить номер и код не придётся.",
"webPushConfirm": "Продолжить",
"webPushPasswordExplainer": "На аккаунте включена двухфакторная защита.",
"webPushPasswordHintLabel": "Подсказка: {hint}",
"@webPushPasswordHintLabel": {
"placeholders": {
"hint": {
"type": "String"
}
}
},
"webPushPasswordHint": "Пароль",
"webPushInstallTitle": "Установите приложение",
"webPushInstallBody": "Откройте push.komet.pw в Safari, добавьте на экран «Домой» и запустите появившуюся иконку. Из вкладки браузера уведомления не работают — так устроена iOS.\n\nВ приложении разрешите уведомления, создайте подписку и нажмите «Открыть Комет». Дальше подписка зарегистрируется сама.",
"webPushLinkedTitle": "Уведомления подключены",
"webPushLinkedBody": "Подписка зарегистрирована на сервере. Не удаляйте иконку с экрана «Домой» — вместе с ней пропадут уведомления.\n\nЕсли пуши перестанут приходить, откройте приложение и свяжите заново: Apple иногда меняет адрес подписки.",
"webPushOpenSite": "Открыть push.komet.pw",
"webPushSignOut": "Отключить уведомления",
"webPushLinked": "Уведомления подключены",
"webPushLinkFailed": "Не удалось подключить уведомления: {error}",
"@webPushLinkFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"webPushNotAuthorized": "Сначала войдите в разделе «Уведомления через PWA»",
"webPushConnect": "Подключить уведомления",
"webPushWaitingBody": "Комет подтверждает вход веб-сессии с этого устройства. Обычно занимает несколько секунд.",
"webPushNeedsOnline": "Нет связи с сервером. Дождитесь подключения и попробуйте снова."
} }
+12 -12
View File
@@ -857,10 +857,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: intl name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.20.3" version: "0.20.2"
jni: jni:
dependency: transitive dependency: transitive
description: description:
@@ -897,10 +897,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: kolibri name: kolibri
sha256: e8ed4eab5687204a77d449743ceb73b0797f5702afb0a7841610a127fc102710 sha256: e59a569756652b5a68d1260b52d9b2ae6f4c0fc831244f9d8aefa15bde03b370
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.2" version: "0.1.4"
komet_crypto: komet_crypto:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -992,10 +992,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.20" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -1064,10 +1064,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.0" version: "1.18.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -1685,10 +1685,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" version: "0.7.11"
timezone: timezone:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1829,10 +1829,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.2.0"
video_player: video_player:
dependency: "direct main" dependency: "direct main"
description: description:
+2 -2
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.5.18+18 version: 0.5.19+19
environment: environment:
sdk: ^3.10.4 sdk: ^3.10.4
@@ -37,7 +37,7 @@ dependencies:
# Rust networking core (kolibri) — FFI plugin, replaces the Dart transport. # Rust networking core (kolibri) — FFI plugin, replaces the Dart transport.
# Published from the KometTeam/kolibri repo; the native core is compiled at # Published from the KometTeam/kolibri repo; the native core is compiled at
# app build time and pulled from that repo by git tag. # app build time and pulled from that repo by git tag.
kolibri: ^0.1.2 kolibri: ^0.1.4
# Rust message-encryption core — Argon2id + ChaCha20-Poly1305, output encoded # Rust message-encryption core — Argon2id + ChaCha20-Poly1305, output encoded
# as lowercase Cyrillic base32. Separate from kolibri: that is vendored # as lowercase Cyrillic base32. Separate from kolibri: that is vendored
+88
View File
@@ -0,0 +1,88 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/core/webpush/max_web_protocol.dart';
Uint8List _hex(String value) {
final bytes = Uint8List(value.length ~/ 2);
for (var i = 0; i < bytes.length; i++) {
bytes[i] = int.parse(value.substring(i * 2, i * 2 + 2), radix: 16);
}
return bytes;
}
const _responseHex = '0a0100000006010000caf0b985ad7765622d7077612d70726f6d6fc3b270686f6e652d617574682d656e61626c6564c3a86c6f636174696f6ea25255a46c616e67c3b07265672d636f756e7472792d636f6465dc002aa2415aa2414da24b5aa24b47a24d44a2544aa2555aa24745a25448a25452a2544da24145a24c41a24d59a24944a24355a24b48a2564ea24146a2424fa24344a24347a2434fa24744a2474da2494ea24951a24b4ea24b57a24c42a24d4da24e49a2504ba25057a25141a25341a25645a2545aa24547a2434ea25a41a24252';
const _requestHex = '0a00000000060000015482a9757365724167656e748baa64657669636554797065a3574542ae7075736844657669636554797065a757454250555348a66c6f63616c65a27275ac6465766963654c6f63616c65a27275a96f7356657273696f6ea56d61634f53aa6465766963654e616d65a6536166617269af686561646572557365724167656e74d9754d6f7a696c6c612f352e3020284d6163696e746f73683b20496e74656c204d6163204f5320582031305f31355f3729204170706c655765624b69742f3630352e312e313520284b48544d4c2c206c696b65204765636b6f292056657273696f6e2f31382e35205361666172692f3630352e312e3135a56973507761c3aa61707056657273696f6ea732362e362e3230a673637265656eac3935367834343020332e3078a874696d657a6f6e65ad4575726f70652f4d6f73636f77a86465766963654964b06b6f6d65742d636f6465632d74657374';
void main() {
test('заголовок кадра совпадает с эталоном сервера', () {
final frame = _hex(_responseHex);
expect(frame[0], MaxWebFraming.protocolVersion);
final decoded = MaxWebFraming.decode(frame);
expect(decoded.cmd, MaxWebCmd.ok);
expect(decoded.opcode, 6);
expect(decoded.isOk, isTrue);
});
test('распаковка LZ4 и msgpack на живом ответе сервера', () {
final decoded = MaxWebFraming.decode(_hex(_responseHex));
final payload = decoded.payload as Map<Object?, Object?>;
expect(payload['location'], 'RU');
expect(payload['web-pwa-promo'], isTrue);
expect(payload['reg-country-code'], isA<List<Object?>>());
expect(payload.length, 5);
});
test('кодирование кадра байт в байт как у веб-клиента', () {
final expected = _hex(_requestHex);
final payload = jsonDecode(_requestPayloadJson) as Map<String, dynamic>;
final actual = MaxWebFraming.encode(cmd: 0, seq: 0, opcode: 6, payload: payload);
expect(actual.length, expected.length);
expect(actual.sublist(0, MaxWebFraming.headerSize),
expected.sublist(0, MaxWebFraming.headerSize));
});
test('msgpack переживает круговой рейс', () {
final source = <String, Object?>{
'subscribe': true,
'pushToken': 'https://web.push.apple.com/AAA-bbb_ccc',
'secretKey': 'z2sMRx0MgXELERMrtCcK_Q',
'publicKey': 'BOgrn-9cRlyU4jnyxQROVWWrTgpof_3T9UAO6DR6QkNvIbexyAQSEz4J5BBM6VQY6hZklsMq06aSK1oCzF5A644',
'chatsCount': 40,
'presenceSync': -1,
'nested': <String, Object?>{'a': null, 'b': 3.5, 'c': <Object?>[1, 'два', false]},
};
final restored = MaxMsgpack.decode(MaxMsgpack.encode(source)) as Map<Object?, Object?>;
expect(restored['subscribe'], true);
expect(restored['pushToken'], source['pushToken']);
expect(restored['chatsCount'], 40);
expect(restored['presenceSync'], -1);
final nested = restored['nested'] as Map<Object?, Object?>;
expect(nested['a'], isNull);
expect(nested['b'], 3.5);
expect((nested['c'] as List<Object?>)[1], 'два');
});
test('ext(1) разворачивается во вложенное число', () {
final pollingInterval = MaxMsgpack.decode(
Uint8List.fromList([0x81, 0xA8, 0x69, 0x6E, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6C,
0xC7, 0x03, 0x01, 0xD1, 0x13, 0x88]),
) as Map<Object?, Object?>;
expect(pollingInterval['interval'], 5000);
final expiresAt = MaxMsgpack.decode(
Uint8List.fromList([0xC7, 0x09, 0x01, 0xD3, 0x00, 0x00, 0x01, 0xA0, 0x25,
0x5B, 0xE9, 0xD2]),
);
expect(expiresAt, 1787333175762);
});
test('LZ4 разворачивает перекрывающиеся совпадения', () {
final compressed = Uint8List.fromList([0x6E, 0x6B, 0x6F, 0x6D, 0x65, 0x74, 0x20, 0x06, 0x00, 0x46, 0x70, 0x75, 0x73, 0x68, 0x05, 0x00, 0x50, 0x20, 0x70, 0x75, 0x73, 0x68]);
final result = Lz4Block.decompress(compressed, 256);
expect(utf8.decode(result), 'komet komet komet komet push push push push');
});
}
const _requestPayloadJson = r'''{"userAgent": {"deviceType": "WEB", "pushDeviceType": "WEBPUSH", "locale": "ru", "deviceLocale": "ru", "osVersion": "macOS", "deviceName": "Safari", "headerUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15", "isPwa": true, "appVersion": "26.6.20", "screen": "956x440 3.0x", "timezone": "Europe/Moscow"}, "deviceId": "komet-codec-test"}''';