// Version: 1.2.1 | Created: 2026-04-01 | Updated: 2026-04-03 // LiveKitService — fetches a JWT from the Matrix server's /_matrix/livekit/jwt // endpoint, then connects a LiveKit Room using that token. // // The JWT endpoint is defined in AppConfig.livekitJwtUrl and uses the Matrix // access token as Bearer auth. The LiveKit server URL is the same host as the // Matrix server (as configured on chat.m8chat.au). import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:livekit_client/livekit_client.dart'; import 'package:matrix/matrix.dart' show Client; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/auth_state.dart'; import '../../../core/config/app_config.dart'; import '../../../core/network/matrix_client.dart'; part 'livekit_service.g.dart'; /// Failure type for LiveKit connection attempts. sealed class LiveKitFailure { const LiveKitFailure(); } final class LiveKitNotAuthenticated extends LiveKitFailure { const LiveKitNotAuthenticated(); } final class LiveKitJwtFetchFailed extends LiveKitFailure { const LiveKitJwtFetchFailed(this.message); final String message; } final class LiveKitConnectFailed extends LiveKitFailure { const LiveKitConnectFailed(this.message); final String message; } /// Result of a LiveKit connection attempt. sealed class LiveKitResult { const LiveKitResult(); } final class LiveKitConnected extends LiveKitResult { const LiveKitConnected({required this.room}); final Room room; } final class LiveKitFailed extends LiveKitResult { const LiveKitFailed(this.failure); final LiveKitFailure failure; } /// Manages LiveKit room connections for MatrixRTC. class LiveKitService { LiveKitService({required Ref ref}) : _ref = ref; final Ref _ref; Room? _activeRoom; Room? get activeRoom => _activeRoom; /// Connect to LiveKit for [matrixRoomId]. /// /// Steps: /// 1. GET `/_matrix/livekit/jwt?roomId={id}&userId={id}` /// 2. Use returned token + LiveKit WS URL to connect a [Room] Future connect(String matrixRoomId) async { final auth = _ref.read(authProvider); debugPrint('[LiveKit] Auth state: ${auth.runtimeType}'); if (auth is! AuthAuthenticated) { debugPrint('[LiveKit] Not authenticated — cannot fetch JWT'); return const LiveKitFailed(LiveKitNotAuthenticated()); } final userId = auth.userId; debugPrint('[LiveKit] Fetching JWT for room=$matrixRoomId user=$userId'); // Step 1 — get OpenID token from Synapse (proves our identity) final client = _ref.read(matrixClientProvider); final jwtResult = await _fetchJwt( client: client, userId: userId, matrixRoomId: matrixRoomId, ); if (jwtResult is _JwtError) { return LiveKitFailed(LiveKitJwtFetchFailed(jwtResult.message)); } final jwt = (jwtResult as _JwtOk).token; final livekitUrl = (jwtResult).url; // Step 2 — connect to LiveKit final room = Room(); try { await room.connect(livekitUrl, jwt); _activeRoom = room; return LiveKitConnected(room: room); } on Exception catch (e) { await room.disconnect(); await room.dispose(); return LiveKitFailed(LiveKitConnectFailed(e.toString())); } } /// Disconnect and dispose the active room. Future disconnect() async { final room = _activeRoom; _activeRoom = null; if (room != null) { await room.disconnect(); await room.dispose(); } } /// Fetches a LiveKit JWT via the MSC4143 flow: /// 1. Request OpenID token from Synapse (proves our identity) /// 2. POST it to /_matrix/livekit/jwt/sfu/get with the room ID /// 3. Server verifies OpenID token with Synapse and returns LiveKit JWT + URL Future<_JwtFetchResult> _fetchJwt({ required Client client, required String userId, required String matrixRoomId, }) async { try { // Step 1: Get OpenID token from Synapse debugPrint('[LiveKit] Requesting OpenID token from Synapse...'); final openId = await client.requestOpenIdToken(userId, {}); debugPrint('[LiveKit] OpenID token received'); // Step 2: POST to the LiveKit JWT endpoint // The nginx location on matrix.m8chat.au expects /sfu/get sub-path final uri = Uri.parse('${AppConfig.livekitJwtUrl}/sfu/get'); debugPrint('[LiveKit] POSTing to $uri'); final response = await http.post( uri, headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'room': matrixRoomId, 'openid_token': openId.toJson(), }), ); debugPrint('[LiveKit] JWT response: ${response.statusCode}'); if (response.statusCode != 200) { debugPrint('[LiveKit] JWT body: ${response.body}'); return _JwtError( 'JWT endpoint returned ${response.statusCode}: ${response.body}', ); } final json = jsonDecode(response.body) as Map; final token = json['token'] as String?; final url = json['url'] as String?; if (token == null || url == null) { debugPrint('[LiveKit] Response fields: ${json.keys.toList()}'); return _JwtError('JWT response missing token or url fields.'); } debugPrint('[LiveKit] JWT obtained, LiveKit URL: $url'); return _JwtOk(token: token, url: url); } on Exception catch (e) { debugPrint('[LiveKit] Error: $e'); return _JwtError('Failed to get call token: $e'); } } } // Internal result types for JWT fetch — not exposed outside this file. sealed class _JwtFetchResult {} final class _JwtOk extends _JwtFetchResult { _JwtOk({required this.token, required this.url}); final String token; final String url; } final class _JwtError extends _JwtFetchResult { _JwtError(this.message); final String message; } @Riverpod(keepAlive: true) LiveKitService liveKitService(Ref ref) { final service = LiveKitService(ref: ref); ref.onDispose(() async => service.disconnect()); return service; }