Files
tatuchat/lib/widgets/matrix.dart
Welton Moura 0037b0b9cf
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
Initial commit
2026-09-11 15:59:56 -03:00

436 lines
14 KiB
Dart

// 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<Client> clients;
final Map<String, String>? queryParameters;
final SharedPreferences store;
const Matrix({
this.child,
required this.clients,
required this.store,
this.queryParameters,
super.key,
});
@override
MatrixState createState() => MatrixState();
/// Returns the (nearest) Client instance of your application.
static MatrixState of(BuildContext context) =>
Provider.of<MatrixState>(context, listen: false);
}
class MatrixState extends State<Matrix> {
int _activeClient = -1;
String? activeBundle;
SharedPreferences get store => widget.store;
BackgroundPush? backgroundPush;
final ValueNotifier<String?> activeCallRoomId = ValueNotifier(null);
final ValueNotifier<CallPosition> 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;
} else {
Logs().w('Tried to set an unknown client ${cl!.userID} as active');
}
}
List<Client?>? get currentBundle {
if (!hasComplexBundles) {
return List.from(widget.clients);
}
final bundles = accountBundles;
if (bundles.containsKey(activeBundle)) {
return bundles[activeBundle];
}
return bundles.values.first;
}
Map<String?, List<Client?>> get accountBundles {
final resBundles =
<String?, List<({Client? client, AccountBundle? bundle})>>{};
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<String?> voiceMessageEventId = ValueNotifier(null);
Future<Client> 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 = <String, StreamSubscription>{};
final onKeyVerificationRequestSub = <String, StreamSubscription>{};
final onNotification = <String, StreamSubscription>{};
final onLogoutSub = <String, StreamSubscription<LoginState>>{};
final onUiaRequest = <String, StreamSubscription<UiaRequest>>{};
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;
_cancelSubs(c.clientName);
widget.clients.remove(c);
ClientManager.removeClientNameFromStore(c.clientName, store);
InitWithRestoreExtension.deleteSessionBackup(name);
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);
}
},
);
}
}
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<void> 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 }