feat(calls): ws2 signaling → kolibri; media/voice/file/video fixes; webview UA from session; re-pin submodule
This commit is contained in:
@@ -101,6 +101,7 @@ class FileUploader {
|
||||
url: info.url,
|
||||
path: file.path,
|
||||
filename: filename,
|
||||
connection: 'close',
|
||||
)
|
||||
.listen(
|
||||
(e) {
|
||||
@@ -185,6 +186,8 @@ class FileUploader {
|
||||
url: uri.toString(),
|
||||
path: file.path,
|
||||
filename: _syntheticFilename(),
|
||||
contentType: 'application/octet-stream',
|
||||
connection: 'close',
|
||||
),
|
||||
onProgress: onProgress,
|
||||
);
|
||||
@@ -277,6 +280,11 @@ class FileUploader {
|
||||
),
|
||||
onProgress: onProgress,
|
||||
);
|
||||
if (result.error != null || result.status != 200) {
|
||||
logger.w(
|
||||
'uploadVideoFile: status=${result.status} error=${result.error}',
|
||||
);
|
||||
}
|
||||
return result.error == null && result.status == 200;
|
||||
} catch (e) {
|
||||
logger.w('uploadVideoFile: $e');
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../protocol/lz4_block.dart';
|
||||
import 'package:kolibri/kolibri.dart' as kb;
|
||||
|
||||
/// Параметры подключения к звонку (`vcp`), которые сервер присылает в пуше
|
||||
/// входящего звонка (opcode 137) и в ответе на инициацию исходящего.
|
||||
@@ -79,73 +76,19 @@ class ConversationParams {
|
||||
return nowSec >= expiresAt! - 5;
|
||||
}
|
||||
|
||||
static List<String> _splitTurn(Object? value) {
|
||||
if (value is! String || value.isEmpty) return const [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static List<String> _stringList(Object? value) {
|
||||
if (value is! List) return const [];
|
||||
return value.whereType<String>().toList();
|
||||
}
|
||||
|
||||
/// Распаковывает и парсит строку `vcp`. Возвращает `null`, если формат
|
||||
/// не распознан.
|
||||
/// Распаковывает и парсит строку `vcp` через Rust-ядро (kolibri). Возвращает
|
||||
/// `null`, если формат не распознан. Требует инициализации `initKolibri()`.
|
||||
static ConversationParams? decode(String vcp) {
|
||||
final sep = vcp.indexOf(':');
|
||||
if (sep <= 0) return null;
|
||||
|
||||
final rawLen = int.tryParse(vcp.substring(0, sep));
|
||||
if (rawLen == null || rawLen <= 0) return null;
|
||||
|
||||
final Uint8List compressed;
|
||||
try {
|
||||
compressed = base64.decode(vcp.substring(sep + 1));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Uint8List bytes;
|
||||
try {
|
||||
final decompressed = lz4BlockDecompress(compressed, rawLen);
|
||||
bytes = decompressed.length > rawLen
|
||||
? Uint8List.sublistView(decompressed, 0, rawLen)
|
||||
: decompressed;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Object? json;
|
||||
try {
|
||||
json = jsonDecode(utf8.decode(bytes));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
if (json is! Map) return null;
|
||||
|
||||
final token = json['tkn'];
|
||||
final wse = json['wse'];
|
||||
if (token is! String || wse is! String) return null;
|
||||
|
||||
final kb.CallParams? p = kb.decodeVcp(vcp: vcp, conversationId: '');
|
||||
if (p == null) return null;
|
||||
return ConversationParams(
|
||||
token: token,
|
||||
wsEndpoint: wse,
|
||||
wsIps: _stringList(json['wsip']),
|
||||
wtEndpoint: json['wte'] as String?,
|
||||
wtIps: _stringList(json['wtip']),
|
||||
callsApiEndpoint: json['vcae'] as String?,
|
||||
callsApiIps: _stringList(json['vcaip']),
|
||||
clientType: json['srcp'] as String?,
|
||||
expiresAt: json['et'] is int ? json['et'] as int : null,
|
||||
stun: json['stne'] as String?,
|
||||
turn: _splitTurn(json['trne']),
|
||||
turnUser: json['trnu'] as String?,
|
||||
turnPassword: json['trnp'] as String?,
|
||||
isVideo: json['iv'] == true,
|
||||
token: p.token,
|
||||
wsEndpoint: p.wsEndpoint,
|
||||
stun: p.stun,
|
||||
turn: p.turn,
|
||||
turnUser: p.turnUser,
|
||||
turnPassword: p.turnPassword,
|
||||
isVideo: p.isVideo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
import 'package:kolibri/kolibri.dart' as kb;
|
||||
|
||||
import 'conversation_params.dart';
|
||||
|
||||
/// Параметры подключения к сигналинг-сокету ws2.
|
||||
@@ -84,17 +84,19 @@ class Ws2CommandException implements Exception {
|
||||
|
||||
/// Клиент сигналинга звонка поверх WebSocket `ws2`.
|
||||
///
|
||||
/// Конверт сообщений (подтверждено захватом `docs/ws2_capture.log`):
|
||||
/// Тонкий адаптер над Rust-ядром (kolibri [kb.CallSignaling]): ядро держит
|
||||
/// WebSocket, корреляцию `sequence`/`response`, keepalive `ping`→`pong` и
|
||||
/// разбор кадров; здесь — прежний Dart-интерфейс для [call_session].
|
||||
///
|
||||
/// Конверт сообщений:
|
||||
/// - запрос: `{"command": ..., ..., "sequence": N}`
|
||||
/// - ответ: `{"sequence": N, "response": "<command>", "type": "response"}`
|
||||
/// - пуш: `{..., "notification": "<name>", "type": "notification"}`
|
||||
/// - keepalive: текстовый кадр `ping` → ответ `pong`.
|
||||
class Ws2Signaling {
|
||||
final Ws2Config config;
|
||||
|
||||
WebSocket? _socket;
|
||||
int _sequence = 0;
|
||||
final Map<int, Completer<Map<String, dynamic>>> _pending = {};
|
||||
kb.CallSignaling? _call;
|
||||
StreamSubscription<String>? _notifSub;
|
||||
|
||||
final _notifications = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _closed = Completer<Object?>();
|
||||
@@ -104,106 +106,60 @@ class Ws2Signaling {
|
||||
/// Пуши сервера (`type == "notification"`). Фильтруй по полю `notification`.
|
||||
Stream<Map<String, dynamic>> get notifications => _notifications.stream;
|
||||
|
||||
/// Завершается, когда сокет закрыт (значение — причина закрытия, если была).
|
||||
/// Завершается, когда сокет закрыт.
|
||||
Future<Object?> get done => _closed.future;
|
||||
|
||||
bool get isConnected => _socket != null;
|
||||
bool get isConnected => _call?.isConnected() ?? false;
|
||||
|
||||
Future<void> connect() async {
|
||||
final socket = await WebSocket.connect(
|
||||
config.uri.toString(),
|
||||
headers: {'User-Agent': 'okhttp/4.12.0'},
|
||||
final call = await kb.connectCallSignaling(
|
||||
url: config.uri.toString(),
|
||||
userAgent: 'okhttp/4.12.0',
|
||||
);
|
||||
_socket = socket;
|
||||
socket.listen(
|
||||
_onFrame,
|
||||
onError: _onDone,
|
||||
_call = call;
|
||||
_notifSub = call.notifications().listen(
|
||||
(json) {
|
||||
Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(json);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (decoded is Map<String, dynamic>) _notifications.add(decoded);
|
||||
},
|
||||
onError: (_) => _onDone(null),
|
||||
onDone: () => _onDone(null),
|
||||
cancelOnError: false,
|
||||
);
|
||||
}
|
||||
|
||||
void _onFrame(dynamic frame) {
|
||||
if (frame is String && frame == 'ping') {
|
||||
_socket?.add('pong');
|
||||
return;
|
||||
}
|
||||
|
||||
final String text;
|
||||
if (frame is String) {
|
||||
text = frame;
|
||||
} else if (frame is List<int>) {
|
||||
text = utf8.decode(frame);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(text);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (decoded is! Map<String, dynamic>) return;
|
||||
|
||||
final label =
|
||||
decoded['notification'] ?? decoded['response'] ?? decoded['type'];
|
||||
final dump = jsonEncode(decoded);
|
||||
logger.t('[ws2] ← $label');
|
||||
logger.t(dump.length > 1500
|
||||
? '${dump.substring(0, 1500)}… (${dump.length}b)'
|
||||
: dump);
|
||||
|
||||
final type = decoded['type'];
|
||||
if (type == 'response' || type == 'error') {
|
||||
final seq = decoded['sequence'];
|
||||
if (seq is int) {
|
||||
final completer = _pending.remove(seq);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete(decoded);
|
||||
}
|
||||
}
|
||||
if (type == 'error') _notifications.add(decoded);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'notification' || decoded.containsKey('notification')) {
|
||||
_notifications.add(decoded);
|
||||
}
|
||||
}
|
||||
|
||||
void _onDone(Object? error) {
|
||||
for (final c in _pending.values) {
|
||||
if (!c.isCompleted) c.completeError(error ?? const SocketException('ws2 closed'));
|
||||
}
|
||||
_pending.clear();
|
||||
if (!_closed.isCompleted) _closed.complete(error);
|
||||
if (!_notifications.isClosed) _notifications.close();
|
||||
}
|
||||
|
||||
/// Отправляет команду и ждёт ответ сервера. Бросает [Ws2CommandException],
|
||||
/// если в ответе есть поле `error`.
|
||||
/// если сервер вернул ошибку.
|
||||
Future<Map<String, dynamic>> sendCommand(
|
||||
String command, {
|
||||
Map<String, dynamic> extra = const {},
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) {
|
||||
final socket = _socket;
|
||||
if (socket == null) {
|
||||
}) async {
|
||||
final call = _call;
|
||||
if (call == null) {
|
||||
return Future.error(StateError('ws2 не подключён'));
|
||||
}
|
||||
|
||||
final seq = ++_sequence;
|
||||
final completer = Completer<Map<String, dynamic>>();
|
||||
_pending[seq] = completer;
|
||||
|
||||
socket.add(jsonEncode({'command': command, ...extra, 'sequence': seq}));
|
||||
|
||||
return completer.future.timeout(timeout).then((response) {
|
||||
final error = response['error'];
|
||||
if (error != null) throw Ws2CommandException(command, error);
|
||||
return response;
|
||||
});
|
||||
try {
|
||||
final response = await call
|
||||
.sendCommand(command: command, extraJson: jsonEncode(extra))
|
||||
.timeout(timeout);
|
||||
final decoded = jsonDecode(response);
|
||||
return decoded is Map<String, dynamic>
|
||||
? decoded
|
||||
: <String, dynamic>{};
|
||||
} catch (e) {
|
||||
throw Ws2CommandException(command, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Передаёт SDP (offer/answer) другому участнику.
|
||||
@@ -329,7 +285,9 @@ class Ws2Signaling {
|
||||
extra: {'mediaSource': mediaSource, 'layers': layers});
|
||||
|
||||
Future<void> close() async {
|
||||
await _socket?.close();
|
||||
_socket = null;
|
||||
await _notifSub?.cancel();
|
||||
_notifSub = null;
|
||||
_call?.close();
|
||||
_call = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// LZ4 block декомпрессия (без frame-заголовка).
|
||||
///
|
||||
/// Сервер шлёт block-формат как в транспорте (payload пакетов), так и в
|
||||
/// `vcp`-параметрах звонка. dart_lz4 поддерживает только frame-формат, поэтому
|
||||
/// block распаковывается вручную.
|
||||
Uint8List lz4BlockDecompress(Uint8List src, int maxSize) {
|
||||
var out = Uint8List(1024);
|
||||
int outLen = 0;
|
||||
int pos = 0;
|
||||
|
||||
void ensure(int extra) {
|
||||
if (outLen + extra > maxSize) throw StateError('LZ4: превышен лимит');
|
||||
if (outLen + extra <= out.length) return;
|
||||
var newCap = out.length * 2;
|
||||
while (newCap < outLen + extra) {
|
||||
newCap *= 2;
|
||||
}
|
||||
if (newCap > maxSize) newCap = maxSize;
|
||||
final grown = Uint8List(newCap);
|
||||
grown.setRange(0, outLen, out);
|
||||
out = grown;
|
||||
}
|
||||
|
||||
while (pos < src.length) {
|
||||
final token = src[pos++];
|
||||
var litLen = token >> 4;
|
||||
|
||||
if (litLen == 15) {
|
||||
while (pos < src.length) {
|
||||
final b = src[pos++];
|
||||
litLen += b;
|
||||
if (b != 255) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (litLen > 0) {
|
||||
ensure(litLen);
|
||||
out.setRange(outLen, outLen + litLen, src, pos);
|
||||
outLen += litLen;
|
||||
pos += litLen;
|
||||
}
|
||||
|
||||
if (pos >= src.length) break;
|
||||
|
||||
if (pos + 1 >= src.length) throw StateError('LZ4: unexpected end of input');
|
||||
final offset = src[pos] | (src[pos + 1] << 8);
|
||||
pos += 2;
|
||||
if (offset == 0) throw StateError('LZ4: offset = 0');
|
||||
|
||||
var matchLen = (token & 0x0F) + 4;
|
||||
if ((token & 0x0F) == 0x0F) {
|
||||
while (pos < src.length) {
|
||||
final b = src[pos++];
|
||||
matchLen += b;
|
||||
if (b != 255) break;
|
||||
}
|
||||
}
|
||||
|
||||
ensure(matchLen);
|
||||
final start = outLen - offset;
|
||||
if (start < 0) throw StateError('LZ4: offset за пределами вывода');
|
||||
for (var i = 0; i < matchLen; i++) {
|
||||
out[outLen + i] = out[start + i];
|
||||
}
|
||||
outLen += matchLen;
|
||||
}
|
||||
|
||||
return Uint8List.sublistView(out, 0, outLen);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:kolibri/kolibri.dart' show initKolibri;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../backend/api.dart';
|
||||
@@ -53,6 +54,9 @@ Future<void> _handleCallDecline(String payloadJson) async {
|
||||
}
|
||||
if (vcp.isEmpty || conversationId.isEmpty) return;
|
||||
|
||||
// Фоновый изолят: инициализируем ядро перед vcp-декодом/сигналингом.
|
||||
await initKolibri();
|
||||
|
||||
final params = ConversationParams.decode(vcp);
|
||||
if (params == null) return;
|
||||
|
||||
@@ -83,6 +87,7 @@ Future<void> _handleReply(String payloadJson, String text) async {
|
||||
if (account == 0 || chatId == 0) return;
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await initKolibri();
|
||||
if (AppInstance.isNamed) {
|
||||
try {
|
||||
SharedPreferences.setPrefix('flutter.${AppInstance.id}.');
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../backend/modules/webapp.dart';
|
||||
import '../../../core/storage/spoofing_service.dart';
|
||||
import '../../../main.dart' show api;
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/error_view.dart';
|
||||
import '../../widgets/webview_permission_prompt.dart';
|
||||
@@ -63,7 +64,11 @@ class _WebAppScreenState extends State<WebAppScreen> {
|
||||
_launch = null;
|
||||
});
|
||||
try {
|
||||
_userAgent = await SpoofingService.getWebViewUserAgent() ?? '';
|
||||
// Тот же UA, что уходит в sessionInit (из handshake-устройства ядра),
|
||||
// чтобы веб-аппы видели нативный клиент; фолбэк — браузерный UA спуфа.
|
||||
_userAgent = api.session?.userAgent() ??
|
||||
await SpoofingService.getWebViewUserAgent() ??
|
||||
'';
|
||||
final launch = await widget.loader();
|
||||
if (!mounted) return;
|
||||
setState(() => _launch = launch);
|
||||
|
||||
@@ -201,14 +201,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
dart_lz4:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dart_lz4
|
||||
sha256: e2e9c30fdf83a7a1e63bc6c4d90786cf795c38eefee40b1f4c35ea6480c91db9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
dart_webrtc:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+2
-2
@@ -35,13 +35,13 @@ dependencies:
|
||||
intl: any
|
||||
|
||||
# Rust networking core (kolibri) — FFI plugin, replaces the Dart transport.
|
||||
# Vendored as a git submodule (third_party/kolibri).
|
||||
# DEV: local path for fast iteration; re-pin to third_party/kolibri submodule
|
||||
# (path: third_party/kolibri/kolibri-dart) before merge.
|
||||
kolibri:
|
||||
path: third_party/kolibri/kolibri-dart
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
dart_lz4: ^1.0.0
|
||||
crypto: ^3.0.7
|
||||
ffi: ^2.1.0
|
||||
logger: ^2.6.2
|
||||
|
||||
Vendored
+1
-1
Submodule third_party/kolibri updated: 949e70a1a5...8f6836cac2
Reference in New Issue
Block a user