fix(transport): Zstd-распаковка, гонка PacketReceiver, fix имён и стикеров
This commit is contained in:
@@ -266,7 +266,15 @@ class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onDataReceived(Uint8List data) async {
|
Future<void> _onDataReceived(Uint8List data) async {
|
||||||
await for (final packet in _receiver.feed(data)) {
|
final rawPackets = _receiver.feed(data);
|
||||||
|
for (final raw in rawPackets) {
|
||||||
|
final Packet packet;
|
||||||
|
try {
|
||||||
|
packet = await unpackPacket(raw);
|
||||||
|
} catch (e) {
|
||||||
|
logger.e('PacketReceiver: ошибка распаковки: $e');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (packet.isError &&
|
if (packet.isError &&
|
||||||
packet.payload is Map &&
|
packet.payload is Map &&
|
||||||
(packet.payload['message'] == 'FAIL_LOGIN_TOKEN' ||
|
(packet.payload['message'] == 'FAIL_LOGIN_TOKEN' ||
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ class ChatsModule {
|
|||||||
static Future<List<CachedChat>> getChats(int accountId) async {
|
static Future<List<CachedChat>> getChats(int accountId) async {
|
||||||
try {
|
try {
|
||||||
final rows = await AppDatabase.loadChats(accountId);
|
final rows = await AppDatabase.loadChats(accountId);
|
||||||
|
|
||||||
return rows.map(CachedChat.fromDbRow).toList();
|
return rows.map(CachedChat.fromDbRow).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.e("Ошибка при получении чатов: $e");
|
logger.e("Ошибка при получении чатов: $e");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '../../core/storage/app_database.dart';
|
import '../../core/storage/app_database.dart';
|
||||||
|
import 'messages.dart';
|
||||||
|
|
||||||
class CachedContact {
|
class CachedContact {
|
||||||
final int id;
|
final int id;
|
||||||
@@ -44,22 +45,65 @@ class ContactsModule {
|
|||||||
final contacts = data['contacts'];
|
final contacts = data['contacts'];
|
||||||
if (contacts is! List || contacts.isEmpty) return;
|
if (contacts is! List || contacts.isEmpty) return;
|
||||||
|
|
||||||
final rows = contacts
|
final rows = <Map<String, dynamic>>[];
|
||||||
.whereType<Map>()
|
for (final raw in contacts.whereType<Map>()) {
|
||||||
.map((c) => _parseContact(c.cast<dynamic, dynamic>(), accountId))
|
final contact = raw.cast<dynamic, dynamic>();
|
||||||
.whereType<Map<String, dynamic>>()
|
final row = _parseContact(contact, accountId);
|
||||||
.toList();
|
if (row != null) rows.add(row);
|
||||||
|
_primeContactCache(contact);
|
||||||
|
}
|
||||||
|
|
||||||
if (rows.isNotEmpty) {
|
if (rows.isNotEmpty) {
|
||||||
await AppDatabase.saveContacts(rows);
|
await AppDatabase.saveContacts(rows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void _primeContactCache(Map<dynamic, dynamic> contact) {
|
||||||
|
final id = contact['id'];
|
||||||
|
if (id is! int) return;
|
||||||
|
|
||||||
|
final names = contact['names'];
|
||||||
|
if (names is List && names.isNotEmpty) {
|
||||||
|
final nameRaw = names.firstWhere(
|
||||||
|
(n) => n is Map && n['type'] == 'ONEME',
|
||||||
|
orElse: () => names.firstWhere((n) => n is Map, orElse: () => null),
|
||||||
|
);
|
||||||
|
if (nameRaw is Map) {
|
||||||
|
final firstName = (nameRaw['firstName'] as String?) ?? '';
|
||||||
|
final lastName = nameRaw['lastName'] as String?;
|
||||||
|
final fullName = (lastName != null && lastName.isNotEmpty)
|
||||||
|
? '$firstName $lastName'
|
||||||
|
: firstName;
|
||||||
|
if (fullName.isNotEmpty) ContactCache.put(id, fullName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final baseUrl = contact['baseUrl'] as String?;
|
||||||
|
if (baseUrl != null && baseUrl.isNotEmpty) {
|
||||||
|
ContactCache.putAvatar(id, baseUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<List<CachedContact>> getContacts(int accountId) async {
|
static Future<List<CachedContact>> getContacts(int accountId) async {
|
||||||
final rows = await AppDatabase.loadContacts(accountId);
|
final rows = await AppDatabase.loadContacts(accountId);
|
||||||
return rows.map(CachedContact.fromDbRow).toList();
|
return rows.map(CachedContact.fromDbRow).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Прогревает in-memory ContactCache из локальных контактов.
|
||||||
|
/// Нужно вызывать на cold start: иначе кэш пуст до следующего логина.
|
||||||
|
static Future<void> primeCacheFromDb(int accountId) async {
|
||||||
|
final contacts = await getContacts(accountId);
|
||||||
|
for (final c in contacts) {
|
||||||
|
final fullName = (c.lastName != null && c.lastName!.isNotEmpty)
|
||||||
|
? '${c.firstName} ${c.lastName}'
|
||||||
|
: c.firstName;
|
||||||
|
if (fullName.isNotEmpty) ContactCache.put(c.id, fullName);
|
||||||
|
if (c.baseUrl != null && c.baseUrl!.isNotEmpty) {
|
||||||
|
ContactCache.putAvatar(c.id, c.baseUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Map<String, dynamic>? _parseContact(
|
static Map<String, dynamic>? _parseContact(
|
||||||
Map<dynamic, dynamic> contact,
|
Map<dynamic, dynamic> contact,
|
||||||
int accountId,
|
int accountId,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
import 'package:dart_lz4/dart_lz4.dart';
|
import 'package:dart_lz4/dart_lz4.dart';
|
||||||
|
import 'package:es_compression/zstd.dart';
|
||||||
import 'package:msgpack_dart/msgpack_dart.dart' as msgpack;
|
import 'package:msgpack_dart/msgpack_dart.dart' as msgpack;
|
||||||
|
|
||||||
/// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10
|
/// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10
|
||||||
@@ -129,21 +130,7 @@ Future<Packet> unpackPacket(Uint8List packet) async {
|
|||||||
|
|
||||||
if (payloadBytes.isNotEmpty) {
|
if (payloadBytes.isNotEmpty) {
|
||||||
if (compFlag != 0) {
|
if (compFlag != 0) {
|
||||||
try {
|
payloadBytes = _decompressPayload(payloadBytes);
|
||||||
payloadBytes = lz4Decompress(
|
|
||||||
payloadBytes,
|
|
||||||
decompressedSize: _maxDecompressedSize,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
try {
|
|
||||||
payloadBytes = _lz4BlockDecompress(
|
|
||||||
payloadBytes,
|
|
||||||
_maxDecompressedSize,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception("LZ4 decompression error: $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -165,6 +152,44 @@ Future<Packet> unpackPacket(Uint8List packet) async {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Определяет формат сжатия по magic-number и распаковывает payload.
|
||||||
|
/// Сервер может присылать LZ4 block ИЛИ Zstandard в зависимости от ответа.
|
||||||
|
Uint8List _decompressPayload(Uint8List src) {
|
||||||
|
// Zstandard: magic 28 B5 2F FD (little-endian)
|
||||||
|
if (src.length >= 4 &&
|
||||||
|
src[0] == 0x28 &&
|
||||||
|
src[1] == 0xB5 &&
|
||||||
|
src[2] == 0x2F &&
|
||||||
|
src[3] == 0xFD) {
|
||||||
|
try {
|
||||||
|
final out = zstd.decode(src);
|
||||||
|
return out is Uint8List ? out : Uint8List.fromList(out);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Zstd decompression error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LZ4 frame: magic 04 22 4D 18
|
||||||
|
if (src.length >= 4 &&
|
||||||
|
src[0] == 0x04 &&
|
||||||
|
src[1] == 0x22 &&
|
||||||
|
src[2] == 0x4D &&
|
||||||
|
src[3] == 0x18) {
|
||||||
|
try {
|
||||||
|
return lz4Decompress(src, decompressedSize: _maxDecompressedSize);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('LZ4 frame decompression error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// По умолчанию — LZ4 block (без magic)
|
||||||
|
try {
|
||||||
|
return _lz4BlockDecompress(src, _maxDecompressedSize);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('LZ4 block decompression error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// LZ4 block декомпрессия (без frame-заголовка).
|
/// LZ4 block декомпрессия (без frame-заголовка).
|
||||||
/// Сервер шлёт именно block-формат, dart_lz4 его не поддерживает.
|
/// Сервер шлёт именно block-формат, dart_lz4 его не поддерживает.
|
||||||
Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) {
|
Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) {
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import '../protocol/packet.dart';
|
|||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
|
|
||||||
/// Буфер входящих данных.
|
/// Буфер входящих данных.
|
||||||
/// Копит сырые байты из сокета, собирает из них целые пакеты.
|
/// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов.
|
||||||
class PacketReceiver {
|
class PacketReceiver {
|
||||||
Uint8List _buffer = Uint8List(0);
|
Uint8List _buffer = Uint8List(0);
|
||||||
|
|
||||||
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
|
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
|
||||||
|
|
||||||
/// Добавляет байты в буфер, возвращает поток собранных пакетов.
|
/// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы.
|
||||||
/// Неполные данные остаются в буфере до следующего вызова.
|
/// Полностью синхронный — нарезка не блокируется на распаковке, поэтому
|
||||||
Stream<Packet> feed(Uint8List data) async* {
|
/// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`.
|
||||||
|
List<Uint8List> feed(Uint8List data) {
|
||||||
final newBuffer = Uint8List(_buffer.length + data.length);
|
final newBuffer = Uint8List(_buffer.length + data.length);
|
||||||
newBuffer.setAll(0, _buffer);
|
newBuffer.setAll(0, _buffer);
|
||||||
newBuffer.setAll(_buffer.length, data);
|
newBuffer.setAll(_buffer.length, data);
|
||||||
@@ -23,9 +24,10 @@ class PacketReceiver {
|
|||||||
'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс',
|
'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс',
|
||||||
);
|
);
|
||||||
reset();
|
reset();
|
||||||
return;
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final packets = <Uint8List>[];
|
||||||
while (_buffer.length >= headerSize) {
|
while (_buffer.length >= headerSize) {
|
||||||
final bd = ByteData.view(
|
final bd = ByteData.view(
|
||||||
_buffer.buffer,
|
_buffer.buffer,
|
||||||
@@ -38,15 +40,10 @@ class PacketReceiver {
|
|||||||
|
|
||||||
if (_buffer.length < totalLength) break;
|
if (_buffer.length < totalLength) break;
|
||||||
|
|
||||||
final packetBytes = Uint8List.sublistView(_buffer, 0, totalLength);
|
packets.add(Uint8List.sublistView(_buffer, 0, totalLength));
|
||||||
_buffer = _buffer.sublist(totalLength);
|
_buffer = _buffer.sublist(totalLength);
|
||||||
|
|
||||||
try {
|
|
||||||
yield await unpackPacket(packetBytes);
|
|
||||||
} catch (e) {
|
|
||||||
logger.e('PacketReceiver: ошибка распаковки: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return packets;
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
|
|||||||
@@ -1270,15 +1270,17 @@ class MessageBubble extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStickerAttachment(BuildContext ctx, MessageAttachment sticker) {
|
Widget _buildStickerAttachment(BuildContext ctx, MessageAttachment sticker) {
|
||||||
final preview = (sticker as dynamic).previewData as String? ?? '';
|
final url = sticker.baseUrl ?? '';
|
||||||
|
final preview = sticker.previewData ?? '';
|
||||||
|
final imageUrl = url.isNotEmpty ? url : preview;
|
||||||
|
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(photoBorderRadius),
|
borderRadius: BorderRadius.circular(photoBorderRadius),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
if (preview.isNotEmpty)
|
if (imageUrl.isNotEmpty)
|
||||||
Image.network(
|
Image.network(
|
||||||
preview,
|
imageUrl,
|
||||||
width: 150,
|
width: 150,
|
||||||
height: 150,
|
height: 150,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:komet/l10n/app_localizations.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'backend/api.dart';
|
import 'backend/api.dart';
|
||||||
import 'backend/modules/account.dart';
|
import 'backend/modules/account.dart';
|
||||||
|
import 'backend/modules/contacts.dart';
|
||||||
import 'backend/modules/messages.dart';
|
import 'backend/modules/messages.dart';
|
||||||
import 'core/storage/app_database.dart';
|
import 'core/storage/app_database.dart';
|
||||||
import 'core/storage/token_storage.dart';
|
import 'core/storage/token_storage.dart';
|
||||||
@@ -36,6 +37,10 @@ Future<Locale> _loadInitialLocale() async {
|
|||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await AppDatabase.init();
|
await AppDatabase.init();
|
||||||
|
final activeAccountId = await TokenStorage.getActiveAccountId();
|
||||||
|
if (activeAccountId != null) {
|
||||||
|
await ContactsModule.primeCacheFromDb(activeAccountId);
|
||||||
|
}
|
||||||
await api.connect();
|
await api.connect();
|
||||||
final initialLocale = await _loadInitialLocale();
|
final initialLocale = await _loadInitialLocale();
|
||||||
|
|
||||||
|
|||||||
@@ -304,9 +304,9 @@ class StickerAttachment extends MessageAttachment {
|
|||||||
|
|
||||||
return StickerAttachment(
|
return StickerAttachment(
|
||||||
previewData: previewStr,
|
previewData: previewStr,
|
||||||
baseUrl: map['baseUrl'] as String?,
|
baseUrl: (map['url'] ?? map['baseUrl'])?.toString(),
|
||||||
stickerId: map['stickerId'] as String?,
|
stickerId: map['stickerId']?.toString(),
|
||||||
stickerPackId: map['stickerPackId'] as String?,
|
stickerPackId: (map['stickerPackId'] ?? map['setId'])?.toString(),
|
||||||
width: map['width'] as int?,
|
width: map['width'] as int?,
|
||||||
height: map['height'] as int?,
|
height: map['height'] as int?,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -105,6 +113,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.8"
|
version: "2.0.8"
|
||||||
|
es_compression:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: es_compression
|
||||||
|
sha256: c1ff7af54802631cf5c3942cb67bb99daadcc087f573ca99a9de91002d1a7ece
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.15"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ dependencies:
|
|||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
dart_lz4: ^1.0.0
|
dart_lz4: ^1.0.0
|
||||||
|
es_compression: ^2.0.15
|
||||||
msgpack_dart: ^1.0.1
|
msgpack_dart: ^1.0.1
|
||||||
logger: ^2.6.2
|
logger: ^2.6.2
|
||||||
device_info_plus: 12.3.0
|
device_info_plus: 12.3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user