From 1f58c9e21d78f2c8671fb347e57c1c3f627f4df4 Mon Sep 17 00:00:00 2001 From: help4bis Date: Fri, 3 Apr 2026 06:21:08 +1000 Subject: [PATCH] =?UTF-8?q?fix:=20calls=20=E2=80=94=20correct=20MSC4143=20?= =?UTF-8?q?JWT=20flow=20+=20message=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calls: - JWT fetch now uses correct MSC4143 flow: get OpenID token from Synapse, then POST to /_matrix/livekit/jwt/sfu/get (was using GET with Bearer token to wrong path — returned 301→404) - Error messages now visible for 3 seconds before popping screen (was flashing away instantly — user couldn't see failure reason) - Voice vs video calls differentiated via ?video=0/1 query param - Debug logging added to JWT flow for troubleshooting Messages: - Chat timeline now shows newest at bottom (standard behaviour). Was reversed twice: SDK returns newest-first, code reversed to oldest-first, then ListView(reverse:true) put oldest at bottom. Removed the extra .reversed — newest-first + reverse:true = correct. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/app/router.dart | 4 +- lib/features/calls/data/livekit_service.dart | 54 +++++++++++++------ .../calls/presentation/call_screen.dart | 39 ++++++++++---- lib/features/chat/data/chat_repository.dart | 4 +- .../chat/presentation/chat_screen.dart | 10 ++-- 5 files changed, 79 insertions(+), 32 deletions(-) diff --git a/lib/app/router.dart b/lib/app/router.dart index 6447379..b7851f4 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -87,9 +87,9 @@ GoRouter router(Ref ref) { GoRoute( path: AppRoutes.call, builder: (context, state) { - // safe: path param is guaranteed by route definition final roomId = state.pathParameters['roomId']!; - return CallScreen(roomId: roomId); + final isVideo = state.uri.queryParameters['video'] != '0'; + return CallScreen(roomId: roomId, isVideo: isVideo); }, ), GoRoute( diff --git a/lib/features/calls/data/livekit_service.dart b/lib/features/calls/data/livekit_service.dart index ed8f43d..2efdf8b 100644 --- a/lib/features/calls/data/livekit_service.dart +++ b/lib/features/calls/data/livekit_service.dart @@ -1,4 +1,4 @@ -// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 +// 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. // @@ -8,13 +8,16 @@ 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'; @@ -68,18 +71,21 @@ class LiveKitService { /// 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 accessToken = auth.accessToken; final userId = auth.userId; + debugPrint('[LiveKit] Fetching JWT for room=$matrixRoomId user=$userId'); - // Step 1 — fetch JWT from Matrix server + // Step 1 — get OpenID token from Synapse (proves our identity) + final client = _ref.read(matrixClientProvider); final jwtResult = await _fetchJwt( - accessToken: accessToken, - matrixRoomId: matrixRoomId, + client: client, userId: userId, + matrixRoomId: matrixRoomId, ); if (jwtResult is _JwtError) { return LiveKitFailed(LiveKitJwtFetchFailed(jwtResult.message)); @@ -110,39 +116,57 @@ class LiveKitService { } } + /// 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 String accessToken, - required String matrixRoomId, + required Client client, required String userId, + required String matrixRoomId, }) async { - final uri = Uri.parse( - AppConfig.livekitJwtUrl, - ).replace(queryParameters: {'roomId': matrixRoomId, 'userId': userId}); - try { - final response = await http.get( + // 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: {'Authorization': 'Bearer $accessToken'}, + 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; - // The server returns { token: "...", url: "wss://..." } per MSC4143. 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) { - return _JwtError('Network error fetching JWT: $e'); + debugPrint('[LiveKit] Error: $e'); + return _JwtError('Failed to get call token: $e'); } } } diff --git a/lib/features/calls/presentation/call_screen.dart b/lib/features/calls/presentation/call_screen.dart index 6e74de2..9b3b885 100644 --- a/lib/features/calls/presentation/call_screen.dart +++ b/lib/features/calls/presentation/call_screen.dart @@ -1,4 +1,4 @@ -// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 +// Version: 1.2.1 | Created: 2026-04-01 | Updated: 2026-04-03 // Full call screen with LiveKit video/audio. // - Remote video: full screen background // - Local video: picture-in-picture overlay (bottom right) @@ -16,9 +16,14 @@ import '../domain/call_state.dart'; import 'call_controller.dart'; class CallScreen extends ConsumerStatefulWidget { - const CallScreen({super.key, required this.roomId}); + const CallScreen({ + super.key, + required this.roomId, + this.isVideo = true, + }); final String roomId; + final bool isVideo; @override ConsumerState createState() => _CallScreenState(); @@ -28,12 +33,11 @@ class _CallScreenState extends ConsumerState { @override void initState() { super.initState(); - // Start the call as soon as the screen opens. - // Using addPostFrameCallback so the provider is ready. WidgetsBinding.instance.addPostFrameCallback((_) { - ref - .read(callControllerProvider.notifier) - .joinCall(Uri.decodeComponent(widget.roomId), withVideo: true); + ref.read(callControllerProvider.notifier).joinCall( + Uri.decodeComponent(widget.roomId), + withVideo: widget.isVideo, + ); }); } @@ -41,10 +45,25 @@ class _CallScreenState extends ConsumerState { Widget build(BuildContext context) { final callState = ref.watch(callControllerProvider); - // Pop back automatically when call ends. + // On call ended: show error reason for 3 seconds before popping, + // so the user can see WHY the call failed. ref.listen(callControllerProvider, (_, next) { - if (next is CallEnded && context.canPop()) { - context.pop(); + if (next is CallEnded && mounted) { + final reason = next.reason; + if (reason != null && reason.isNotEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(reason), + backgroundColor: Theme.of(context).colorScheme.error, + duration: const Duration(seconds: 3), + ), + ); + } + // Delay pop so the user sees the error. + final nav = GoRouter.of(context); + Future.delayed(const Duration(seconds: 3), () { + if (mounted) nav.pop(); + }); } }); diff --git a/lib/features/chat/data/chat_repository.dart b/lib/features/chat/data/chat_repository.dart index a02ae85..da25b9d 100644 --- a/lib/features/chat/data/chat_repository.dart +++ b/lib/features/chat/data/chat_repository.dart @@ -109,7 +109,9 @@ class ChatRepository { for (final e in timeline.events) { models.add(_toModel(e, timeline, myUserId)); } - return models.reversed.toList(); + // Matrix SDK timeline.events is newest-first. Keep that order because + // the chat ListView uses reverse:true — index 0 (newest) at the bottom. + return models; } MessageModel _toModel(Event event, Timeline timeline, String myUserId) { diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index ae4a61a..3a6060b 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -72,15 +72,17 @@ class _ChatScreenState extends ConsumerState { IconButton( icon: const Icon(Icons.call), tooltip: 'Voice call', - onPressed: () => - context.push('/calls/${Uri.encodeComponent(_decodedRoomId)}'), + onPressed: () => context.push( + '/calls/${Uri.encodeComponent(_decodedRoomId)}?video=0', + ), ), // Video call IconButton( icon: const Icon(Icons.videocam_outlined), tooltip: 'Video call', - onPressed: () => - context.push('/calls/${Uri.encodeComponent(_decodedRoomId)}'), + onPressed: () => context.push( + '/calls/${Uri.encodeComponent(_decodedRoomId)}?video=1', + ), ), IconButton( icon: const Icon(Icons.more_vert),