diff --git a/.changes/connection-check b/.changes/connection-check new file mode 100644 index 000000000..015f81ce8 --- /dev/null +++ b/.changes/connection-check @@ -0,0 +1 @@ +minor type="added" "Add ConnectionCheck utility for diagnosing connection issues (port of the client-sdk-js connection helper)" diff --git a/README.md b/README.md index 32d52f0c4..5adcddd3b 100644 --- a/README.md +++ b/README.md @@ -574,6 +574,38 @@ These controls are accessible on the `RemoteTrackPublication` object. For more info, see [Subscribing to tracks](https://docs.livekit.io/home/client/tracks/subscribe/). +### Diagnosing connection issues + +`ConnectionCheck` runs a set of connection diagnostics against a LiveKit server — the same checks that power [livekit.io/connection-test](https://livekit.io/connection-test). Run it proactively at app start, or reactively when `room.connect()` fails, to determine why a connection cannot be established (firewall, VPN, blocked TURN, etc.). + +```dart +final connectionCheck = ConnectionCheck(url, token); +final listener = connectionCheck.createListener(); +listener.on((event) { + print('${event.info.name}: ${event.info.status.name}'); + for (final log in event.info.logs) { + print(' $log'); + } +}); + +// the recommended minimum set of checks +await connectionCheck.checkWebsocket(); +await connectionCheck.checkWebRTC(); +await connectionCheck.checkTURN(); + +// additional checks +await connectionCheck.checkReconnect(); +await connectionCheck.checkPublishAudio(); // requires microphone permission +await connectionCheck.checkPublishVideo(); // requires camera permission +await connectionCheck.checkConnectionProtocol(); +await connectionCheck.checkCloudRegion(); // LiveKit Cloud only + +print('all checks passed: ${connectionCheck.isSuccess}'); + +await listener.dispose(); +await connectionCheck.dispose(); +``` + ## Getting help / Contributing Please join us on [Slack](https://livekit.io/join-slack) to get help from our devs / community members. We welcome your contributions(PRs) and details can be discussed there. diff --git a/example/lib/pages/connect.dart b/example/lib/pages/connect.dart index 9ffe51331..67e526631 100644 --- a/example/lib/pages/connect.dart +++ b/example/lib/pages/connect.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:livekit_client/livekit_client.dart'; +import 'package:livekit_example/pages/connection_check.dart'; import 'package:livekit_example/pages/prejoin.dart'; import 'package:livekit_example/widgets/text_field.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -160,6 +161,20 @@ class _ConnectPageState extends State { } } + Future _connectionCheck(BuildContext ctx) async { + // Save URL and Token for convenience + await _writePrefs(); + if (!ctx.mounted) return; + await Navigator.push( + ctx, + MaterialPageRoute( + builder: (_) => ConnectionCheckPage( + url: _uriCtrl.text, + token: _tokenCtrl.text, + )), + ); + } + void _setSimulcast(bool? value) async { if (value == null || _simulcast == value) return; setState(() { @@ -356,6 +371,10 @@ class _ConnectPageState extends State { ], ), ), + TextButton( + onPressed: _busy ? null : () => unawaited(_connectionCheck(context)), + child: const Text('Connection Check'), + ), ], ), ), diff --git a/example/lib/pages/connection_check.dart b/example/lib/pages/connection_check.dart new file mode 100644 index 000000000..8a7ede80b --- /dev/null +++ b/example/lib/pages/connection_check.dart @@ -0,0 +1,187 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:livekit_client/livekit_client.dart'; + +class ConnectionCheckPage extends StatefulWidget { + // + const ConnectionCheckPage({ + required this.url, + required this.token, + super.key, + }); + + final String url; + final String token; + + @override + State createState() => _ConnectionCheckPageState(); +} + +class _ConnectionCheckPageState extends State { + // + ConnectionCheck? _connectionCheck; + EventsListener? _listener; + final Map _results = {}; + bool _running = false; + + @override + void dispose() { + unawaited(_cleanUp()); + super.dispose(); + } + + Future _cleanUp() async { + await _listener?.dispose(); + _listener = null; + await _connectionCheck?.dispose(); + _connectionCheck = null; + } + + Future _runChecks() async { + await _cleanUp(); + + final connectionCheck = ConnectionCheck(widget.url, widget.token); + final listener = connectionCheck.createListener(); + listener.on((event) { + if (!mounted) return; + setState(() { + _results[event.checkId] = event.info; + }); + }); + + setState(() { + _connectionCheck = connectionCheck; + _listener = listener; + _results.clear(); + _running = true; + }); + + final checks = Function()>[ + connectionCheck.checkWebsocket, + connectionCheck.checkWebRTC, + connectionCheck.checkTURN, + connectionCheck.checkReconnect, + connectionCheck.checkPublishAudio, + connectionCheck.checkPublishVideo, + connectionCheck.checkConnectionProtocol, + connectionCheck.checkCloudRegion, + ]; + try { + for (final check in checks) { + // stop starting new checks once the page is gone + if (!mounted) break; + await check(); + } + } catch (error) { + print('Connection check error: $error'); + } finally { + if (mounted) { + setState(() { + _running = false; + }); + } + } + } + + Widget _iconFor(CheckStatus status) { + switch (status) { + case CheckStatus.idle: + return const Icon(Icons.radio_button_unchecked); + case CheckStatus.running: + return const SizedBox( + width: 24, + height: 24, + child: Padding( + padding: EdgeInsets.all(2), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + case CheckStatus.skipped: + return const Icon(Icons.skip_next, color: Colors.grey); + case CheckStatus.success: + return const Icon(Icons.check_circle, color: Colors.green); + case CheckStatus.failed: + return const Icon(Icons.error, color: Colors.red); + } + } + + Color _colorFor(CheckLogLevel level) { + switch (level) { + case CheckLogLevel.info: + return Colors.grey; + case CheckLogLevel.warning: + return Colors.orange; + case CheckLogLevel.error: + return Colors.red; + } + } + + @override + Widget build(BuildContext context) { + final results = _results.entries.toList()..sort((a, b) => a.key.compareTo(b.key)); + return Scaffold( + appBar: AppBar( + title: const Text('Connection Check'), + ), + body: Column( + children: [ + Expanded( + child: ListView( + children: [ + for (final entry in results) + ExpansionTile( + initiallyExpanded: true, + leading: _iconFor(entry.value.status), + title: Text(entry.value.name), + subtitle: Text(entry.value.description), + children: [ + for (final log in entry.value.logs) + ListTile( + dense: true, + visualDensity: VisualDensity.compact, + title: Text( + log.message, + style: TextStyle( + fontSize: 13, + color: _colorFor(log.level), + ), + ), + ), + ], + ), + ], + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: ElevatedButton( + onPressed: _running ? null : () => unawaited(_runChecks()), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_running) + const Padding( + padding: EdgeInsets.only(right: 10), + child: SizedBox( + height: 15, + width: 15, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + ), + const Text('Run checks'), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/livekit_client.dart b/lib/livekit_client.dart index 218dca413..73397d65f 100644 --- a/lib/livekit_client.dart +++ b/lib/livekit_client.dart @@ -14,6 +14,11 @@ import 'src/types/rpc.dart' show kRpcVersion; +export 'src/connection_check/checks/checker.dart'; +export 'src/connection_check/checks/cloud_region.dart' show RegionStats; +export 'src/connection_check/checks/connection_protocol.dart' show ProtocolStats; +export 'src/connection_check/connection_check.dart'; +export 'src/connection_check/events.dart'; export 'src/constants.dart'; export 'src/core/room.dart'; export 'src/core/room_preconnect.dart'; diff --git a/lib/src/connection_check/checks/checker.dart b/lib/src/connection_check/checks/checker.dart new file mode 100644 index 000000000..1bc89e1bf --- /dev/null +++ b/lib/src/connection_check/checks/checker.dart @@ -0,0 +1,365 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import '../../core/engine.dart'; +import '../../core/room.dart'; +import '../../core/signal_client.dart'; +import '../../events.dart'; +import '../../exceptions.dart'; +import '../../internal/events.dart'; +import '../../managers/event.dart'; +import '../../options.dart'; +import '../../proto/livekit_rtc.pb.dart' as lk_rtc; +import '../../support/disposable.dart'; +import '../../support/websocket.dart'; +import '../../types/internal.dart'; +import '../../types/other.dart'; +import '../events.dart'; + +/// Status of a [Checker]. +enum CheckStatus { + idle, + running, + skipped, + success, + failed, +} + +/// Severity of a single [CheckLog] entry. +enum CheckLogLevel { + info, + warning, + error, +} + +/// Connection protocols compared by the connection protocol check. +enum CheckProtocol { + udp, + tcp, +} + +/// A single log line produced while a [Checker] runs. +class CheckLog { + const CheckLog({ + required this.level, + required this.message, + }); + + final CheckLogLevel level; + final String message; + + @override + String toString() => '[${level.name}] $message'; +} + +/// A snapshot of a [Checker]'s state. +class CheckInfo { + const CheckInfo({ + required this.name, + required this.description, + required this.status, + required this.logs, + this.data, + }); + + /// Name of the check, defaults to the [Checker]'s class name. + final String name; + + /// Human readable description of what the check verifies. + final String description; + + final CheckStatus status; + + final List logs; + + /// Check specific data, e.g. `ProtocolStats` for the connection protocol + /// check or `RegionStats` for the cloud region check. + final Object? data; + + @override + String toString() => '$runtimeType(name: $name, status: ${status.name}, logs: ${logs.length})'; +} + +/// Options shared by all [Checker]s of a `ConnectionCheck` run. +class CheckerOptions { + CheckerOptions({ + this.errorsAsWarnings = false, + this.roomOptions, + this.connectOptions, + this.protocol, + }); + + /// When true, errors are logged as warnings and don't fail the check. + bool errorsAsWarnings; + + /// Options for the [Room] instances created by each check. + RoomOptions? roomOptions; + + /// Options used when connecting to the LiveKit server. + ConnectOptions? connectOptions; + + /// Preferred connection protocol. Set by the connection protocol check so + /// that subsequent checks (e.g. cloud region) use the better protocol. + CheckProtocol? protocol; +} + +/// Exception thrown when a check cannot complete. +class CheckException implements Exception { + const CheckException(this.message); + + final String message; + + @override + String toString() => message; +} + +/// Base class for a single connection diagnostic, a Dart port of the +/// `Checker` class from client-sdk-js' `ConnectionCheck` utility. +/// +/// Subclasses implement [perform] and report progress through +/// [appendMessage], [appendWarning] and [appendError]. Any log entry with +/// [CheckLogLevel.error] (including uncaught exceptions thrown from +/// [perform]) marks the check as [CheckStatus.failed]. +/// +/// A [Checker] is single-use: [run] may only be called once, and the checker +/// should be disposed with [dispose] afterwards. +abstract class Checker extends Disposable with EventsEmittable { + Checker( + this.url, + this.token, { + CheckerOptions? options, + Room? room, + }) : options = options ?? CheckerOptions(), + connectOptions = options?.connectOptions, + _ownsRoom = room == null, + room = room ?? Room(roomOptions: options?.roomOptions ?? const RoomOptions()) { + onDispose(() async { + await events.dispose(); + if (_ownsRoom) { + await this.room.dispose(); + } + }); + } + + /// URL of the LiveKit server to run the check against. + /// + /// Not final because some checks (e.g. TURN) resolve a region specific URL. + String url; + + /// Access token used for the check. + final String token; + + /// The room instance used by this check. + final Room room; + + /// Whether [room] was created (and is therefore disposed) by this checker. + final bool _ownsRoom; + + final CheckerOptions options; + + ConnectOptions? connectOptions; + + CheckStatus status = CheckStatus.idle; + + final List logs = []; + + /// WebSocket connector used by [signalJoin], exposed for testing. + @visibleForTesting + WebSocketConnector? wsConnector; + + /// Name of this check, defaults to the class name. + String get name => '$runtimeType'; + + /// Human readable description of what this check verifies. + String get description; + + /// Check specific data included in [getInfo], null by default. + Object? get data => null; + + /// The actual check logic, implemented by subclasses. + @protected + Future perform(); + + @protected + Engine get engine => room.engine; + + @protected + NetworkOptions get networkOptions => options.roomOptions?.networkOptions ?? const NetworkOptions(); + + /// Runs the check once and returns the final [CheckInfo]. + Future run() async { + if (status != CheckStatus.idle) { + throw StateError('check is running already'); + } + setStatus(CheckStatus.running); + + try { + await perform(); + } catch (err) { + if (options.errorsAsWarnings) { + appendWarning(messageFor(err)); + } else { + appendError(messageFor(err)); + } + } + + try { + await disconnect(); + } catch (err) { + // don't let a failing disconnect (e.g. after a connect that failed + // mid-handshake) prevent the check from reporting its result + appendWarning('failed to disconnect: ${messageFor(err)}'); + } + + // sleep for a bit to ensure disconnect + await Future.delayed(const Duration(milliseconds: 500)); + + if (status != CheckStatus.skipped) { + setStatus(isSuccess() ? CheckStatus.success : CheckStatus.failed); + } + + return getInfo(); + } + + @protected + bool isSuccess() => !logs.any((log) => log.level == CheckLogLevel.error); + + /// Connects [room] to [url] (or the checker's default URL) unless it is + /// already connected. + @protected + Future connect([String? url]) async { + if (room.connectionState == ConnectionState.connected) { + return room; + } + await room.connect(url ?? this.url, token, connectOptions: connectOptions); + return room; + } + + @protected + Future disconnect() async { + if (room.connectionState != ConnectionState.disconnected) { + await room.disconnect(); + // wait for it to go through + await Future.delayed(const Duration(milliseconds: 500)); + } + } + + /// Marks this check as skipped (e.g. not applicable to the server). + @protected + void skip() => setStatus(CheckStatus.skipped); + + /// Asks the server to restrict ICE candidates to [protocol] and forces a + /// full reconnect so the switch takes effect. Mirrors + /// `simulateScenario('force-tcp')` from client-sdk-js. + /// + /// [CheckProtocol.udp] is a no-op since fresh connections already prefer + /// UDP (this matches the JS SDK, which has no `force-udp` scenario). + @protected + Future switchProtocol(CheckProtocol protocol) async { + if (protocol == CheckProtocol.udp) { + return; + } + final listener = room.createListener(); + try { + // unlike client-sdk-js, the reconnect is always triggered explicitly + // below, so simply wait for it to complete + final reconnected = listener.waitFor( + duration: const Duration(seconds: 10), + onTimeout: () => throw CheckException('Could not reconnect using ${protocol.name} protocol after 10 seconds'), + ); + reconnected.ignore(); + engine.signalClient.sendSimulateScenario(switchCandidate: true); + engine.fullReconnectOnNext = true; + await engine.handleReconnect(ClientDisconnectReason.leaveReconnect); + await reconnected; + } finally { + await listener.dispose(); + } + } + + /// Performs a signal-level join (WebSocket only, no peer connections) and + /// returns the server's join response. + @protected + Future signalJoin(String url) async { + final signalClient = SignalClient(wsConnector ?? LiveKitWebSocket.connect); + final listener = signalClient.createListener(); + try { + final joinCompleter = Completer(); + listener.once((event) { + if (!joinCompleter.isCompleted) { + joinCompleter.complete(event.response); + } + }); + await signalClient.connect( + url, + token, + connectOptions: connectOptions ?? const ConnectOptions(), + roomOptions: options.roomOptions ?? const RoomOptions(), + ); + return await joinCompleter.future.timeout( + (connectOptions ?? const ConnectOptions()).timeouts.connection, + onTimeout: () => throw const CheckException('Did not receive a join response'), + ); + } finally { + await listener.dispose(); + await signalClient.dispose(); + } + } + + /// Extracts a human readable message from [error]. + @protected + String messageFor(Object error) { + if (error is LiveKitException) { + return error.message; + } + return error.toString(); + } + + @protected + void appendMessage(String message) { + logs.add(CheckLog(level: CheckLogLevel.info, message: message)); + events.emit(CheckerUpdateEvent(info: getInfo())); + } + + @protected + void appendWarning(String message) { + logs.add(CheckLog(level: CheckLogLevel.warning, message: message)); + events.emit(CheckerUpdateEvent(info: getInfo())); + } + + @protected + void appendError(String message) { + logs.add(CheckLog(level: CheckLogLevel.error, message: message)); + events.emit(CheckerUpdateEvent(info: getInfo())); + } + + @protected + void setStatus(CheckStatus newStatus) { + status = newStatus; + events.emit(CheckerUpdateEvent(info: getInfo())); + } + + /// The current snapshot of this check. + CheckInfo getInfo() => CheckInfo( + name: name, + description: description, + status: status, + logs: List.unmodifiable(logs), + data: data, + ); +} diff --git a/lib/src/connection_check/checks/cloud_region.dart b/lib/src/connection_check/checks/cloud_region.dart new file mode 100644 index 000000000..851d13da8 --- /dev/null +++ b/lib/src/connection_check/checks/cloud_region.dart @@ -0,0 +1,173 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; + +import '../../participant/local.dart'; +import '../../stats/stats.dart'; +import '../../support/region_url_provider.dart'; +import '../../types/data_stream.dart'; +import 'checker.dart'; + +/// Connection statistics for a single LiveKit Cloud region. +class RegionStats { + const RegionStats({ + required this.region, + required this.rtt, + required this.duration, + }); + + final String region; + + /// Current round trip time in milliseconds. + final num rtt; + + /// Time in milliseconds it took to send 1MB of data to the region. + final num duration; + + @override + String toString() => '$runtimeType(region: $region, rtt: $rtt, duration: $duration)'; +} + +/// Checks connection quality to the closest LiveKit Cloud regions and +/// determines the one with the best quality. +/// +/// Skipped when the server is not a LiveKit Cloud instance. +class CloudRegionCheck extends Checker { + CloudRegionCheck(super.url, super.token, {super.options, super.room}); + + RegionStats? _bestStats; + + @override + String get description => 'Cloud regions'; + + @override + Object? get data => _bestStats; + + @override + Future perform() async { + final regionProvider = RegionUrlProvider( + url: url, + token: token, + networkOptions: networkOptions, + ); + if (!regionProvider.isCloud()) { + skip(); + return; + } + + final regionStats = []; + final seenUrls = {}; + for (var i = 0; i < 3; i++) { + final regionUrl = await regionProvider.getNextBestRegionUrl(); + if (regionUrl == null) { + break; + } + if (!seenUrls.add(regionUrl)) { + continue; + } + final stats = await _checkCloudRegion(regionUrl); + appendMessage('${stats.region} RTT: ${stats.rtt}ms, duration: ${stats.duration}ms'); + regionStats.add(stats); + } + + if (regionStats.isEmpty) { + throw const CheckException('No regions could be checked'); + } + regionStats.sort((a, b) { + final score = (a.duration - b.duration) * 0.5 + (a.rtt - b.rtt) * 0.5; + return score.compareTo(0); + }); + final bestRegion = regionStats.first; + _bestStats = bestRegion; + appendMessage('best Cloud region: ${bestRegion.region}'); + } + + Future _checkCloudRegion(String url) async { + await connect(url); + if (options.protocol == CheckProtocol.tcp) { + await switchProtocol(CheckProtocol.tcp); + } + final region = room.serverRegion; + if (region == null || region.isEmpty) { + throw const CheckException('Region not found'); + } + + final localParticipant = room.localParticipant; + if (localParticipant == null) { + throw const CheckException('Room has no local participant'); + } + + // send ~1MB of data over a text stream and measure how long it takes + final writer = await localParticipant.streamText(StreamTextOptions(topic: 'test')); + const chunkSize = 1000; // each chunk is about 1000 bytes + const totalSize = 1000000; // approximately 1MB of data + const numChunks = totalSize ~/ chunkSize; // will yield 1000 chunks + final chunkData = 'A' * chunkSize; + + final stopwatch = Stopwatch()..start(); + for (var i = 0; i < numChunks; i++) { + await writer.write(chunkData); + } + await writer.close(); + stopwatch.stop(); + + num rtt = 10000; + final publisherPc = engine.publisher?.pc; + if (publisherPc != null) { + rtt = await _currentRoundTripTimeMs(publisherPc) ?? rtt; + } + final regionStats = RegionStats( + region: region, + rtt: rtt, + duration: stopwatch.elapsedMilliseconds, + ); + + await disconnect(); + return regionStats; + } +} + +/// Reads the current round trip time (in milliseconds) of the selected +/// candidate pair from [pc]'s stats, or null when unavailable. +Future _currentRoundTripTimeMs(rtc.RTCPeerConnection pc) async { + final stats = await pc.getStats(); + String? selectedPairId; + final candidatePairs = {}; + rtc.StatsReport? selectedPair; + for (final report in stats) { + if (report.type == 'transport') { + selectedPairId = getStringValFromReport(report.values, 'selectedCandidatePairId') ?? selectedPairId; + } else if (report.type == 'candidate-pair') { + candidatePairs[report.id] = report; + // fallback for platforms that don't report a transport stat + final selected = getBoolValFromReport(report.values, 'selected'); + final nominated = getBoolValFromReport(report.values, 'nominated'); + if (selected || (nominated && selectedPair == null)) { + selectedPair = report; + } + } + } + if (selectedPairId != null) { + selectedPair = candidatePairs[selectedPairId] ?? selectedPair; + } + if (selectedPair == null) { + return null; + } + final rttSeconds = getNumValFromReport(selectedPair.values, 'currentRoundTripTime'); + if (rttSeconds == null) { + return null; + } + return rttSeconds * 1000; +} diff --git a/lib/src/connection_check/checks/connection_protocol.dart b/lib/src/connection_check/checks/connection_protocol.dart new file mode 100644 index 000000000..ec0cb6b42 --- /dev/null +++ b/lib/src/connection_check/checks/connection_protocol.dart @@ -0,0 +1,180 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/foundation.dart'; + +import '../../options.dart'; +import '../../publication/local.dart'; +import '../../stats/stats.dart'; +import '../../track/local/video.dart'; +import '../../types/video_encoding.dart'; +import 'checker.dart'; + +/// Aggregated upstream statistics gathered while publishing over a single +/// connection protocol. +class ProtocolStats { + ProtocolStats({required this.protocol}); + + final CheckProtocol protocol; + num packetsLost = 0; + num packetsSent = 0; + + /// Approximate time (in seconds) the encoder was limited, keyed by reason + /// (e.g. `bandwidth`, `cpu`). Sampled from `qualityLimitationReason` once + /// per stats interval. + final Map qualityLimitationDurations = {}; + + // total metrics measure sum of all measurements, along with a count + num rttTotal = 0; + num jitterTotal = 0; + num bitrateTotal = 0; + int count = 0; + + @override + String toString() => '$runtimeType(protocol: ${protocol.name}, packetsSent: $packetsSent, ' + 'packetsLost: $packetsLost, count: $count)'; +} + +const _testDuration = Duration(seconds: 10); +const _statsInterval = Duration(seconds: 1); + +/// Compares connection quality between UDP and TCP and determines the better +/// protocol for the current network. +/// +/// Unlike client-sdk-js, which publishes an animated canvas track, this check +/// publishes a camera track to generate upstream traffic — so camera +/// permissions are required. +class ConnectionProtocolCheck extends Checker { + ConnectionProtocolCheck(super.url, super.token, {super.options, super.room}); + + ProtocolStats? _bestStats; + + @override + String get description => 'Connection via UDP vs TCP'; + + @override + Object? get data => _bestStats; + + @override + Future perform() async { + final udpStats = await _checkConnectionProtocol(CheckProtocol.udp); + final tcpStats = await _checkConnectionProtocol(CheckProtocol.tcp); + _bestStats = udpStats; + // udp is typically the better protocol. however, we'd prefer TCP when + // either of these conditions are true: + // 1. the bandwidth limitation is worse on UDP by 500ms + // 2. the packet loss is higher on UDP by 1% + final udpBandwidthLimit = udpStats.qualityLimitationDurations['bandwidth'] ?? 0; + final tcpBandwidthLimit = tcpStats.qualityLimitationDurations['bandwidth'] ?? 0; + if (udpBandwidthLimit - tcpBandwidthLimit > 0.5 || + (udpStats.packetsSent > 0 && (udpStats.packetsLost - tcpStats.packetsLost) / udpStats.packetsSent > 0.01)) { + appendMessage('best connection quality via tcp'); + _bestStats = tcpStats; + } else { + appendMessage('best connection quality via udp'); + } + + final stats = _bestStats!; + if (stats.count > 0) { + // computeBitrateForSenderStats returns bits-per-second on native + // platforms and kilobits-per-second on web. Normalize the average to + // megabits-per-second before displaying. + final avgBitrate = stats.bitrateTotal / stats.count; + final avgMbps = kIsWeb ? avgBitrate / 1e3 : avgBitrate / 1e6; + appendMessage('upstream bitrate: ${avgMbps.toStringAsFixed(2)} mbps'); + appendMessage('RTT: ${(stats.rttTotal / stats.count * 1000).toStringAsFixed(2)} ms'); + appendMessage('jitter: ${(stats.jitterTotal / stats.count * 1000).toStringAsFixed(2)} ms'); + } + + if (stats.packetsLost > 0 && stats.packetsSent > 0) { + appendWarning('packets lost: ${(stats.packetsLost / stats.packetsSent * 100).toStringAsFixed(2)}%'); + } + final bandwidthLimited = stats.qualityLimitationDurations['bandwidth'] ?? 0; + if (bandwidthLimited > 1) { + appendWarning('bandwidth limited ${(bandwidthLimited / _testDuration.inSeconds * 100).toStringAsFixed(2)}%'); + } + final cpuLimited = stats.qualityLimitationDurations['cpu'] ?? 0; + if (cpuLimited > 0) { + appendWarning('cpu limited ${(cpuLimited / _testDuration.inSeconds * 100).toStringAsFixed(2)}%'); + } + } + + Future _checkConnectionProtocol(CheckProtocol protocol) async { + await connect(); + await switchProtocol(protocol); + + // client-sdk-js publishes an animated canvas track here; canvas capture + // isn't available in Flutter, so publish a camera track instead to + // generate upstream traffic. + final track = await LocalVideoTrack.createCameraTrack(); + late final LocalTrackPublication pub; + final localParticipant = room.localParticipant; + try { + if (localParticipant == null) { + throw const CheckException('Room has no local participant'); + } + pub = await localParticipant.publishVideoTrack( + track, + publishOptions: const VideoPublishOptions( + simulcast: false, + degradationPreference: DegradationPreference.maintainResolution, + videoEncoding: VideoEncoding( + maxBitrate: 2000000, + maxFramerate: 30, + ), + ), + ); + } catch (err) { + // the room won't clean up a track that was never published, + // so stop capturing here + await track.dispose(); + rethrow; + } + + final protocolStats = ProtocolStats(protocol: protocol); + // prime the previous sample so every counted sample has a bitrate + final initialLayers = await track.getSenderStats(); + VideoSenderStats? prevStats = initialLayers.isEmpty ? null : initialLayers.first; + + // gather stats once a second + final samples = _testDuration.inMilliseconds ~/ _statsInterval.inMilliseconds; + for (var i = 0; i < samples; i++) { + await Future.delayed(_statsInterval); + final layers = await track.getSenderStats(); + if (layers.isEmpty) { + continue; + } + // simulcast is disabled, so there is a single layer + final stats = layers.first; + protocolStats.packetsSent = stats.packetsSent ?? protocolStats.packetsSent; + protocolStats.packetsLost = stats.packetsLost ?? protocolStats.packetsLost; + final limitationReason = stats.qualityLimitationReason; + if (limitationReason != null && limitationReason.isNotEmpty && limitationReason != 'none') { + // browsers report cumulative `qualityLimitationDurations` directly; + // approximate it by sampling the limitation reason once per interval. + protocolStats.qualityLimitationDurations[limitationReason] = + (protocolStats.qualityLimitationDurations[limitationReason] ?? 0) + _statsInterval.inSeconds; + } + protocolStats.bitrateTotal += computeBitrateForSenderStats(stats, prevStats); + protocolStats.rttTotal += stats.roundTripTime ?? 0; + protocolStats.jitterTotal += stats.jitter ?? 0; + protocolStats.count++; + prevStats = stats; + } + + await localParticipant.removePublishedTrack(pub.sid); + await disconnect(); + return protocolStats; + } +} diff --git a/lib/src/connection_check/checks/publish_audio.dart b/lib/src/connection_check/checks/publish_audio.dart new file mode 100644 index 000000000..ab7dd65d7 --- /dev/null +++ b/lib/src/connection_check/checks/publish_audio.dart @@ -0,0 +1,71 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../track/local/audio.dart'; +import 'checker.dart'; + +/// Verifies that audio can be captured from the microphone and published to +/// the server. +class PublishAudioCheck extends Checker { + PublishAudioCheck(super.url, super.token, {super.options, super.room}); + + @override + String get description => 'Can publish audio'; + + @override + Future perform() async { + final room = await connect(); + + final track = await LocalAudioTrack.create(); + + try { + final localParticipant = room.localParticipant; + if (localParticipant == null) { + throw const CheckException('Room has no local participant'); + } + await localParticipant.publishAudioTrack(track); + } catch (err) { + // the room won't clean up a track that was never published, + // so stop capturing here + await track.dispose(); + rethrow; + } + + // wait for a few seconds to publish + await Future.delayed(const Duration(seconds: 3)); + + // verify RTC stats that it's publishing + final stats = await track.getSenderStats(); + if (stats == null) { + throw const CheckException('Could not get RTCStats'); + } + final numPackets = stats.packetsSent ?? 0; + if (numPackets == 0) { + throw const CheckException('Could not determine packets are sent'); + } + + // WebAudio-based silence detection isn't available in Flutter, so inspect + // the captured audio energy from the RTC media-source stats instead. + final totalAudioEnergy = stats.audioSourceStats?.totalAudioEnergy; + if (totalAudioEnergy == null) { + appendWarning('could not verify microphone audio: media-source stats unavailable on this platform'); + } else if (totalAudioEnergy == 0) { + throw const CheckException('unable to detect audio from microphone'); + } else { + appendMessage('detected audio from microphone'); + } + + appendMessage('published $numPackets audio packets'); + } +} diff --git a/lib/src/connection_check/checks/publish_video.dart b/lib/src/connection_check/checks/publish_video.dart new file mode 100644 index 000000000..5b655ad4e --- /dev/null +++ b/lib/src/connection_check/checks/publish_video.dart @@ -0,0 +1,75 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../track/local/video.dart'; +import 'checker.dart'; + +/// Verifies that video can be captured from the camera and published to the +/// server. +class PublishVideoCheck extends Checker { + PublishVideoCheck(super.url, super.token, {super.options, super.room}); + + @override + String get description => 'Can publish video'; + + @override + Future perform() async { + final room = await connect(); + + final track = await LocalVideoTrack.createCameraTrack(); + + try { + final localParticipant = room.localParticipant; + if (localParticipant == null) { + throw const CheckException('Room has no local participant'); + } + await localParticipant.publishVideoTrack(track); + } catch (err) { + // the room won't clean up a track that was never published, + // so stop capturing here + await track.dispose(); + rethrow; + } + + // wait for a few seconds to publish + await Future.delayed(const Duration(seconds: 5)); + + // verify RTC stats that it's publishing + final stats = await track.getSenderStats(); + if (stats.isEmpty) { + throw const CheckException('Could not get RTCStats'); + } + num numPackets = 0; + num numFrames = 0; + for (final layer in stats) { + numPackets += layer.packetsSent ?? 0; + numFrames += layer.framesSent ?? 0; + } + if (numPackets == 0) { + throw const CheckException('Could not determine packets are sent'); + } + // Canvas-based black frame detection isn't available in Flutter, so use + // the sent frame counter to verify the camera is producing frames + // (only enforced on platforms that report it). + if (stats.any((layer) => layer.framesSent != null)) { + if (numFrames == 0) { + throw const CheckException('unable to detect frames from camera'); + } + appendMessage('received video frames'); + } else { + appendWarning('could not verify camera frames: framesSent stats unavailable on this platform'); + } + appendMessage('published $numPackets video packets'); + } +} diff --git a/lib/src/connection_check/checks/reconnect.dart b/lib/src/connection_check/checks/reconnect.dart new file mode 100644 index 000000000..ce9266607 --- /dev/null +++ b/lib/src/connection_check/checks/reconnect.dart @@ -0,0 +1,66 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; + +import '../../events.dart'; +import '../../types/other.dart'; +import 'checker.dart'; + +/// Verifies that a connection can be resumed after an interruption. +class ReconnectCheck extends Checker { + ReconnectCheck(super.url, super.token, {super.options, super.room}); + + @override + String get description => 'Resuming connection after interruption'; + + @override + Future perform() async { + final room = await connect(); + var reconnectingTriggered = false; + var reconnected = false; + + final reconnectCompleter = Completer(); + + final listener = room.createListener(); + listener + ..on((_) => reconnectingTriggered = true) + ..once((_) { + reconnected = true; + if (!reconnectCompleter.isCompleted) { + reconnectCompleter.complete(); + } + }); + + try { + // Forcefully close the underlying signal WebSocket; the engine should + // notice and resume the session on its own. + await engine.signalClient.cleanUp(); + + await reconnectCompleter.future.timeout( + const Duration(seconds: 5), + onTimeout: () {}, + ); + + if (!reconnectingTriggered) { + throw const CheckException('Did not attempt to reconnect'); + } else if (!reconnected || room.connectionState != ConnectionState.connected) { + appendWarning('reconnection is only possible in Redis-based configurations'); + throw const CheckException('Not able to reconnect'); + } + } finally { + await listener.dispose(); + } + } +} diff --git a/lib/src/connection_check/checks/turn.dart b/lib/src/connection_check/checks/turn.dart new file mode 100644 index 000000000..97a8adc67 --- /dev/null +++ b/lib/src/connection_check/checks/turn.dart @@ -0,0 +1,84 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../options.dart'; +import '../../support/region_url_provider.dart'; +import '../../types/other.dart'; +import 'checker.dart'; + +/// Verifies that a connection via a TURN relay can be established. +class TURNCheck extends Checker { + TURNCheck(super.url, super.token, {super.options, super.room}); + + @override + String get description => 'Can connect via TURN'; + + @override + Future perform() async { + if (isCloudUrl(Uri.parse(url))) { + appendMessage('Using region specific url'); + url = await RegionUrlProvider( + url: url, + token: token, + networkOptions: networkOptions, + ).getNextBestRegionUrl() ?? + url; + } + final joinRes = await signalJoin(url); + + var hasTLS = false; + var hasTURN = false; + var hasSTUN = false; + + for (final iceServer in joinRes.iceServers) { + for (final serverUrl in iceServer.urls) { + if (serverUrl.startsWith('turn:')) { + hasTURN = true; + hasSTUN = true; + } else if (serverUrl.startsWith('turns:')) { + hasTURN = true; + hasSTUN = true; + hasTLS = true; + } + if (serverUrl.startsWith('stun:')) { + hasSTUN = true; + } + } + } + if (!hasSTUN) { + appendWarning('No STUN servers configured on server side.'); + } else if (hasTURN && !hasTLS) { + appendWarning('TURN is configured server side, but TURN/TLS is unavailable.'); + } + if (connectOptions?.rtcConfiguration.iceServers != null || hasTURN) { + final baseOptions = connectOptions ?? const ConnectOptions(); + await room.connect( + url, + token, + connectOptions: ConnectOptions( + autoSubscribe: baseOptions.autoSubscribe, + rtcConfiguration: baseOptions.rtcConfiguration.copyWith( + iceTransportPolicy: RTCIceTransportPolicy.relay, + ), + protocolVersion: baseOptions.protocolVersion, + clientProtocolVersion: baseOptions.clientProtocolVersion, + timeouts: baseOptions.timeouts, + ), + ); + } else { + appendWarning('No TURN servers configured.'); + skip(); + } + } +} diff --git a/lib/src/connection_check/checks/webrtc.dart b/lib/src/connection_check/checks/webrtc.dart new file mode 100644 index 000000000..84b785f85 --- /dev/null +++ b/lib/src/connection_check/checks/webrtc.dart @@ -0,0 +1,151 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart'; + +import '../../internal/events.dart'; +import '../../logger.dart'; +import 'checker.dart'; + +/// Verifies that a WebRTC (peer) connection to the LiveKit server can be +/// established, and inspects the ICE candidates provided by the server. +class WebRTCCheck extends Checker { + WebRTCCheck(super.url, super.token, {super.options, super.room}); + + @override + String get description => 'Establishing WebRTC connection'; + + @override + Future perform() async { + var hasTcp = false; + var hasIpv4Udp = false; + + // Observe the ICE candidates trickled by the server over the signal + // connection (equivalent of hooking `onTrickle` in client-sdk-js). + final signalListener = engine.signalClient.createListener(); + signalListener.on((event) { + final candidate = parseIceCandidate(event.candidate.candidate ?? ''); + if (candidate == null) { + return; + } + var str = '${candidate.protocol} ${candidate.address}:${candidate.port} ${candidate.type}'; + if (isIpPrivate(candidate.address)) { + str += ' (private)'; + } else { + if (candidate.protocol == 'tcp' && candidate.tcpType == 'passive') { + hasTcp = true; + str += ' (passive)'; + } else if (candidate.protocol == 'udp') { + hasIpv4Udp = true; + } + } + appendMessage(str); + }); + + try { + await connect(); + logger.fine('now the room is connected'); + } catch (err) { + appendWarning('ports need to be open on firewall in order to connect.'); + rethrow; + } finally { + await signalListener.dispose(); + } + if (!hasTcp) { + appendWarning('Server is not configured for ICE/TCP'); + } + if (!hasIpv4Udp) { + appendWarning('No public IPv4 UDP candidates were found. Your server is likely not configured correctly'); + } + } +} + +/// Parsed representation of an ICE candidate attribute (RFC 5245 +/// `candidate:` line). +@visibleForTesting +class ParsedIceCandidate { + const ParsedIceCandidate({ + required this.protocol, + required this.address, + required this.port, + required this.type, + this.tcpType, + }); + + /// Transport protocol, `udp` or `tcp`. + final String protocol; + + final String address; + + final int port; + + /// Candidate type: `host`, `srflx`, `prflx` or `relay`. + final String type; + + /// TCP candidate type: `active`, `passive` or `so`. + final String? tcpType; +} + +/// Parses an ICE candidate attribute line, returns null if [sdp] is not a +/// valid candidate line. +@visibleForTesting +ParsedIceCandidate? parseIceCandidate(String sdp) { + var line = sdp.trim(); + if (line.startsWith('a=')) { + line = line.substring(2); + } + if (line.startsWith('candidate:')) { + line = line.substring('candidate:'.length); + } + final parts = line.split(RegExp(r'\s+')); + if (parts.length < 8 || parts[6] != 'typ') { + return null; + } + final port = int.tryParse(parts[5]); + if (port == null) { + return null; + } + String? tcpType; + for (var i = 8; i + 1 < parts.length; i += 2) { + if (parts[i] == 'tcptype') { + tcpType = parts[i + 1]; + } + } + return ParsedIceCandidate( + protocol: parts[2].toLowerCase(), + address: parts[4], + port: port, + type: parts[7], + tcpType: tcpType, + ); +} + +/// True if [address] is in a private IPv4 range. +@visibleForTesting +bool isIpPrivate(String address) { + final parts = address.split('.'); + if (parts.length == 4) { + if (parts[0] == '10') { + return true; + } else if (parts[0] == '192' && parts[1] == '168') { + return true; + } else if (parts[0] == '172') { + final second = int.tryParse(parts[1]); + if (second != null && second >= 16 && second <= 31) { + return true; + } + } + } + return false; +} diff --git a/lib/src/connection_check/checks/websocket.dart b/lib/src/connection_check/checks/websocket.dart new file mode 100644 index 000000000..4d3e98e7c --- /dev/null +++ b/lib/src/connection_check/checks/websocket.dart @@ -0,0 +1,67 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../proto/livekit_models.pb.dart' as lk_models; +import '../../proto/livekit_rtc.pb.dart' as lk_rtc; +import '../../support/region_url_provider.dart'; +import 'checker.dart'; + +/// Verifies that a WebSocket connection to the LiveKit server can be +/// established (signal connection). +class WebSocketCheck extends Checker { + WebSocketCheck(super.url, super.token, {super.options, super.room}); + + @override + String get description => 'Connecting to signal connection via WebSocket'; + + @override + Future perform() async { + if (url.startsWith('ws:') || url.startsWith('http:')) { + appendWarning('Server is insecure, clients may block connections to it'); + } + + lk_rtc.JoinResponse? joinRes; + Object? lastError; + try { + joinRes = await signalJoin(url); + } catch (err) { + lastError = err; + if (isCloudUrl(Uri.parse(url))) { + appendMessage('Initial connection failed with error ${messageFor(err)}. Retrying with region fallback'); + final regionProvider = RegionUrlProvider( + url: url, + token: token, + networkOptions: networkOptions, + ); + final regionUrl = await regionProvider.getNextBestRegionUrl(); + if (regionUrl != null) { + joinRes = await signalJoin(regionUrl); + appendMessage('Fallback to region worked. To avoid initial connections failing, ' + 'ensure you\'re calling room.prepareConnection() ahead of time'); + } + } + } + if (joinRes != null) { + appendMessage('Connected to server, version ${joinRes.serverVersion}.'); + if (joinRes.hasServerInfo() && + joinRes.serverInfo.edition == lk_models.ServerInfo_Edition.Cloud && + joinRes.serverInfo.region.isNotEmpty) { + appendMessage('LiveKit Cloud: ${joinRes.serverInfo.region}'); + } + } else { + appendError('Websocket connection could not be established' + '${lastError != null ? ': ${messageFor(lastError)}' : ''}'); + } + } +} diff --git a/lib/src/connection_check/connection_check.dart b/lib/src/connection_check/connection_check.dart new file mode 100644 index 000000000..fede6d098 --- /dev/null +++ b/lib/src/connection_check/connection_check.dart @@ -0,0 +1,157 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../managers/event.dart'; +import '../support/disposable.dart'; +import 'checks/checker.dart'; +import 'checks/cloud_region.dart'; +import 'checks/connection_protocol.dart'; +import 'checks/publish_audio.dart'; +import 'checks/publish_video.dart'; +import 'checks/reconnect.dart'; +import 'checks/turn.dart'; +import 'checks/webrtc.dart'; +import 'checks/websocket.dart'; +import 'events.dart'; + +/// Utility to diagnose connection issues with a LiveKit server, a Dart port +/// of the `ConnectionCheck` helper from client-sdk-js. +/// +/// Run it proactively (e.g. at app start) or reactively when connecting to a +/// room fails, to determine why a connection cannot be established (firewall, +/// VPN, blocked TURN, etc.). +/// +/// ```dart +/// final connectionCheck = ConnectionCheck(url, token); +/// final listener = connectionCheck.createListener(); +/// listener.on((event) { +/// print('check ${event.info.name}: ${event.info.status}'); +/// }); +/// +/// // the recommended minimum set of checks +/// await connectionCheck.checkWebsocket(); +/// await connectionCheck.checkWebRTC(); +/// await connectionCheck.checkTURN(); +/// +/// print('all checks passed: ${connectionCheck.isSuccess}'); +/// await listener.dispose(); +/// await connectionCheck.dispose(); +/// ``` +/// +/// Each check connects to the server independently and returns a [CheckInfo] +/// describing the outcome. [checkPublishAudio], [checkPublishVideo] and +/// [checkConnectionProtocol] capture from the microphone/camera and therefore +/// require the corresponding permissions to be granted beforehand. +class ConnectionCheck extends Disposable with EventsEmittable { + ConnectionCheck( + this.url, + this.token, { + CheckerOptions? options, + }) : options = options ?? CheckerOptions() { + onDispose(() async { + await events.dispose(); + }); + } + + /// URL of the LiveKit server to run the checks against. + final String url; + + /// Access token used for the checks. + final String token; + + /// Options shared by all checks of this run. + final CheckerOptions options; + + final Map _checkResults = {}; + + /// True when no check has failed (so far). + bool get isSuccess => _checkResults.values.every((r) => r.status != CheckStatus.failed); + + /// Results of all checks that have been run (or are running). + List getResults() => List.unmodifiable(_checkResults.values); + + int _getNextCheckId() { + final nextId = _checkResults.length; + _checkResults[nextId] = const CheckInfo( + name: '', + description: '', + status: CheckStatus.idle, + logs: [], + ); + return nextId; + } + + void _updateCheck(int checkId, CheckInfo info) { + _checkResults[checkId] = info; + events.emit(ConnectionCheckUpdateEvent(checkId: checkId, info: info)); + } + + /// Runs a single [Checker], reporting its progress through + /// [ConnectionCheckUpdateEvent]s, and disposes it when done. + /// + /// Used by all `check*` methods; can also be used to run a custom + /// [Checker] subclass as part of this run. + Future runCheck(Checker check) async { + if (check.status != CheckStatus.idle) { + throw StateError('check is running already'); + } + final checkId = _getNextCheckId(); + final listener = check.createListener(); + listener.on((event) => _updateCheck(checkId, event.info)); + try { + return await check.run(); + } finally { + _updateCheck(checkId, check.getInfo()); + await listener.dispose(); + await check.dispose(); + } + } + + /// Verifies that a WebSocket connection to the server can be established. + Future checkWebsocket() => runCheck(WebSocketCheck(url, token, options: options)); + + /// Verifies that a WebRTC (peer) connection can be established. + Future checkWebRTC() => runCheck(WebRTCCheck(url, token, options: options)); + + /// Verifies that a connection via a TURN relay can be established. + Future checkTURN() => runCheck(TURNCheck(url, token, options: options)); + + /// Verifies that a connection can be resumed after an interruption. + Future checkReconnect() => runCheck(ReconnectCheck(url, token, options: options)); + + /// Verifies that microphone audio can be captured and published. + Future checkPublishAudio() => runCheck(PublishAudioCheck(url, token, options: options)); + + /// Verifies that camera video can be captured and published. + Future checkPublishVideo() => runCheck(PublishVideoCheck(url, token, options: options)); + + /// Compares connection quality between UDP and TCP. + /// + /// The better protocol is stored in [CheckerOptions.protocol] so that + /// subsequent checks (e.g. [checkCloudRegion]) use it. The returned + /// [CheckInfo.data] contains the winning [ProtocolStats]. + Future checkConnectionProtocol() async { + final info = await runCheck(ConnectionProtocolCheck(url, token, options: options)); + final data = info.data; + if (data is ProtocolStats) { + options.protocol = data.protocol; + } + return info; + } + + /// Checks connection quality to the closest LiveKit Cloud regions and + /// determines the best one. Skipped for non-Cloud servers. The returned + /// [CheckInfo.data] contains the best region's [RegionStats]. + Future checkCloudRegion() => runCheck(CloudRegionCheck(url, token, options: options)); +} diff --git a/lib/src/connection_check/events.dart b/lib/src/connection_check/events.dart new file mode 100644 index 000000000..26203ef4c --- /dev/null +++ b/lib/src/connection_check/events.dart @@ -0,0 +1,52 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../events.dart'; +import 'checks/checker.dart'; + +/// Base type for events emitted by `ConnectionCheck`. +mixin ConnectionCheckEvent implements LiveKitEvent {} + +/// Base type for events emitted by a [Checker]. +mixin CheckerEvent implements LiveKitEvent {} + +/// Emitted by `ConnectionCheck` whenever the state of one of its checks +/// changes (status change or a new log entry). +class ConnectionCheckUpdateEvent with ConnectionCheckEvent { + const ConnectionCheckUpdateEvent({ + required this.checkId, + required this.info, + }); + + /// Identifies the check within this `ConnectionCheck` run. + final int checkId; + + /// The latest snapshot of the check. + final CheckInfo info; + + @override + String toString() => '$runtimeType(checkId: $checkId, info: $info)'; +} + +/// Emitted by a [Checker] whenever its state changes (status change or a new +/// log entry). +class CheckerUpdateEvent with CheckerEvent { + const CheckerUpdateEvent({required this.info}); + + /// The latest snapshot of the check. + final CheckInfo info; + + @override + String toString() => '$runtimeType(info: $info)'; +} diff --git a/test/connection_check/checker_test.dart b/test/connection_check/checker_test.dart new file mode 100644 index 000000000..45ee44f00 --- /dev/null +++ b/test/connection_check/checker_test.dart @@ -0,0 +1,125 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@Timeout(Duration(seconds: 10)) +library; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/livekit_client.dart'; +import '../mock/fake_checker.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Checker', () { + test('reports success when perform completes without errors', () async { + final checker = FakeChecker(onPerform: (checker) async { + checker.addMessage('all good'); + }); + final info = await checker.run(); + expect(info.status, CheckStatus.success); + expect(info.name, 'FakeChecker'); + expect(info.description, 'Fake check for testing'); + expect(info.logs, hasLength(1)); + expect(info.logs.first.level, CheckLogLevel.info); + expect(info.logs.first.message, 'all good'); + await checker.dispose(); + }); + + test('reports failure when perform throws', () async { + final checker = FakeChecker(onPerform: (checker) async { + throw const CheckException('something went wrong'); + }); + final info = await checker.run(); + expect(info.status, CheckStatus.failed); + expect(info.logs, hasLength(1)); + expect(info.logs.first.level, CheckLogLevel.error); + expect(info.logs.first.message, 'something went wrong'); + await checker.dispose(); + }); + + test('reports failure when an error is appended', () async { + final checker = FakeChecker(onPerform: (checker) async { + checker.addError('bad'); + checker.addMessage('but continued'); + }); + final info = await checker.run(); + expect(info.status, CheckStatus.failed); + expect(info.logs, hasLength(2)); + await checker.dispose(); + }); + + test('warnings do not fail the check', () async { + final checker = FakeChecker(onPerform: (checker) async { + checker.addWarning('be careful'); + }); + final info = await checker.run(); + expect(info.status, CheckStatus.success); + expect(info.logs.first.level, CheckLogLevel.warning); + await checker.dispose(); + }); + + test('errorsAsWarnings turns a thrown error into a warning', () async { + final checker = FakeChecker( + onPerform: (checker) async { + throw const CheckException('not fatal here'); + }, + options: CheckerOptions(errorsAsWarnings: true), + ); + final info = await checker.run(); + expect(info.status, CheckStatus.success); + expect(info.logs, hasLength(1)); + expect(info.logs.first.level, CheckLogLevel.warning); + expect(info.logs.first.message, 'not fatal here'); + await checker.dispose(); + }); + + test('skip marks the check as skipped', () async { + final checker = FakeChecker(onPerform: (checker) async { + checker.doSkip(); + }); + final info = await checker.run(); + expect(info.status, CheckStatus.skipped); + await checker.dispose(); + }); + + test('emits an update event for every log entry and status change', () async { + final checker = FakeChecker(onPerform: (checker) async { + checker.addMessage('one'); + checker.addMessage('two'); + }); + final listener = checker.createListener(); + final updates = []; + listener.on((event) => updates.add(event.info)); + final info = await checker.run(); + // wait for pending events to be delivered + await Future.delayed(const Duration(milliseconds: 10)); + // running + 2 logs + final status + expect(updates, hasLength(4)); + expect(updates.first.status, CheckStatus.running); + expect(updates.last.status, CheckStatus.success); + expect(info.logs, hasLength(2)); + await listener.dispose(); + await checker.dispose(); + }); + + test('cannot be run twice', () async { + final checker = FakeChecker(); + await checker.run(); + expect(() => checker.run(), throwsStateError); + await checker.dispose(); + }); + }); +} diff --git a/test/connection_check/connection_check_test.dart b/test/connection_check/connection_check_test.dart new file mode 100644 index 000000000..76bfa7177 --- /dev/null +++ b/test/connection_check/connection_check_test.dart @@ -0,0 +1,82 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@Timeout(Duration(seconds: 10)) +library; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/livekit_client.dart'; +import '../mock/fake_checker.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ConnectionCheck', () { + test('aggregates results of multiple checks', () async { + final connectionCheck = ConnectionCheck('ws://www.example.com', 'token'); + + final ok = await connectionCheck.runCheck(FakeChecker(onPerform: (checker) async { + checker.addMessage('fine'); + })); + expect(ok.status, CheckStatus.success); + expect(connectionCheck.isSuccess, true); + + final failed = await connectionCheck.runCheck(FakeChecker(onPerform: (checker) async { + throw const CheckException('nope'); + })); + expect(failed.status, CheckStatus.failed); + expect(connectionCheck.isSuccess, false); + + final results = connectionCheck.getResults(); + expect(results, hasLength(2)); + expect(results[0].status, CheckStatus.success); + expect(results[1].status, CheckStatus.failed); + + await connectionCheck.dispose(); + }); + + test('emits update events with unique check ids', () async { + final connectionCheck = ConnectionCheck('ws://www.example.com', 'token'); + final listener = connectionCheck.createListener(); + final seenIds = {}; + final updates = []; + listener.on((event) { + seenIds.add(event.checkId); + updates.add(event); + }); + + await connectionCheck.runCheck(FakeChecker()); + await connectionCheck.runCheck(FakeChecker()); + // wait for pending events to be delivered + await Future.delayed(const Duration(milliseconds: 10)); + + expect(seenIds, {0, 1}); + expect(updates.last.info.status, CheckStatus.success); + + await listener.dispose(); + await connectionCheck.dispose(); + }); + + test('skipped checks do not fail the run', () async { + final connectionCheck = ConnectionCheck('ws://www.example.com', 'token'); + final skipped = await connectionCheck.runCheck(FakeChecker(onPerform: (checker) async { + checker.doSkip(); + })); + expect(skipped.status, CheckStatus.skipped); + expect(connectionCheck.isSuccess, true); + await connectionCheck.dispose(); + }); + }); +} diff --git a/test/connection_check/webrtc_candidate_test.dart b/test/connection_check/webrtc_candidate_test.dart new file mode 100644 index 000000000..c6c532283 --- /dev/null +++ b/test/connection_check/webrtc_candidate_test.dart @@ -0,0 +1,80 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/src/connection_check/checks/webrtc.dart'; + +void main() { + group('parseIceCandidate', () { + test('parses a udp host candidate', () { + final candidate = parseIceCandidate('candidate:842163049 1 udp 1677729535 203.0.113.5 40183 typ srflx'); + expect(candidate, isNotNull); + expect(candidate!.protocol, 'udp'); + expect(candidate.address, '203.0.113.5'); + expect(candidate.port, 40183); + expect(candidate.type, 'srflx'); + expect(candidate.tcpType, isNull); + }); + + test('parses a passive tcp candidate with extensions', () { + final candidate = parseIceCandidate( + 'candidate:1467250027 1 tcp 1518280447 198.51.100.1 443 typ host tcptype passive generation 0'); + expect(candidate, isNotNull); + expect(candidate!.protocol, 'tcp'); + expect(candidate.address, '198.51.100.1'); + expect(candidate.port, 443); + expect(candidate.type, 'host'); + expect(candidate.tcpType, 'passive'); + }); + + test('parses a relay candidate with raddr/rport', () { + final candidate = parseIceCandidate( + 'candidate:3098175849 1 udp 25108223 192.0.2.10 60690 typ relay raddr 203.0.113.5 rport 40183'); + expect(candidate, isNotNull); + expect(candidate!.type, 'relay'); + expect(candidate.address, '192.0.2.10'); + }); + + test('accepts an a= prefixed line', () { + final candidate = parseIceCandidate('a=candidate:842163049 1 udp 1677729535 203.0.113.5 40183 typ host'); + expect(candidate, isNotNull); + expect(candidate!.type, 'host'); + }); + + test('returns null on malformed input', () { + expect(parseIceCandidate(''), isNull); + expect(parseIceCandidate('not a candidate'), isNull); + expect(parseIceCandidate('candidate:1 1 udp 1 1.2.3.4 notaport typ host'), isNull); + expect(parseIceCandidate('candidate:1 1 udp 1 1.2.3.4 1234 nottyp host'), isNull); + }); + }); + + group('isIpPrivate', () { + test('detects private IPv4 ranges', () { + expect(isIpPrivate('10.0.0.1'), true); + expect(isIpPrivate('192.168.1.10'), true); + expect(isIpPrivate('172.16.0.1'), true); + expect(isIpPrivate('172.31.255.255'), true); + }); + + test('treats public addresses as public', () { + expect(isIpPrivate('172.15.0.1'), false); + expect(isIpPrivate('172.32.0.1'), false); + expect(isIpPrivate('8.8.8.8'), false); + expect(isIpPrivate('203.0.113.5'), false); + expect(isIpPrivate('2001:db8::1'), false); + }); + }); +} diff --git a/test/connection_check/websocket_check_test.dart b/test/connection_check/websocket_check_test.dart new file mode 100644 index 000000000..cf2c844f9 --- /dev/null +++ b/test/connection_check/websocket_check_test.dart @@ -0,0 +1,71 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@Timeout(Duration(seconds: 10)) +library; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/livekit_client.dart'; +import 'package:livekit_client/src/connection_check/checks/websocket.dart'; +import 'package:livekit_client/src/support/websocket.dart'; +import '../core/signal_client_test.dart'; +import '../mock/websocket_mock.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('WebSocketCheck', () { + test('succeeds when the server answers with a join response', () async { + final connector = MockWebSocketConnector(); + final check = WebSocketCheck(exampleUri, token); + check.wsConnector = connector.connect; + + final resultFuture = check.run(); + // wait for the check to open its (mock) socket, then reply with a join + while (connector.handlers == null) { + await Future.delayed(const Duration(milliseconds: 10)); + } + connector.onData(joinResponse.writeToBuffer()); + + final info = await resultFuture; + expect(info.status, CheckStatus.success); + expect( + info.logs.map((log) => log.message), + contains(contains('Connected to server, version 99.999')), + ); + // ws:// scheme should produce an insecure-server warning + expect( + info.logs.where((log) => log.level == CheckLogLevel.warning), + isNotEmpty, + ); + await check.dispose(); + }); + + test('fails when the socket cannot be opened', () async { + final connector = MockWebSocketConnector(); + connector.connectError = WebSocketException('Failed to connect'); + final check = WebSocketCheck(exampleUri, token); + check.wsConnector = connector.connect; + + final info = await check.run(); + expect(info.status, CheckStatus.failed); + expect( + info.logs.map((log) => log.message), + contains(contains('Websocket connection could not be established')), + ); + await check.dispose(); + }); + }); +} diff --git a/test/mock/fake_checker.dart b/test/mock/fake_checker.dart new file mode 100644 index 000000000..5e38d5993 --- /dev/null +++ b/test/mock/fake_checker.dart @@ -0,0 +1,44 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:livekit_client/src/connection_check/checks/checker.dart'; + +/// A [Checker] with injectable behavior, to test the [Checker] base class and +/// `ConnectionCheck` orchestration without a server. +class FakeChecker extends Checker { + FakeChecker({ + Future Function(FakeChecker checker)? onPerform, + CheckerOptions? options, + }) : _onPerform = onPerform, + super('ws://www.example.com', 'token', options: options); + + final Future Function(FakeChecker checker)? _onPerform; + + @override + String get description => 'Fake check for testing'; + + @override + Future perform() async { + await _onPerform?.call(this); + } + + // Re-expose protected members so test callbacks can drive the check. + void addMessage(String message) => appendMessage(message); + + void addWarning(String message) => appendWarning(message); + + void addError(String message) => appendError(message); + + void doSkip() => skip(); +}