Skip to content

Commit c52d710

Browse files
authored
feat: add ConnectionCheck utility for connection diagnostics (#1134)
## Summary This PR ports the `ConnectionCheck` connection-diagnostics utility from [client-sdk-js](https://github.com/livekit/client-sdk-js/tree/main/src/connectionHelper) to Dart, the same checks that power [livekit.io/connection-test](https://livekit.io/connection-test). Flutter apps can now run structured preflight diagnostics proactively (at app start) or reactively (when `room.connect()` fails) to determine *why* a connection cannot be established (firewall, VPN, blocked TURN, etc.), instead of asking users to reproduce on web. Ref: [Diagnosing Connection Errors with Connection Test Utility](https://kb.livekit.io/articles/3972989092-diagnosing-connection-errors-with-connectionchecker) ## Demo <img width="3840" height="2160" alt="rtc_connection_check" src="https://github.com/user-attachments/assets/6359a952-4ac1-4a0b-89a5-33045024f1e2" /> ## API ```dart final connectionCheck = ConnectionCheck(url, token); final listener = connectionCheck.createListener(); listener.on<ConnectionCheckUpdateEvent>((event) { print('${event.info.name}: ${event.info.status.name}'); }); // recommended minimum set await connectionCheck.checkWebsocket(); await connectionCheck.checkWebRTC(); await connectionCheck.checkTURN(); // additional checks await connectionCheck.checkReconnect(); await connectionCheck.checkPublishAudio(); await connectionCheck.checkPublishVideo(); await connectionCheck.checkConnectionProtocol(); await connectionCheck.checkCloudRegion(); print('all checks passed: ${connectionCheck.isSuccess}'); await listener.dispose(); await connectionCheck.dispose(); ``` ## Testing - `flutter test`: 364 passing (20 new tests covering the `Checker` base lifecycle, `ConnectionCheck` orchestration/eventing, ICE candidate parsing, and `WebSocketCheck` against the mock WebSocket connector). - `flutter analyze`, `dart format`, `import_sorter`, `check_version` passing - Manually tested web, iOS, Android ## Example `example/lib/pages/connection_check.dart` adds a "Connection Check" page (launched from the connect screen) that runs all checks and displays per-check status + logs.
1 parent 166d6ae commit c52d710

21 files changed

Lines changed: 2087 additions & 0 deletions

.changes/connection-check

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
minor type="added" "Add ConnectionCheck utility for diagnosing connection issues (port of the client-sdk-js connection helper)"

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,38 @@ These controls are accessible on the `RemoteTrackPublication` object.
574574

575575
For more info, see [Subscribing to tracks](https://docs.livekit.io/home/client/tracks/subscribe/).
576576

577+
### Diagnosing connection issues
578+
579+
`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.).
580+
581+
```dart
582+
final connectionCheck = ConnectionCheck(url, token);
583+
final listener = connectionCheck.createListener();
584+
listener.on<ConnectionCheckUpdateEvent>((event) {
585+
print('${event.info.name}: ${event.info.status.name}');
586+
for (final log in event.info.logs) {
587+
print(' $log');
588+
}
589+
});
590+
591+
// the recommended minimum set of checks
592+
await connectionCheck.checkWebsocket();
593+
await connectionCheck.checkWebRTC();
594+
await connectionCheck.checkTURN();
595+
596+
// additional checks
597+
await connectionCheck.checkReconnect();
598+
await connectionCheck.checkPublishAudio(); // requires microphone permission
599+
await connectionCheck.checkPublishVideo(); // requires camera permission
600+
await connectionCheck.checkConnectionProtocol();
601+
await connectionCheck.checkCloudRegion(); // LiveKit Cloud only
602+
603+
print('all checks passed: ${connectionCheck.isSuccess}');
604+
605+
await listener.dispose();
606+
await connectionCheck.dispose();
607+
```
608+
577609
## Getting help / Contributing
578610

579611
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.

example/lib/pages/connect.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:flutter/material.dart';
22
import 'package:flutter_svg/flutter_svg.dart';
33
import 'package:livekit_client/livekit_client.dart';
4+
import 'package:livekit_example/pages/connection_check.dart';
45
import 'package:livekit_example/pages/prejoin.dart';
56
import 'package:livekit_example/widgets/text_field.dart';
67
import 'package:shared_preferences/shared_preferences.dart';
@@ -160,6 +161,20 @@ class _ConnectPageState extends State<ConnectPage> {
160161
}
161162
}
162163

164+
Future<void> _connectionCheck(BuildContext ctx) async {
165+
// Save URL and Token for convenience
166+
await _writePrefs();
167+
if (!ctx.mounted) return;
168+
await Navigator.push<void>(
169+
ctx,
170+
MaterialPageRoute(
171+
builder: (_) => ConnectionCheckPage(
172+
url: _uriCtrl.text,
173+
token: _tokenCtrl.text,
174+
)),
175+
);
176+
}
177+
163178
void _setSimulcast(bool? value) async {
164179
if (value == null || _simulcast == value) return;
165180
setState(() {
@@ -356,6 +371,10 @@ class _ConnectPageState extends State<ConnectPage> {
356371
],
357372
),
358373
),
374+
TextButton(
375+
onPressed: _busy ? null : () => unawaited(_connectionCheck(context)),
376+
child: const Text('Connection Check'),
377+
),
359378
],
360379
),
361380
),
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/material.dart';
4+
5+
import 'package:livekit_client/livekit_client.dart';
6+
7+
class ConnectionCheckPage extends StatefulWidget {
8+
//
9+
const ConnectionCheckPage({
10+
required this.url,
11+
required this.token,
12+
super.key,
13+
});
14+
15+
final String url;
16+
final String token;
17+
18+
@override
19+
State<StatefulWidget> createState() => _ConnectionCheckPageState();
20+
}
21+
22+
class _ConnectionCheckPageState extends State<ConnectionCheckPage> {
23+
//
24+
ConnectionCheck? _connectionCheck;
25+
EventsListener<ConnectionCheckEvent>? _listener;
26+
final Map<int, CheckInfo> _results = {};
27+
bool _running = false;
28+
29+
@override
30+
void dispose() {
31+
unawaited(_cleanUp());
32+
super.dispose();
33+
}
34+
35+
Future<void> _cleanUp() async {
36+
await _listener?.dispose();
37+
_listener = null;
38+
await _connectionCheck?.dispose();
39+
_connectionCheck = null;
40+
}
41+
42+
Future<void> _runChecks() async {
43+
await _cleanUp();
44+
45+
final connectionCheck = ConnectionCheck(widget.url, widget.token);
46+
final listener = connectionCheck.createListener();
47+
listener.on<ConnectionCheckUpdateEvent>((event) {
48+
if (!mounted) return;
49+
setState(() {
50+
_results[event.checkId] = event.info;
51+
});
52+
});
53+
54+
setState(() {
55+
_connectionCheck = connectionCheck;
56+
_listener = listener;
57+
_results.clear();
58+
_running = true;
59+
});
60+
61+
final checks = <Future<CheckInfo> Function()>[
62+
connectionCheck.checkWebsocket,
63+
connectionCheck.checkWebRTC,
64+
connectionCheck.checkTURN,
65+
connectionCheck.checkReconnect,
66+
connectionCheck.checkPublishAudio,
67+
connectionCheck.checkPublishVideo,
68+
connectionCheck.checkConnectionProtocol,
69+
connectionCheck.checkCloudRegion,
70+
];
71+
try {
72+
for (final check in checks) {
73+
// stop starting new checks once the page is gone
74+
if (!mounted) break;
75+
await check();
76+
}
77+
} catch (error) {
78+
print('Connection check error: $error');
79+
} finally {
80+
if (mounted) {
81+
setState(() {
82+
_running = false;
83+
});
84+
}
85+
}
86+
}
87+
88+
Widget _iconFor(CheckStatus status) {
89+
switch (status) {
90+
case CheckStatus.idle:
91+
return const Icon(Icons.radio_button_unchecked);
92+
case CheckStatus.running:
93+
return const SizedBox(
94+
width: 24,
95+
height: 24,
96+
child: Padding(
97+
padding: EdgeInsets.all(2),
98+
child: CircularProgressIndicator(strokeWidth: 2),
99+
),
100+
);
101+
case CheckStatus.skipped:
102+
return const Icon(Icons.skip_next, color: Colors.grey);
103+
case CheckStatus.success:
104+
return const Icon(Icons.check_circle, color: Colors.green);
105+
case CheckStatus.failed:
106+
return const Icon(Icons.error, color: Colors.red);
107+
}
108+
}
109+
110+
Color _colorFor(CheckLogLevel level) {
111+
switch (level) {
112+
case CheckLogLevel.info:
113+
return Colors.grey;
114+
case CheckLogLevel.warning:
115+
return Colors.orange;
116+
case CheckLogLevel.error:
117+
return Colors.red;
118+
}
119+
}
120+
121+
@override
122+
Widget build(BuildContext context) {
123+
final results = _results.entries.toList()..sort((a, b) => a.key.compareTo(b.key));
124+
return Scaffold(
125+
appBar: AppBar(
126+
title: const Text('Connection Check'),
127+
),
128+
body: Column(
129+
children: [
130+
Expanded(
131+
child: ListView(
132+
children: [
133+
for (final entry in results)
134+
ExpansionTile(
135+
initiallyExpanded: true,
136+
leading: _iconFor(entry.value.status),
137+
title: Text(entry.value.name),
138+
subtitle: Text(entry.value.description),
139+
children: [
140+
for (final log in entry.value.logs)
141+
ListTile(
142+
dense: true,
143+
visualDensity: VisualDensity.compact,
144+
title: Text(
145+
log.message,
146+
style: TextStyle(
147+
fontSize: 13,
148+
color: _colorFor(log.level),
149+
),
150+
),
151+
),
152+
],
153+
),
154+
],
155+
),
156+
),
157+
SafeArea(
158+
child: Padding(
159+
padding: const EdgeInsets.all(20),
160+
child: ElevatedButton(
161+
onPressed: _running ? null : () => unawaited(_runChecks()),
162+
child: Row(
163+
mainAxisSize: MainAxisSize.min,
164+
children: [
165+
if (_running)
166+
const Padding(
167+
padding: EdgeInsets.only(right: 10),
168+
child: SizedBox(
169+
height: 15,
170+
width: 15,
171+
child: CircularProgressIndicator(
172+
color: Colors.white,
173+
strokeWidth: 2,
174+
),
175+
),
176+
),
177+
const Text('Run checks'),
178+
],
179+
),
180+
),
181+
),
182+
),
183+
],
184+
),
185+
);
186+
}
187+
}

lib/livekit_client.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414

1515
import 'src/types/rpc.dart' show kRpcVersion;
1616

17+
export 'src/connection_check/checks/checker.dart';
18+
export 'src/connection_check/checks/cloud_region.dart' show RegionStats;
19+
export 'src/connection_check/checks/connection_protocol.dart' show ProtocolStats;
20+
export 'src/connection_check/connection_check.dart';
21+
export 'src/connection_check/events.dart';
1722
export 'src/constants.dart';
1823
export 'src/core/room.dart';
1924
export 'src/core/room_preconnect.dart';

0 commit comments

Comments
 (0)