412 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Dart
		
	
	
	
			
		
		
	
	
			412 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Dart
		
	
	
	
| import 'dart:convert';
 | |
| import 'dart:ui';
 | |
| 
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter/services.dart';
 | |
| 
 | |
| import 'package:extera_next/generated/l10n/l10n.dart';
 | |
| import 'package:matrix/encryption.dart';
 | |
| import 'package:matrix/matrix.dart';
 | |
| 
 | |
| import 'package:extera_next/widgets/adaptive_dialogs/adaptive_dialog_action.dart';
 | |
| import 'package:extera_next/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
 | |
| import 'package:extera_next/widgets/avatar.dart';
 | |
| import 'package:extera_next/widgets/future_loading_dialog.dart';
 | |
| 
 | |
| class KeyVerificationDialog extends StatefulWidget {
 | |
|   Future<void> show(BuildContext context) => showAdaptiveDialog(
 | |
|         context: context,
 | |
|         builder: (context) => this,
 | |
|         barrierDismissible: false,
 | |
|       );
 | |
| 
 | |
|   final KeyVerification request;
 | |
| 
 | |
|   const KeyVerificationDialog({
 | |
|     super.key,
 | |
|     required this.request,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   KeyVerificationPageState createState() => KeyVerificationPageState();
 | |
| }
 | |
| 
 | |
| class KeyVerificationPageState extends State<KeyVerificationDialog> {
 | |
|   void Function()? originalOnUpdate;
 | |
|   late final List<dynamic> sasEmoji;
 | |
| 
 | |
|   @override
 | |
|   void initState() {
 | |
|     originalOnUpdate = widget.request.onUpdate;
 | |
|     widget.request.onUpdate = () {
 | |
|       originalOnUpdate?.call();
 | |
|       setState(() {});
 | |
|     };
 | |
|     widget.request.client.getProfileFromUserId(widget.request.userId).then((p) {
 | |
|       profile = p;
 | |
|       setState(() {});
 | |
|     });
 | |
|     rootBundle.loadString('assets/sas-emoji.json').then((e) {
 | |
|       sasEmoji = json.decode(e);
 | |
|       setState(() {});
 | |
|     });
 | |
|     super.initState();
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   void dispose() {
 | |
|     widget.request.onUpdate =
 | |
|         originalOnUpdate; // don't want to get updates anymore
 | |
|     if (![KeyVerificationState.error, KeyVerificationState.done]
 | |
|         .contains(widget.request.state)) {
 | |
|       widget.request.cancel('m.user');
 | |
|     }
 | |
|     super.dispose();
 | |
|   }
 | |
| 
 | |
|   Profile? profile;
 | |
| 
 | |
|   Future<void> checkInput(String input) async {
 | |
|     if (input.isEmpty) return;
 | |
| 
 | |
|     final valid = await showFutureLoadingDialog(
 | |
|       context: context,
 | |
|       future: () async {
 | |
|         // make sure the loading spinner shows before we test the keys
 | |
|         await Future.delayed(const Duration(milliseconds: 100));
 | |
|         var valid = false;
 | |
|         try {
 | |
|           await widget.request.openSSSS(keyOrPassphrase: input);
 | |
|           valid = true;
 | |
|         } catch (_) {
 | |
|           valid = false;
 | |
|         }
 | |
|         return valid;
 | |
|       },
 | |
|     );
 | |
|     if (valid.error != null) {
 | |
|       await showOkAlertDialog(
 | |
|         useRootNavigator: false,
 | |
|         context: context,
 | |
|         title: L10n.of(context).incorrectPassphraseOrKey,
 | |
|       );
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     final theme = Theme.of(context);
 | |
| 
 | |
|     User? user;
 | |
|     final directChatId =
 | |
|         widget.request.client.getDirectChatFromUserId(widget.request.userId);
 | |
|     if (directChatId != null) {
 | |
|       user = widget.request.client
 | |
|           .getRoomById(directChatId)!
 | |
|           .unsafeGetUserFromMemoryOrFallback(widget.request.userId);
 | |
|     }
 | |
|     final displayName =
 | |
|         user?.calcDisplayname() ?? widget.request.userId.localpart!;
 | |
|     var title = Text(L10n.of(context).verifyTitle);
 | |
|     Widget body;
 | |
|     final buttons = <Widget>[];
 | |
| 
 | |
|     switch (widget.request.state) {
 | |
|       case KeyVerificationState.showQRSuccess:
 | |
|       case KeyVerificationState.confirmQRScan:
 | |
|         throw 'Not implemented';
 | |
|       case KeyVerificationState.askSSSS:
 | |
|         // prompt the user for their ssss passphrase / key
 | |
|         final textEditingController = TextEditingController();
 | |
|         String input;
 | |
|         body = Container(
 | |
|           margin: const EdgeInsets.only(left: 8.0, right: 8.0),
 | |
|           child: Column(
 | |
|             mainAxisSize: MainAxisSize.min,
 | |
|             children: <Widget>[
 | |
|               Text(
 | |
|                 L10n.of(context).askSSSSSign,
 | |
|                 style: const TextStyle(fontSize: 20),
 | |
|               ),
 | |
|               Container(height: 10),
 | |
|               TextField(
 | |
|                 controller: textEditingController,
 | |
|                 autofocus: false,
 | |
|                 autocorrect: false,
 | |
|                 onSubmitted: (s) {
 | |
|                   input = s;
 | |
|                   checkInput(input);
 | |
|                 },
 | |
|                 minLines: 1,
 | |
|                 maxLines: 1,
 | |
|                 obscureText: true,
 | |
|                 decoration: InputDecoration(
 | |
|                   hintText: L10n.of(context).passphraseOrKey,
 | |
|                   prefixStyle: TextStyle(color: theme.colorScheme.primary),
 | |
|                   suffixStyle: TextStyle(color: theme.colorScheme.primary),
 | |
|                   border: const OutlineInputBorder(),
 | |
|                 ),
 | |
|               ),
 | |
|             ],
 | |
|           ),
 | |
|         );
 | |
|         buttons.add(
 | |
|           AdaptiveDialogAction(
 | |
|             child: Text(
 | |
|               L10n.of(context).submit,
 | |
|             ),
 | |
|             onPressed: () => checkInput(textEditingController.text),
 | |
|           ),
 | |
|         );
 | |
|         buttons.add(
 | |
|           AdaptiveDialogAction(
 | |
|             child: Text(
 | |
|               L10n.of(context).skip,
 | |
|             ),
 | |
|             onPressed: () => widget.request.openSSSS(skip: true),
 | |
|           ),
 | |
|         );
 | |
|         break;
 | |
|       case KeyVerificationState.askAccept:
 | |
|         title = Text(L10n.of(context).newVerificationRequest);
 | |
|         body = Column(
 | |
|           mainAxisSize: MainAxisSize.min,
 | |
|           children: [
 | |
|             const SizedBox(height: 16),
 | |
|             Avatar(
 | |
|               mxContent: user?.avatarUrl,
 | |
|               name: displayName,
 | |
|               size: Avatar.defaultSize * 2,
 | |
|             ),
 | |
|             const SizedBox(height: 16),
 | |
|             Text(
 | |
|               L10n.of(context).askVerificationRequest(displayName),
 | |
|             ),
 | |
|           ],
 | |
|         );
 | |
|         buttons.add(
 | |
|           AdaptiveDialogAction(
 | |
|             onPressed: () => widget.request
 | |
|                 .rejectVerification()
 | |
|                 .then((_) => Navigator.of(context, rootNavigator: false).pop()),
 | |
|             child: Text(
 | |
|               L10n.of(context).reject,
 | |
|               style: TextStyle(color: theme.colorScheme.error),
 | |
|             ),
 | |
|           ),
 | |
|         );
 | |
|         buttons.add(
 | |
|           AdaptiveDialogAction(
 | |
|             onPressed: () => widget.request.acceptVerification(),
 | |
|             child: Text(L10n.of(context).accept),
 | |
|           ),
 | |
|         );
 | |
|         break;
 | |
|       case KeyVerificationState.askChoice:
 | |
|       case KeyVerificationState.waitingAccept:
 | |
|         body = Center(
 | |
|           child: Column(
 | |
|             children: <Widget>[
 | |
|               const SizedBox(height: 16),
 | |
|               Stack(
 | |
|                 alignment: Alignment.center,
 | |
|                 children: [
 | |
|                   Avatar(
 | |
|                     mxContent: user?.avatarUrl,
 | |
|                     name: displayName,
 | |
|                   ),
 | |
|                   const SizedBox(
 | |
|                     width: Avatar.defaultSize + 2,
 | |
|                     height: Avatar.defaultSize + 2,
 | |
|                     child: CircularProgressIndicator(strokeWidth: 2),
 | |
|                   ),
 | |
|                 ],
 | |
|               ),
 | |
|               const SizedBox(height: 16),
 | |
|               Text(
 | |
|                 L10n.of(context).waitingPartnerAcceptRequest,
 | |
|                 textAlign: TextAlign.center,
 | |
|               ),
 | |
|             ],
 | |
|           ),
 | |
|         );
 | |
|         buttons.add(
 | |
|           AdaptiveDialogAction(
 | |
|             onPressed: () => widget.request.cancel(),
 | |
|             child: Text(L10n.of(context).cancel),
 | |
|           ),
 | |
|         );
 | |
| 
 | |
|         break;
 | |
|       case KeyVerificationState.askSas:
 | |
|         TextSpan compareWidget;
 | |
|         // maybe add a button to switch between the two and only determine default
 | |
|         // view for if "emoji" is a present sasType or not?
 | |
| 
 | |
|         if (widget.request.sasTypes.contains('emoji')) {
 | |
|           title = Text(
 | |
|             L10n.of(context).compareEmojiMatch,
 | |
|             maxLines: 1,
 | |
|             style: const TextStyle(fontSize: 16),
 | |
|           );
 | |
|           compareWidget = TextSpan(
 | |
|             children: widget.request.sasEmojis
 | |
|                 .map((e) => WidgetSpan(child: _Emoji(e, sasEmoji)))
 | |
|                 .toList(),
 | |
|           );
 | |
|         } else {
 | |
|           title = Text(L10n.of(context).compareNumbersMatch);
 | |
|           final numbers = widget.request.sasNumbers;
 | |
|           final numbstr = '${numbers[0]}-${numbers[1]}-${numbers[2]}';
 | |
|           compareWidget =
 | |
|               TextSpan(text: numbstr, style: const TextStyle(fontSize: 40));
 | |
|         }
 | |
|         body = Column(
 | |
|           mainAxisSize: MainAxisSize.min,
 | |
|           children: <Widget>[
 | |
|             Text.rich(
 | |
|               compareWidget,
 | |
|               textAlign: TextAlign.center,
 | |
|             ),
 | |
|           ],
 | |
|         );
 | |
|         buttons.add(
 | |
|           AdaptiveDialogAction(
 | |
|             onPressed: () => widget.request.rejectSas(),
 | |
|             child: Text(
 | |
|               L10n.of(context).theyDontMatch,
 | |
|               style: TextStyle(color: theme.colorScheme.error),
 | |
|             ),
 | |
|           ),
 | |
|         );
 | |
|         buttons.add(
 | |
|           AdaptiveDialogAction(
 | |
|             onPressed: () => widget.request.acceptSas(),
 | |
|             child: Text(L10n.of(context).theyMatch),
 | |
|           ),
 | |
|         );
 | |
|         break;
 | |
|       case KeyVerificationState.waitingSas:
 | |
|         final acceptText = widget.request.sasTypes.contains('emoji')
 | |
|             ? L10n.of(context).waitingPartnerEmoji
 | |
|             : L10n.of(context).waitingPartnerNumbers;
 | |
|         body = Column(
 | |
|           mainAxisSize: MainAxisSize.min,
 | |
|           children: <Widget>[
 | |
|             const SizedBox(height: 16),
 | |
|             const CircularProgressIndicator.adaptive(strokeWidth: 2),
 | |
|             const SizedBox(height: 16),
 | |
|             Text(
 | |
|               acceptText,
 | |
|               textAlign: TextAlign.center,
 | |
|             ),
 | |
|           ],
 | |
|         );
 | |
|         break;
 | |
|       case KeyVerificationState.done:
 | |
|         title = Text(L10n.of(context).verifySuccess);
 | |
|         body = const Padding(
 | |
|           padding: EdgeInsets.all(16.0),
 | |
|           child: Icon(
 | |
|             Icons.verified_outlined,
 | |
|             color: Colors.green,
 | |
|             size: 128.0,
 | |
|           ),
 | |
|         );
 | |
|         buttons.add(
 | |
|           AdaptiveDialogAction(
 | |
|             child: Text(
 | |
|               L10n.of(context).close,
 | |
|             ),
 | |
|             onPressed: () => Navigator.of(context, rootNavigator: false).pop(),
 | |
|           ),
 | |
|         );
 | |
|         break;
 | |
|       case KeyVerificationState.error:
 | |
|         title = const Text('');
 | |
|         body = Column(
 | |
|           mainAxisSize: MainAxisSize.min,
 | |
|           children: <Widget>[
 | |
|             const SizedBox(height: 16),
 | |
|             Icon(Icons.cancel, color: theme.colorScheme.error, size: 64.0),
 | |
|             const SizedBox(height: 16),
 | |
|             // TODO: Add better error UI to user
 | |
|             Text(
 | |
|               'Error ${widget.request.canceledCode}: ${widget.request.canceledReason}',
 | |
|               textAlign: TextAlign.center,
 | |
|             ),
 | |
|           ],
 | |
|         );
 | |
|         buttons.add(
 | |
|           AdaptiveDialogAction(
 | |
|             child: Text(
 | |
|               L10n.of(context).close,
 | |
|             ),
 | |
|             onPressed: () => Navigator.of(context, rootNavigator: false).pop(),
 | |
|           ),
 | |
|         );
 | |
|         break;
 | |
|     }
 | |
| 
 | |
|     return AlertDialog.adaptive(
 | |
|       title: title,
 | |
|       content: SizedBox(
 | |
|         height: 256,
 | |
|         width: 256,
 | |
|         child: ListView(
 | |
|           children: [body],
 | |
|         ),
 | |
|       ),
 | |
|       actions: buttons,
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class _Emoji extends StatelessWidget {
 | |
|   final KeyVerificationEmoji emoji;
 | |
|   final List<dynamic>? sasEmoji;
 | |
| 
 | |
|   const _Emoji(this.emoji, this.sasEmoji);
 | |
| 
 | |
|   String getLocalizedName() {
 | |
|     final sasEmoji = this.sasEmoji;
 | |
|     if (sasEmoji == null) {
 | |
|       // asset is still being loaded
 | |
|       return emoji.name;
 | |
|     }
 | |
|     final translations = Map<String, String?>.from(
 | |
|       sasEmoji[emoji.number]['translated_descriptions'],
 | |
|     );
 | |
|     translations['en'] = emoji.name;
 | |
|     for (final locale in PlatformDispatcher.instance.locales) {
 | |
|       final wantLocaleParts = locale.toString().split('_');
 | |
|       final wantLanguage = wantLocaleParts.removeAt(0);
 | |
|       for (final haveLocale in translations.keys) {
 | |
|         final haveLocaleParts = haveLocale.split('_');
 | |
|         final haveLanguage = haveLocaleParts.removeAt(0);
 | |
|         if (haveLanguage == wantLanguage &&
 | |
|             (Set.from(haveLocaleParts)..removeAll(wantLocaleParts)).isEmpty &&
 | |
|             (translations[haveLocale]?.isNotEmpty ?? false)) {
 | |
|           return translations[haveLocale]!;
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|     return emoji.name;
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return Column(
 | |
|       mainAxisSize: MainAxisSize.min,
 | |
|       children: <Widget>[
 | |
|         Text(emoji.emoji, style: const TextStyle(fontSize: 50)),
 | |
|         Padding(
 | |
|           padding: const EdgeInsets.symmetric(horizontal: 4.0),
 | |
|           child: Text(getLocalizedName()),
 | |
|         ),
 | |
|         const SizedBox(height: 10, width: 5),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| }
 |