feat: уведомления на iOS через PWA
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
import '../../backend/api.dart';
|
||||
import '../../frontend/debug/log_export.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/swipe_route.dart';
|
||||
import '../../main.dart';
|
||||
import '../webpush/max_web_socket.dart';
|
||||
import '../webpush/web_push_service.dart';
|
||||
import 'desktop_url_scheme.dart';
|
||||
import 'max_link.dart';
|
||||
|
||||
@@ -24,8 +29,10 @@ class DeepLinkService {
|
||||
String? _pending;
|
||||
bool _pendingLogExport = false;
|
||||
String? _pendingExternalCallback;
|
||||
WebPushSubscription? _pendingWebPush;
|
||||
String? _lastExternalCallback;
|
||||
Timer? _externalCallbackRetry;
|
||||
Timer? _webPushRetry;
|
||||
Timer? _logExportRetry;
|
||||
bool _ready = false;
|
||||
bool _started = false;
|
||||
@@ -58,6 +65,12 @@ class DeepLinkService {
|
||||
_flushPending();
|
||||
return;
|
||||
}
|
||||
final webPush = _parseWebPushLink(uri);
|
||||
if (webPush != null) {
|
||||
_pendingWebPush = webPush;
|
||||
_flushPending();
|
||||
return;
|
||||
}
|
||||
if (_isExternalCallback(uri)) {
|
||||
final callbackUrl = uri.toString();
|
||||
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;
|
||||
final pending = _pending;
|
||||
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) {
|
||||
final scheme = uri.scheme.toLowerCase();
|
||||
final host = uri.host.toLowerCase();
|
||||
@@ -176,6 +254,8 @@ class DeepLinkService {
|
||||
void dispose() {
|
||||
_logExportRetry?.cancel();
|
||||
_logExportRetry = null;
|
||||
_webPushRetry?.cancel();
|
||||
_webPushRetry = null;
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
_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/settings_card.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
import 'web_push_screen.dart';
|
||||
|
||||
class NotificationsScreen extends StatefulWidget {
|
||||
const NotificationsScreen({super.key});
|
||||
@@ -89,6 +90,12 @@ class _NotificationsScreenState extends State<NotificationsScreen>
|
||||
if (mounted) setState(() => _hapticsEnabled = value);
|
||||
}
|
||||
|
||||
void _openWebPush() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const WebPushScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onFkmChanged(bool value) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (!FkmController.instance.isSupported) {
|
||||
@@ -159,6 +166,19 @@ class _NotificationsScreenState extends State<NotificationsScreen>
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
|
||||
children: [
|
||||
if (Platform.isIOS) ...[
|
||||
SettingsCard(
|
||||
children: [
|
||||
SettingsNavTile(
|
||||
icon: Symbols.install_mobile,
|
||||
label: l10n.webPushTitle,
|
||||
onTap: _openWebPush,
|
||||
isLast: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
SectionHeader(
|
||||
l10n.notificationsFkmSectionTitle,
|
||||
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",
|
||||
"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.",
|
||||
|
||||
"proxySettingsTitle": "Proxy",
|
||||
"proxyTypeNone": "Disabled",
|
||||
"proxyTypeSocks5": "SOCKS5",
|
||||
@@ -63,7 +62,6 @@
|
||||
"proxyDisable": "Disable proxy",
|
||||
"proxySettingsSaved": "Proxy settings applied",
|
||||
"proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)",
|
||||
|
||||
"spoofScreenTitle": "Session spoofing",
|
||||
"spoofEnableTitle": "Device spoofing",
|
||||
"spoofEnableSubtitleOn": "Enabled for this account",
|
||||
@@ -207,7 +205,6 @@
|
||||
"chatInfoHasBots": "has bots:",
|
||||
"chatInfoBlockedCount": "blocked in group:",
|
||||
"chatInfoOfficialStatus": "official status:",
|
||||
"chatInfoLastChanged": "last changed:",
|
||||
"chatInfoJoined": "joined:",
|
||||
"chatInfoGroupCreated": "group created:",
|
||||
"chatInfoGroupOwner": "group owner:",
|
||||
@@ -375,7 +372,6 @@
|
||||
"appearancePreviewHowIsIt": "How do you like it?",
|
||||
"appearancePreviewHmm": "hmm...",
|
||||
"appearancePreviewNotBad": "Not bad at all!",
|
||||
|
||||
"callKometDetectedNotification": "This person uses Komet! :3",
|
||||
"callStatusConnecting": "Connecting",
|
||||
"callGroupConnecting": "Connecting…",
|
||||
@@ -445,7 +441,6 @@
|
||||
"callBadgeNoiseSuppression": "Noise suppression",
|
||||
"callBadgeAnimoji": "Animoji",
|
||||
"callInfoNoDataYet": "Data will appear after connecting…",
|
||||
|
||||
"hubTitleMenu": "Komet",
|
||||
"hubChatPageTitle": "Anonymous chat",
|
||||
"hubGamesTitle": "Games",
|
||||
@@ -466,7 +461,6 @@
|
||||
"hubCheckersLost": "You lost",
|
||||
"hubCheckersYourMove": "Your move",
|
||||
"hubCheckersOpponentMove": "Opponent's move…",
|
||||
|
||||
"scheduledPickTimeTitle": "When to send",
|
||||
"scheduledEditTitle": "Edit",
|
||||
"scheduledMessageTextHint": "Message text",
|
||||
@@ -485,7 +479,6 @@
|
||||
"scheduledAttachLocation": "Location",
|
||||
"scheduledAttachForwarded": "Forwarded",
|
||||
"scheduledAttachGeneric": "Attachment",
|
||||
|
||||
"contactProfileLoadError": "Error: {error}",
|
||||
"@contactProfileLoadError": {
|
||||
"placeholders": {
|
||||
@@ -510,7 +503,6 @@
|
||||
"contactProfileInfoDescription": "Description",
|
||||
"contactProfileInfoLink": "Link",
|
||||
"contactProfileInfoFlags": "Flags",
|
||||
|
||||
"nfcPeerNameFallback": "Contact #{id}",
|
||||
"@nfcPeerNameFallback": {
|
||||
"placeholders": {
|
||||
@@ -549,7 +541,6 @@
|
||||
},
|
||||
"nfcAdded": "Added",
|
||||
"nfcAddContact": "Add contact",
|
||||
|
||||
"chatInfoTabGeneralChats": "Common chats",
|
||||
"chatInfoTabMedia": "Media",
|
||||
"chatInfoTabFiles": "Files",
|
||||
@@ -747,7 +738,6 @@
|
||||
"chatInfoRowComments": "Comments",
|
||||
"chatInfoRowRkn": "Roskomnadzor approved",
|
||||
"chatInfoRowOnlyAdmin": "Admins only",
|
||||
|
||||
"securityTitle": "Security",
|
||||
"securityLoadError": "Loading error: {error}",
|
||||
"@securityLoadError": {
|
||||
@@ -801,7 +791,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"passwordEntryWrongPassword": "Wrong password",
|
||||
"passwordEntryConfirmTitle": "Confirm password",
|
||||
"passwordEntryCurrentPasswordHint": "Current password",
|
||||
@@ -1178,5 +1167,37 @@
|
||||
"videoEditorProcessing": "Processing video…",
|
||||
"videoEditorExportFailed": "Failed to process the video",
|
||||
"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:
|
||||
/// **'Quality'**
|
||||
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
|
||||
|
||||
@@ -2706,4 +2706,69 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
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",
|
||||
"codeError2faMissing": "Ошибка: отсутствуют данные для 2FA",
|
||||
"codeConfirmation2faWarning": "По умолчанию код приходит в МАХ. Если код не приходит по SMS - не заходите в Komet/MAX 30 минут, и попробуйте заново.",
|
||||
|
||||
"proxySettingsTitle": "Прокси",
|
||||
"proxyTypeNone": "Выключен",
|
||||
"proxyTypeSocks5": "SOCKS5",
|
||||
@@ -63,7 +62,6 @@
|
||||
"proxyDisable": "Отключить прокси",
|
||||
"proxySettingsSaved": "Настройки прокси применены",
|
||||
"proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)",
|
||||
|
||||
"spoofScreenTitle": "Подмена данных сессии",
|
||||
"spoofEnableTitle": "Подмена устройства",
|
||||
"spoofEnableSubtitleOn": "Включена для этого аккаунта",
|
||||
@@ -200,7 +198,6 @@
|
||||
"chatInfoHasBots": "Есть боты:",
|
||||
"chatInfoBlockedCount": "в ЧС группы:",
|
||||
"chatInfoOfficialStatus": "Официальный статус:",
|
||||
"chatInfoLastChanged": "последнее изменение:",
|
||||
"chatInfoJoined": "Зашли в:",
|
||||
"chatInfoGroupCreated": "Группа создана в:",
|
||||
"chatInfoGroupOwner": "Создатель группы:",
|
||||
@@ -333,7 +330,6 @@
|
||||
"appearancePreviewHowIsIt": "Как тебе?",
|
||||
"appearancePreviewHmm": "хм...",
|
||||
"appearancePreviewNotBad": "Вполне неплохо!",
|
||||
|
||||
"callKometDetectedNotification": "Этот человек использует Komet! :3",
|
||||
"callStatusConnecting": "Соединение...",
|
||||
"callGroupConnecting": "Соединение...",
|
||||
@@ -396,7 +392,6 @@
|
||||
"callBadgeNoiseSuppression": "Шумоподавление",
|
||||
"callBadgeAnimoji": "Анимодзи",
|
||||
"callInfoNoDataYet": "Данные появятся после соединения…",
|
||||
|
||||
"hubTitleMenu": "Komet",
|
||||
"hubChatPageTitle": "Анонимный чат",
|
||||
"hubGamesTitle": "Игры",
|
||||
@@ -417,7 +412,6 @@
|
||||
"hubCheckersLost": "Вы проиграли",
|
||||
"hubCheckersYourMove": "Ваш ход",
|
||||
"hubCheckersOpponentMove": "Ход соперника…",
|
||||
|
||||
"scheduledPickTimeTitle": "Когда отправить",
|
||||
"scheduledEditTitle": "Изменить",
|
||||
"scheduledMessageTextHint": "Текст сообщения",
|
||||
@@ -436,7 +430,6 @@
|
||||
"scheduledAttachLocation": "Геопозиция",
|
||||
"scheduledAttachForwarded": "Переслано",
|
||||
"scheduledAttachGeneric": "Вложение",
|
||||
|
||||
"contactProfileLoadError": "Ошибка: {error}",
|
||||
"contactProfileBot": "Бот",
|
||||
"contactProfileOnline": "В сети",
|
||||
@@ -454,7 +447,6 @@
|
||||
"contactProfileInfoDescription": "Описание",
|
||||
"contactProfileInfoLink": "Ссылка",
|
||||
"contactProfileInfoFlags": "Флаги",
|
||||
|
||||
"nfcPeerNameFallback": "Контакт #{id}",
|
||||
"nfcPeerFirstNameFallback": "Контакт",
|
||||
"nfcContactAdded": "Контакт добавлен",
|
||||
@@ -472,7 +464,6 @@
|
||||
"nfcPeerIdFallback": "ID {id}",
|
||||
"nfcAdded": "Добавлено",
|
||||
"nfcAddContact": "Добавить контакт",
|
||||
|
||||
"chatInfoTabGeneralChats": "Общие чаты",
|
||||
"chatInfoTabMedia": "Медиа",
|
||||
"chatInfoTabFiles": "Файлы",
|
||||
@@ -589,7 +580,6 @@
|
||||
"chatInfoRowComments": "Комментарии",
|
||||
"chatInfoRowRkn": "РКН",
|
||||
"chatInfoRowOnlyAdmin": "Только адм.",
|
||||
|
||||
"securityTitle": "Безопасность",
|
||||
"securityLoadError": "Ошибка загрузки: {error}",
|
||||
"securitySaveError": "Ошибка сохранения: {error}",
|
||||
@@ -622,7 +612,6 @@
|
||||
"securityAudioTranscription": "Транскрибация аудио",
|
||||
"securityBlacklistTitle": "Чёрный список",
|
||||
"securityBlacklistNotification": "Чёрный список: {count} контактов",
|
||||
|
||||
"passwordEntryWrongPassword": "Неверный пароль",
|
||||
"passwordEntryConfirmTitle": "Подтвердите пароль",
|
||||
"passwordEntryCurrentPasswordHint": "Текущий пароль",
|
||||
@@ -922,5 +911,37 @@
|
||||
"videoEditorProcessing": "Обработка видео…",
|
||||
"videoEditorExportFailed": "Не удалось обработать видео",
|
||||
"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"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.3"
|
||||
version: "0.20.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -897,10 +897,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: kolibri
|
||||
sha256: e8ed4eab5687204a77d449743ceb73b0797f5702afb0a7841610a127fc102710
|
||||
sha256: e59a569756652b5a68d1260b52d9b2ae6f4c0fc831244f9d8aefa15bde03b370
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
version: "0.1.4"
|
||||
komet_crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -992,10 +992,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.20"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1064,10 +1064,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.0"
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1685,10 +1685,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
version: "0.7.11"
|
||||
timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1829,10 +1829,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.2.0"
|
||||
video_player:
|
||||
dependency: "direct main"
|
||||
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
|
||||
# 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.
|
||||
version: 0.5.18+18
|
||||
version: 0.5.19+19
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.4
|
||||
@@ -37,7 +37,7 @@ dependencies:
|
||||
# Rust networking core (kolibri) — FFI plugin, replaces the Dart transport.
|
||||
# Published from the KometTeam/kolibri repo; the native core is compiled at
|
||||
# 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
|
||||
# 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