Release IKAR 1.0.7: port navigation and sharing improvements, harden calls
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

This commit is contained in:
sevenhill
2026-10-08 20:38:54 +03:00
parent 68f7957043
commit 317cfdae38
37 changed files with 1483 additions and 282 deletions
+133 -33
View File
@@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform;
show TargetPlatform, defaultTargetPlatform, visibleForTesting;
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../config/app_camera.dart';
@@ -127,6 +127,8 @@ class CallSession {
static const Duration _maxReconnectDelay = Duration(seconds: 20);
bool get isReconnecting => _reconnecting;
String? _failureReason;
String? get failureReason => _failureReason;
Timer? _levelTimer;
final Map<int, int> _speakHold = {};
@@ -238,7 +240,11 @@ class CallSession {
: DateTime.now().difference(_activeSince!).inSeconds;
void _setState(CallSessionState s) {
if (_current == s || _current == CallSessionState.ended) return;
if (_state.isClosed ||
_current == s ||
_current == CallSessionState.ended) {
return;
}
if (s == CallSessionState.active) _activeSince ??= DateTime.now();
_current = s;
_state.add(s);
@@ -360,6 +366,7 @@ class CallSession {
_reconnecting = false;
if (!_ended) {
logger.w('[call] reconnect gave up');
_failureReason = 'Не удалось восстановить соединение для звонка';
_end();
}
}
@@ -368,6 +375,8 @@ class CallSession {
if (_ended || _iceRestarting || _topology == 'SERVER') return;
if (_iceRestarts >= _maxIceRestarts) {
logger.w('[call] ice restart budget exhausted, ending call');
_failureReason =
'Не удалось установить соединение для звонка. Проверьте сеть';
_end();
return;
}
@@ -398,10 +407,15 @@ class CallSession {
_probeChannel = null;
await _closeSfuChannels();
try {
await _pc?.close();
} catch (_) {}
final previousPc = _pc;
_pc = null;
previousPc?.onIceCandidate = null;
previousPc?.onTrack = null;
previousPc?.onConnectionState = null;
previousPc?.onIceConnectionState = null;
try {
await previousPc?.close();
} catch (_) {}
_audioSender = null;
_videoSender = null;
@@ -409,6 +423,7 @@ class CallSession {
_remoteDescSet = false;
_pendingCandidates.clear();
_accepted = false;
_gotConnection = false;
_mediaConnected = false;
_sfuSessionId = null;
await _clearParticipantStreams();
@@ -785,6 +800,9 @@ class CallSession {
}
Future<void> _onConnection(Map<String, dynamic> msg) async {
if (_ended) return;
// A wake command or reconnect can repeat the handshake notification.
if (_gotConnection) return;
_gotConnection = true;
logger.i('[call] connection notification received');
final convParams = msg['conversationParams'];
@@ -808,8 +826,13 @@ class CallSession {
}
final pc = await _createPc(ice);
if (_ended) {
await pc.close();
return;
}
_pc = pc;
await _addLocalMedia(pc);
if (_ended || !identical(_pc, pc)) return;
await pc.addTransceiver(
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
@@ -846,12 +869,13 @@ class CallSession {
final done = _gatherDone;
if (done != null && !done.isCompleted) done.complete();
};
pc.onTrack = (event) => unawaited(_onRemoteTrack(event));
pc.onTrack = (event) => _runCallTask(() => _onRemoteTrack(event));
pc.onDataChannel = (channel) {
if (!_qlyraProbeEnabled) return;
_bindProbeChannel(channel, ask: false);
};
pc.onIceConnectionState = (s) {
if (_ended || !identical(_pc, pc)) return;
logger.i('[call] ice $s');
if (s != RTCIceConnectionState.RTCIceConnectionStateFailed) return;
if (_topology != 'SERVER' || _ended) return;
@@ -865,6 +889,7 @@ class CallSession {
);
};
pc.onConnectionState = (s) {
if (_ended || !identical(_pc, pc)) return;
logger.i('[call] pc $s');
final connected =
s == RTCPeerConnectionState.RTCPeerConnectionStateConnected;
@@ -876,9 +901,9 @@ class CallSession {
if (role == CallRole.joiner || _topology == 'SERVER') {
_setState(CallSessionState.active);
}
unawaited(applyAudioRoute());
unawaited(_resolvePath());
unawaited(_collectReceivers());
_runCallTask(applyAudioRoute);
_runCallTask(_resolvePath);
_runCallTask(_collectReceivers);
}
}
if (_topology == 'SERVER') return;
@@ -905,15 +930,22 @@ class CallSession {
await _resetMicRoute();
}
await _selectMicInsideEngine();
_localStream = await navigator.mediaDevices.getUserMedia({
if (_ended || !identical(_pc, pc)) return;
final stream = await navigator.mediaDevices.getUserMedia({
'audio': AudioDevices.micConstraints(
_micDeviceId,
monitorCapture: _monitorCapture,
),
'video': _wantVideo,
});
for (final track in _localStream!.getTracks()) {
final sender = await pc.addTrack(track, _localStream!);
if (_ended || !identical(_pc, pc)) {
await _disposeStream(stream);
return;
}
_localStream = stream;
for (final track in stream.getTracks()) {
if (_ended || !identical(_pc, pc)) return;
final sender = await pc.addTrack(track, stream);
if (track.kind == 'audio') _audioSender = sender;
}
_applyAudioTracks();
@@ -1136,7 +1168,7 @@ class CallSession {
final id = _participantIdFrom(key.split(':').first);
if (id != null) _slotParticipant[slot] = id;
});
unawaited(_rebindSlotTracks());
_runCallTask(_rebindSlotTracks);
}
Future<void> _rebindSlotTracks() async {
@@ -1829,6 +1861,7 @@ class CallSession {
}
Future<void> _onRemoteTrack(RTCTrackEvent event) async {
if (_ended) return;
logger.t(
'[call] remote track: ${event.track.kind} id=${event.track.id} '
'streams=${event.streams.length}',
@@ -1836,7 +1869,9 @@ class CallSession {
await _bindParticipantTrack(event.track);
if (event.streams.isNotEmpty) {
_remoteStreamRef = event.streams.first;
_remoteStream.add(event.streams.first);
if (!_ended && !_remoteStream.isClosed) {
_remoteStream.add(event.streams.first);
}
} else {
await _collectReceivers();
}
@@ -1887,7 +1922,7 @@ class CallSession {
await stream.addTrack(track);
} catch (_) {}
}
_remoteStream.add(stream);
if (!_ended && !_remoteStream.isClosed) _remoteStream.add(stream);
}
Future<void> _logSenders() async {
@@ -1971,7 +2006,7 @@ class CallSession {
Future<void> _onTransmittedData(Map<String, dynamic> msg) async {
final pc = _pc;
if (pc == null) return;
if (pc == null || _ended) return;
final data = msg['data'];
if (data is! Map) return;
@@ -2075,7 +2110,35 @@ class CallSession {
}
}
void _runCallTask(Future<void> Function() operation) {
if (_ended) return;
unawaited(() async {
try {
await operation();
} catch (error, stack) {
if (!_ended) {
logger.w(
'[call] asynchronous operation failed',
error: error,
stackTrace: stack,
);
}
}
}());
}
@visibleForTesting
void bindCandidateSignalingForTesting(Ws2Signaling signaling, int peerId) {
_signaling = signaling;
_peerId = peerId;
}
@visibleForTesting
void receiveLocalCandidateForTesting(RTCIceCandidate candidate) =>
_onLocalCandidate(candidate);
void _onLocalCandidate(RTCIceCandidate candidate) {
if (_ended) return;
final line = candidate.candidate;
if (line != null && line.contains(' typ relay')) {
final done = _gatherDone;
@@ -2083,15 +2146,24 @@ class CallSession {
}
if (_topology == 'SERVER') return;
final peerId = _peerId;
if (peerId == null || candidate.candidate == null) return;
_signaling?.transmitCandidate(
participantId: peerId,
participantType: _peerType,
deviceIdx: _peerDeviceIdx,
candidate: candidate.candidate!,
sdpMid: candidate.sdpMid ?? '0',
sdpMLineIndex: candidate.sdpMLineIndex ?? 0,
);
final signaling = _signaling;
if (peerId == null || line == null || signaling == null) return;
_runCallTask(() async {
try {
await signaling.transmitCandidate(
participantId: peerId,
participantType: _peerType,
deviceIdx: _peerDeviceIdx,
candidate: line,
sdpMid: candidate.sdpMid ?? '0',
sdpMLineIndex: candidate.sdpMLineIndex ?? 0,
);
} catch (error) {
if (_ended || !identical(_signaling, signaling)) return;
logger.w('[call] ICE signaling interrupted: $error');
_onSignalingLost();
}
});
}
Future<void> accept({bool activate = true}) async {
@@ -2316,35 +2388,63 @@ class CallSession {
if (_ended) return;
_ended = true;
_setState(CallSessionState.ended);
_dispose();
unawaited(
_dispose().catchError((Object error, StackTrace stack) {
logger.w('[call] cleanup failed', error: error, stackTrace: stack);
}),
);
}
Future<void> _dispose() async {
_levelTimer?.cancel();
_videoStatsTimer?.cancel();
final pc = _pc;
_pc = null;
if (pc != null) {
pc.onIceCandidate = null;
pc.onTrack = null;
pc.onConnectionState = null;
pc.onIceConnectionState = null;
pc.onIceGatheringState = null;
pc.onDataChannel = null;
}
try {
await _probeChannel?.close();
} catch (_) {}
_probeChannel = null;
await _closeSfuChannels();
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
await track.stop();
try {
await _closeSfuChannels();
} catch (e) {
logger.w('[call] channel cleanup: $e');
}
await _localStream?.dispose();
await _disposeStream(_localStream);
_localStream = null;
await _disposeMicStream();
await PulseAudio.closeBridge();
try {
await PulseAudio.closeBridge();
} catch (e) {
logger.w('[call] audio cleanup: $e');
}
await _disposeStream(_cameraStream);
await _disposeStream(_screenStream);
_cameraStream = null;
_screenStream = null;
await _pc?.close();
try {
await pc?.close();
} catch (e) {
logger.w('[call] peer cleanup: $e');
}
if (_ownRemoteStream) {
try {
await _remoteStreamRef?.dispose();
} catch (_) {}
}
await _clearParticipantStreams();
await _signaling?.close();
try {
await _signaling?.close();
} catch (e) {
logger.w('[call] signaling cleanup: $e');
}
if (!_participantStreamUpdates.isClosed) {
await _participantStreamUpdates.close();
}