feat: Phase 3 — E2EE calls, Jitsi conferencing, help tab, web security model

- LiveKit call E2EE: CallE2EEManager exchanges encryption keys via Matrix
  to-device events (m.rtc.encryption_keys) for interop with Element X
- Olm bootstrapped in index.html before Flutter init; main.dart logs result
- Encrypted messages shown with lock icon and informative fallback text
- Profile screen: key restore dialog + security setup (cross-signing/backup)
- Jitsi feature: welcome screen (public, no login), conference tab, full-screen
  embed via JitsiMeetExternalAPI, JitsiLink parser for all common link formats
- Help tab: expandable cards for encryption, video calls, account management
- Web security model: no session persistence — device ID only across visits
- Media auth: MSC3916 authenticated endpoint for avatars (Synapse 1.120+)
- Router: welcome route as public landing page; jitsi route as public
- Manifest/index.html: M8Chat branding, dark theme colours

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 05:28:14 +10:00
parent a241d0a7ab
commit b95471b0b4
27 changed files with 2076 additions and 226 deletions

View File

@@ -1,10 +1,10 @@
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-03
// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-04-10
// 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).
// E2EE: Frame encryption is enabled via livekit_client's E2EEOptions.
// Encryption keys are exchanged with Element X via m.rtc.encryption_keys
// to-device events (see call_e2ee.dart).
import 'dart:convert';
import 'dart:math';
@@ -19,6 +19,7 @@ import '../../../core/auth/auth_notifier.dart';
import '../../../core/auth/auth_state.dart';
import '../../../core/config/app_config.dart';
import '../../../core/network/matrix_client.dart';
import 'call_e2ee.dart';
part 'livekit_service.g.dart';
@@ -67,6 +68,7 @@ class LiveKitService {
Room? _activeRoom;
String? _activeMatrixRoomId;
String? _membershipId;
CallE2EEManager? _e2eeManager;
Room? get activeRoom => _activeRoom;
@@ -111,14 +113,37 @@ class LiveKitService {
_activeMatrixRoomId = matrixRoomId;
debugPrint('[LiveKit] Call member state event sent');
// Step 3 — connect to LiveKit
final room = Room();
// Step 3 — initialise E2EE key exchange
_e2eeManager = CallE2EEManager(
client: client,
matrixRoomId: matrixRoomId,
);
final keyProvider = await _e2eeManager!.init();
// Step 4 — connect to LiveKit with frame encryption
// COOP/COEP headers on the server enable SharedArrayBuffer for the
// E2EE web worker. Without them the worker crashes.
final room = Room(
roomOptions: RoomOptions(
e2eeOptions: E2EEOptions(keyProvider: keyProvider),
),
);
try {
await room.connect(livekitUrl, jwt);
_activeRoom = room;
// Step 5 — set our local key and share with other participants
final localIdentity = room.localParticipant?.identity;
debugPrint('[LiveKit] Connected, local identity: $localIdentity');
if (localIdentity != null) {
await _e2eeManager!.setLocalKey(localIdentity);
}
await _e2eeManager!.sendKeyToParticipants();
return LiveKitConnected(room: room);
} on Exception catch (e) {
// Clean up the state event on failure
await _e2eeManager?.dispose();
_e2eeManager = null;
await _clearCallMemberEvent(client, matrixRoomId, userId);
await room.disconnect();
await room.dispose();
@@ -136,6 +161,10 @@ class LiveKitService {
_activeMatrixRoomId = null;
_membershipId = null;
// Clean up E2EE key exchange
await _e2eeManager?.dispose();
_e2eeManager = null;
// Step 1: Leave LiveKit — Element X will see us disappear from the SFU
if (room != null) {
await room.disconnect();
@@ -255,6 +284,7 @@ class LiveKitService {
}
/// Clear the call.member state event (remove our membership).
/// Element X expects empty content `{}` — not `{'memberships': []}`.
Future<void> _clearCallMemberEvent(
Client client,
String matrixRoomId,
@@ -265,8 +295,9 @@ class LiveKitService {
matrixRoomId,
_kCallMemberEventType,
userId,
{'memberships': []},
{},
);
debugPrint('[LiveKit] Call member event cleared for $userId');
} catch (e) {
debugPrint('[LiveKit] Failed to clear call member event: $e');
}