diff --git a/assets/encryption.png b/assets/encryption.png new file mode 100644 index 00000000..af2ba3c2 Binary files /dev/null and b/assets/encryption.png differ diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 6d0e9346..c5c0579e 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2979,5 +2979,10 @@ "oldDisplayName": {} } }, - "newSpaceDescription": "Spaces allows you to consolidate your chats and build private or public communities." + "newSpaceDescription": "Spaces allows you to consolidate your chats and build private or public communities.", + "encryptThisChat": "Encrypt this chat", + "endToEndEncryption": "End to end encryption", + "disableEncryptionWarning": "For security reasons you can not disable encryption in a chat, where it has been enabled before.", + "sorryThatsNotPossible": "Sorry... that is not possible", + "deviceKeys": "Device keys:" } diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 41683a6b..bfe4c449 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -24,6 +24,8 @@ abstract class AppConfig { static String get privacyUrl => _privacyUrl; static const String enablePushTutorial = 'https://gitlab.com/famedly/fluffychat/-/wikis/Push-Notifications-without-Google-Services'; + static const String encryptionTutorial = + 'https://gitlab.com/famedly/fluffychat/-/wikis/How-to-use-end-to-end-encryption-in-FluffyChat'; static const String appId = 'im.fluffychat.FluffyChat'; static const String appOpenUrlScheme = 'im.fluffychat'; static String _webBaseUrl = 'https://fluffychat.im/web'; diff --git a/lib/pages/chat/encryption_button.dart b/lib/pages/chat/encryption_button.dart index 2d3edee6..1c4bef33 100644 --- a/lib/pages/chat/encryption_button.dart +++ b/lib/pages/chat/encryption_button.dart @@ -1,93 +1,45 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; -import 'package:adaptive_dialog/adaptive_dialog.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'; import '../../widgets/matrix.dart'; -class EncryptionButton extends StatefulWidget { +class EncryptionButton extends StatelessWidget { final Room room; const EncryptionButton(this.room, {Key? key}) : super(key: key); - @override - EncryptionButtonState createState() => EncryptionButtonState(); -} - -class EncryptionButtonState extends State { - StreamSubscription? _onSyncSub; - - void _enableEncryptionAction() async { - if (widget.room.encrypted) { - VRouter.of(context).toSegments(['rooms', widget.room.id, 'encryption']); - return; - } - if (widget.room.joinRules == JoinRules.public) { - await showOkAlertDialog( - useRootNavigator: false, - context: context, - okLabel: L10n.of(context)!.ok, - message: L10n.of(context)!.noEncryptionForPublicRooms, - ); - return; - } - if (await showOkCancelAlertDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context)!.enableEncryption, - message: widget.room.client.encryptionEnabled - ? L10n.of(context)!.enableEncryptionWarning - : L10n.of(context)!.needPantalaimonWarning, - okLabel: L10n.of(context)!.yes, - cancelLabel: L10n.of(context)!.cancel, - ) == - OkCancelResult.ok) { - await showFutureLoadingDialog( - context: context, - future: () => widget.room.enableEncryption(), - ); - // we want to enable the lock icon - setState(() {}); - } - } - - @override - void dispose() { - _onSyncSub?.cancel(); - super.dispose(); - } @override Widget build(BuildContext context) { - if (widget.room.encrypted) { - _onSyncSub ??= Matrix.of(context) - .client - .onSync - .stream - .where((s) => s.deviceLists != null) - .listen((s) => setState(() {})); - } - return FutureBuilder( - future: widget.room.calcEncryptionHealthState(), - builder: (BuildContext context, snapshot) => IconButton( - tooltip: widget.room.encrypted - ? L10n.of(context)!.encrypted - : L10n.of(context)!.encryptionNotEnabled, - icon: Icon( - widget.room.encrypted - ? Icons.lock_outlined - : Icons.lock_open_outlined, - size: 20, - color: widget.room.joinRules != JoinRules.public && - !widget.room.encrypted - ? Colors.red - : snapshot.data == EncryptionHealthState.unverifiedDevices - ? Colors.orange - : null), - onPressed: _enableEncryptionAction, - )); + return StreamBuilder( + stream: Matrix.of(context) + .client + .onSync + .stream + .where((s) => s.deviceLists != null), + builder: (context, snapshot) { + return FutureBuilder( + future: room.calcEncryptionHealthState(), + builder: (BuildContext context, snapshot) => IconButton( + tooltip: room.encrypted + ? L10n.of(context)!.encrypted + : L10n.of(context)!.encryptionNotEnabled, + icon: Icon( + room.encrypted + ? Icons.lock_outlined + : Icons.lock_open_outlined, + size: 20, + color: room.joinRules != JoinRules.public && + !room.encrypted + ? Colors.red + : snapshot.data == + EncryptionHealthState.unverifiedDevices + ? Colors.orange + : null), + onPressed: () => VRouter.of(context) + .toSegments(['rooms', room.id, 'encryption']), + )); + }); } } diff --git a/lib/pages/chat_encryption_settings/chat_encryption_settings.dart b/lib/pages/chat_encryption_settings/chat_encryption_settings.dart index 5892cf9b..b85a672b 100644 --- a/lib/pages/chat_encryption_settings/chat_encryption_settings.dart +++ b/lib/pages/chat_encryption_settings/chat_encryption_settings.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/encryption.dart'; import 'package:matrix/matrix.dart'; import 'package:vrouter/vrouter.dart'; @@ -19,49 +22,68 @@ class ChatEncryptionSettings extends StatefulWidget { class ChatEncryptionSettingsController extends State { String? get roomId => VRouter.of(context).pathParameters['roomid']; + Room get room => Matrix.of(context).client.getRoomById(roomId!)!; + Future unblock(DeviceKeys key) async { if (key.blocked) { await key.setBlocked(false); } } - Future onSelected( - BuildContext context, String action, DeviceKeys key) async { - final room = Matrix.of(context).client.getRoomById(roomId!); - switch (action) { - case 'verify': - await unblock(key); - final req = key.startVerification(); - req.onUpdate = () { - if (req.state == KeyVerificationState.done) { - setState(() {}); - } - }; - await KeyVerificationDialog(request: req).show(context); - break; - case 'verify_user': - await unblock(key); - final req = - await room!.client.userDeviceKeys[key.userId]!.startVerification(); - req.onUpdate = () { - if (req.state == KeyVerificationState.done) { - setState(() {}); - } - }; - await KeyVerificationDialog(request: req).show(context); - break; - case 'block': - if (key.directVerified) { - await key.setVerified(false); - } - await key.setBlocked(true); - setState(() {}); - break; - case 'unblock': - await unblock(key); - setState(() {}); - break; + void enableEncryption(_) async { + if (room.encrypted) { + showOkAlertDialog( + context: context, + title: L10n.of(context)!.sorryThatsNotPossible, + message: L10n.of(context)!.disableEncryptionWarning, + ); + return; } + if (room.joinRules == JoinRules.public) { + showOkAlertDialog( + context: context, + title: L10n.of(context)!.sorryThatsNotPossible, + message: L10n.of(context)!.noEncryptionForPublicRooms, + ); + return; + } + if (!room.canChangeStateEvent(EventTypes.Encryption)) { + showOkAlertDialog( + context: context, + title: L10n.of(context)!.sorryThatsNotPossible, + message: L10n.of(context)!.noPermission, + ); + return; + } + final consent = await showOkCancelAlertDialog( + context: context, + title: L10n.of(context)!.areYouSure, + message: L10n.of(context)!.enableEncryptionWarning, + okLabel: L10n.of(context)!.yes, + cancelLabel: L10n.of(context)!.cancel, + ); + if (consent != OkCancelResult.ok) return; + await showFutureLoadingDialog( + context: context, + future: () => room.enableEncryption(), + ); + } + + void startVerification() async { + final req = await room.client.userDeviceKeys[room.directChatMatrixID]! + .startVerification(); + req.onUpdate = () { + if (req.state == KeyVerificationState.done) { + setState(() {}); + } + }; + await KeyVerificationDialog(request: req).show(context); + } + + void toggleDeviceKey(DeviceKeys key) { + setState(() { + key.setBlocked(!key.blocked); + }); } @override diff --git a/lib/pages/chat_encryption_settings/chat_encryption_settings_view.dart b/lib/pages/chat_encryption_settings/chat_encryption_settings_view.dart index 71cd6abb..2f5fc360 100644 --- a/lib/pages/chat_encryption_settings/chat_encryption_settings_view.dart +++ b/lib/pages/chat_encryption_settings/chat_encryption_settings_view.dart @@ -2,14 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:vrouter/vrouter.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat_encryption_settings/chat_encryption_settings.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/layouts/max_width_body.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import '../../utils/matrix_sdk_extensions.dart/device_extension.dart'; -import '../../widgets/m2_popup_menu_button.dart'; class ChatEncryptionSettingsView extends StatelessWidget { final ChatEncryptionSettingsController controller; @@ -19,184 +16,148 @@ class ChatEncryptionSettingsView extends StatelessWidget { @override Widget build(BuildContext context) { - final room = Matrix.of(context).client.getRoomById(controller.roomId!)!; - - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.close_outlined), - onPressed: () => - VRouter.of(context).toSegments(['rooms', controller.roomId!]), - ), - title: Text(L10n.of(context)!.tapOnDeviceToVerify), - elevation: 0, - ), - body: MaxWidthBody( - withScrolling: true, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text(L10n.of(context)!.deviceVerifyDescription), - leading: CircleAvatar( - backgroundColor: Theme.of(context).secondaryHeaderColor, - foregroundColor: Theme.of(context).colorScheme.secondary, - child: const Icon(Icons.lock), + final room = controller.room; + return StreamBuilder( + stream: room.client.onSync.stream.where( + (s) => s.rooms?.join?[room.id] != null || s.deviceLists != null), + builder: (context, _) => Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.close_outlined), + onPressed: () => VRouter.of(context) + .toSegments(['rooms', controller.roomId!]), + ), + title: Text(L10n.of(context)!.endToEndEncryption), + actions: [ + TextButton( + onPressed: () => launch(AppConfig.encryptionTutorial), + child: Text(L10n.of(context)!.help), + ), + ], ), - ), - const Divider(height: 1), - StreamBuilder( - stream: room.onUpdate.stream, - builder: (context, snapshot) { - return FutureBuilder>( - future: room.getUserDeviceKeys(), - builder: (BuildContext context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text( - '${L10n.of(context)!.oopsSomethingWentWrong}: ${snapshot.error}'), - ); - } - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 2)); - } - final deviceKeys = snapshot.data!; - return ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: deviceKeys.length, - itemBuilder: (BuildContext context, int i) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (i == 0 || - deviceKeys[i].userId != - deviceKeys[i - 1].userId) ...{ - const Divider(height: 1, thickness: 1), - M2PopupMenuButton( - onSelected: (dynamic action) => controller - .onSelected(context, action, deviceKeys[i]), - itemBuilder: (c) { - final items = >[]; - if (room - .client - .userDeviceKeys[deviceKeys[i].userId]! - .verified == - UserVerifiedStatus.unknown) { - items.add(PopupMenuItem( - value: 'verify_user', - child: Text(L10n.of(context)!.verifyUser), - )); - } - return items; - }, - child: ListTile( - leading: Avatar( - mxContent: room - .unsafeGetUserFromMemoryOrFallback( - deviceKeys[i].userId) - .avatarUrl, - name: room - .unsafeGetUserFromMemoryOrFallback( - deviceKeys[i].userId) - .calcDisplayname(), - ), - title: Text( - room - .unsafeGetUserFromMemoryOrFallback( - deviceKeys[i].userId) - .calcDisplayname(), - ), - subtitle: Text( - deviceKeys[i].userId, - style: const TextStyle( - fontWeight: FontWeight.w300), - ), - ), - ), - }, - M2PopupMenuButton( - onSelected: (dynamic action) => controller - .onSelected(context, action, deviceKeys[i]), - itemBuilder: (c) { - final items = >[]; - if (deviceKeys[i].blocked || - !deviceKeys[i].verified) { - items.add(PopupMenuItem( - value: deviceKeys[i].userId == - room.client.userID - ? 'verify' - : 'verify_user', - child: Text(L10n.of(context)!.verifyStart), - )); - } - if (deviceKeys[i].blocked) { - items.add(PopupMenuItem( - value: 'unblock', - child: - Text(L10n.of(context)!.unblockDevice), - )); - } - if (!deviceKeys[i].blocked) { - items.add(PopupMenuItem( - value: 'block', - child: Text(L10n.of(context)!.blockDevice), - )); - } - return items; - }, - child: ListTile( - leading: CircleAvatar( - foregroundColor: Colors.white, - backgroundColor: deviceKeys[i].color, - child: Icon(deviceKeys[i].icon), - ), - title: Text( - deviceKeys[i].displayname, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Row( - children: [ - Text( - deviceKeys[i].deviceId!, - style: const TextStyle( - fontWeight: FontWeight.w300), - ), - const Spacer(), - Text( - deviceKeys[i].blocked - ? L10n.of(context)!.blocked - : deviceKeys[i].verified - ? L10n.of(context)!.verified - : L10n.of(context)!.unverified, - style: TextStyle( - fontSize: 14, - color: deviceKeys[i].color, - ), - ), - ], - ), - ), - ), - ], + body: ListView( + children: [ + SwitchListTile( + secondary: CircleAvatar( + foregroundColor: + Theme.of(context).colorScheme.onPrimaryContainer, + backgroundColor: + Theme.of(context).colorScheme.primaryContainer, + child: const Icon(Icons.lock_outlined)), + title: Text(L10n.of(context)!.encryptThisChat), + value: room.encrypted, + onChanged: controller.enableEncryption, + ), + Center( + child: Image.asset( + 'assets/encryption.png', + width: 212, + ), + ), + const Divider(height: 1), + if (room.isDirectChat) + Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: ElevatedButton.icon( + onPressed: controller.startVerification, + icon: const Icon(Icons.verified_outlined), + label: Text(L10n.of(context)!.verifyStart), ), - ); - }, - ); - }), - ], - ), - ), - ); + ), + ), + if (room.encrypted) ...[ + const Divider(height: 1), + ListTile( + title: Text( + L10n.of(context)!.deviceKeys, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + StreamBuilder( + stream: room.onUpdate.stream, + builder: (context, snapshot) => + FutureBuilder>( + future: room.getUserDeviceKeys(), + builder: (BuildContext context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text( + '${L10n.of(context)!.oopsSomethingWentWrong}: ${snapshot.error}'), + ); + } + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2)); + } + final deviceKeys = snapshot.data!; + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: deviceKeys.length, + itemBuilder: (BuildContext context, int i) => + SwitchListTile( + value: !deviceKeys[i].blocked, + activeColor: deviceKeys[i].verified + ? Colors.green + : Colors.orange, + onChanged: (_) => controller + .toggleDeviceKey(deviceKeys[i]), + title: Row( + children: [ + Expanded( + child: Text( + deviceKeys[i].deviceId ?? + L10n.of(context)!.unknownDevice, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0), + child: Chip( + label: Text( + deviceKeys[i].userId, + style: const TextStyle( + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ), + ), + ], + ), + subtitle: Text( + deviceKeys[i] + .ed25519Key + ?.replaceAllMapped( + RegExp(r'.{4}'), + (s) => '${s.group(0)} ') ?? + L10n.of(context)! + .unknownEncryptionAlgorithm, + style: + const TextStyle(fontFamily: 'Mono'), + ), + ), + ); + }), + ), + ] else + Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Text( + L10n.of(context)!.encryptionNotEnabled, + style: const TextStyle( + fontStyle: FontStyle.italic, + ), + ), + ), + ), + ], + ), + )); } } - -extension on DeviceKeys { - Color get color => blocked - ? Colors.red - : verified - ? Colors.green - : Colors.orange; -} diff --git a/lib/widgets/fluffy_chat_app.dart b/lib/widgets/fluffy_chat_app.dart index 47334805..5c9025fc 100644 --- a/lib/widgets/fluffy_chat_app.dart +++ b/lib/widgets/fluffy_chat_app.dart @@ -64,6 +64,7 @@ class FluffyChatAppState extends State { return VRouter( key: FluffyChatApp.routerKey, title: AppConfig.applicationName, + debugShowCheckedModeBanner: false, themeMode: themeMode, theme: FluffyThemes.buildTheme(Brightness.light, primaryColor), darkTheme: FluffyThemes.buildTheme(Brightness.dark, primaryColor),