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,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. // All route definitions in one place.
// GoRouter is created ONCE (keepAlive) and re-evaluates redirects via // GoRouter is created ONCE (keepAlive) and re-evaluates redirects via
// refreshListenable — avoids the GoRouter recreation bug from ref.watch. // 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/auth/presentation/login_screen.dart';
import '../features/calls/presentation/call_screen.dart'; import '../features/calls/presentation/call_screen.dart';
import '../features/chat/presentation/chat_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/profile/presentation/profile_screen.dart';
import '../features/rooms/presentation/rooms_screen.dart'; import '../features/rooms/presentation/rooms_screen.dart';
import '../features/spaces/presentation/spaces_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. /// Route path constants — use these instead of raw strings.
abstract final class AppRoutes { abstract final class AppRoutes {
static const String root = '/'; static const String root = '/';
static const String welcome = '/welcome';
static const String login = '/login'; static const String login = '/login';
static const String rooms = '/rooms'; static const String rooms = '/rooms';
static const String chat = '/rooms/:roomId'; static const String chat = '/rooms/:roomId';
static const String call = '/calls/:roomId'; static const String call = '/calls/:roomId';
static const String profile = '/profile'; static const String profile = '/profile';
static const String spaces = '/spaces'; static const String spaces = '/spaces';
static const String jitsi = '/jitsi';
} }
/// ChangeNotifier that GoRouter listens to for redirect re-evaluation. /// ChangeNotifier that GoRouter listens to for redirect re-evaluation.
@@ -35,6 +39,13 @@ class _AuthRefreshNotifier extends ChangeNotifier {
void notify() => notifyListeners(); void notify() => notifyListeners();
} }
/// Routes that are accessible without authentication.
const _publicRoutes = {
AppRoutes.welcome,
AppRoutes.login,
AppRoutes.jitsi,
};
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
GoRouter router(Ref ref) { GoRouter router(Ref ref) {
// Create once — notifier triggers re-redirect without recreating the router. // 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. // Read (not watch) so redirect does not itself trigger provider changes.
final authState = ref.read(authProvider); final authState = ref.read(authProvider);
final isLoggedIn = authState is AuthAuthenticated; 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. // While restoring session, stay on splash.
if (authState is AuthInitial || authState is AuthLoading) return null; if (authState is AuthInitial || authState is AuthLoading) return null;
// Unauthenticated users must go to login. // Unauthenticated users hitting a protected route → welcome screen.
if (!isLoggedIn && !isOnLogin) return AppRoutes.login; if (!isLoggedIn && !isPublicRoute) return AppRoutes.welcome;
// Authenticated users should not see the login screen. // Authenticated users on welcome or login → go straight to rooms.
if (isLoggedIn && isOnLogin) return AppRoutes.rooms; if (isLoggedIn &&
(currentPath == AppRoutes.welcome ||
currentPath == AppRoutes.login ||
currentPath == AppRoutes.root)) {
return AppRoutes.rooms;
}
return null; return null;
}, },
@@ -68,6 +85,10 @@ GoRouter router(Ref ref) {
path: AppRoutes.root, path: AppRoutes.root,
builder: (context, state) => const _SplashRedirectPage(), builder: (context, state) => const _SplashRedirectPage(),
), ),
GoRoute(
path: AppRoutes.welcome,
builder: (context, state) => const WelcomeScreen(),
),
GoRoute( GoRoute(
path: AppRoutes.login, path: AppRoutes.login,
builder: (context, state) => const LoginScreen(), builder: (context, state) => const LoginScreen(),
@@ -100,6 +121,13 @@ GoRouter router(Ref ref) {
path: AppRoutes.spaces, path: AppRoutes.spaces,
builder: (context, state) => const SpacesScreen(), builder: (context, state) => const SpacesScreen(),
), ),
GoRoute(
path: AppRoutes.jitsi,
builder: (context, state) {
final meetingUrl = state.extra as String? ?? '';
return JitsiScreen(meetingUrl: meetingUrl);
},
),
], ],
); );
} }

View File

@@ -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. // Riverpod notifier that owns the auth state machine.
// All login/logout/session-restore transitions go through here. //
// WEB SECURITY MODEL: Every visit requires a fresh login. No session
import 'dart:async'; // 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 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../features/auth/data/auth_repository.dart'; import '../../features/auth/data/auth_repository.dart';
import '../../features/auth/domain/auth_failure.dart'; import '../../features/auth/domain/auth_failure.dart';
import '../storage/sync_persistence_service.dart'; import '../storage/sync_persistence_service.dart';
import 'auth_state.dart'; import 'auth_state.dart';
import 'secure_storage.dart';
part 'auth_notifier.g.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) @Riverpod(keepAlive: true)
class AuthNotifier extends _$AuthNotifier { class AuthNotifier extends _$AuthNotifier {
@override @override
AuthState build() { AuthState build() {
// Kick off session restore immediately; start in [AuthInitial]. // No session restore on web — always require fresh login.
_restoreSession(); return const AuthState.unauthenticated();
return const AuthState.initial();
} }
/// Tries to restore a previous session from secure storage.
Future<void> _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<void> login({ Future<void> login({
required String username, required String username,
required String password, required String password,
@@ -91,38 +31,38 @@ class AuthNotifier extends _$AuthNotifier {
try { try {
final repo = ref.read(authRepositoryProvider); final repo = ref.read(authRepositoryProvider);
final response = await repo.login(username: username, password: password); debugPrint('[Auth] Logging in...');
final storage = ref.read(secureStorageProvider); final response = await repo.login(
await storage.saveCredentials( username: username,
accessToken: response.accessToken, password: password,
userId: response.userId,
deviceId: response.deviceId,
); );
debugPrint('[Auth] Login OK as ${response.userId} '
'device=${response.deviceId}');
state = AuthState.authenticated( state = AuthState.authenticated(
userId: response.userId, userId: response.userId,
accessToken: response.accessToken, accessToken: response.accessToken,
deviceId: response.deviceId, deviceId: response.deviceId,
); );
// Start background sync-to-database persistence now that we are logged in. // Start background sync-to-database persistence.
ref.read(syncPersistenceServiceProvider).start(); try {
ref.read(syncPersistenceServiceProvider).start();
} catch (_) {}
} on AuthFailure catch (failure) { } on AuthFailure catch (failure) {
state = AuthState.unauthenticated(failure: failure.userMessage); state = AuthState.unauthenticated(failure: failure.userMessage);
} }
} }
/// Logs out the current user, clears storage, and resets to unauthenticated.
Future<void> logout() async { Future<void> logout() async {
state = const AuthState.loading(); state = const AuthState.loading();
final repo = ref.read(authRepositoryProvider); final repo = ref.read(authRepositoryProvider);
await repo.logout(); await repo.logout();
final storage = ref.read(secureStorageProvider); // Keep the device ID in storage so the next login reuses it.
await storage.clearCredentials();
state = const AuthState.unauthenticated(); state = const AuthState.unauthenticated();
} }
} }

View File

@@ -1,83 +1,30 @@
// Version: 1.0.0 | Created: 2026-04-01 // Version: 2.0.0 | Created: 2026-04-01 | Updated: 2026-04-11
// Typed wrapper around flutter_secure_storage. // Minimal storage: only the Matrix device ID is persisted so that
// All token read/write operations go through this class — never call // encryption keys accumulate on one device across logins.
// flutter_secure_storage directly from feature code. // Access tokens are NOT stored — every browser visit requires login.
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../config/app_config.dart';
part 'secure_storage.g.dart'; part 'secure_storage.g.dart';
/// Provides a configured [SecureStorage] instance.
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
SecureStorage secureStorage(Ref ref) => const SecureStorage(); 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 { class SecureStorage {
const SecureStorage(); const SecureStorage();
static const _kDeviceId = 'm8chat_device_id';
static const FlutterSecureStorage _storage = FlutterSecureStorage( static const FlutterSecureStorage _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true), aOptions: AndroidOptions(encryptedSharedPreferences: true),
); );
Future<void> saveCredentials({ Future<void> saveDeviceId(String deviceId) async {
required String accessToken, await _storage.write(key: _kDeviceId, value: deviceId);
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<StoredCredentials?> loadCredentials() async { Future<String?> loadDeviceId() async {
final accessToken = await _storage.read( return _storage.read(key: _kDeviceId);
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<void> 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);
} }
} }
/// 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;
}

View File

@@ -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. // 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 { abstract final class AppConfig {
static const String matrixBaseUrl = 'https://matrix.m8chat.au'; static const String matrixBaseUrl = 'https://matrix.m8chat.au';
static const String matrixServerName = 'matrix.m8chat.au'; static const String matrixServerName = 'matrix.m8chat.au';
@@ -14,11 +11,8 @@ abstract final class AppConfig {
'turns:matrix.m8chat.au:5349', 'turns:matrix.m8chat.au:5349',
]; ];
static const String appName = 'M8Chat'; static const String appName = 'M8Chat';
static const String appVersion = '1.0.0'; static const String appVersion = '1.3.0';
// Secure storage key names // Jitsi conferencing
static const String storageKeyAccessToken = 'access_token'; static const String jitsiDomain = 'conf.m8chat.au';
static const String storageKeyUserId = 'user_id';
static const String storageKeyDeviceId = 'device_id';
static const String storageKeyHomeserver = 'homeserver';
} }

View File

@@ -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. // Auth repository: handles all Matrix login/logout API interactions.
// Uses the Matrix Dart SDK — no raw HTTP calls for auth. // Uses the Matrix Dart SDK — no raw HTTP calls for auth.
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -34,15 +35,16 @@ class AuthRepository {
required String password, required String password,
}) async { }) async {
try { 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); _client.homeserver = Uri.parse(AppConfig.matrixBaseUrl);
return await _client.login( final response = await _client.login(
LoginType.mLoginPassword, LoginType.mLoginPassword,
identifier: AuthenticationUserIdentifier(user: username), identifier: AuthenticationUserIdentifier(user: username),
password: password, password: password,
initialDeviceDisplayName: AppConfig.appName, initialDeviceDisplayName: AppConfig.appName,
); );
debugPrint('[M8Chat] Login complete. encryptionEnabled=${_client.encryptionEnabled}');
debugPrint('[M8Chat] encryption=${_client.encryption}');
return response;
} on MatrixException catch (e) { } on MatrixException catch (e) {
throw switch (e.errcode) { throw switch (e.errcode) {
'M_FORBIDDEN' => const AuthFailure.invalidCredentials(), 'M_FORBIDDEN' => const AuthFailure.invalidCredentials(),
@@ -77,19 +79,5 @@ class AuthRepository {
} }
} }
/// Restores an existing Matrix session using a stored access token. // restoreSession() removed — web app requires fresh login every visit.
Future<void> 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,
);
}
} }

View File

@@ -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<ToDeviceEvent>? _toDeviceSub;
Uint8List? _localKey;
final int _localKeyIndex = 0;
BaseKeyProvider? get keyProvider => _keyProvider;
/// Create the key provider and start listening for incoming keys.
Future<BaseKeyProvider> 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<void> 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<void> 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 = <String, Object>{
'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<dynamic>? ?? [];
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<DeviceKeys> 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<dynamic>?;
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<void> 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<int>.generate(length, (_) => rng.nextInt(256)));
}
}

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 // LiveKitService — fetches a JWT from the Matrix server's /_matrix/livekit/jwt
// endpoint, then connects a LiveKit Room using that token. // endpoint, then connects a LiveKit Room using that token.
// //
// The JWT endpoint is defined in AppConfig.livekitJwtUrl and uses the Matrix // E2EE: Frame encryption is enabled via livekit_client's E2EEOptions.
// access token as Bearer auth. The LiveKit server URL is the same host as the // Encryption keys are exchanged with Element X via m.rtc.encryption_keys
// Matrix server (as configured on chat.m8chat.au). // to-device events (see call_e2ee.dart).
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
@@ -19,6 +19,7 @@ import '../../../core/auth/auth_notifier.dart';
import '../../../core/auth/auth_state.dart'; import '../../../core/auth/auth_state.dart';
import '../../../core/config/app_config.dart'; import '../../../core/config/app_config.dart';
import '../../../core/network/matrix_client.dart'; import '../../../core/network/matrix_client.dart';
import 'call_e2ee.dart';
part 'livekit_service.g.dart'; part 'livekit_service.g.dart';
@@ -67,6 +68,7 @@ class LiveKitService {
Room? _activeRoom; Room? _activeRoom;
String? _activeMatrixRoomId; String? _activeMatrixRoomId;
String? _membershipId; String? _membershipId;
CallE2EEManager? _e2eeManager;
Room? get activeRoom => _activeRoom; Room? get activeRoom => _activeRoom;
@@ -111,14 +113,37 @@ class LiveKitService {
_activeMatrixRoomId = matrixRoomId; _activeMatrixRoomId = matrixRoomId;
debugPrint('[LiveKit] Call member state event sent'); debugPrint('[LiveKit] Call member state event sent');
// Step 3 — connect to LiveKit // Step 3 — initialise E2EE key exchange
final room = Room(); _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 { try {
await room.connect(livekitUrl, jwt); await room.connect(livekitUrl, jwt);
_activeRoom = room; _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); return LiveKitConnected(room: room);
} on Exception catch (e) { } on Exception catch (e) {
// Clean up the state event on failure await _e2eeManager?.dispose();
_e2eeManager = null;
await _clearCallMemberEvent(client, matrixRoomId, userId); await _clearCallMemberEvent(client, matrixRoomId, userId);
await room.disconnect(); await room.disconnect();
await room.dispose(); await room.dispose();
@@ -136,6 +161,10 @@ class LiveKitService {
_activeMatrixRoomId = null; _activeMatrixRoomId = null;
_membershipId = 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 // Step 1: Leave LiveKit — Element X will see us disappear from the SFU
if (room != null) { if (room != null) {
await room.disconnect(); await room.disconnect();
@@ -255,6 +284,7 @@ class LiveKitService {
} }
/// Clear the call.member state event (remove our membership). /// Clear the call.member state event (remove our membership).
/// Element X expects empty content `{}` — not `{'memberships': []}`.
Future<void> _clearCallMemberEvent( Future<void> _clearCallMemberEvent(
Client client, Client client,
String matrixRoomId, String matrixRoomId,
@@ -265,8 +295,9 @@ class LiveKitService {
matrixRoomId, matrixRoomId,
_kCallMemberEventType, _kCallMemberEventType,
userId, userId,
{'memberships': []}, {},
); );
debugPrint('[LiveKit] Call member event cleared for $userId');
} catch (e) { } catch (e) {
debugPrint('[LiveKit] Failed to clear call member event: $e'); debugPrint('[LiveKit] Failed to clear call member event: $e');
} }

View File

@@ -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. // Chat repository — bridges Matrix SDK timeline to app domain models.
// Phase 2 additions: sendFile, sendReaction, redactEvent, reply support. // Phase 2 additions: sendFile, sendReaction, redactEvent, reply support.
@@ -191,6 +191,7 @@ class ChatRepository {
MessageType _messageType(Event event) { MessageType _messageType(Event event) {
if (event.redacted) return MessageType.redacted; if (event.redacted) return MessageType.redacted;
if (event.type == EventTypes.Encrypted) return MessageType.encrypted;
return switch (event.messageType) { return switch (event.messageType) {
MessageTypes.Text => MessageType.text, MessageTypes.Text => MessageType.text,

View File

@@ -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. // Immutable message model for the chat timeline.
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
@@ -14,6 +14,7 @@ enum MessageType {
video, video,
sticker, sticker,
redacted, redacted,
encrypted,
unsupported, unsupported,
} }

View File

@@ -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. // Message bubble widget. Handles text, images, files, redacted, replies.
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
@@ -138,6 +138,22 @@ class _MessageContentBody extends StatelessWidget {
fontStyle: FontStyle.italic, 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( _ => Text(
message.body ?? 'Unsupported message type', message.body ?? 'Unsupported message type',
style: TextStyle( style: TextStyle(

View File

@@ -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<Widget> 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),
),
),
],
),
);
}
}

View File

@@ -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<bool> _ensureScriptLoaded() async {
if (_scriptLoaded) return true;
if (_scriptLoading) {
// Wait for in-flight load to finish.
for (var i = 0; i < 50; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
if (_scriptLoaded) return true;
}
return false;
}
_scriptLoading = true;
final completer = Completer<bool>();
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<void> 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 = <String, Object?>{
'startAudioMuted': 0,
'startVideoMuted': 0,
'disableDeepLinking': true,
'prejoinPageEnabled': true,
}.jsify();
final interfaceConfigOverwrite = <String, Object?>{
'SHOW_CHROME_EXTENSION_BANNER': false,
}.jsify();
final options = <String, Object?>{
'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'] = <String, Object?>{
'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();
}

View File

@@ -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;
}
}

View File

@@ -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<ConferenceTab> createState() => _ConferenceTabState();
}
class _ConferenceTabState extends State<ConferenceTab> {
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),
),
),
),
],
),
),
),
);
}
}

View File

@@ -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<JitsiScreen> createState() => _JitsiScreenState();
}
class _JitsiScreenState extends State<JitsiScreen> {
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),
);
}
}

View File

@@ -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<WelcomeScreen> createState() => _WelcomeScreenState();
}
class _WelcomeScreenState extends State<WelcomeScreen> {
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,
],
),
),
);
}
}

View File

@@ -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<bool> showKeyRestoreDialog(BuildContext context, Client client) async {
final result = await showDialog<bool>(
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<void> _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),
],
);
}
}

View File

@@ -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. // Profile screen. Shows current user info and logout button.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -10,6 +10,8 @@ import '../../../core/config/app_config.dart';
import '../../../core/network/matrix_client.dart'; import '../../../core/network/matrix_client.dart';
import '../../../shared/utils/matrix_id.dart'; import '../../../shared/utils/matrix_id.dart';
import '../../../shared/widgets/matrix_avatar.dart'; import '../../../shared/widgets/matrix_avatar.dart';
import 'key_restore_dialog.dart';
import 'security_setup_dialog.dart';
class ProfileScreen extends ConsumerWidget { class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key, this.embedded = false}); const ProfileScreen({super.key, this.embedded = false});
@@ -85,14 +87,57 @@ class ProfileScreen extends ConsumerWidget {
ListTile( ListTile(
leading: const Icon(Icons.lock_outline), leading: const Icon(Icons.lock_outline),
title: const Text('End-to-end encryption'), 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, enabled: false,
), ),
ListTile( ListTile(
leading: const Icon(Icons.verified_user_outlined), leading: const Icon(Icons.key_outlined),
title: const Text('Verify devices'), title: const Text('Restore message keys'),
subtitle: const Text('Cross-signing setup — coming in Phase 2'), subtitle: const Text(
enabled: false, '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( ListTile(
leading: const Icon(Icons.password_outlined), leading: const Icon(Icons.password_outlined),

View File

@@ -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<bool> showSecuritySetupDialog(
BuildContext context, Client client) async {
final result = await showDialog<bool>(
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<void> _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<void> _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<void> _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<void> _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'),
),
],
},
);
}
}

View File

@@ -2,6 +2,7 @@
// Rooms repository. Reads room list from the Matrix SDK client. // Rooms repository. Reads room list from the Matrix SDK client.
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/network/matrix_client.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. /// Returns the current room list, sorted unread-first then by last activity.
List<RoomModel> getRooms() { List<RoomModel> getRooms() {
final rooms = _client.rooms; 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(); final models = rooms.map(_toModel).toList();
models.sort((a, b) { models.sort((a, b) {

View File

@@ -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. // Main rooms list screen with bottom navigation.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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 '../../profile/presentation/profile_screen.dart';
import '../../spaces/presentation/spaces_screen.dart'; import '../../spaces/presentation/spaces_screen.dart';
import 'room_tile.dart'; import 'room_tile.dart';
@@ -37,6 +39,16 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
selectedIcon: Icon(Icons.person), selectedIcon: Icon(Icons.person),
label: 'Profile', 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() { Widget _buildBody() {
@@ -44,6 +56,8 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
0 => const _RoomListBody(), 0 => const _RoomListBody(),
1 => const SpacesScreen(embedded: true), 1 => const SpacesScreen(embedded: true),
2 => const ProfileScreen(embedded: true), 2 => const ProfileScreen(embedded: true),
3 => const ConferenceTab(),
4 => const HelpTab(),
_ => const _RoomListBody(), _ => const _RoomListBody(),
}; };
} }

View File

@@ -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. // Application entry point.
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:olm/olm.dart' as olm;
import 'app/app.dart'; import 'app/app.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); 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. // Catch Flutter framework errors (widget build, rendering) without crashing.
FlutterError.onError = (details) { FlutterError.onError = (details) {
debugPrint('[M8Chat] Flutter error: ${details.exceptionAsString()}'); debugPrint('[M8Chat] Flutter error: ${details.exceptionAsString()}');

View File

@@ -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. // 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'; import 'package:matrix/matrix.dart';
/// Resolves an `mxc://` [Uri] to an HTTP download URL using the client's /// Resolves an `mxc://` [Uri] to an authenticated HTTP download URL.
/// homeserver. Returns `null` if the URI is not an mxc scheme or the client /// Returns `null` if the URI is not mxc:// or the client is not connected.
/// has no homeserver set.
///
/// This is synchronous — suitable for use in non-async model mapping.
String? resolveMxcUrl(Client client, Uri? mxcUri) { String? resolveMxcUrl(Client client, Uri? mxcUri) {
if (mxcUri == null || !mxcUri.isScheme('mxc')) return null; if (mxcUri == null || !mxcUri.isScheme('mxc')) return null;
final homeserver = client.homeserver; final homeserver = client.homeserver;
if (homeserver == null) return null; if (homeserver == null) return null;
// Build the media download path per the Matrix spec.
final serverName = mxcUri.host; final serverName = mxcUri.host;
final port = mxcUri.hasPort ? ':${mxcUri.port}' : ''; final port = mxcUri.hasPort ? ':${mxcUri.port}' : '';
final mediaId = mxcUri.path; // includes leading / 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(); .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';
} }

View File

@@ -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. // Shared utility for generating a last-message preview string from a Room.
// Used by both RoomsRepository and SyncPersistenceService to avoid duplication. // Used by both RoomsRepository and SyncPersistenceService to avoid duplication.
@@ -12,7 +12,7 @@ String? lastMessagePreview(Room room) {
return switch (lastEvent.type) { return switch (lastEvent.type) {
EventTypes.Message => lastEvent.body, EventTypes.Message => lastEvent.body,
EventTypes.Encrypted => 'Encrypted message', EventTypes.Encrypted => '\u{1F512} Encrypted — use Element to read',
EventTypes.Sticker => 'Sticker', EventTypes.Sticker => 'Sticker',
_ => null, _ => null,
}; };

View File

@@ -1,7 +1,7 @@
name: m8chat_app name: m8chat_app
description: "M8Chat — Matrix chat client for Android, iOS, and Web." description: "M8Chat — Matrix chat client for Android, iOS, and Web."
publish_to: 'none' publish_to: 'none'
version: 1.1.0+2 version: 1.2.0+3
environment: environment:
sdk: '>=3.11.0 <4.0.0' sdk: '>=3.11.0 <4.0.0'
@@ -12,6 +12,7 @@ dependencies:
# Matrix SDK # Matrix SDK
matrix: ^0.33.0 matrix: ^0.33.0
olm: ^2.0.4
# State management # State management
flutter_riverpod: ^3.0.0 flutter_riverpod: ^3.0.0
@@ -42,6 +43,9 @@ dependencies:
# Emoji reactions # Emoji reactions
emoji_picker_flutter: ^4.0.0 emoji_picker_flutter: ^4.0.0
# Web platform (required for dart:js_interop DOM access)
web: ^1.1.0
# UI # UI
cached_network_image: ^3.4.0 cached_network_image: ^3.4.0
timeago: ^3.7.0 timeago: ^3.7.0

View File

@@ -18,12 +18,12 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project."> <meta name="description" content="M8Chat — secure chat and conferencing.">
<!-- iOS meta tags & icons --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="m8chat_app"> <meta name="apple-mobile-web-app-title" content="M8Chat">
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon --> <!-- Favicon -->
@@ -49,12 +49,43 @@
} }
</script> </script>
<!-- <!--
Olm (E2EE) disabled for Phase 1 web builds. The Matrix SDK gracefully Olm (E2EE): load olm.js synchronously, await Olm.init() for WASM, THEN
skips encryption when Olm is not loaded — unencrypted rooms work fine. inject flutter_bootstrap.js so the Matrix SDK finds global Olm ready.
E2EE requires persistent Olm account storage (IndexedDB) to avoid If Olm fails to load (e.g. WASM blocked by browser), Flutter boots anyway
device key conflicts on /keys/upload (400). Deferred to Phase 3. without encryption — unencrypted rooms still work.
The olm.js and olm.wasm files remain deployed for future use.
--> -->
<script src="flutter_bootstrap.js" async></script> <script src="olm.js"></script>
<script>
(function() {
function bootFlutter() {
var s = document.createElement('script');
s.src = 'flutter_bootstrap.js';
s.async = true;
document.body.appendChild(s);
}
// Ensure Olm is accessible on both window and globalThis.
// dart2js may resolve bare 'Olm' via different global references.
if (typeof Olm !== 'undefined') {
window.Olm = Olm;
if (typeof globalThis !== 'undefined') globalThis.Olm = Olm;
if (typeof self !== 'undefined') self.Olm = Olm;
}
if (typeof Olm !== 'undefined' && Olm.init) {
console.log('[M8Chat] Olm.js loaded, calling Olm.init()...');
Olm.init().then(function() {
console.log('[M8Chat] Olm.init() SUCCESS — E2EE available');
console.log('[M8Chat] window.Olm =', window.Olm);
console.log('[M8Chat] typeof Olm.Account =', typeof Olm.Account);
bootFlutter();
}).catch(function(e) {
console.warn('[M8Chat] Olm init FAILED, booting without E2EE:', e);
bootFlutter();
});
} else {
console.warn('[M8Chat] olm.js did NOT load — typeof Olm =', typeof Olm);
bootFlutter();
}
})();
</script>
</body> </body>
</html> </html>

View File

@@ -1,11 +1,11 @@
{ {
"name": "m8chat_app", "name": "M8Chat",
"short_name": "m8chat_app", "short_name": "M8Chat",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#0175C2", "background_color": "#0F0F1A",
"theme_color": "#0175C2", "theme_color": "#5C35C9",
"description": "A new Flutter project.", "description": "M8Chat — secure chat and conferencing.",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,
"icons": [ "icons": [