feat: уведомления на iOS через PWA
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 (1–65535)",
|
"proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)",
|
||||||
|
|
||||||
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"}''';
|
||||||
Reference in New Issue
Block a user