// SPDX-FileCopyrightText: 2019-Present Christian Kußowski // SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat // // SPDX-License-Identifier: AGPL-3.0-or-later import 'dart:async'; import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/utils/client_manager.dart'; import 'package:tatchat/utils/init_with_restore.dart'; import 'package:tatchat/utils/matrix_sdk_extensions/matrix_file_extension.dart'; import 'package:tatchat/utils/notification_background_handler.dart'; import 'package:tatchat/utils/platform_infos.dart'; import 'package:tatchat/utils/uia_request_manager.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:tatchat/widgets/tatu_chat_app.dart'; import 'package:tatchat/widgets/future_loading_dialog.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:intl/intl.dart'; import 'package:just_audio/just_audio.dart'; import 'package:material_ui/material_ui.dart'; import 'package:matrix/encryption.dart'; import 'package:matrix/matrix.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:universal_html/html.dart' as html; import 'package:url_launcher/url_launcher_string.dart'; import '../config/setting_keys.dart'; import '../pages/key_verification/key_verification_dialog.dart'; import '../utils/account_bundles.dart'; import '../utils/background_push.dart'; import 'local_notifications_extension.dart'; class Matrix extends StatefulWidget { final Widget? child; final List clients; final Map? queryParameters; final SharedPreferences store; final ValueChanged? onActiveClientChanged; const Matrix({ this.child, required this.clients, required this.store, this.onActiveClientChanged, this.queryParameters, super.key, }); @override MatrixState createState() => MatrixState(); /// Returns the (nearest) Client instance of your application. static MatrixState of(BuildContext context) => Provider.of(context, listen: false); } class MatrixState extends State { int _activeClient = -1; String? activeBundle; SharedPreferences get store => widget.store; BackgroundPush? backgroundPush; final ValueNotifier activeCallRoomId = ValueNotifier(null); final ValueNotifier callPosition = ValueNotifier( CallPosition.fullScreen, ); Client get client { if (_activeClient < 0 || _activeClient >= widget.clients.length) { return currentBundle!.first!; } return widget.clients[_activeClient]; } bool get isMultiAccount => widget.clients.length > 1; int getClientIndexByMatrixId(String matrixId) => widget.clients.indexWhere((client) => client.userID == matrixId); void setActiveClient(Client? cl) { final i = widget.clients.indexWhere((c) => c == cl); if (i != -1) { _activeClient = i; _notifyActiveClientChanged(); } else { Logs().w('Tried to set an unknown client ${cl!.userID} as active'); } } void _notifyActiveClientChanged() { final callback = widget.onActiveClientChanged; if (callback == null) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; callback(widget.clients.isEmpty ? null : client); }); } List? get currentBundle { if (!hasComplexBundles) { return List.from(widget.clients); } final bundles = accountBundles; if (bundles.containsKey(activeBundle)) { return bundles[activeBundle]; } return bundles.values.first; } Map> get accountBundles { final resBundles = >{}; for (var i = 0; i < widget.clients.length; i++) { final bundles = widget.clients[i].accountBundles; for (final bundle in bundles) { if (bundle.name == null) { continue; } resBundles[bundle.name] ??= []; resBundles[bundle.name]!.add(( client: widget.clients[i], bundle: bundle, )); } } for (final b in resBundles.values) { b.sort( (a, b) => a.bundle!.priority == null ? 1 : b.bundle!.priority == null ? -1 : a.bundle!.priority!.compareTo(b.bundle!.priority!), ); } return resBundles.map( (k, v) => MapEntry(k, v.map((vv) => vv.client).toList()), ); } bool get hasComplexBundles => accountBundles.values.any((v) => v.length > 1); Client? _loginClientCandidate; AudioPlayer? audioPlayer; final ValueNotifier voiceMessageEventId = ValueNotifier(null); Future getLoginClient() async { if (widget.clients.isNotEmpty && !client.isLogged()) { return client; } final candidate = _loginClientCandidate ??= await ClientManager.createClient( '${AppSettings.applicationName.value}-${DateTime.now().millisecondsSinceEpoch}', store, ) ..onLoginStateChanged.stream .where((l) => l == LoginState.loggedIn) .first .then((_) { if (!widget.clients.contains(_loginClientCandidate)) { widget.clients.add(_loginClientCandidate!); } ClientManager.addClientNameToStore( _loginClientCandidate!.clientName, store, ); _registerSubs(_loginClientCandidate!.clientName); setActiveClient(_loginClientCandidate); _loginClientCandidate = null; TatuChatApp.router.go('/backup'); }); if (widget.clients.isEmpty) widget.clients.add(candidate); return candidate; } Client? getClientByName(String name) => widget.clients.firstWhereOrNull((c) => c.clientName == name); final onRoomKeyRequestSub = {}; final onKeyVerificationRequestSub = {}; final onNotification = {}; final onLogoutSub = >{}; final onUiaRequest = >{}; String? _cachedPassword; Timer? _cachedPasswordClearTimer; String? get cachedPassword => _cachedPassword; set cachedPassword(String? p) { Logs().d('Password cached'); _cachedPasswordClearTimer?.cancel(); _cachedPassword = p; _cachedPasswordClearTimer = Timer(const Duration(minutes: 10), () { _cachedPassword = null; Logs().d('Cached Password cleared'); }); } String? get activeRoomId { final route = TatuChatApp.router.routeInformationProvider.value.uri.path; if (!route.startsWith('/rooms/')) return null; return route.split('/')[2]; } @override void initState() { super.initState(); _listener = AppLifecycleListener(onStateChange: didChangeAppLifecycleState); initMatrix(); } AppLifecycleListener? _listener; void _registerSubs(String name) { final c = getClientByName(name); if (c == null) { Logs().w( 'Attempted to register subscriptions for non-existing client $name', ); return; } onRoomKeyRequestSub[name] ??= c.onRoomKeyRequest.stream.listen(( RoomKeyRequest request, ) async { if (widget.clients.any( ((cl) => cl.userID == request.requestingDevice.userId && cl.identityKey == request.requestingDevice.curve25519Key), )) { Logs().i( '[Key Request] Request is from one of our own clients, forwarding the key...', ); await request.forwardKey(); } }); onKeyVerificationRequestSub[name] ??= c.onKeyVerificationRequest.stream .listen((KeyVerification request) async { var hidPopup = false; request.onUpdate = () { if (!hidPopup && { KeyVerificationState.done, KeyVerificationState.error, }.contains(request.state)) { TatuChatApp.router.pop('dialog'); } hidPopup = true; }; request.onUpdate = null; hidPopup = true; await KeyVerificationDialog(request: request).show( TatuChatApp.router.routerDelegate.navigatorKey.currentContext ?? context, ); }); onLogoutSub[name] ??= c.onLoginStateChanged.stream .where((state) => state == LoginState.loggedOut) .listen((_) { final loggedInWithMultipleClients = widget.clients.length > 1; final activeClient = client; final removedIndex = widget.clients.indexOf(c); final wasActive = removedIndex != -1 && activeClient == c; _cancelSubs(c.clientName); widget.clients.remove(c); ClientManager.removeClientNameFromStore(c.clientName, store); InitWithRestoreExtension.deleteSessionBackup(name); if (removedIndex != -1 && _activeClient > removedIndex) { _activeClient--; } if (wasActive) { _activeClient = -1; } if (removedIndex != -1) { _notifyActiveClientChanged(); } if (loggedInWithMultipleClients) { final snackbarContext = TatuChatApp.router.routerDelegate.navigatorKey.currentContext ?? context; if (!snackbarContext.mounted) return; final l10n = L10n.of(snackbarContext); ScaffoldMessenger.of( snackbarContext, ).showSnackBar(SnackBar(content: Text(l10n.oneClientLoggedOut))); return; } TatuChatApp.router.go('/'); }); onUiaRequest[name] ??= c.onUiaRequest.stream.listen(uiaRequestHandler); if (PlatformInfos.isWeb || PlatformInfos.isLinux) { FlutterLocalNotificationsPlugin().initialize( settings: InitializationSettings( linux: LinuxInitializationSettings( defaultActionName: FluffyChatNotificationActions.open.name, ), ), onDidReceiveNotificationResponse: (response) => notificationTap( response, clients: widget.clients, router: TatuChatApp.router, l10n: null, ), ); c.onSync.stream.first.then((s) { html.Notification.requestPermission(); onNotification[name] ??= c.onNotification.stream.listen( showLocalNotification, ); }); } } void _cancelSubs(String name) { onRoomKeyRequestSub[name]?.cancel(); onRoomKeyRequestSub.remove(name); onKeyVerificationRequestSub[name]?.cancel(); onKeyVerificationRequestSub.remove(name); onLogoutSub[name]?.cancel(); onLogoutSub.remove(name); onNotification[name]?.cancel(); onNotification.remove(name); } void initMatrix() { for (final c in widget.clients) { _registerSubs(c.clientName); } if (PlatformInfos.isMobile) { backgroundPush = BackgroundPush( this, onFcmError: (errorMsg, {Uri? link}) async { final context = TatuChatApp.router.routerDelegate.navigatorKey.currentContext ?? this.context; if (!context.mounted) return; final result = await showOkCancelAlertDialog( context: context, title: L10n.of(context).pushNotificationsNotAvailable, message: errorMsg, okLabel: link == null ? L10n.of(context).ok : L10n.of(context).learnMore, cancelLabel: L10n.of(context).doNotShowAgain, ); if (result == OkCancelResult.ok && link != null) { launchUrlString( link.toString(), mode: LaunchMode.externalApplication, ); } if (result == OkCancelResult.cancel) { await AppSettings.showNoGoogle.setItem(true); } }, ); } _notifyActiveClientChanged(); } void didChangeAppLifecycleState(AppLifecycleState state) { final foreground = state != AppLifecycleState.inactive && state != AppLifecycleState.paused; for (final client in widget.clients) { client.syncPresence = state == AppLifecycleState.resumed ? null : PresenceType.unavailable; if (PlatformInfos.isMobile) { client.backgroundSync = foreground; client.requestHistoryOnLimitedTimeline = !foreground; Logs().v('Set background sync to', foreground); } } } @override void dispose() { _listener?.dispose(); for (final sub in onRoomKeyRequestSub.values) { sub.cancel(); } for (final sub in onKeyVerificationRequestSub.values) { sub.cancel(); } for (final sub in onLogoutSub.values) { sub.cancel(); } for (final sub in onNotification.values) { sub.cancel(); } for (final sub in onUiaRequest.values) { sub.cancel(); } onRoomKeyRequestSub.clear(); onKeyVerificationRequestSub.clear(); onLogoutSub.clear(); onNotification.clear(); onUiaRequest.clear(); voiceMessageEventId.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Provider(create: (_) => this, child: widget.child); } Future dehydrateAction(BuildContext context) async { final l10n = L10n.of(context); final response = await showOkCancelAlertDialog( context: context, isDestructive: true, title: l10n.dehydrate, message: l10n.dehydrateWarning, ); if (response != OkCancelResult.ok) { return; } if (!context.mounted) return; final result = await showFutureLoadingDialog( context: context, future: client.exportDump, ); final export = result.result; if (export == null) return; final exportBytes = Uint8List.fromList(const Utf8Codec().encode(export)); final exportFileName = 'fluffychat-export-${DateFormat(DateFormat.YEAR_MONTH_DAY).format(DateTime.now())}.fluffybackup'; final file = MatrixFile(bytes: exportBytes, name: exportFileName); if (!context.mounted) return; file.save(context); } } enum CallPosition { fullScreen, top }