diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index dcd9011a..223da318 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -1561,6 +1561,12 @@ "type": "text", "placeholders": {} }, + "addAccount": "Add account", + "editBundlesForAccount": "Edit bundles for this account", + "addToBundle": "Add to bundle", + "removeFromBundle": "Remove from this bundle", + "bundleName": "Bundle name", + "enableMultiAccounts": "Enable multi accounts on this device", "openInMaps": "Open in maps", "@openInMaps": { "type": "text", diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 6b4c0f78..f2872cf4 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 8.0 + 9.0 diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 813b5eb7..34c302d0 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -217,12 +217,12 @@ class AppRoutes { buildTransition: _fadeTransition, stackedRoutes: [ VWidget( - path: '/login', + path: 'login', widget: Login(), buildTransition: _fadeTransition, ), VWidget( - path: '/signup', + path: 'signup', widget: SignupPage(), buildTransition: _fadeTransition, ), @@ -296,6 +296,23 @@ class AppRoutes { widget: DevicesSettings(), buildTransition: _dynamicTransition, ), + VWidget( + path: 'add', + widget: HomeserverPicker(), + buildTransition: _fadeTransition, + stackedRoutes: [ + VWidget( + path: 'login', + widget: Login(), + buildTransition: _fadeTransition, + ), + VWidget( + path: 'signup', + widget: SignupPage(), + buildTransition: _fadeTransition, + ), + ], + ), ], ), VWidget( diff --git a/lib/config/themes.dart b/lib/config/themes.dart index eae5f19f..f9ab67f7 100644 --- a/lib/config/themes.dart +++ b/lib/config/themes.dart @@ -8,6 +8,8 @@ import 'app_config.dart'; abstract class FluffyThemes { static const double columnWidth = 360.0; + static bool isColumnMode(BuildContext context) => + MediaQuery.of(context).size.width > columnWidth * 2; static const fallbackTextStyle = TextStyle(fontFamily: 'NotoSans', fontFamilyFallback: ['NotoEmoji']); diff --git a/lib/main.dart b/lib/main.dart index 0177df27..74b0591e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:adaptive_theme/adaptive_theme.dart'; -import 'package:matrix/encryption/utils/key_verification.dart'; +import 'package:fluffychat/utils/client_manager.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/routes.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -18,8 +18,6 @@ import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:universal_html/html.dart' as html; import 'package:vrouter/vrouter.dart'; -import 'utils/matrix_sdk_extensions.dart/flutter_matrix_hive_database.dart'; -import 'widgets/layouts/wait_for_login.dart'; import 'widgets/lock_screen.dart'; import 'widgets/matrix.dart'; import 'config/themes.dart'; @@ -35,27 +33,10 @@ void main() async { FlutterError.onError = (FlutterErrorDetails details) => Zone.current.handleUncaughtError(details.exception, details.stack); - final client = Client( - PlatformInfos.clientName, - enableE2eeRecovery: true, - verificationMethods: { - KeyVerificationMethod.numbers, - if (PlatformInfos.isMobile || PlatformInfos.isLinux) - KeyVerificationMethod.emoji, - }, - importantStateEvents: { - 'im.ponies.room_emotes', // we want emotes to work properly - }, - databaseBuilder: FlutterMatrixHiveStore.hiveDatabaseBuilder, - supportedLoginTypes: { - AuthenticationTypes.password, - if (PlatformInfos.isMobile || PlatformInfos.isWeb) AuthenticationTypes.sso - }, - compute: compute, - ); + final clients = await ClientManager.getClients(); if (PlatformInfos.isMobile) { - BackgroundPush.clientOnly(client); + BackgroundPush.clientOnly(clients.first); } final queryParameters = {}; @@ -68,24 +49,24 @@ void main() async { () => runApp(PlatformInfos.isMobile ? AppLock( builder: (args) => FluffyChatApp( - client: client, + clients: clients, queryParameters: queryParameters, ), lockScreen: LockScreen(), enabled: false, ) - : FluffyChatApp(client: client, queryParameters: queryParameters)), + : FluffyChatApp(clients: clients, queryParameters: queryParameters)), SentryController.captureException, ); } class FluffyChatApp extends StatefulWidget { final Widget testWidget; - final Client client; + final List clients; final Map queryParameters; const FluffyChatApp( - {Key key, this.testWidget, @required this.client, this.queryParameters}) + {Key key, this.testWidget, @required this.clients, this.queryParameters}) : super(key: key); /// getInitialLink may rereturn the value multiple times if this view is @@ -101,7 +82,15 @@ class _FluffyChatAppState extends State { final GlobalKey _matrix = GlobalKey(); GlobalKey _router; bool columnMode; - String _initialUrl = '/'; + String _initialUrl; + + @override + void initState() { + super.initState(); + _initialUrl = + widget.clients.any((client) => client.isLogged()) ? '/rooms' : '/home'; + } + @override Widget build(BuildContext context) { return AdaptiveTheme( @@ -159,8 +148,8 @@ class _FluffyChatAppState extends State { key: _matrix, context: context, router: _router, - client: widget.client, - child: WaitForInitPage(child), + clients: widget.clients, + child: child, ); }, ); diff --git a/lib/pages/chat.dart b/lib/pages/chat.dart index 2c0e067e..bc983703 100644 --- a/lib/pages/chat.dart +++ b/lib/pages/chat.dart @@ -32,6 +32,7 @@ import 'send_location_dialog.dart'; import 'sticker_picker_dialog.dart'; import '../utils/matrix_sdk_extensions.dart/filtered_timeline_extension.dart'; import '../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart'; +import '../utils/account_bundles.dart'; class Chat extends StatefulWidget { final Widget sideView; @@ -45,6 +46,8 @@ class Chat extends StatefulWidget { class ChatController extends State { Room room; + Client sendingClient; + Timeline timeline; MatrixState matrix; @@ -222,6 +225,14 @@ class ChatController extends State { TextEditingController sendController = TextEditingController(); + void setSendingClient(Client c) => setState(() { + sendingClient = c; + }); + + void setActiveClient(Client c) => setState(() { + Matrix.of(context).setActiveClient(c); + }); + Future send() async { if (sendController.text.trim().isEmpty) return; var parseCommands = true; @@ -447,19 +458,51 @@ class ChatController extends State { for (final event in selectedEvents) { await showFutureLoadingDialog( context: context, - future: () => - event.status > 0 ? event.redactEvent() : event.remove()); + future: () async { + if (event.status > 0) { + if (event.canRedact) { + await event.redactEvent(); + } else { + final client = currentRoomBundle.firstWhere( + (cl) => selectedEvents.first.senderId == cl.userID, + orElse: () => null); + if (client == null) { + return; + } + final room = client.getRoomById(roomId); + await Event.fromJson(event.toJson(), room).redactEvent(); + } + } else { + await event.remove(); + } + }); } setState(() => selectedEvents.clear()); } + List get currentRoomBundle { + final clients = matrix.currentBundle; + clients.removeWhere((c) => c.getRoomById(roomId) == null); + return clients; + } + bool get canRedactSelectedEvents { + final clients = matrix.currentBundle; for (final event in selectedEvents) { - if (event.canRedact == false) return false; + if (event.canRedact == false && + !(clients.any((cl) => event.senderId == cl.userID))) return false; } return true; } + bool get canEditSelectedEvents { + if (selectedEvents.length != 1 || selectedEvents.first.status < 1) { + return false; + } + return currentRoomBundle + .any((cl) => selectedEvents.first.senderId == cl.userID); + } + void forwardEventsAction() async { if (selectedEvents.length == 1) { Matrix.of(context).shareContent = selectedEvents.first.content; @@ -584,6 +627,13 @@ class ChatController extends State { }); void editSelectedEventAction() { + final client = currentRoomBundle.firstWhere( + (cl) => selectedEvents.first.senderId == cl.userID, + orElse: () => null); + if (client == null) { + return; + } + setSendingClient(client); setState(() { pendingText = sendController.text; editEvent = selectedEvents.first; @@ -689,6 +739,19 @@ class ChatController extends State { } void onInputBarChanged(String text) { + final clients = currentRoomBundle; + for (final client in clients) { + final prefix = client.sendPrefix; + if ((prefix?.isNotEmpty ?? false) && + text.toLowerCase() == '${prefix.toLowerCase()} ') { + setSendingClient(client); + setState(() { + inputText = ''; + sendController.text = ''; + }); + return; + } + } typingCoolDown?.cancel(); typingCoolDown = Timer(Duration(seconds: 2), () { typingCoolDown = null; diff --git a/lib/pages/chat_list.dart b/lib/pages/chat_list.dart index 78411034..e2d51aa5 100644 --- a/lib/pages/chat_list.dart +++ b/lib/pages/chat_list.dart @@ -19,6 +19,7 @@ import 'package:uni_links/uni_links.dart'; import 'package:vrouter/vrouter.dart'; import '../main.dart'; import '../widgets/matrix.dart'; +import '../../utils/account_bundles.dart'; import '../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart'; import '../utils/url_launcher.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -409,6 +410,93 @@ class ChatListController extends State { } } + void setActiveClient(Client client) { + VRouter.of(context).to('/rooms'); + setState(() { + _activeSpaceId = null; + selectedRoomIds.clear(); + Matrix.of(context).setActiveClient(client); + }); + } + + void setActiveBundle(String bundle) => setState(() { + _activeSpaceId = null; + selectedRoomIds.clear(); + Matrix.of(context).activeBundle = bundle; + if (!Matrix.of(context) + .currentBundle + .any((client) => client == Matrix.of(context).client)) { + Matrix.of(context) + .setActiveClient(Matrix.of(context).currentBundle.first); + } + }); + + void editBundlesForAccount(String userId) async { + final client = Matrix.of(context) + .widget + .clients[Matrix.of(context).getClientIndexByMatrixId(userId)]; + final action = await showConfirmationDialog( + context: context, + title: L10n.of(context).editBundlesForAccount, + actions: [ + AlertDialogAction( + key: EditBundleAction.addToBundle, + label: L10n.of(context).addToBundle, + ), + if (Matrix.of(context).activeBundle != null) + AlertDialogAction( + key: EditBundleAction.removeFromBundle, + label: L10n.of(context).removeFromBundle, + ), + ], + ); + if (action == null) return; + switch (action) { + case EditBundleAction.addToBundle: + final bundle = await showTextInputDialog( + context: context, + title: L10n.of(context).bundleName, + textFields: [ + DialogTextField(hintText: L10n.of(context).bundleName) + ]); + if (bundle.isEmpty && bundle.single.isEmpty) return; + await showFutureLoadingDialog( + context: context, + future: () => client.setAccountBundle(bundle.single), + ); + break; + case EditBundleAction.removeFromBundle: + await showFutureLoadingDialog( + context: context, + future: () => + client.removeFromAccountBundle(Matrix.of(context).activeBundle), + ); + } + } + + bool get displayBundles => + Matrix.of(context).hasComplexBundles && + Matrix.of(context).accountBundles.keys.length > 1; + + String get secureActiveBundle { + if (Matrix.of(context).activeBundle == null || + !Matrix.of(context) + .accountBundles + .keys + .contains(Matrix.of(context).activeBundle)) { + return Matrix.of(context).accountBundles.keys.first; + } + return Matrix.of(context).activeBundle; + } + + void resetActiveBundle() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + setState(() { + Matrix.of(context).activeBundle = null; + }); + }); + } + @override Widget build(BuildContext context) { Matrix.of(context).navigatorContext = context; @@ -424,3 +512,5 @@ class ChatListController extends State { return ChatListView(this); } } + +enum EditBundleAction { addToBundle, removeFromBundle } diff --git a/lib/pages/homeserver_picker.dart b/lib/pages/homeserver_picker.dart index 4e485a24..c3fa0df7 100644 --- a/lib/pages/homeserver_picker.dart +++ b/lib/pages/homeserver_picker.dart @@ -47,13 +47,13 @@ class HomeserverPickerController extends State { showFutureLoadingDialog( context: context, future: () async { - if (Matrix.of(context).client.homeserver == null) { - await Matrix.of(context).client.checkHomeserver( + if (Matrix.of(context).getLoginClient().homeserver == null) { + await Matrix.of(context).getLoginClient().checkHomeserver( await Store() .getItem(HomeserverPickerController.ssoHomeserverKey), ); } - await Matrix.of(context).client.login( + await Matrix.of(context).getLoginClient().login( LoginType.mLoginToken, token: token, initialDeviceDisplayName: PlatformInfos.clientName, @@ -117,7 +117,7 @@ class HomeserverPickerController extends State { isLoading = true; }); final wellKnown = - await Matrix.of(context).client.checkHomeserver(homeserver); + await Matrix.of(context).getLoginClient().checkHomeserver(homeserver); var jitsi = wellKnown?.additionalProperties ?.tryGet>('im.vector.riot.jitsi') @@ -177,13 +177,13 @@ class HomeserverPickerController extends State { .any((flow) => flow['type'] == AuthenticationTypes.sso); Future> getLoginTypes() async { - _rawLoginTypes ??= await Matrix.of(context).client.request( + _rawLoginTypes ??= await Matrix.of(context).getLoginClient().request( RequestType.GET, '/client/r0/login', ); if (registrationSupported == null) { try { - await Matrix.of(context).client.register(); + await Matrix.of(context).getLoginClient().register(); registrationSupported = true; } on MatrixException catch (e) { registrationSupported = e.requireAdditionalAuthentication ?? false; @@ -200,14 +200,14 @@ class HomeserverPickerController extends State { if (kIsWeb) { // We store the homserver in the local storage instead of a redirect // parameter because of possible CSRF attacks. - Store().setItem( - ssoHomeserverKey, Matrix.of(context).client.homeserver.toString()); + Store().setItem(ssoHomeserverKey, + Matrix.of(context).getLoginClient().homeserver.toString()); } final redirectUrl = kIsWeb ? AppConfig.webBaseUrl + '/#/' : AppConfig.appOpenUrlScheme.toLowerCase() + '://login'; final url = - '${Matrix.of(context).client.homeserver?.toString()}/_matrix/client/r0/login/sso/redirect/${Uri.encodeComponent(id)}?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}'; + '${Matrix.of(context).getLoginClient().homeserver?.toString()}/_matrix/client/r0/login/sso/redirect/${Uri.encodeComponent(id)}?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}'; if (PlatformInfos.isMobile) { browser ??= ChromeSafariBrowser(); browser.open(url: Uri.parse(url)); @@ -216,7 +216,7 @@ class HomeserverPickerController extends State { } } - void signUpAction() => VRouter.of(context).to('/signup'); + void signUpAction() => VRouter.of(context).to('signup'); bool _initialized = false; diff --git a/lib/pages/login.dart b/lib/pages/login.dart index 05eb957b..94d1ca66 100644 --- a/lib/pages/login.dart +++ b/lib/pages/login.dart @@ -64,7 +64,7 @@ class LoginController extends State { } else { identifier = AuthenticationUserIdentifier(user: username); } - await matrix.client.login(LoginType.mLoginPassword, + await matrix.getLoginClient().login(LoginType.mLoginPassword, identifier: identifier, // To stay compatible with older server versions // ignore: deprecated_member_use @@ -98,12 +98,13 @@ class LoginController extends State { setState(() => usernameError = null); if (!userId.isValidMatrixId) return; try { - final oldHomeserver = Matrix.of(context).client.homeserver; + final oldHomeserver = Matrix.of(context).getLoginClient().homeserver; var newDomain = Uri.https(userId.domain, ''); - Matrix.of(context).client.homeserver = newDomain; + Matrix.of(context).getLoginClient().homeserver = newDomain; DiscoveryInformation wellKnownInformation; try { - wellKnownInformation = await Matrix.of(context).client.getWellknown(); + wellKnownInformation = + await Matrix.of(context).getLoginClient().getWellknown(); if (wellKnownInformation.mHomeserver?.baseUrl?.toString()?.isNotEmpty ?? false) { newDomain = wellKnownInformation.mHomeserver.baseUrl; @@ -120,8 +121,8 @@ class LoginController extends State { .checkHomeserver(newDomain) .catchError((e) => null), ); - if (Matrix.of(context).client.homeserver == null) { - Matrix.of(context).client.homeserver = oldHomeserver; + if (Matrix.of(context).getLoginClient().homeserver == null) { + Matrix.of(context).getLoginClient().homeserver = oldHomeserver; // okay, the server we checked does not appear to be a matrix server Logs().v( '$newDomain is not running a homeserver, asking to use $oldHomeserver'); @@ -178,11 +179,12 @@ class LoginController extends State { Matrix.of(context).client.generateUniqueTransactionId(); final response = await showFutureLoadingDialog( context: context, - future: () => Matrix.of(context).client.requestTokenToResetPasswordEmail( - clientSecret, - input.single, - sendAttempt++, - ), + future: () => + Matrix.of(context).getLoginClient().requestTokenToResetPasswordEmail( + clientSecret, + input.single, + sendAttempt++, + ), ); if (response.error != null) return; final ok = await showOkAlertDialog( @@ -211,7 +213,7 @@ class LoginController extends State { if (password == null) return; final success = await showFutureLoadingDialog( context: context, - future: () => Matrix.of(context).client.changePassword( + future: () => Matrix.of(context).getLoginClient().changePassword( password.single, auth: AuthenticationThreePidCreds( type: AuthenticationTypes.emailIdentity, diff --git a/lib/pages/settings_account.dart b/lib/pages/settings_account.dart index 83bbc392..3f3a3036 100644 --- a/lib/pages/settings_account.dart +++ b/lib/pages/settings_account.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/matrix.dart'; +import 'package:vrouter/vrouter.dart'; class SettingsAccount extends StatefulWidget { const SettingsAccount({Key key}) : super(key: key); @@ -144,6 +145,8 @@ class SettingsAccountController extends State { ); } + void addAccountAction() => VRouter.of(context).to('add'); + @override Widget build(BuildContext context) { final client = Matrix.of(context).client; diff --git a/lib/pages/signup.dart b/lib/pages/signup.dart index 69ad4f6b..020316c7 100644 --- a/lib/pages/signup.dart +++ b/lib/pages/signup.dart @@ -37,7 +37,7 @@ class SignupPageController extends State { setState(() => loading = true); try { - final client = Matrix.of(context).client; + final client = Matrix.of(context).getLoginClient(); await client.uiaRequestBackground( (auth) => client.register( username: usernameController.text, diff --git a/lib/pages/views/chat_list_view.dart b/lib/pages/views/chat_list_view.dart index 5e071581..438b6e80 100644 --- a/lib/pages/views/chat_list_view.dart +++ b/lib/pages/views/chat_list_view.dart @@ -1,4 +1,9 @@ +import 'dart:math'; +import 'package:async/async.dart'; +import 'package:fluffychat/config/themes.dart'; + import 'package:fluffychat/widgets/avatar.dart'; +import 'package:flutter/widgets.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/pages/chat_list.dart'; import 'package:fluffychat/widgets/connection_status_header.dart'; @@ -9,6 +14,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:vrouter/vrouter.dart'; import '../../widgets/matrix.dart'; +import '../../utils/account_bundles.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import '../../utils/stream_extension.dart'; @@ -17,6 +23,59 @@ class ChatListView extends StatelessWidget { const ChatListView(this.controller, {Key key}) : super(key: key); + List getBottomBarItems(BuildContext context) { + final displayClients = Matrix.of(context).currentBundle; + if (displayClients.isEmpty) { + displayClients.addAll(Matrix.of(context).widget.clients); + controller.resetActiveBundle(); + } + final items = displayClients.map((client) { + return BottomNavigationBarItem( + label: client.userID, + icon: FutureBuilder( + future: client.ownProfile, + builder: (context, snapshot) { + return InkWell( + borderRadius: BorderRadius.circular(32), + onTap: () => controller.setActiveClient(client), + onLongPress: () => + controller.editBundlesForAccount(client.userID), + child: Avatar( + snapshot.data?.avatarUrl, + snapshot.data?.displayName ?? client.userID.localpart, + size: 32, + ), + ); + }), + ); + }).toList(); + + if (controller.displayBundles && false) { + items.insert( + 0, + BottomNavigationBarItem( + label: 'Bundles', + icon: PopupMenuButton( + icon: Icon( + Icons.menu, + color: Theme.of(context).textTheme.bodyText1.color, + ), + onSelected: controller.setActiveBundle, + itemBuilder: (context) => Matrix.of(context) + .accountBundles + .keys + .map( + (bundle) => PopupMenuItem( + value: bundle, + child: Text(bundle), + ), + ) + .toList(), + ))); + } + return items; + } + @override Widget build(BuildContext context) { return StreamBuilder( @@ -216,12 +275,98 @@ class ChatListView extends StatelessWidget { child: Icon(CupertinoIcons.chat_bubble), ) : null, + bottomNavigationBar: Matrix.of(context).isMultiAccount + ? StreamBuilder( + stream: StreamGroup.merge(Matrix.of(context) + .widget + .clients + .map((client) => client.onSync.stream.where((s) => + s.accountData != null && + s.accountData + .any((e) => e.type == accountBundlesType)))), + builder: (context, _) => Material( + color: Theme.of(context) + .bottomNavigationBarTheme + .backgroundColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Divider(height: 1), + Builder(builder: (context) { + final items = getBottomBarItems(context); + if (items.length == 1) { + return Padding( + padding: const EdgeInsets.all(7.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + items.single.icon, + Text(items.single.label), + ], + ), + ); + } + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: max( + FluffyThemes.isColumnMode(context) + ? FluffyThemes.columnWidth + : MediaQuery.of(context).size.width, + Matrix.of(context).widget.clients.length * + 84.0, + ), + child: BottomNavigationBar( + elevation: 0, + onTap: (i) => controller.setActiveClient( + Matrix.of(context).currentBundle[i]), + currentIndex: Matrix.of(context) + .currentBundle + .indexWhere( + (client) => + client == + Matrix.of(context).client, + ), + showUnselectedLabels: false, + showSelectedLabels: true, + type: BottomNavigationBarType.shifting, + selectedItemColor: + Theme.of(context).primaryColor, + items: items, + ), + ), + ); + }), + if (controller.displayBundles) + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 12, + ), + child: SizedBox( + width: double.infinity, + child: CupertinoSlidingSegmentedControl( + groupValue: controller.secureActiveBundle, + onValueChanged: controller.setActiveBundle, + children: Map.fromEntries(Matrix.of(context) + .accountBundles + .keys + .map((bundle) => + MapEntry(bundle, Text(bundle)))), + ), + ), + ), + ], + ), + ), + ) + : null, drawer: controller.spaces.isEmpty ? null : Drawer( child: SafeArea( child: ListView.builder( - itemCount: controller.spaces.length + 1, + itemCount: controller.spaces.length, itemBuilder: (context, i) { if (i == 0) { return ListTile( diff --git a/lib/pages/views/chat_view.dart b/lib/pages/views/chat_view.dart index ffa1ea0d..3c1ae124 100644 --- a/lib/pages/views/chat_view.dart +++ b/lib/pages/views/chat_view.dart @@ -37,9 +37,10 @@ class ChatView extends StatelessWidget { @override Widget build(BuildContext context) { - controller.matrix = Matrix.of(context); + controller.matrix ??= Matrix.of(context); final client = controller.matrix.client; - controller.room ??= client.getRoomById(controller.roomId); + controller.sendingClient ??= client; + controller.room = controller.sendingClient.getRoomById(controller.roomId); if (controller.room == null) { return Scaffold( appBar: AppBar( @@ -147,10 +148,7 @@ class ChatView extends StatelessWidget { : Text(controller.selectedEvents.length.toString()), actions: controller.selectMode ? [ - if (controller.selectedEvents.length == 1 && - controller.selectedEvents.first.status > 0 && - controller.selectedEvents.first.senderId == - client.userID) + if (controller.canEditSelectedEvents) IconButton( icon: Icon(Icons.edit_outlined), tooltip: L10n.of(context).edit, @@ -680,6 +678,14 @@ class ChatView extends StatelessWidget { alignment: Alignment.center, child: EncryptionButton(controller.room), ), + if (controller.matrix.isMultiAccount && + controller.matrix.currentBundle.length > + 1) + Container( + height: 56, + alignment: Alignment.center, + child: _ChatAccountPicker(controller), + ), Expanded( child: Padding( padding: const EdgeInsets.symmetric( @@ -792,3 +798,58 @@ class _EditContent extends StatelessWidget { ); } } + +class _ChatAccountPicker extends StatelessWidget { + final ChatController controller; + + const _ChatAccountPicker(this.controller, {Key key}) : super(key: key); + + void _popupMenuButtonSelected(String mxid) { + final client = controller.matrix.currentBundle + .firstWhere((cl) => cl.userID == mxid, orElse: () => null); + if (client == null) { + Logs().w('Attempted to switch to a non-existing client $mxid'); + return; + } + controller.setSendingClient(client); + } + + @override + Widget build(BuildContext context) { + controller.matrix ??= Matrix.of(context); + final clients = controller.currentRoomBundle; + return Padding( + padding: const EdgeInsets.all(8.0), + child: FutureBuilder( + future: controller.sendingClient.ownProfile, + builder: (context, snapshot) => PopupMenuButton( + onSelected: _popupMenuButtonSelected, + itemBuilder: (BuildContext context) => clients + .map((client) => PopupMenuItem( + value: client.userID, + child: FutureBuilder( + future: client.ownProfile, + builder: (context, snapshot) => ListTile( + leading: Avatar( + snapshot.data?.avatarUrl, + snapshot.data?.displayName ?? client.userID.localpart, + size: 20, + ), + title: + Text(snapshot.data?.displayName ?? client.userID), + contentPadding: EdgeInsets.all(0), + ), + ), + )) + .toList(), + child: Avatar( + snapshot.data?.avatarUrl, + snapshot.data?.displayName ?? + controller.matrix.client.userID.localpart, + size: 20, + ), + ), + ), + ); + } +} diff --git a/lib/pages/views/homeserver_picker_view.dart b/lib/pages/views/homeserver_picker_view.dart index ce9e9d80..cc5f64db 100644 --- a/lib/pages/views/homeserver_picker_view.dart +++ b/lib/pages/views/homeserver_picker_view.dart @@ -100,7 +100,8 @@ class HomeserverPickerView extends StatelessWidget { imageUrl: Uri.parse( identityProvider.icon) .getDownloadLink( - Matrix.of(context).client) + Matrix.of(context) + .getLoginClient()) .toString(), width: 24, height: 24, @@ -128,7 +129,7 @@ class HomeserverPickerView extends StatelessWidget { Expanded( child: _LoginButton( onPressed: () => - VRouter.of(context).to('/login'), + VRouter.of(context).to('login'), icon: Icon(Icons.login_outlined), labelText: L10n.of(context).login, ), diff --git a/lib/pages/views/login_view.dart b/lib/pages/views/login_view.dart index fffd7f91..f87cc377 100644 --- a/lib/pages/views/login_view.dart +++ b/lib/pages/views/login_view.dart @@ -19,7 +19,7 @@ class LoginView extends StatelessWidget { elevation: 0, title: Text( L10n.of(context).logInTo(Matrix.of(context) - .client + .getLoginClient() .homeserver .toString() .replaceFirst('https://', '')), diff --git a/lib/pages/views/settings_account_view.dart b/lib/pages/views/settings_account_view.dart index 55b0a31b..0c77a04a 100644 --- a/lib/pages/views/settings_account_view.dart +++ b/lib/pages/views/settings_account_view.dart @@ -20,6 +20,13 @@ class SettingsAccountView extends StatelessWidget { withScrolling: true, child: Column( children: [ + ListTile( + trailing: Icon(Icons.add_box_outlined), + title: Text(L10n.of(context).addAccount), + subtitle: Text(L10n.of(context).enableMultiAccounts), + onTap: controller.addAccountAction, + ), + Divider(height: 1), ListTile( trailing: Icon(Icons.edit_outlined), title: Text(L10n.of(context).editDisplayname), @@ -38,6 +45,7 @@ class SettingsAccountView extends StatelessWidget { title: Text(L10n.of(context).devices), onTap: () => VRouter.of(context).to('devices'), ), + Divider(height: 1), ListTile( trailing: Icon(Icons.exit_to_app_outlined), title: Text(L10n.of(context).logout), diff --git a/lib/pages/views/signup_view.dart b/lib/pages/views/signup_view.dart index 8d77628c..320f2209 100644 --- a/lib/pages/views/signup_view.dart +++ b/lib/pages/views/signup_view.dart @@ -38,7 +38,7 @@ class SignupPageView extends StatelessWidget { labelText: L10n.of(context).username, prefixText: '@', suffixText: - ':${Matrix.of(context).client.homeserver.host}'), + ':${Matrix.of(context).getLoginClient().homeserver.host}'), ), ), Divider(), diff --git a/lib/utils/account_bundles.dart b/lib/utils/account_bundles.dart new file mode 100644 index 00000000..eb9d96f4 --- /dev/null +++ b/lib/utils/account_bundles.dart @@ -0,0 +1,99 @@ +import 'package:matrix/matrix.dart'; + +class AccountBundles { + String prefix; + List bundles; + + AccountBundles({this.prefix, this.bundles}); + + AccountBundles.fromJson(Map json) + : prefix = json.tryGet('prefix'), + bundles = json['bundles'] is List + ? json['bundles'] + .map((b) { + try { + return AccountBundle.fromJson(b); + } catch (_) { + return null; + } + }) + .whereType() + .toList() + : null; + + Map toJson() => { + if (prefix != null) 'prefix': prefix, + if (bundles != null) 'bundles': bundles.map((v) => v.toJson()).toList(), + }; +} + +class AccountBundle { + String name; + int priority; + + AccountBundle({this.name, this.priority}); + + AccountBundle.fromJson(Map json) + : name = json.tryGet('name'), + priority = json.tryGet('priority'); + + Map toJson() => { + if (name != null) 'name': name, + if (priority != null) 'priority': priority, + }; +} + +const accountBundlesType = 'im.fluffychat.account_bundles'; + +extension AccountBundlesExtension on Client { + List get accountBundles { + List ret; + if (accountData.containsKey(accountBundlesType)) { + ret = AccountBundles.fromJson(accountData[accountBundlesType].content) + .bundles; + } + ret ??= []; + if (ret.isEmpty) { + ret.add(AccountBundle( + name: userID, + priority: 0, + )); + } + return ret; + } + + Future setAccountBundle(String name, [int priority]) async { + final data = + AccountBundles.fromJson(accountData[accountBundlesType]?.content ?? {}); + var foundBundle = false; + data.bundles ??= []; + for (final bundle in data.bundles) { + if (bundle.name == name) { + bundle.priority = priority; + foundBundle = true; + break; + } + } + if (!foundBundle) { + data.bundles.add(AccountBundle(name: name, priority: priority)); + } + await setAccountData(userID, accountBundlesType, data.toJson()); + } + + Future removeFromAccountBundle(String name) async { + if (!accountData.containsKey(accountBundlesType)) { + return; // nothing to do + } + final data = + AccountBundles.fromJson(accountData[accountBundlesType].content); + if (data.bundles == null) return; + data.bundles.removeWhere((b) => b.name == name); + await setAccountData(userID, accountBundlesType, data.toJson()); + } + + String get sendPrefix { + final data = + AccountBundles.fromJson(accountData[accountBundlesType]?.content ?? {}); + return data.prefix; + } +} diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart new file mode 100644 index 00000000..e0d86f05 --- /dev/null +++ b/lib/utils/client_manager.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:flutter/foundation.dart'; +import 'package:matrix/encryption/utils/key_verification.dart'; +import 'package:matrix/matrix.dart'; + +import 'famedlysdk_store.dart'; +import 'matrix_sdk_extensions.dart/flutter_matrix_hive_database.dart'; + +abstract class ClientManager { + static const String clientNamespace = 'im.fluffychat.store.clients'; + static Future> getClients() async { + final clientNames = {}; + try { + final rawClientNames = await Store().getItem(clientNamespace); + if (rawClientNames != null) { + final clientNamesList = + (jsonDecode(rawClientNames) as List).cast(); + clientNames.addAll(clientNamesList); + } + } catch (e, s) { + Logs().w('Client names in store are corrupted', e, s); + await Store().deleteItem(clientNamespace); + } + if (clientNames.isEmpty) clientNames.add(PlatformInfos.clientName); + final clients = clientNames.map(createClient).toList(); + await Future.wait(clients.map((client) => client + .init() + .catchError((e, s) => Logs().e('Unable to initialize client', e, s)))); + if (clients.length > 1 && clients.any((c) => !c.isLogged())) { + final loggedOutClients = clients.where((c) => !c.isLogged()).toList(); + for (final client in loggedOutClients) { + clientNames.remove(client.clientName); + clients.remove(client); + } + await Store().setItem(clientNamespace, jsonEncode(clientNames.toList())); + } + return clients; + } + + static Future addClientNameToStore(String clientName) async { + final clientNamesList = []; + final rawClientNames = await Store().getItem(clientNamespace); + if (rawClientNames != null) { + final stored = (jsonDecode(rawClientNames) as List).cast(); + clientNamesList.addAll(stored); + } + clientNamesList.add(clientName); + await Store().setItem(clientNamespace, jsonEncode(clientNamesList)); + } + + static Client createClient(String clientName) => Client( + clientName, + enableE2eeRecovery: true, + verificationMethods: { + KeyVerificationMethod.numbers, + if (PlatformInfos.isMobile || PlatformInfos.isLinux) + KeyVerificationMethod.emoji, + }, + importantStateEvents: { + 'im.ponies.room_emotes', // we want emotes to work properly + }, + databaseBuilder: FlutterMatrixHiveStore.hiveDatabaseBuilder, + supportedLoginTypes: { + AuthenticationTypes.password, + if (PlatformInfos.isMobile || PlatformInfos.isWeb) + AuthenticationTypes.sso + }, + compute: compute, + ); +} diff --git a/lib/widgets/layouts/loading_view.dart b/lib/widgets/layouts/loading_view.dart index fa755065..9c5f2712 100644 --- a/lib/widgets/layouts/loading_view.dart +++ b/lib/widgets/layouts/loading_view.dart @@ -7,10 +7,16 @@ import 'package:flutter/material.dart'; class LoadingView extends StatelessWidget { @override Widget build(BuildContext context) { - if (Matrix.of(context).loginState != null) { + if (Matrix.of(context) + .widget + .clients + .every((client) => client.loginState != null)) { WidgetsBinding.instance.addPostFrameCallback( (_) => VRouter.of(context).to( - Matrix.of(context).loginState == LoginState.loggedIn + Matrix.of(context) + .widget + .clients + .any((client) => client.loginState == LoginState.loggedIn) ? '/rooms' : '/home', queryParameters: VRouter.of(context).queryParameters, diff --git a/lib/widgets/layouts/one_page_card.dart b/lib/widgets/layouts/one_page_card.dart index 22f07763..436a60a6 100644 --- a/lib/widgets/layouts/one_page_card.dart +++ b/lib/widgets/layouts/one_page_card.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; class OnePageCard extends StatelessWidget { @@ -12,7 +13,8 @@ class OnePageCard extends StatelessWidget { static num breakpoint = FluffyThemes.columnWidth * 2; @override Widget build(BuildContext context) { - return MediaQuery.of(context).size.width <= breakpoint + return MediaQuery.of(context).size.width <= breakpoint || + Matrix.of(context).client.isLogged() ? child : Container( decoration: BoxDecoration( diff --git a/lib/widgets/layouts/wait_for_login.dart b/lib/widgets/layouts/wait_for_login.dart deleted file mode 100644 index 29a42f84..00000000 --- a/lib/widgets/layouts/wait_for_login.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:fluffychat/pages/views/empty_page_view.dart'; -import 'package:matrix/matrix.dart'; -import 'package:flutter/material.dart'; - -import '../matrix.dart'; - -class WaitForInitPage extends StatelessWidget { - final Widget page; - const WaitForInitPage(this.page, {Key key}) : super(key: key); - - @override - Widget build(BuildContext context) { - if (Matrix.of(context).loginState == null) { - return StreamBuilder( - stream: Matrix.of(context).client.onLoginStateChanged.stream, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return EmptyPage(loading: true); - } - return page; - }); - } - return page; - } -} diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index d9aadb29..27afacae 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:convert'; import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:fluffychat/utils/client_manager.dart'; import 'package:matrix/encryption.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart'; @@ -23,6 +24,7 @@ import '../pages/key_verification_dialog.dart'; import '../utils/platform_infos.dart'; import '../config/app_config.dart'; import '../config/setting_keys.dart'; +import '../utils/account_bundles.dart'; import '../utils/background_push.dart'; import 'package:vrouter/vrouter.dart'; @@ -35,7 +37,7 @@ class Matrix extends StatefulWidget { final BuildContext context; - final Client client; + final List clients; final Map queryParameters; @@ -43,7 +45,7 @@ class Matrix extends StatefulWidget { this.child, @required this.router, @required this.context, - @required this.client, + @required this.clients, this.queryParameters, Key key, }) : super(key: key); @@ -57,12 +59,98 @@ class Matrix extends StatefulWidget { } class MatrixState extends State with WidgetsBindingObserver { - Client get client => widget.client; + int activeClient = 0; + String activeBundle; Store store = Store(); BuildContext navigatorContext; BackgroundPush _backgroundPush; + Client get client => widget.clients[_safeActiveClient]; + + bool get isMultiAccount => widget.clients.length > 1; + + int getClientIndexByMatrixId(String matrixId) => + widget.clients.indexWhere((client) => client.userID == matrixId); + + int get _safeActiveClient { + if (activeClient < 0 || activeClient >= widget.clients.length) { + return 0; + } + return activeClient; + } + + void setActiveClient(Client cl) { + final i = widget.clients.indexWhere((c) => c == cl); + if (i != null) { + activeClient = i; + } else { + Logs().w('Tried to set an unknown client ${cl.userID} as active'); + } + } + + 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(_AccountBundleWithClient( + 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; + + Client getLoginClient() { + final multiAccount = client.isLogged(); + if (!multiAccount) return client; + _loginClientCandidate ??= ClientManager.createClient( + client.generateUniqueTransactionId()) + ..onLoginStateChanged + .stream + .where((l) => l == LoginState.loggedIn) + .first + .then((_) { + widget.clients.add(_loginClientCandidate); + ClientManager.addClientNameToStore(_loginClientCandidate.clientName); + _registerSubs(_loginClientCandidate.clientName); + widget.router.currentState.to('/rooms'); + }); + return _loginClientCandidate; + } + + Client getClientByName(String name) => widget.clients + .firstWhere((c) => c.clientName == name, orElse: () => null); + Map get shareContent => _shareContent; set shareContent(Map content) { _shareContent = content; @@ -78,8 +166,8 @@ class MatrixState extends State with WidgetsBindingObserver { void _initWithStore() async { try { - await client.init(); if (client.isLogged()) { + // TODO: Figure out how this works in multi account final statusMsg = await store.getItem(SettingKeys.ownStatusMessage); if (statusMsg?.isNotEmpty ?? false) { Logs().v('Send cached status message: "$statusMsg"'); @@ -97,15 +185,15 @@ class MatrixState extends State with WidgetsBindingObserver { } } - StreamSubscription onRoomKeyRequestSub; - StreamSubscription onKeyVerificationRequestSub; - StreamSubscription onJitsiCallSub; - StreamSubscription onNotification; - StreamSubscription onLoginStateChanged; - StreamSubscription onUiaRequest; + final onRoomKeyRequestSub = {}; + final onKeyVerificationRequestSub = {}; + final onJitsiCallSub = {}; + final onNotification = {}; + final onLoginStateChanged = >{}; + final onUiaRequest = >{}; StreamSubscription onFocusSub; StreamSubscription onBlurSub; - StreamSubscription onOwnPresence; + final onOwnPresence = >{}; String _cachedPassword; String get cachedPassword { @@ -165,14 +253,12 @@ class MatrixState extends State with WidgetsBindingObserver { return uiaRequest .cancel(Exception(L10n.of(context).serverRequiresEmail)); } - final clientSecret = - Matrix.of(context).client.generateUniqueTransactionId(); - final currentThreepidCreds = - await Matrix.of(context).client.requestTokenToRegisterEmail( - clientSecret, - emailInput.single, - 0, - ); + final clientSecret = client.generateUniqueTransactionId(); + final currentThreepidCreds = await client.requestTokenToRegisterEmail( + clientSecret, + emailInput.single, + 0, + ); final auth = AuthenticationThreePidCreds( session: uiaRequest.session, type: AuthenticationTypes.emailIdentity, @@ -289,21 +375,14 @@ class MatrixState extends State with WidgetsBindingObserver { } } - LoginState loginState; - - void initMatrix() { - // Display the app lock - if (PlatformInfos.isMobile) { - WidgetsBinding.instance.addPostFrameCallback((_) { - FlutterSecureStorage().read(key: SettingKeys.appLockKey).then((lock) { - if (lock?.isNotEmpty ?? false) { - AppLock.of(widget.context).enable(); - AppLock.of(widget.context).showLockScreen(); - } - }); - }); + void _registerSubs(String name) { + final c = getClientByName(name); + if (c == null) { + Logs().w( + 'Attempted to register subscriptions for non-existing client $name'); + return; } - onKeyVerificationRequestSub ??= client.onKeyVerificationRequest.stream + onKeyVerificationRequestSub[name] ??= c.onKeyVerificationRequest.stream .listen((KeyVerification request) async { var hidPopup = false; request.onUpdate = () { @@ -334,51 +413,95 @@ class MatrixState extends State with WidgetsBindingObserver { await request.rejectVerification(); } }); - _initWithStore(); - - if (kIsWeb) { - onFocusSub = html.window.onFocus.listen((_) => webHasFocus = true); - onBlurSub = html.window.onBlur.listen((_) => webHasFocus = false); - } - onLoginStateChanged ??= client.onLoginStateChanged.stream.listen((state) { - if (loginState != state) { - loginState = state; - final isInLoginRoutes = {'/home', '/login', '/signup'} - .contains(widget.router.currentState.url); - if (widget.router.currentState.url == '/' || - (state == LoginState.loggedIn) == isInLoginRoutes) { + onLoginStateChanged[name] ??= c.onLoginStateChanged.stream.listen((state) { + final loggedInWithMultipleClients = widget.clients.length > 1; + if (state != LoginState.loggedIn) { + _cancelSubs(c.clientName); + widget.clients.remove(c); + } + if (loggedInWithMultipleClients) { + // TODO: display a nicer toast + showOkAlertDialog( + useRootNavigator: false, + context: navigatorContext, + title: 'Login state of client $name changed', + message: 'New login state: $state', + okLabel: L10n.of(widget.context).ok, + ); + if (state != LoginState.loggedIn) { widget.router.currentState.to( - loginState == LoginState.loggedIn ? '/rooms' : '/home', + '/rooms', queryParameters: widget.router.currentState.queryParameters, ); } + } else { + widget.router.currentState.to( + state == LoginState.loggedIn ? '/rooms' : '/home', + queryParameters: widget.router.currentState.queryParameters, + ); } }); - // Cache and resend status message - onOwnPresence ??= client.onPresence.stream.listen((presence) { - if (client.isLogged() && - client.userID == presence.senderId && + onOwnPresence[name] ??= c.onPresence.stream.listen((presence) { + if (c.isLogged() && + c.userID == presence.senderId && presence.presence?.statusMsg != null) { Logs().v('Update status message: "${presence.presence.statusMsg}"'); store.setItem( SettingKeys.ownStatusMessage, presence.presence.statusMsg); } }); - - onUiaRequest ??= client.onUiaRequest.stream.listen(_onUiaRequest); + onUiaRequest[name] ??= c.onUiaRequest.stream.listen(_onUiaRequest); if (PlatformInfos.isWeb || PlatformInfos.isLinux) { - client.onSync.stream.first.then((s) { + c.onSync.stream.first.then((s) { html.Notification.requestPermission(); - onNotification ??= client.onEvent.stream + onNotification[name] ??= c.onEvent.stream .where((e) => e.type == EventUpdateType.timeline && [EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted] .contains(e.content['type']) && - e.content['sender'] != client.userID) + e.content['sender'] != c.userID) .listen(_showLocalNotification); }); } + } + + void _cancelSubs(String name) { + onRoomKeyRequestSub[name]?.cancel(); + onRoomKeyRequestSub.remove(name); + onKeyVerificationRequestSub[name]?.cancel(); + onKeyVerificationRequestSub.remove(name); + onLoginStateChanged[name]?.cancel(); + onLoginStateChanged.remove(name); + onOwnPresence[name]?.cancel(); + onOwnPresence.remove(name); + onNotification[name]?.cancel(); + onNotification.remove(name); + } + + void initMatrix() { + // Display the app lock + if (PlatformInfos.isMobile) { + WidgetsBinding.instance.addPostFrameCallback((_) { + FlutterSecureStorage().read(key: SettingKeys.appLockKey).then((lock) { + if (lock?.isNotEmpty ?? false) { + AppLock.of(widget.context).enable(); + AppLock.of(widget.context).showLockScreen(); + } + }); + }); + } + + _initWithStore(); + + for (final c in widget.clients) { + _registerSubs(c.clientName); + } + + if (kIsWeb) { + onFocusSub = html.window.onFocus.listen((_) => webHasFocus = true); + onBlurSub = html.window.onBlur.listen((_) => webHasFocus = false); + } if (PlatformInfos.isMobile) { _backgroundPush = BackgroundPush( @@ -445,11 +568,12 @@ class MatrixState extends State with WidgetsBindingObserver { void dispose() { WidgetsBinding.instance.removeObserver(this); - onRoomKeyRequestSub?.cancel(); - onKeyVerificationRequestSub?.cancel(); - onLoginStateChanged?.cancel(); - onOwnPresence?.cancel(); - onNotification?.cancel(); + onRoomKeyRequestSub.values.map((s) => s.cancel()); + onKeyVerificationRequestSub.values.map((s) => s.cancel()); + onLoginStateChanged.values.map((s) => s.cancel()); + onOwnPresence.values.map((s) => s.cancel()); + onNotification.values.map((s) => s.cancel()); + onFocusSub?.cancel(); onBlurSub?.cancel(); _backgroundPush?.onLogin?.cancel(); @@ -491,3 +615,9 @@ class FixedThreepidCreds extends ThreepidCreds { return data; } } + +class _AccountBundleWithClient { + final Client client; + final AccountBundle bundle; + _AccountBundleWithClient({this.client, this.bundle}); +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 6762d487..7252d430 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import audioplayers import file_selector_macos import firebase_core import flutter_local_notifications +import geolocator_apple import package_info_plus_macos import path_provider_macos import share_plus_macos @@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 504d0f27..f9aedfa5 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -23,6 +23,8 @@ PODS: - FMDB (2.7.5): - FMDB/standard (= 2.7.5) - FMDB/standard (2.7.5) + - geolocator_apple (1.2.0): + - FlutterMacOS - GoogleDataTransport (8.4.0): - GoogleUtilities/Environment (~> 7.2) - nanopb (~> 2.30908.0) @@ -59,6 +61,7 @@ DEPENDENCIES: - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - FlutterMacOS (from `Flutter/ephemeral`) + - geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos`) - package_info_plus_macos (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus_macos/macos`) - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) - share_plus_macos (from `Flutter/ephemeral/.symlinks/plugins/share_plus_macos/macos`) @@ -89,6 +92,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos FlutterMacOS: :path: Flutter/ephemeral + geolocator_apple: + :path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos package_info_plus_macos: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus_macos/macos path_provider_macos: @@ -114,6 +119,7 @@ SPEC CHECKSUMS: flutter_local_notifications: 3805ca215b2fb7f397d78b66db91f6a747af52e4 FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a + geolocator_apple: 821be05bbdb1b49500e029ebcbf2d6acf2dfb966 GoogleDataTransport: cd9db2180fcecd8da1b561aea31e3e56cf834aa7 GoogleUtilities: 284cddc7fffc14ae1907efb6f78ab95c1fccaedc nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96 @@ -128,4 +134,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: c7161fcf45d4fd9025dc0f48a76d6e64e52f8176 -COCOAPODS: 1.10.1 +COCOAPODS: 1.11.0 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index a24159a9..7020e98d 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -269,6 +269,7 @@ "${BUILT_PRODUCTS_DIR}/audioplayers/audioplayers.framework", "${BUILT_PRODUCTS_DIR}/file_selector_macos/file_selector_macos.framework", "${BUILT_PRODUCTS_DIR}/flutter_local_notifications/flutter_local_notifications.framework", + "${BUILT_PRODUCTS_DIR}/geolocator_apple/geolocator_apple.framework", "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", "${BUILT_PRODUCTS_DIR}/package_info_plus_macos/package_info_plus_macos.framework", "${BUILT_PRODUCTS_DIR}/path_provider_macos/path_provider_macos.framework", @@ -289,6 +290,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/audioplayers.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_selector_macos.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_local_notifications.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/geolocator_apple.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info_plus_macos.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_macos.framework",