diff --git a/lib/app/router.dart b/lib/app/router.dart index b7851f4..2e52a18 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -1,4 +1,4 @@ -// Version: 1.0.2 | Created: 2026-04-01 +// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-05 // All route definitions in one place. // GoRouter is created ONCE (keepAlive) and re-evaluates redirects via // refreshListenable — avoids the GoRouter recreation bug from ref.watch. @@ -12,6 +12,8 @@ import '../core/auth/auth_state.dart'; import '../features/auth/presentation/login_screen.dart'; import '../features/calls/presentation/call_screen.dart'; import '../features/chat/presentation/chat_screen.dart'; +import '../features/jitsi/presentation/jitsi_screen.dart'; +import '../features/jitsi/presentation/welcome_screen.dart'; import '../features/profile/presentation/profile_screen.dart'; import '../features/rooms/presentation/rooms_screen.dart'; import '../features/spaces/presentation/spaces_screen.dart'; @@ -21,12 +23,14 @@ part 'router.g.dart'; /// Route path constants — use these instead of raw strings. abstract final class AppRoutes { static const String root = '/'; + static const String welcome = '/welcome'; static const String login = '/login'; static const String rooms = '/rooms'; static const String chat = '/rooms/:roomId'; static const String call = '/calls/:roomId'; static const String profile = '/profile'; static const String spaces = '/spaces'; + static const String jitsi = '/jitsi'; } /// ChangeNotifier that GoRouter listens to for redirect re-evaluation. @@ -35,6 +39,13 @@ class _AuthRefreshNotifier extends ChangeNotifier { void notify() => notifyListeners(); } +/// Routes that are accessible without authentication. +const _publicRoutes = { + AppRoutes.welcome, + AppRoutes.login, + AppRoutes.jitsi, +}; + @Riverpod(keepAlive: true) GoRouter router(Ref ref) { // Create once — notifier triggers re-redirect without recreating the router. @@ -50,16 +61,22 @@ GoRouter router(Ref ref) { // Read (not watch) so redirect does not itself trigger provider changes. final authState = ref.read(authProvider); final isLoggedIn = authState is AuthAuthenticated; - final isOnLogin = state.matchedLocation == AppRoutes.login; + final currentPath = state.matchedLocation; + final isPublicRoute = _publicRoutes.contains(currentPath); // While restoring session, stay on splash. if (authState is AuthInitial || authState is AuthLoading) return null; - // Unauthenticated users must go to login. - if (!isLoggedIn && !isOnLogin) return AppRoutes.login; + // Unauthenticated users hitting a protected route → welcome screen. + if (!isLoggedIn && !isPublicRoute) return AppRoutes.welcome; - // Authenticated users should not see the login screen. - if (isLoggedIn && isOnLogin) return AppRoutes.rooms; + // Authenticated users on welcome or login → go straight to rooms. + if (isLoggedIn && + (currentPath == AppRoutes.welcome || + currentPath == AppRoutes.login || + currentPath == AppRoutes.root)) { + return AppRoutes.rooms; + } return null; }, @@ -68,6 +85,10 @@ GoRouter router(Ref ref) { path: AppRoutes.root, builder: (context, state) => const _SplashRedirectPage(), ), + GoRoute( + path: AppRoutes.welcome, + builder: (context, state) => const WelcomeScreen(), + ), GoRoute( path: AppRoutes.login, builder: (context, state) => const LoginScreen(), @@ -100,6 +121,13 @@ GoRouter router(Ref ref) { path: AppRoutes.spaces, builder: (context, state) => const SpacesScreen(), ), + GoRoute( + path: AppRoutes.jitsi, + builder: (context, state) { + final meetingUrl = state.extra as String? ?? ''; + return JitsiScreen(meetingUrl: meetingUrl); + }, + ), ], ); } diff --git a/lib/core/auth/auth_notifier.dart b/lib/core/auth/auth_notifier.dart index 5e0936d..b0318ee 100644 --- a/lib/core/auth/auth_notifier.dart +++ b/lib/core/auth/auth_notifier.dart @@ -1,88 +1,28 @@ -// Version: 1.1.1 | Created: 2026-04-01 +// Version: 2.0.0 | Created: 2026-04-01 | Updated: 2026-04-11 // Riverpod notifier that owns the auth state machine. -// All login/logout/session-restore transitions go through here. - -import 'dart:async'; +// +// WEB SECURITY MODEL: Every visit requires a fresh login. No session +// persistence. Only the Matrix device ID is saved across sessions so +// encryption keys accumulate on one device instead of creating ghosts. +import 'package:flutter/foundation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../features/auth/data/auth_repository.dart'; import '../../features/auth/domain/auth_failure.dart'; import '../storage/sync_persistence_service.dart'; import 'auth_state.dart'; -import 'secure_storage.dart'; part 'auth_notifier.g.dart'; -/// The single source of truth for authentication state. -/// -/// keepAlive: true — auth state must persist for the entire app lifetime. -/// GoRouter watches this provider to decide which route to show. @Riverpod(keepAlive: true) class AuthNotifier extends _$AuthNotifier { @override AuthState build() { - // Kick off session restore immediately; start in [AuthInitial]. - _restoreSession(); - return const AuthState.initial(); + // No session restore on web — always require fresh login. + return const AuthState.unauthenticated(); } - /// Tries to restore a previous session from secure storage. - Future _restoreSession() async { - state = const AuthState.loading(); - - final storage = ref.read(secureStorageProvider); - final credentials = await storage.loadCredentials(); - - if (credentials == null) { - state = const AuthState.unauthenticated(); - return; - } - - try { - final repo = ref.read(authRepositoryProvider); - - // Timeout: client.init() starts the Matrix sync loop and can hang - // indefinitely if the token is expired or the server is unreachable. - // After 12 seconds we give up and send the user to the login screen. - await repo - .restoreSession( - accessToken: credentials.accessToken, - userId: credentials.userId, - deviceId: credentials.deviceId, - ) - .timeout( - const Duration(seconds: 12), - onTimeout: () => throw Exception('Session restore timed out'), - ); - - state = AuthState.authenticated( - userId: credentials.userId, - accessToken: credentials.accessToken, - deviceId: credentials.deviceId, - ); - - // Resume background persistence — fire-and-forget so it never blocks auth. - try { - ref.read(syncPersistenceServiceProvider).start(); - } catch (_) { - // Persistence failure is non-fatal; the app works without it. - } - } on AuthFailure { - // Stored credentials are invalid; force re-login. - await storage.clearCredentials(); - state = const AuthState.unauthenticated(); - } on Exception { - // Covers: timeout, network offline, Olm init failure. - // Clear stale credentials so the login screen appears cleanly. - await storage.clearCredentials(); - state = const AuthState.unauthenticated(); - } - } - - /// Attempts to log in with [username] and [password]. - /// - /// Transitions: loading → authenticated | unauthenticated(failure). Future login({ required String username, required String password, @@ -91,38 +31,38 @@ class AuthNotifier extends _$AuthNotifier { try { final repo = ref.read(authRepositoryProvider); - final response = await repo.login(username: username, password: password); + debugPrint('[Auth] Logging in...'); - final storage = ref.read(secureStorageProvider); - await storage.saveCredentials( - accessToken: response.accessToken, - userId: response.userId, - deviceId: response.deviceId, + final response = await repo.login( + username: username, + password: password, ); + debugPrint('[Auth] Login OK as ${response.userId} ' + 'device=${response.deviceId}'); + state = AuthState.authenticated( userId: response.userId, accessToken: response.accessToken, deviceId: response.deviceId, ); - // Start background sync-to-database persistence now that we are logged in. - ref.read(syncPersistenceServiceProvider).start(); + // Start background sync-to-database persistence. + try { + ref.read(syncPersistenceServiceProvider).start(); + } catch (_) {} } on AuthFailure catch (failure) { state = AuthState.unauthenticated(failure: failure.userMessage); } } - /// Logs out the current user, clears storage, and resets to unauthenticated. Future logout() async { state = const AuthState.loading(); final repo = ref.read(authRepositoryProvider); await repo.logout(); - final storage = ref.read(secureStorageProvider); - await storage.clearCredentials(); - + // Keep the device ID in storage so the next login reuses it. state = const AuthState.unauthenticated(); } } diff --git a/lib/core/auth/secure_storage.dart b/lib/core/auth/secure_storage.dart index 5710868..582951d 100644 --- a/lib/core/auth/secure_storage.dart +++ b/lib/core/auth/secure_storage.dart @@ -1,83 +1,30 @@ -// Version: 1.0.0 | Created: 2026-04-01 -// Typed wrapper around flutter_secure_storage. -// All token read/write operations go through this class — never call -// flutter_secure_storage directly from feature code. +// Version: 2.0.0 | Created: 2026-04-01 | Updated: 2026-04-11 +// Minimal storage: only the Matrix device ID is persisted so that +// encryption keys accumulate on one device across logins. +// Access tokens are NOT stored — every browser visit requires login. import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../config/app_config.dart'; - part 'secure_storage.g.dart'; -/// Provides a configured [SecureStorage] instance. @Riverpod(keepAlive: true) SecureStorage secureStorage(Ref ref) => const SecureStorage(); -/// Typed wrapper around [FlutterSecureStorage]. -/// -/// Uses AES encryption on Android and Keychain on iOS. -/// On Web, data is stored in localStorage with encryption — acceptable for -/// access tokens but NOT for E2EE private keys (Phase 2 concern). class SecureStorage { const SecureStorage(); + static const _kDeviceId = 'm8chat_device_id'; + static const FlutterSecureStorage _storage = FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), ); - Future saveCredentials({ - required String accessToken, - required String userId, - required String deviceId, - }) async { - await _storage.write( - key: AppConfig.storageKeyAccessToken, - value: accessToken, - ); - await _storage.write(key: AppConfig.storageKeyUserId, value: userId); - await _storage.write(key: AppConfig.storageKeyDeviceId, value: deviceId); - await _storage.write( - key: AppConfig.storageKeyHomeserver, - value: AppConfig.matrixBaseUrl, - ); + Future saveDeviceId(String deviceId) async { + await _storage.write(key: _kDeviceId, value: deviceId); } - Future loadCredentials() async { - final accessToken = await _storage.read( - key: AppConfig.storageKeyAccessToken, - ); - final userId = await _storage.read(key: AppConfig.storageKeyUserId); - final deviceId = await _storage.read(key: AppConfig.storageKeyDeviceId); - - if (accessToken == null || userId == null || deviceId == null) { - return null; - } - - return StoredCredentials( - accessToken: accessToken, - userId: userId, - deviceId: deviceId, - ); - } - - Future clearCredentials() async { - await _storage.delete(key: AppConfig.storageKeyAccessToken); - await _storage.delete(key: AppConfig.storageKeyUserId); - await _storage.delete(key: AppConfig.storageKeyDeviceId); - await _storage.delete(key: AppConfig.storageKeyHomeserver); + Future loadDeviceId() async { + return _storage.read(key: _kDeviceId); } } - -/// Holds the credentials retrieved from secure storage. -class StoredCredentials { - const StoredCredentials({ - required this.accessToken, - required this.userId, - required this.deviceId, - }); - - final String accessToken; - final String userId; - final String deviceId; -} diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 4a0092d..6438cae 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -1,9 +1,6 @@ -// Version: 1.0.0 | Created: 2026-04-01 +// Version: 2.0.0 | Created: 2026-04-01 | Updated: 2026-04-11 // App-wide constants. Change matrixBaseUrl for different environments. -/// Central configuration for the M8Chat application. -/// All server URLs and app-level constants live here — never scatter -/// magic strings through feature code. abstract final class AppConfig { static const String matrixBaseUrl = 'https://matrix.m8chat.au'; static const String matrixServerName = 'matrix.m8chat.au'; @@ -14,11 +11,8 @@ abstract final class AppConfig { 'turns:matrix.m8chat.au:5349', ]; static const String appName = 'M8Chat'; - static const String appVersion = '1.0.0'; + static const String appVersion = '1.3.0'; - // Secure storage key names - static const String storageKeyAccessToken = 'access_token'; - static const String storageKeyUserId = 'user_id'; - static const String storageKeyDeviceId = 'device_id'; - static const String storageKeyHomeserver = 'homeserver'; + // Jitsi conferencing + static const String jitsiDomain = 'conf.m8chat.au'; } diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index dfd175f..9561d7f 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -1,7 +1,8 @@ -// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02 +// Version: 2.0.0 | Created: 2026-04-01 | Updated: 2026-04-11 // Auth repository: handles all Matrix login/logout API interactions. // Uses the Matrix Dart SDK — no raw HTTP calls for auth. +import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -34,15 +35,16 @@ class AuthRepository { required String password, }) async { try { - // Set homeserver directly — avoids network round-trips and version checks - // that checkHomeserver() makes. The setter is public in matrix 0.33.0. _client.homeserver = Uri.parse(AppConfig.matrixBaseUrl); - return await _client.login( + final response = await _client.login( LoginType.mLoginPassword, identifier: AuthenticationUserIdentifier(user: username), password: password, initialDeviceDisplayName: AppConfig.appName, ); + debugPrint('[M8Chat] Login complete. encryptionEnabled=${_client.encryptionEnabled}'); + debugPrint('[M8Chat] encryption=${_client.encryption}'); + return response; } on MatrixException catch (e) { throw switch (e.errcode) { 'M_FORBIDDEN' => const AuthFailure.invalidCredentials(), @@ -77,19 +79,5 @@ class AuthRepository { } } - /// Restores an existing Matrix session using a stored access token. - Future restoreSession({ - required String accessToken, - required String userId, - required String deviceId, - }) async { - await _client.init( - newToken: accessToken, - newUserID: userId, - newDeviceID: deviceId, - newDeviceName: AppConfig.appName, - newHomeserver: Uri.parse(AppConfig.matrixBaseUrl), - newOlmAccount: null, - ); - } + // restoreSession() removed — web app requires fresh login every visit. } diff --git a/lib/features/calls/data/call_e2ee.dart b/lib/features/calls/data/call_e2ee.dart new file mode 100644 index 0000000..09b00e5 --- /dev/null +++ b/lib/features/calls/data/call_e2ee.dart @@ -0,0 +1,185 @@ +// Version: 1.0.0 | Created: 2026-04-10 +// E2EE key exchange for LiveKit calls via Matrix to-device events. +// Implements the m.rtc.encryption_keys protocol for interop with Element X. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:matrix/matrix.dart' show Client, DeviceKeys, ToDeviceEvent; + +/// Event type used by Element X / Element Call for key exchange. +const kEncryptionKeysEventType = 'm.rtc.encryption_keys'; + +/// Manages E2EE key generation, distribution, and reception for LiveKit calls. +/// +/// Flow: +/// 1. [init] creates a per-participant [BaseKeyProvider] and generates a local key. +/// 2. After LiveKit connects, call [setLocalKey] with the local participant identity. +/// 3. Call [sendKeyToParticipants] to share our key via encrypted to-device events. +/// 4. Incoming keys from Element X are received via [Client.onToDeviceEvent] and +/// set on the [BaseKeyProvider] automatically. +class CallE2EEManager { + CallE2EEManager({ + required Client client, + required String matrixRoomId, + }) : _client = client, + _matrixRoomId = matrixRoomId; + + final Client _client; + final String _matrixRoomId; + BaseKeyProvider? _keyProvider; + StreamSubscription? _toDeviceSub; + Uint8List? _localKey; + final int _localKeyIndex = 0; + + BaseKeyProvider? get keyProvider => _keyProvider; + + /// Create the key provider and start listening for incoming keys. + Future init() async { + _keyProvider = await BaseKeyProvider.create(sharedKey: false); + + // Generate a random 32-byte encryption key for our media tracks. + _localKey = _secureRandomBytes(32); + + // Listen for incoming encryption keys from other call participants. + _toDeviceSub = _client.onToDeviceEvent.stream.listen(_onToDeviceEvent); + + debugPrint('[E2EE] Key provider created, local key generated ' + '(${base64Encode(_localKey!).substring(0, 8)}…)'); + return _keyProvider!; + } + + /// Set our local key on the provider using the LiveKit participant identity. + /// Call this AFTER the LiveKit Room is connected and localParticipant is available. + Future setLocalKey(String localIdentity) async { + if (_keyProvider == null || _localKey == null) return; + await _keyProvider!.setRawKey( + _localKey!, + participantId: localIdentity, + keyIndex: _localKeyIndex, + ); + debugPrint('[E2EE] Local key set for identity=$localIdentity'); + } + + /// Send our encryption key to all other call members via encrypted to-device. + Future sendKeyToParticipants() async { + if (_localKey == null) return; + + final room = _client.getRoomById(_matrixRoomId); + if (room == null) { + debugPrint('[E2EE] Room $_matrixRoomId not found — cannot send keys'); + return; + } + + // Find other participants from the MSC3401 call.member state events. + final memberEvents = + room.states['org.matrix.msc3401.call.member'] ?? {}; + + final content = { + 'keys': [ + {'index': _localKeyIndex, 'key': base64Encode(_localKey!)}, + ], + 'call_id': '', + 'device_id': _client.deviceID ?? '', + 'room_id': _matrixRoomId, + }; + + for (final entry in memberEvents.entries) { + final userId = entry.key; + if (userId == _client.userID) continue; + + // Parse device_id from the call.member event to target the correct device. + final memberships = + entry.value.content['memberships'] as List? ?? []; + if (memberships.isEmpty) continue; + + await _client.userDeviceKeysLoading; + final deviceKeyMap = _client.userDeviceKeys[userId]?.deviceKeys; + if (deviceKeyMap == null || deviceKeyMap.isEmpty) { + debugPrint('[E2EE] No device keys for $userId — skipping'); + continue; + } + + // Send to all devices of this user that are in the call. + final List targets = []; + for (final m in memberships) { + final deviceId = m['device_id'] as String?; + if (deviceId != null && deviceKeyMap.containsKey(deviceId)) { + targets.add(deviceKeyMap[deviceId]!); + } + } + + // Fallback: if no specific device matched, send to all devices. + if (targets.isEmpty) { + targets.addAll(deviceKeyMap.values); + } + + try { + await _client.sendToDeviceEncrypted( + targets, + kEncryptionKeysEventType, + content, + ); + debugPrint( + '[E2EE] Sent key to $userId (${targets.length} device(s))'); + } catch (e) { + debugPrint('[E2EE] Failed to send key to $userId: $e'); + } + } + } + + /// Handle incoming to-device encryption key events from other participants. + void _onToDeviceEvent(ToDeviceEvent event) { + if (event.type != kEncryptionKeysEventType) return; + + // Only process keys for our active call room. + final eventRoomId = event.content['room_id'] as String?; + if (eventRoomId != null && eventRoomId != _matrixRoomId) return; + + final keys = event.content['keys'] as List?; + if (keys == null || keys.isEmpty) { + debugPrint( + '[E2EE] Received $kEncryptionKeysEventType with empty keys ' + 'from ${event.sender}'); + return; + } + + final senderId = event.sender; + debugPrint('[E2EE] Received encryption key(s) from $senderId'); + + for (final keyEntry in keys) { + final index = (keyEntry['index'] as num?)?.toInt() ?? 0; + final keyB64 = keyEntry['key'] as String?; + if (keyB64 == null) continue; + + final keyBytes = base64Decode(keyB64); + + // The participant identity in LiveKit should match the Matrix user ID + // (set by lk-jwt-service). Log both for debugging. + _keyProvider?.setRawKey( + keyBytes, + participantId: senderId, + keyIndex: index, + ); + debugPrint( + '[E2EE] Key set for participant=$senderId index=$index ' + '(${keyB64.substring(0, 8)}…)'); + } + } + + Future dispose() async { + await _toDeviceSub?.cancel(); + _toDeviceSub = null; + _localKey = null; + _keyProvider = null; + debugPrint('[E2EE] Disposed'); + } + + static Uint8List _secureRandomBytes(int length) { + final rng = Random.secure(); + return Uint8List.fromList( + List.generate(length, (_) => rng.nextInt(256))); + } +} diff --git a/lib/features/calls/data/livekit_service.dart b/lib/features/calls/data/livekit_service.dart index 29eab0a..1cf5ff7 100644 --- a/lib/features/calls/data/livekit_service.dart +++ b/lib/features/calls/data/livekit_service.dart @@ -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 _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'); } diff --git a/lib/features/chat/data/chat_repository.dart b/lib/features/chat/data/chat_repository.dart index 135a2d6..577b3c6 100644 --- a/lib/features/chat/data/chat_repository.dart +++ b/lib/features/chat/data/chat_repository.dart @@ -1,4 +1,4 @@ -// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 +// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-10 // Chat repository — bridges Matrix SDK timeline to app domain models. // Phase 2 additions: sendFile, sendReaction, redactEvent, reply support. @@ -191,6 +191,7 @@ class ChatRepository { MessageType _messageType(Event event) { if (event.redacted) return MessageType.redacted; + if (event.type == EventTypes.Encrypted) return MessageType.encrypted; return switch (event.messageType) { MessageTypes.Text => MessageType.text, diff --git a/lib/features/chat/domain/message_model.dart b/lib/features/chat/domain/message_model.dart index 9c72c0a..ebac7db 100644 --- a/lib/features/chat/domain/message_model.dart +++ b/lib/features/chat/domain/message_model.dart @@ -1,4 +1,4 @@ -// Version: 1.0.0 | Created: 2026-04-01 +// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-10 // Immutable message model for the chat timeline. import 'package:freezed_annotation/freezed_annotation.dart'; @@ -14,6 +14,7 @@ enum MessageType { video, sticker, redacted, + encrypted, unsupported, } diff --git a/lib/features/chat/presentation/message_bubble.dart b/lib/features/chat/presentation/message_bubble.dart index e8a9fa1..933b697 100644 --- a/lib/features/chat/presentation/message_bubble.dart +++ b/lib/features/chat/presentation/message_bubble.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-10 // Message bubble widget. Handles text, images, files, redacted, replies. import 'package:cached_network_image/cached_network_image.dart'; @@ -138,6 +138,22 @@ class _MessageContentBody extends StatelessWidget { fontStyle: FontStyle.italic, ), ), + MessageType.encrypted => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lock_outline, size: 14, color: textColour.withAlpha(153)), + const SizedBox(width: 6), + Flexible( + child: Text( + 'Encrypted — use Element to read', + style: TextStyle( + color: textColour.withAlpha(153), + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), _ => Text( message.body ?? 'Unsupported message type', style: TextStyle( diff --git a/lib/features/help/presentation/help_tab.dart b/lib/features/help/presentation/help_tab.dart new file mode 100644 index 0000000..54568e4 --- /dev/null +++ b/lib/features/help/presentation/help_tab.dart @@ -0,0 +1,332 @@ +// Version: 1.1.0 | Created: 2026-04-11 | Updated: 2026-04-11 +// Help tab with in-app guidance for common tasks and troubleshooting. + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:web/web.dart' as web; + +import '../../../core/network/matrix_client.dart'; +import '../../profile/presentation/security_setup_dialog.dart'; + +void _launchUrl(BuildContext context, String url) { + web.window.open(url, '_blank'); +} + +class HelpTab extends ConsumerWidget { + const HelpTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final client = ref.watch(matrixClientProvider); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + 'Help & Troubleshooting', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + + // --- Encrypted messages --- + _HelpCard( + icon: Icons.lock_outline, + title: 'I see "Encrypted — use Element to read"', + children: [ + const _HelpParagraph( + 'This means your device does not have the keys needed to ' + 'read those messages. This happens when:', + ), + const _HelpBullet( + 'You are using M8Chat on a new device or browser.'), + const _HelpBullet( + 'The messages were sent before you set up security.'), + const _HelpBullet( + 'Your browser data was cleared.'), + const SizedBox(height: 12), + const _HelpSubheading('How to fix it'), + const _HelpParagraph( + '1. Go to the Profile tab.\n' + '2. Tap Security & Privacy to expand it.\n' + '3. Tap Security setup.\n' + '4. If you have set up security before, enter your ' + 'recovery key. If not, a new one will be created for you.\n' + '5. Save the recovery key somewhere safe.\n' + '6. After setup, go back to your rooms. Messages should ' + 'now be readable.', + ), + const SizedBox(height: 12), + const _HelpSubheading('What is a recovery key?'), + const _HelpParagraph( + 'A recovery key is a long code starting with "EsTc" that ' + 'unlocks your encrypted messages. It was shown to you when ' + 'you first set up security in Element or M8Chat. If you ' + 'saved it, you can use it to restore your messages on any ' + 'device.', + ), + const SizedBox(height: 12), + Builder( + builder: (context) => OutlinedButton.icon( + onPressed: () => + showSecuritySetupDialog(context, client), + icon: const Icon(Icons.shield_outlined), + label: const Text('Open Security Setup'), + ), + ), + ], + ), + + // --- New to M8Chat --- + const _HelpCard( + icon: Icons.chat_bubble_outline, + title: 'Getting started with M8Chat', + children: [ + _HelpParagraph( + 'M8Chat is a secure messaging app built on the Matrix ' + 'protocol. All your messages are end-to-end encrypted, ' + 'meaning only you and the people you chat with can read ' + 'them.', + ), + SizedBox(height: 12), + _HelpSubheading('First steps'), + _HelpParagraph( + '1. Set up security: go to Profile > Security & Privacy ' + '> Security setup. This creates your encryption keys and ' + 'backs them up.\n' + '2. Save your recovery key — you will need it if you ever ' + 'log in on a new device.\n' + '3. Start chatting. Tap the pencil icon at the top right ' + 'to search for users and start a conversation.', + ), + ], + ), + + // --- Joining a conference --- + const _HelpCard( + icon: Icons.videocam_outlined, + title: 'Joining a video conference', + children: [ + _HelpParagraph( + 'M8Chat supports Jitsi video conferences. To join one:', + ), + SizedBox(height: 8), + _HelpParagraph( + '1. Tap the Conference tab at the bottom.\n' + '2. Paste the meeting link (e.g. conf.m8chat.au/MyMeeting).\n' + '3. Tap Join Meeting.\n\n' + 'You can also make voice and video calls directly from any ' + 'chat room using the phone and camera icons at the top of ' + 'the conversation.', + ), + ], + ), + + // --- Video call issues --- + const _HelpCard( + icon: Icons.videocam_off_outlined, + title: 'Video call problems', + children: [ + _HelpSubheading('Scrambled or black video'), + _HelpParagraph( + 'If video appears scrambled when calling someone on Element X, ' + 'this is an encryption compatibility issue that is being ' + 'worked on. Try calling from the same app on both ends.', + ), + SizedBox(height: 8), + _HelpSubheading('No audio or robotic sound'), + _HelpParagraph( + 'This is usually caused by browser permissions. Make sure ' + 'you have allowed microphone and camera access when ' + 'prompted. On some devices (especially Sailfish OS), the ' + 'audio codec support may be limited.', + ), + ], + ), + + // --- Account & privacy --- + const _HelpCard( + icon: Icons.security_outlined, + title: 'Account & privacy', + children: [ + _HelpSubheading('Who can read my messages?'), + _HelpParagraph( + 'Only you and the people in the conversation. M8Chat uses ' + 'end-to-end encryption — not even the server operator can ' + 'read your messages.', + ), + SizedBox(height: 8), + _HelpSubheading('Can I use multiple devices?'), + _HelpParagraph( + 'Yes. Set up security on each device using the same ' + 'recovery key. Your messages will be available on all ' + 'devices where security is set up.', + ), + SizedBox(height: 8), + _HelpSubheading('What happens if I lose my recovery key?'), + _HelpParagraph( + 'You will not be able to read old messages on new devices. ' + 'New messages will still work. You can create a new ' + 'recovery key from the Security setup, but old messages ' + 'encrypted with the previous key will remain unreadable.', + ), + ], + ), + + // --- Account management --- + _HelpCard( + icon: Icons.manage_accounts_outlined, + title: 'Account management', + children: [ + const _HelpParagraph( + 'All account changes need to be made through the M8Chat ' + 'website. This includes:', + ), + const _HelpBullet('Changing your password'), + const _HelpBullet('Membership settings and billing'), + const _HelpBullet('Updating your profile or email'), + const _HelpBullet('Deleting your account'), + const SizedBox(height: 12), + Builder( + builder: (context) => OutlinedButton.icon( + onPressed: () => _launchUrl(context, 'https://m8chat.au'), + icon: const Icon(Icons.open_in_new), + label: const Text('Go to m8chat.au'), + ), + ), + const SizedBox(height: 12), + const _HelpSubheading('Privacy'), + const _HelpParagraph( + 'No account details or other privacy or user identifiers ' + 'are saved or stored on this application. All data stays ' + 'on the M8Chat server and is protected by end-to-end ' + 'encryption.', + ), + const SizedBox(height: 12), + const _HelpParagraph( + 'For more information about what happens if things go ' + 'wrong, including data recovery and account issues:', + ), + const SizedBox(height: 8), + Builder( + builder: (context) => OutlinedButton.icon( + onPressed: () => _launchUrl( + context, 'https://m8chat.au/when-it-all-goes-wrong/'), + icon: const Icon(Icons.open_in_new), + label: const Text('When it all goes wrong'), + ), + ), + ], + ), + + const SizedBox(height: 32), + Center( + child: Text( + 'Need more help? Visit m8chat.au or contact the M8Chat team.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(102), + ), + ), + ), + const SizedBox(height: 16), + ], + ); + } +} + +// --- Reusable help widgets --- + +class _HelpCard extends StatelessWidget { + const _HelpCard({ + required this.icon, + required this.title, + required this.children, + }); + + final IconData icon; + final String title; + final List children; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ExpansionTile( + leading: Icon(icon, color: theme.colorScheme.primary), + title: Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + childrenPadding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ); + } +} + +class _HelpParagraph extends StatelessWidget { + const _HelpParagraph(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5), + ); + } +} + +class _HelpSubheading extends StatelessWidget { + const _HelpSubheading(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + text, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _HelpBullet extends StatelessWidget { + const _HelpBullet(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 8, top: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('\u2022 '), + Expanded( + child: Text( + text, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(height: 1.5), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/jitsi/data/jitsi_web_service.dart b/lib/features/jitsi/data/jitsi_web_service.dart new file mode 100644 index 0000000..97205fb --- /dev/null +++ b/lib/features/jitsi/data/jitsi_web_service.dart @@ -0,0 +1,168 @@ +// Version: 1.1.0 | Created: 2026-04-05 | Updated: 2026-04-10 +// Web implementation: uses JitsiMeetExternalAPI via dart:js_interop. +// This file is web-only — guarded by the Flutter web build. +// The external_api.js script is lazy-loaded on first use — not in index.html. +// This prevents privacy browsers (Brave, Mullvad) from blocking the app +// at startup due to a cross-origin script from conf.m8chat.au. + +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:ui_web' as ui_web; + +import 'package:web/web.dart' as web; + +import '../../../core/config/app_config.dart'; + +/// Manages a single Jitsi iframe session via the JitsiMeetExternalAPI. +class JitsiWebService { + JitsiWebService._(); + static final instance = JitsiWebService._(); + + JSObject? _api; + bool _viewRegistered = false; + bool _scriptLoaded = false; + bool _scriptLoading = false; + + /// Unique view type for HtmlElementView. + static const String viewType = 'jitsi-meet-container'; + + /// Lazy-loads the JitsiMeetExternalAPI script from conf.m8chat.au. + /// Returns true if the script loaded successfully, false otherwise. + Future _ensureScriptLoaded() async { + if (_scriptLoaded) return true; + if (_scriptLoading) { + // Wait for in-flight load to finish. + for (var i = 0; i < 50; i++) { + await Future.delayed(const Duration(milliseconds: 100)); + if (_scriptLoaded) return true; + } + return false; + } + _scriptLoading = true; + + final completer = Completer(); + final script = + web.document.createElement('script') as web.HTMLScriptElement; + script.src = 'https://${AppConfig.jitsiDomain}/external_api.js'; + script.async = true; + script.onload = (web.Event event) { + _scriptLoaded = true; + _scriptLoading = false; + completer.complete(true); + }.toJS; + script.onerror = (web.Event event) { + _scriptLoading = false; + completer.complete(false); + }.toJS; + web.document.head?.append(script); + + return completer.future; + } + + /// Registers the platform view factory (once). + void ensureViewRegistered() { + if (_viewRegistered) return; + ui_web.platformViewRegistry.registerViewFactory( + viewType, + (int viewId, {Object? params}) { + final div = web.document.createElement('div') as web.HTMLDivElement; + div.id = 'jitsi-container-$viewId'; + div.style + ..width = '100%' + ..height = '100%'; + return div; + }, + ); + _viewRegistered = true; + } + + /// Starts a Jitsi meeting inside the container div created by the platform view. + /// Call this AFTER the HtmlElementView has been mounted (e.g. in a post-frame callback). + /// Lazy-loads the Jitsi script on first call. + Future joinMeeting({ + required String roomName, + String? jwt, + String? displayName, + String? avatarUrl, + void Function()? onReadyToClose, + }) async { + dispose(); // tear down any previous meeting + + // Lazy-load the external_api.js script if not already present. + final loaded = await _ensureScriptLoaded(); + if (!loaded) return; + + // Find the container div — there should be exactly one with our prefix. + final containers = web.document.querySelectorAll('[id^="jitsi-container-"]'); + if (containers.length == 0) return; + final parentNode = containers.item(containers.length - 1); + if (parentNode == null) return; + + final configOverwrite = { + 'startAudioMuted': 0, + 'startVideoMuted': 0, + 'disableDeepLinking': true, + 'prejoinPageEnabled': true, + }.jsify(); + + final interfaceConfigOverwrite = { + 'SHOW_CHROME_EXTENSION_BANNER': false, + }.jsify(); + + final options = { + 'roomName': roomName, + 'parentNode': parentNode, + 'width': '100%', + 'height': '100%', + 'configOverwrite': configOverwrite, + 'interfaceConfigOverwrite': interfaceConfigOverwrite, + }; + + if (jwt != null && jwt.isNotEmpty) { + options['jwt'] = jwt; + } + + if (displayName != null && displayName.isNotEmpty) { + options['userInfo'] = { + 'displayName': displayName, + if (avatarUrl != null) 'avatarUrl': avatarUrl, + }.jsify(); + } + + final jsOptions = options.jsify(); + _api = _createJitsiApi(AppConfig.jitsiDomain.toJS, jsOptions as JSObject); + + if (onReadyToClose != null && _api != null) { + _addEventListener(_api!, 'readyToClose'.toJS, () { + onReadyToClose(); + }.toJS); + } + } + + /// Cleans up the Jitsi iframe. + void dispose() { + if (_api != null) { + _disposeApi(_api!); + _api = null; + } + } +} + +/// Calls `new JitsiMeetExternalAPI(domain, options)`. +@JS('JitsiMeetExternalAPI') +external JSObject _createJitsiApi(JSString domain, JSObject options); + +/// Calls `api.addEventListener(event, callback)`. +@JS() +extension type _JitsiApi(JSObject _) implements JSObject { + external void addEventListener(JSString event, JSFunction callback); + external void dispose(); +} + +void _addEventListener(JSObject api, JSString event, JSFunction callback) { + (api as _JitsiApi).addEventListener(event, callback); +} + +void _disposeApi(JSObject api) { + (api as _JitsiApi).dispose(); +} diff --git a/lib/features/jitsi/domain/jitsi_link.dart b/lib/features/jitsi/domain/jitsi_link.dart new file mode 100644 index 0000000..b670266 --- /dev/null +++ b/lib/features/jitsi/domain/jitsi_link.dart @@ -0,0 +1,43 @@ +// Version: 1.0.0 | Created: 2026-04-05 +// Parses a Jitsi meeting link into room name and optional JWT. + +/// Parsed Jitsi meeting link. +class JitsiLink { + const JitsiLink({required this.roomName, this.jwt}); + + final String roomName; + final String? jwt; + + /// Parses a Jitsi link in common formats: + /// - `https://conf.m8chat.au/RoomName` + /// - `https://conf.m8chat.au/RoomName?jwt=abc123` + /// - `conf.m8chat.au/RoomName?jwt=abc123` + /// - `RoomName` (bare room name) + static JitsiLink? tryParse(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) return null; + + // Try as a URL first. + var uriString = trimmed; + if (!uriString.contains('://') && uriString.contains('/')) { + uriString = 'https://$uriString'; + } + + final uri = Uri.tryParse(uriString); + if (uri != null && uri.pathSegments.isNotEmpty && uri.host.isNotEmpty) { + final roomName = uri.pathSegments + .where((s) => s.isNotEmpty) + .join('/'); + if (roomName.isEmpty) return null; + final jwt = uri.queryParameters['jwt']; + return JitsiLink(roomName: roomName, jwt: jwt); + } + + // Bare room name — no slashes, no protocol. + if (!trimmed.contains('/') && !trimmed.contains(' ')) { + return JitsiLink(roomName: trimmed); + } + + return null; + } +} diff --git a/lib/features/jitsi/presentation/conference_tab.dart b/lib/features/jitsi/presentation/conference_tab.dart new file mode 100644 index 0000000..d2c721f --- /dev/null +++ b/lib/features/jitsi/presentation/conference_tab.dart @@ -0,0 +1,96 @@ +// Version: 1.0.0 | Created: 2026-04-10 +// Embedded conference join widget for the bottom nav tab. + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Inline widget shown inside the RoomsScreen bottom nav. +/// Lets authenticated users paste a Jitsi meeting link and join. +class ConferenceTab extends StatefulWidget { + const ConferenceTab({super.key}); + + @override + State createState() => _ConferenceTabState(); +} + +class _ConferenceTabState extends State { + final _linkController = TextEditingController(); + + @override + void dispose() { + _linkController.dispose(); + super.dispose(); + } + + void _joinConference() { + final link = _linkController.text.trim(); + if (link.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please paste a meeting link.')), + ); + return; + } + context.push('/jitsi', extra: link); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.videocam_outlined, + size: 64, + color: theme.colorScheme.primary.withAlpha(180), + ), + const SizedBox(height: 16), + Text( + 'Join a Conference', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Paste your meeting link below to join.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(153), + ), + ), + const SizedBox(height: 32), + TextField( + controller: _linkController, + decoration: const InputDecoration( + hintText: 'conf.m8chat.au/YourMeeting', + prefixIcon: Icon(Icons.link), + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.go, + onSubmitted: (_) => _joinConference(), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _joinConference, + icon: const Icon(Icons.videocam), + label: const Text('Join Meeting'), + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(52), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/jitsi/presentation/jitsi_screen.dart b/lib/features/jitsi/presentation/jitsi_screen.dart new file mode 100644 index 0000000..030bcd9 --- /dev/null +++ b/lib/features/jitsi/presentation/jitsi_screen.dart @@ -0,0 +1,121 @@ +// Version: 1.2.0 | Created: 2026-04-05 | Updated: 2026-04-10 +// Full-screen Jitsi meeting embedded via HtmlElementView (web platform view). + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../data/jitsi_web_service.dart'; +import '../domain/jitsi_link.dart'; + +class JitsiScreen extends StatefulWidget { + const JitsiScreen({super.key, required this.meetingUrl}); + + /// The raw meeting link or room name provided by the user. + final String meetingUrl; + + @override + State createState() => _JitsiScreenState(); +} + +class _JitsiScreenState extends State { + late final JitsiLink? _link; + bool _meetingStarted = false; + bool _meetingEnded = false; + + @override + void initState() { + super.initState(); + _link = JitsiLink.tryParse(widget.meetingUrl); + if (_link != null) { + JitsiWebService.instance.ensureViewRegistered(); + } + } + + @override + void dispose() { + JitsiWebService.instance.dispose(); + super.dispose(); + } + + void _startMeeting() { + if (_link == null || _meetingStarted) return; + _meetingStarted = true; + + // Post-frame so the HtmlElementView div is mounted in the DOM. + WidgetsBinding.instance.addPostFrameCallback((_) { + JitsiWebService.instance.joinMeeting( + roomName: _link.roomName, + jwt: _link.jwt, + onReadyToClose: () { + if (mounted) { + setState(() => _meetingEnded = true); + } + }, + ); + }); + } + + @override + Widget build(BuildContext context) { + if (_link == null) { + return Scaffold( + appBar: AppBar(title: const Text('Conference')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.link_off, size: 48), + const SizedBox(height: 16), + const Text('Could not parse the meeting link.'), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => context.go('/rooms'), + child: const Text('Back'), + ), + ], + ), + ), + ), + ); + } + + if (_meetingEnded) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.videocam_off_outlined, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Meeting ended', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => context.go('/rooms'), + child: const Text('Back to M8Chat'), + ), + ], + ), + ), + ), + ); + } + + // Render the Jitsi iframe and kick off the meeting once mounted. + _startMeeting(); + + return Scaffold( + body: const HtmlElementView(viewType: JitsiWebService.viewType), + ); + } +} diff --git a/lib/features/jitsi/presentation/welcome_screen.dart b/lib/features/jitsi/presentation/welcome_screen.dart new file mode 100644 index 0000000..b5ed766 --- /dev/null +++ b/lib/features/jitsi/presentation/welcome_screen.dart @@ -0,0 +1,218 @@ +// Version: 1.0.0 | Created: 2026-04-05 +// Welcome/landing screen — first thing unauthenticated users see. +// Two paths: join a Jitsi conference (no login) or sign in for Matrix chat. + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:go_router/go_router.dart'; + +class WelcomeScreen extends StatefulWidget { + const WelcomeScreen({super.key}); + + @override + State createState() => _WelcomeScreenState(); +} + +class _WelcomeScreenState extends State { + final _linkController = TextEditingController(); + + @override + void dispose() { + _linkController.dispose(); + super.dispose(); + } + + void _joinConference() { + final link = _linkController.text.trim(); + if (link.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please paste a meeting link.')), + ); + return; + } + context.go('/jitsi', extra: link); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // --- Logo & tagline --- + Column( + children: [ + SvgPicture.asset( + 'assets/images/m8logo.svg', + width: 80, + height: 80, + ), + const SizedBox(height: 16), + Text( + 'M8Chat', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 8), + Text( + 'Chat and conference — all in one place', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(153), + ), + ), + ], + ), + + const SizedBox(height: 48), + + // --- Conference section --- + _SectionCard( + theme: theme, + isDark: isDark, + icon: Icons.videocam_outlined, + title: 'Join a Conference', + subtitle: 'Paste your meeting link to join instantly — no account needed.', + child: Column( + children: [ + TextField( + controller: _linkController, + decoration: const InputDecoration( + hintText: 'conf.m8chat.au/YourMeeting', + prefixIcon: Icon(Icons.link), + ), + textInputAction: TextInputAction.go, + onSubmitted: (_) => _joinConference(), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: _joinConference, + icon: const Icon(Icons.videocam), + label: const Text('Join Meeting'), + ), + ], + ), + ), + + const SizedBox(height: 20), + + // --- Divider --- + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'or', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(102), + ), + ), + ), + const Expanded(child: Divider()), + ], + ), + + const SizedBox(height: 20), + + // --- Sign in section --- + _SectionCard( + theme: theme, + isDark: isDark, + icon: Icons.chat_bubble_outline, + title: 'Sign In to Chat', + subtitle: 'Access your rooms, messages and video calls.', + child: OutlinedButton.icon( + onPressed: () => context.go('/login'), + icon: const Icon(Icons.login), + label: const Text('Sign In'), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide(color: theme.colorScheme.primary), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// Styled card for each welcome section. +class _SectionCard extends StatelessWidget { + const _SectionCard({ + required this.theme, + required this.isDark, + required this.icon, + required this.title, + required this.subtitle, + required this.child, + }); + + final ThemeData theme; + final bool isDark; + final IconData icon; + final String title; + final String subtitle; + final Widget child; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: theme.colorScheme.primary, size: 28), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(153), + ), + ), + const SizedBox(height: 16), + child, + ], + ), + ), + ); + } +} diff --git a/lib/features/profile/presentation/key_restore_dialog.dart b/lib/features/profile/presentation/key_restore_dialog.dart new file mode 100644 index 0000000..ac7cf0a --- /dev/null +++ b/lib/features/profile/presentation/key_restore_dialog.dart @@ -0,0 +1,213 @@ +// Version: 1.0.0 | Created: 2026-04-11 +// Dialog for entering a recovery key to restore encrypted message keys +// from the server-side key backup (SSSS / Secure Secret Storage). + +import 'package:flutter/material.dart'; +import 'package:matrix/encryption/ssss.dart'; +import 'package:matrix/matrix.dart'; + +/// Shows a dialog that asks the user for their recovery key, unlocks SSSS, +/// and restores all Megolm session keys from the server backup. +/// +/// Returns `true` if keys were restored successfully, `false` otherwise. +Future showKeyRestoreDialog(BuildContext context, Client client) async { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _KeyRestoreDialog(client: client), + ); + return result ?? false; +} + +class _KeyRestoreDialog extends StatefulWidget { + const _KeyRestoreDialog({required this.client}); + final Client client; + + @override + State<_KeyRestoreDialog> createState() => _KeyRestoreDialogState(); +} + +enum _RestorePhase { input, unlocking, downloading, done, error } + +class _KeyRestoreDialogState extends State<_KeyRestoreDialog> { + final _controller = TextEditingController(); + _RestorePhase _phase = _RestorePhase.input; + String? _error; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _restore() async { + final key = _controller.text.trim(); + if (key.isEmpty) return; + + final enc = widget.client.encryption; + if (enc == null) { + setState(() { + _phase = _RestorePhase.error; + _error = 'Encryption is not available.'; + }); + return; + } + + // Phase 1: Unlock SSSS with the recovery key + setState(() => _phase = _RestorePhase.unlocking); + try { + final ssssKey = enc.ssss.open(); + await ssssKey.unlock(keyOrPassphrase: key); + debugPrint('[KeyRestore] SSSS unlocked successfully'); + } on InvalidPassphraseException { + setState(() { + _phase = _RestorePhase.error; + _error = 'Invalid recovery key. Please check and try again.'; + }); + return; + } on Exception catch (e) { + setState(() { + _phase = _RestorePhase.error; + _error = 'Could not unlock: ${e.toString().split('\n').first}'; + }); + return; + } + + // Phase 2: Download and decrypt all keys from backup + setState(() => _phase = _RestorePhase.downloading); + try { + await enc.keyManager.loadAllKeys(); + debugPrint('[KeyRestore] All keys loaded from backup'); + setState(() => _phase = _RestorePhase.done); + } on MatrixException catch (e) { + if (e.errcode == 'M_NOT_FOUND') { + setState(() { + _phase = _RestorePhase.error; + _error = 'No key backup found on the server for this account.'; + }); + } else { + setState(() { + _phase = _RestorePhase.error; + _error = 'Server error: ${e.errorMessage}'; + }); + } + } on Exception catch (e) { + setState(() { + _phase = _RestorePhase.error; + _error = 'Failed to download keys: ${e.toString().split('\n').first}'; + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(switch (_phase) { + _RestorePhase.input => 'Restore Message Keys', + _RestorePhase.unlocking => 'Unlocking...', + _RestorePhase.downloading => 'Downloading Keys...', + _RestorePhase.done => 'Keys Restored', + _RestorePhase.error => 'Restore Failed', + }), + content: switch (_phase) { + _RestorePhase.input => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Enter your recovery key to decrypt old messages. ' + 'This is the key you were given when you set up ' + 'encryption in Element.', + ), + const SizedBox(height: 16), + TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: 'EsTc oRgW rqHN...', + labelText: 'Recovery key', + border: OutlineInputBorder(), + ), + maxLines: 3, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _restore(), + ), + ], + ), + _RestorePhase.unlocking => const _ProgressContent( + message: 'Verifying recovery key...', + ), + _RestorePhase.downloading => const _ProgressContent( + message: 'Downloading and decrypting message keys...\n' + 'This may take a moment.', + ), + _RestorePhase.done => const Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, color: Colors.green, size: 48), + SizedBox(height: 16), + Text( + 'Message keys have been restored. ' + 'Go back to your rooms — previously encrypted ' + 'messages should now be readable.', + ), + ], + ), + _RestorePhase.error => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 48), + const SizedBox(height: 16), + Text(_error ?? 'An unknown error occurred.'), + ], + ), + }, + actions: switch (_phase) { + _RestorePhase.input => [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: _restore, + child: const Text('Restore'), + ), + ], + _RestorePhase.unlocking || _RestorePhase.downloading => [], + _RestorePhase.done => [ + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Done'), + ), + ], + _RestorePhase.error => [ + TextButton( + onPressed: () => + setState(() => _phase = _RestorePhase.input), + child: const Text('Try again'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Close'), + ), + ], + }, + ); + } +} + +class _ProgressContent extends StatelessWidget { + const _ProgressContent({required this.message}); + final String message; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(message, textAlign: TextAlign.center), + ], + ); + } +} diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 201f720..af0cba4 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02 +// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-04-11 // Profile screen. Shows current user info and logout button. import 'package:flutter/material.dart'; @@ -10,6 +10,8 @@ import '../../../core/config/app_config.dart'; import '../../../core/network/matrix_client.dart'; import '../../../shared/utils/matrix_id.dart'; import '../../../shared/widgets/matrix_avatar.dart'; +import 'key_restore_dialog.dart'; +import 'security_setup_dialog.dart'; class ProfileScreen extends ConsumerWidget { const ProfileScreen({super.key, this.embedded = false}); @@ -85,14 +87,57 @@ class ProfileScreen extends ConsumerWidget { ListTile( leading: const Icon(Icons.lock_outline), title: const Text('End-to-end encryption'), - subtitle: const Text('Setup coming in Phase 3'), + subtitle: Text( + client.encryptionEnabled + ? 'Active — messages are encrypted' + : 'Not available in this browser', + ), enabled: false, ), ListTile( - leading: const Icon(Icons.verified_user_outlined), - title: const Text('Verify devices'), - subtitle: const Text('Cross-signing setup — coming in Phase 2'), - enabled: false, + leading: const Icon(Icons.key_outlined), + title: const Text('Restore message keys'), + subtitle: const Text( + 'Enter your recovery key to decrypt old messages', + ), + enabled: client.encryptionEnabled, + onTap: client.encryptionEnabled + ? () async { + final restored = + await showKeyRestoreDialog(context, client); + if (restored && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Keys restored — reopen rooms ' + 'to see decrypted messages.'), + ), + ); + } + } + : null, + ), + ListTile( + leading: const Icon(Icons.shield_outlined), + title: const Text('Security setup'), + subtitle: Text( + client.encryption?.crossSigning.enabled == true + ? 'Cross-signing active — device verified' + : 'Set up cross-signing and key backup', + ), + enabled: client.encryptionEnabled, + onTap: client.encryptionEnabled + ? () async { + final completed = + await showSecuritySetupDialog(context, client); + if (completed && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Security setup complete.'), + ), + ); + } + } + : null, ), ListTile( leading: const Icon(Icons.password_outlined), diff --git a/lib/features/profile/presentation/security_setup_dialog.dart b/lib/features/profile/presentation/security_setup_dialog.dart new file mode 100644 index 0000000..2136fdf --- /dev/null +++ b/lib/features/profile/presentation/security_setup_dialog.dart @@ -0,0 +1,416 @@ +// Version: 1.0.0 | Created: 2026-04-11 +// Security setup dialog: drives the Matrix SDK Bootstrap to set up +// SSSS, cross-signing, and online key backup in one flow. +// For new users: creates everything, shows recovery key. +// For existing users: unlocks with recovery key, restores message keys. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:matrix/encryption/ssss.dart'; +import 'package:matrix/encryption/utils/bootstrap.dart'; +import 'package:matrix/matrix.dart'; + +/// Shows the security setup dialog. Returns true if setup completed. +Future showSecuritySetupDialog( + BuildContext context, Client client) async { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _SecuritySetupDialog(client: client), + ); + return result ?? false; +} + +class _SecuritySetupDialog extends StatefulWidget { + const _SecuritySetupDialog({required this.client}); + final Client client; + + @override + State<_SecuritySetupDialog> createState() => _SecuritySetupDialogState(); +} + +enum _Phase { + checking, + needsRecoveryKey, + working, + showRecoveryKey, + restoring, + done, + error, +} + +class _SecuritySetupDialogState extends State<_SecuritySetupDialog> { + final _keyController = TextEditingController(); + _Phase _phase = _Phase.checking; + String? _error; + String? _recoveryKey; + String _statusMessage = 'Checking security setup...'; + late Bootstrap _bootstrap; + + @override + void initState() { + super.initState(); + _start(); + } + + @override + void dispose() { + _keyController.dispose(); + super.dispose(); + } + + Future _start() async { + final enc = widget.client.encryption; + if (enc == null) { + _setError('Encryption is not available in this browser.'); + return; + } + + _bootstrap = enc.bootstrap(onUpdate: (_) {}); + + try { + await _driveBootstrap(); + } catch (e) { + _setError(e.toString().split('\n').first); + } + } + + Future _driveBootstrap() async { + while (_bootstrap.state != BootstrapState.done && + _bootstrap.state != BootstrapState.error) { + debugPrint('[Security] Bootstrap state: ${_bootstrap.state}'); + + switch (_bootstrap.state) { + case BootstrapState.loading: + setState(() { + _phase = _Phase.working; + _statusMessage = 'Setting up...'; + }); + // Wait for the bootstrap to advance via onUpdate + await Future.delayed(const Duration(milliseconds: 200)); + + case BootstrapState.askWipeSsss: + // Existing SSSS found — don't wipe, use it + setState(() { + _phase = _Phase.working; + _statusMessage = 'Found existing security setup...'; + }); + _bootstrap.wipeSsss(false); + + case BootstrapState.askUseExistingSsss: + // Use the existing SSSS + _bootstrap.useExistingSsss(true); + + case BootstrapState.openExistingSsss: + // Need the recovery key from the user + if (!_bootstrap.newSsssKey!.isUnlocked) { + setState(() => _phase = _Phase.needsRecoveryKey); + return; // Wait for user input + } + // Already unlocked, continue + setState(() { + _phase = _Phase.working; + _statusMessage = 'Verifying security keys...'; + }); + await _bootstrap.openExistingSsss(); + + case BootstrapState.askBadSsss: + // Ignore bad secrets and continue + _bootstrap.ignoreBadSecrets(true); + + case BootstrapState.askUnlockSsss: + // Old keys need unlocking — this is a migration scenario + if (_bootstrap.oldSsssKeys != null) { + for (final key in _bootstrap.oldSsssKeys!.values) { + if (!key.isUnlocked) { + setState(() => _phase = _Phase.needsRecoveryKey); + return; // Wait for user input + } + } + } + _bootstrap.unlockedSsss(); + + case BootstrapState.askNewSsss: + // Create new SSSS (no passphrase — recovery key only) + setState(() { + _phase = _Phase.working; + _statusMessage = 'Creating security keys...'; + }); + await _bootstrap.newSsss(); + + case BootstrapState.askSetupCrossSigning: + setState(() => _statusMessage = 'Setting up cross-signing...'); + await _bootstrap.askSetupCrossSigning( + setupMasterKey: true, + setupSelfSigningKey: true, + setupUserSigningKey: true, + ); + + case BootstrapState.askWipeCrossSigning: + // Don't wipe existing cross-signing + await _bootstrap.wipeCrossSigning(false); + + case BootstrapState.askSetupOnlineKeyBackup: + setState(() => _statusMessage = 'Creating key backup...'); + await _bootstrap.askSetupOnlineKeyBackup(true); + + case BootstrapState.askWipeOnlineKeyBackup: + // Don't wipe existing backup + _bootstrap.wipeOnlineKeyBackup(false); + + case BootstrapState.done: + case BootstrapState.error: + break; // Exit loop + } + } + + if (_bootstrap.state == BootstrapState.error) { + _setError('Setup failed. Please try again.'); + return; + } + + // Success — show recovery key if we created new SSSS + _recoveryKey = _bootstrap.newSsssKey?.recoveryKey; + + if (_recoveryKey != null && _phase != _Phase.needsRecoveryKey) { + // New setup — show the recovery key + setState(() => _phase = _Phase.showRecoveryKey); + } else { + // Existing setup — restore keys from backup + await _restoreKeys(); + } + } + + /// Called when the user enters their recovery key for existing SSSS. + Future _unlockWithRecoveryKey() async { + final key = _keyController.text.trim(); + if (key.isEmpty) return; + + setState(() { + _phase = _Phase.working; + _statusMessage = 'Verifying recovery key...'; + }); + + try { + // Unlock whichever SSSS key is waiting + if (_bootstrap.newSsssKey != null && !_bootstrap.newSsssKey!.isUnlocked) { + await _bootstrap.newSsssKey!.unlock(keyOrPassphrase: key); + } + if (_bootstrap.oldSsssKeys != null) { + for (final ssssKey in _bootstrap.oldSsssKeys!.values) { + if (!ssssKey.isUnlocked) { + await ssssKey.unlock(keyOrPassphrase: key); + } + } + } + } on InvalidPassphraseException { + _setError('Invalid recovery key. Please check and try again.'); + return; + } on Exception catch (e) { + _setError('Could not unlock: ${e.toString().split('\n').first}'); + return; + } + + // Continue the bootstrap + try { + await _driveBootstrap(); + } catch (e) { + _setError(e.toString().split('\n').first); + } + } + + /// Download all keys from the online key backup after SSSS is unlocked. + Future _restoreKeys() async { + setState(() { + _phase = _Phase.restoring; + _statusMessage = 'Downloading message keys from backup...'; + }); + + try { + await widget.client.encryption?.keyManager.loadAllKeys(); + debugPrint('[Security] All keys loaded from backup'); + } on MatrixException catch (e) { + if (e.errcode != 'M_NOT_FOUND') { + debugPrint('[Security] Key restore error: ${e.errorMessage}'); + } + // M_NOT_FOUND just means no backup exists yet — not an error + } on Exception catch (e) { + debugPrint('[Security] Key restore error: $e'); + } + + setState(() => _phase = _Phase.done); + } + + void _setError(String message) { + setState(() { + _phase = _Phase.error; + _error = message; + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(switch (_phase) { + _Phase.checking => 'Security Setup', + _Phase.needsRecoveryKey => 'Enter Recovery Key', + _Phase.working || _Phase.restoring => 'Setting Up...', + _Phase.showRecoveryKey => 'Save Your Recovery Key', + _Phase.done => 'Security Setup Complete', + _Phase.error => 'Setup Error', + }), + content: SizedBox( + width: 400, + child: switch (_phase) { + _Phase.checking || _Phase.working || _Phase.restoring => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(_statusMessage, textAlign: TextAlign.center), + ], + ), + _Phase.needsRecoveryKey => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Enter your recovery key to unlock your encrypted ' + 'messages. This is the key shown when you first set ' + 'up encryption in Element.', + ), + const SizedBox(height: 16), + TextField( + controller: _keyController, + decoration: const InputDecoration( + hintText: 'EsTc oRgW rqHN...', + labelText: 'Recovery key or passphrase', + border: OutlineInputBorder(), + ), + maxLines: 3, + onSubmitted: (_) => _unlockWithRecoveryKey(), + ), + ], + ), + _Phase.showRecoveryKey => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Your recovery key has been created. Save it somewhere ' + 'safe — you will need it to restore your messages on ' + 'new devices.', + ), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline, + ), + ), + child: SelectableText( + _recoveryKey ?? '', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 14, + letterSpacing: 1.2, + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + const Icon(Icons.warning_amber, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + 'If you lose this key, you will not be able to ' + 'read your encrypted messages on new devices.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ], + ), + _Phase.done => const Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.verified, color: Colors.green, size: 48), + SizedBox(height: 16), + Text( + 'Security is set up. Cross-signing is active and ' + 'your message keys are backed up. Previously ' + 'encrypted messages should now be readable.', + ), + ], + ), + _Phase.error => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 48), + const SizedBox(height: 16), + Text(_error ?? 'An unknown error occurred.'), + ], + ), + }, + ), + actions: switch (_phase) { + _Phase.checking || _Phase.working || _Phase.restoring => [], + _Phase.needsRecoveryKey => [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: _unlockWithRecoveryKey, + child: const Text('Unlock'), + ), + ], + _Phase.showRecoveryKey => [ + TextButton( + onPressed: () { + Clipboard.setData( + ClipboardData(text: _recoveryKey ?? '')); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Recovery key copied to clipboard')), + ); + }, + child: const Text('Copy'), + ), + ElevatedButton( + onPressed: () async { + await _restoreKeys(); + }, + child: const Text('I saved it — continue'), + ), + ], + _Phase.done => [ + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Done'), + ), + ], + _Phase.error => [ + TextButton( + onPressed: () { + setState(() => _phase = _Phase.needsRecoveryKey); + }, + child: const Text('Try again'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Close'), + ), + ], + }, + ); + } +} diff --git a/lib/features/rooms/data/rooms_repository.dart b/lib/features/rooms/data/rooms_repository.dart index a88f590..bcd481e 100644 --- a/lib/features/rooms/data/rooms_repository.dart +++ b/lib/features/rooms/data/rooms_repository.dart @@ -2,6 +2,7 @@ // Rooms repository. Reads room list from the Matrix SDK client. import 'package:matrix/matrix.dart'; +import 'package:flutter/foundation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/network/matrix_client.dart'; @@ -24,6 +25,10 @@ class RoomsRepository { /// Returns the current room list, sorted unread-first then by last activity. List getRooms() { final rooms = _client.rooms; + debugPrint('[Rooms] client.rooms count: ${rooms.length}'); + for (final r in rooms) { + debugPrint('[Rooms] ${r.id} "${r.getLocalizedDisplayname()}" isSpace=${r.isSpace}'); + } final models = rooms.map(_toModel).toList(); models.sort((a, b) { diff --git a/lib/features/rooms/presentation/rooms_screen.dart b/lib/features/rooms/presentation/rooms_screen.dart index 4b86282..a55eef2 100644 --- a/lib/features/rooms/presentation/rooms_screen.dart +++ b/lib/features/rooms/presentation/rooms_screen.dart @@ -1,10 +1,12 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-11 // Main rooms list screen with bottom navigation. import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../help/presentation/help_tab.dart'; +import '../../jitsi/presentation/conference_tab.dart'; import '../../profile/presentation/profile_screen.dart'; import '../../spaces/presentation/spaces_screen.dart'; import 'room_tile.dart'; @@ -37,6 +39,16 @@ class _RoomsScreenState extends ConsumerState { selectedIcon: Icon(Icons.person), label: 'Profile', ), + NavigationDestination( + icon: Icon(Icons.videocam_outlined), + selectedIcon: Icon(Icons.videocam), + label: 'Conference', + ), + NavigationDestination( + icon: Icon(Icons.help_outline), + selectedIcon: Icon(Icons.help), + label: 'Help', + ), ]; Widget _buildBody() { @@ -44,6 +56,8 @@ class _RoomsScreenState extends ConsumerState { 0 => const _RoomListBody(), 1 => const SpacesScreen(embedded: true), 2 => const ProfileScreen(embedded: true), + 3 => const ConferenceTab(), + 4 => const HelpTab(), _ => const _RoomListBody(), }; } diff --git a/lib/main.dart b/lib/main.dart index 12ff70b..403e524 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,24 @@ -// Version: 1.0.1 | Created: 2026-04-01 +// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-10 // Application entry point. import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:olm/olm.dart' as olm; import 'app/app.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Check Olm availability — this prints to the browser console. + try { + await olm.init(); + debugPrint('[M8Chat] Olm initialized successfully: ${olm.get_library_version()}'); + } catch (e) { + debugPrint('[M8Chat] Olm NOT available: $e'); + } + // Catch Flutter framework errors (widget build, rendering) without crashing. FlutterError.onError = (details) { debugPrint('[M8Chat] Flutter error: ${details.exceptionAsString()}'); diff --git a/lib/shared/utils/mxc_url.dart b/lib/shared/utils/mxc_url.dart index bf99f24..b2ee9bf 100644 --- a/lib/shared/utils/mxc_url.dart +++ b/lib/shared/utils/mxc_url.dart @@ -1,28 +1,32 @@ -// Version: 1.0.0 | Created: 2026-04-02 +// Version: 2.0.0 | Created: 2026-04-02 | Updated: 2026-04-11 // Synchronous MXC URI to HTTP URL resolution. -// Avatars and thumbnails in the room list / sync service need a resolved HTTP -// URL. The Matrix SDK's getDownloadUri() is async (checks authenticated media -// support), but for avatar display we need a synchronous result. // -// This helper builds the legacy v3 media URL which works on all homeservers. +// Synapse 1.120+ requires authenticated media downloads. The old +// /_matrix/media/v3/download/ endpoint is frozen and returns 404. +// We use /_matrix/client/v1/media/download/ with the access token. import 'package:matrix/matrix.dart'; -/// Resolves an `mxc://` [Uri] to an HTTP download URL using the client's -/// homeserver. Returns `null` if the URI is not an mxc scheme or the client -/// has no homeserver set. -/// -/// This is synchronous — suitable for use in non-async model mapping. +/// Resolves an `mxc://` [Uri] to an authenticated HTTP download URL. +/// Returns `null` if the URI is not mxc:// or the client is not connected. String? resolveMxcUrl(Client client, Uri? mxcUri) { if (mxcUri == null || !mxcUri.isScheme('mxc')) return null; final homeserver = client.homeserver; if (homeserver == null) return null; - // Build the media download path per the Matrix spec. final serverName = mxcUri.host; final port = mxcUri.hasPort ? ':${mxcUri.port}' : ''; final mediaId = mxcUri.path; // includes leading / - return homeserver - .resolve('_matrix/media/v3/download/$serverName$port$mediaId') + + // Use the authenticated media endpoint (MSC3916). + final base = homeserver + .resolve('_matrix/client/v1/media/download/$serverName$port$mediaId') .toString(); + + // Append the access token so CachedNetworkImage / Image.network can + // fetch without custom headers. The token is already in the browser's + // JS memory so this doesn't expand the attack surface. + final token = client.accessToken; + if (token == null) return base; + return '$base?access_token=$token'; } diff --git a/lib/shared/utils/room_preview.dart b/lib/shared/utils/room_preview.dart index 9371316..73f1909 100644 --- a/lib/shared/utils/room_preview.dart +++ b/lib/shared/utils/room_preview.dart @@ -1,4 +1,4 @@ -// Version: 1.0.0 | Created: 2026-04-02 +// Version: 1.1.0 | Created: 2026-04-02 | Updated: 2026-04-10 // Shared utility for generating a last-message preview string from a Room. // Used by both RoomsRepository and SyncPersistenceService to avoid duplication. @@ -12,7 +12,7 @@ String? lastMessagePreview(Room room) { return switch (lastEvent.type) { EventTypes.Message => lastEvent.body, - EventTypes.Encrypted => 'Encrypted message', + EventTypes.Encrypted => '\u{1F512} Encrypted — use Element to read', EventTypes.Sticker => 'Sticker', _ => null, }; diff --git a/pubspec.yaml b/pubspec.yaml index 6b063e2..c991192 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: m8chat_app description: "M8Chat — Matrix chat client for Android, iOS, and Web." publish_to: 'none' -version: 1.1.0+2 +version: 1.2.0+3 environment: sdk: '>=3.11.0 <4.0.0' @@ -12,6 +12,7 @@ dependencies: # Matrix SDK matrix: ^0.33.0 + olm: ^2.0.4 # State management flutter_riverpod: ^3.0.0 @@ -42,6 +43,9 @@ dependencies: # Emoji reactions emoji_picker_flutter: ^4.0.0 + # Web platform (required for dart:js_interop DOM access) + web: ^1.1.0 + # UI cached_network_image: ^3.4.0 timeago: ^3.7.0 diff --git a/web/index.html b/web/index.html index 84569da..2183d33 100644 --- a/web/index.html +++ b/web/index.html @@ -18,12 +18,12 @@ - + - + @@ -49,12 +49,43 @@ } - + + diff --git a/web/manifest.json b/web/manifest.json index a214716..72baeb7 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,11 +1,11 @@ { - "name": "m8chat_app", - "short_name": "m8chat_app", + "name": "M8Chat", + "short_name": "M8Chat", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", + "background_color": "#0F0F1A", + "theme_color": "#5C35C9", + "description": "M8Chat — secure chat and conferencing.", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [