511 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Dart
		
	
	
	
			
		
		
	
	
			511 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Dart
		
	
	
	
| import 'package:flutter/material.dart';
 | |
| 
 | |
| import 'package:extera_next/generated/l10n/l10n.dart';
 | |
| import 'package:flutter_secure_storage/flutter_secure_storage.dart';
 | |
| import 'package:matrix/encryption.dart';
 | |
| import 'package:matrix/matrix.dart';
 | |
| 
 | |
| import 'package:extera_next/config/themes.dart';
 | |
| import 'package:extera_next/utils/error_reporter.dart';
 | |
| import 'package:extera_next/utils/fluffy_share.dart';
 | |
| import 'package:extera_next/utils/localized_exception_extension.dart';
 | |
| import 'package:extera_next/utils/platform_infos.dart';
 | |
| import 'package:extera_next/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
 | |
| import 'package:extera_next/widgets/future_loading_dialog.dart';
 | |
| import '../../utils/adaptive_bottom_sheet.dart';
 | |
| import '../key_verification/key_verification_dialog.dart';
 | |
| 
 | |
| class BootstrapDialog extends StatefulWidget {
 | |
|   final bool wipe;
 | |
|   final Client client;
 | |
| 
 | |
|   const BootstrapDialog({
 | |
|     super.key,
 | |
|     this.wipe = false,
 | |
|     required this.client,
 | |
|   });
 | |
| 
 | |
|   Future<bool?> show(BuildContext context) => showAdaptiveBottomSheet(
 | |
|         context: context,
 | |
|         builder: (context) => this,
 | |
|       );
 | |
| 
 | |
|   @override
 | |
|   BootstrapDialogState createState() => BootstrapDialogState();
 | |
| }
 | |
| 
 | |
| class BootstrapDialogState extends State<BootstrapDialog> {
 | |
|   final TextEditingController _recoveryKeyTextEditingController =
 | |
|       TextEditingController();
 | |
| 
 | |
|   late Bootstrap bootstrap;
 | |
| 
 | |
|   String? _recoveryKeyInputError;
 | |
| 
 | |
|   bool _recoveryKeyInputLoading = false;
 | |
| 
 | |
|   String? titleText;
 | |
| 
 | |
|   bool _recoveryKeyStored = false;
 | |
|   bool _recoveryKeyCopied = false;
 | |
| 
 | |
|   bool? _storeInSecureStorage = false;
 | |
| 
 | |
|   bool? _wipe;
 | |
| 
 | |
|   String get _secureStorageKey =>
 | |
|       'ssss_recovery_key_${bootstrap.client.userID}';
 | |
| 
 | |
|   bool get _supportsSecureStorage =>
 | |
|       PlatformInfos.isMobile || PlatformInfos.isDesktop;
 | |
| 
 | |
|   String _getSecureStorageLocalizedName() {
 | |
|     if (PlatformInfos.isAndroid) {
 | |
|       return L10n.of(context).storeInAndroidKeystore;
 | |
|     }
 | |
|     if (PlatformInfos.isIOS || PlatformInfos.isMacOS) {
 | |
|       return L10n.of(context).storeInAppleKeyChain;
 | |
|     }
 | |
|     return L10n.of(context).storeSecurlyOnThisDevice;
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   void initState() {
 | |
|     _createBootstrap(widget.wipe);
 | |
|     super.initState();
 | |
|   }
 | |
| 
 | |
|   void _createBootstrap(bool wipe) async {
 | |
|     _wipe = wipe;
 | |
|     titleText = null;
 | |
|     _recoveryKeyStored = false;
 | |
|     bootstrap =
 | |
|         widget.client.encryption!.bootstrap(onUpdate: (_) => setState(() {}));
 | |
|     final key = await const FlutterSecureStorage().read(key: _secureStorageKey);
 | |
|     if (key == null) return;
 | |
|     _recoveryKeyTextEditingController.text = key;
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     final theme = Theme.of(context);
 | |
|     _wipe ??= widget.wipe;
 | |
|     final buttons = <Widget>[];
 | |
|     Widget body = const CircularProgressIndicator.adaptive();
 | |
|     titleText = L10n.of(context).loadingPleaseWait;
 | |
| 
 | |
|     if (bootstrap.newSsssKey?.recoveryKey != null &&
 | |
|         _recoveryKeyStored == false) {
 | |
|       final key = bootstrap.newSsssKey!.recoveryKey;
 | |
|       titleText = L10n.of(context).recoveryKey;
 | |
|       return Scaffold(
 | |
|         appBar: AppBar(
 | |
|           centerTitle: true,
 | |
|           leading: IconButton(
 | |
|             icon: const Icon(Icons.close),
 | |
|             onPressed: Navigator.of(context).pop,
 | |
|           ),
 | |
|           title: Text(L10n.of(context).recoveryKey),
 | |
|         ),
 | |
|         body: Center(
 | |
|           child: ConstrainedBox(
 | |
|             constraints:
 | |
|                 const BoxConstraints(maxWidth: FluffyThemes.columnWidth * 1.5),
 | |
|             child: ListView(
 | |
|               padding: const EdgeInsets.all(16.0),
 | |
|               children: [
 | |
|                 ListTile(
 | |
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 8.0),
 | |
|                   trailing: CircleAvatar(
 | |
|                     backgroundColor: Colors.transparent,
 | |
|                     child: Icon(
 | |
|                       Icons.info_outlined,
 | |
|                       color: theme.colorScheme.primary,
 | |
|                     ),
 | |
|                   ),
 | |
|                   subtitle: Text(L10n.of(context).chatBackupDescription),
 | |
|                 ),
 | |
|                 const Divider(
 | |
|                   height: 32,
 | |
|                   thickness: 1,
 | |
|                 ),
 | |
|                 TextField(
 | |
|                   minLines: 2,
 | |
|                   maxLines: 4,
 | |
|                   readOnly: true,
 | |
|                   style: const TextStyle(fontFamily: 'RobotoMono'),
 | |
|                   controller: TextEditingController(text: key),
 | |
|                   decoration: const InputDecoration(
 | |
|                     contentPadding: EdgeInsets.all(16),
 | |
|                     suffixIcon: Icon(Icons.key_outlined),
 | |
|                   ),
 | |
|                 ),
 | |
|                 const SizedBox(height: 16),
 | |
|                 if (_supportsSecureStorage)
 | |
|                   CheckboxListTile.adaptive(
 | |
|                     contentPadding: const EdgeInsets.symmetric(horizontal: 8.0),
 | |
|                     value: _storeInSecureStorage,
 | |
|                     activeColor: theme.colorScheme.primary,
 | |
|                     onChanged: (b) {
 | |
|                       setState(() {
 | |
|                         _storeInSecureStorage = b;
 | |
|                       });
 | |
|                     },
 | |
|                     title: Text(_getSecureStorageLocalizedName()),
 | |
|                     subtitle:
 | |
|                         Text(L10n.of(context).storeInSecureStorageDescription),
 | |
|                   ),
 | |
|                 const SizedBox(height: 16),
 | |
|                 CheckboxListTile.adaptive(
 | |
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 8.0),
 | |
|                   value: _recoveryKeyCopied,
 | |
|                   activeColor: theme.colorScheme.primary,
 | |
|                   onChanged: (b) {
 | |
|                     FluffyShare.share(key!, context);
 | |
|                     setState(() => _recoveryKeyCopied = true);
 | |
|                   },
 | |
|                   title: Text(L10n.of(context).copyToClipboard),
 | |
|                   subtitle: Text(L10n.of(context).saveKeyManuallyDescription),
 | |
|                 ),
 | |
|                 const SizedBox(height: 16),
 | |
|                 ElevatedButton.icon(
 | |
|                   icon: const Icon(Icons.check_outlined),
 | |
|                   label: Text(L10n.of(context).next),
 | |
|                   onPressed:
 | |
|                       (_recoveryKeyCopied || _storeInSecureStorage == true)
 | |
|                           ? () {
 | |
|                               if (_storeInSecureStorage == true) {
 | |
|                                 const FlutterSecureStorage().write(
 | |
|                                   key: _secureStorageKey,
 | |
|                                   value: key,
 | |
|                                 );
 | |
|                               }
 | |
|                               setState(() => _recoveryKeyStored = true);
 | |
|                             }
 | |
|                           : null,
 | |
|                 ),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|         ),
 | |
|       );
 | |
|     } else {
 | |
|       switch (bootstrap.state) {
 | |
|         case BootstrapState.loading:
 | |
|           break;
 | |
|         case BootstrapState.askWipeSsss:
 | |
|           WidgetsBinding.instance.addPostFrameCallback(
 | |
|             (_) => bootstrap.wipeSsss(_wipe!),
 | |
|           );
 | |
|           break;
 | |
|         case BootstrapState.askBadSsss:
 | |
|           WidgetsBinding.instance.addPostFrameCallback(
 | |
|             (_) => bootstrap.ignoreBadSecrets(true),
 | |
|           );
 | |
|           break;
 | |
|         case BootstrapState.askUseExistingSsss:
 | |
|           WidgetsBinding.instance.addPostFrameCallback(
 | |
|             (_) => bootstrap.useExistingSsss(!_wipe!),
 | |
|           );
 | |
|           break;
 | |
|         case BootstrapState.askUnlockSsss:
 | |
|           WidgetsBinding.instance.addPostFrameCallback(
 | |
|             (_) => bootstrap.unlockedSsss(),
 | |
|           );
 | |
|           break;
 | |
|         case BootstrapState.askNewSsss:
 | |
|           WidgetsBinding.instance.addPostFrameCallback(
 | |
|             (_) => bootstrap.newSsss(),
 | |
|           );
 | |
|           break;
 | |
|         case BootstrapState.openExistingSsss:
 | |
|           _recoveryKeyStored = true;
 | |
|           return Scaffold(
 | |
|             appBar: AppBar(
 | |
|               centerTitle: true,
 | |
|               leading: IconButton(
 | |
|                 icon: const Icon(Icons.close),
 | |
|                 onPressed: Navigator.of(context).pop,
 | |
|               ),
 | |
|               title: Text(L10n.of(context).chatBackup),
 | |
|             ),
 | |
|             body: Center(
 | |
|               child: ConstrainedBox(
 | |
|                 constraints: const BoxConstraints(
 | |
|                   maxWidth: FluffyThemes.columnWidth * 1.5,
 | |
|                 ),
 | |
|                 child: ListView(
 | |
|                   padding: const EdgeInsets.all(16.0),
 | |
|                   children: [
 | |
|                     ListTile(
 | |
|                       contentPadding:
 | |
|                           const EdgeInsets.symmetric(horizontal: 8.0),
 | |
|                       trailing: Icon(
 | |
|                         Icons.info_outlined,
 | |
|                         color: theme.colorScheme.primary,
 | |
|                       ),
 | |
|                       subtitle: Text(
 | |
|                         L10n.of(context).pleaseEnterRecoveryKeyDescription,
 | |
|                       ),
 | |
|                     ),
 | |
|                     const Divider(height: 32),
 | |
|                     TextField(
 | |
|                       minLines: 1,
 | |
|                       maxLines: 2,
 | |
|                       autocorrect: false,
 | |
|                       readOnly: _recoveryKeyInputLoading,
 | |
|                       autofillHints: _recoveryKeyInputLoading
 | |
|                           ? null
 | |
|                           : [AutofillHints.password],
 | |
|                       controller: _recoveryKeyTextEditingController,
 | |
|                       style: const TextStyle(fontFamily: 'RobotoMono'),
 | |
|                       decoration: InputDecoration(
 | |
|                         contentPadding: const EdgeInsets.all(16),
 | |
|                         hintStyle: TextStyle(
 | |
|                           fontFamily: theme.textTheme.bodyLarge?.fontFamily,
 | |
|                         ),
 | |
|                         prefixIcon: const Icon(Icons.key_outlined),
 | |
|                         labelText: L10n.of(context).recoveryKey,
 | |
|                         hintText: 'Es** **** **** ****',
 | |
|                         errorText: _recoveryKeyInputError,
 | |
|                         errorMaxLines: 2,
 | |
|                       ),
 | |
|                     ),
 | |
|                     const SizedBox(height: 16),
 | |
|                     ElevatedButton.icon(
 | |
|                       style: ElevatedButton.styleFrom(
 | |
|                         foregroundColor: theme.colorScheme.onPrimary,
 | |
|                         iconColor: theme.colorScheme.onPrimary,
 | |
|                         backgroundColor: theme.colorScheme.primary,
 | |
|                       ),
 | |
|                       icon: _recoveryKeyInputLoading
 | |
|                           ? const CircularProgressIndicator.adaptive()
 | |
|                           : const Icon(Icons.lock_open_outlined),
 | |
|                       label: Text(L10n.of(context).unlockOldMessages),
 | |
|                       onPressed: _recoveryKeyInputLoading
 | |
|                           ? null
 | |
|                           : () async {
 | |
|                               setState(() {
 | |
|                                 _recoveryKeyInputError = null;
 | |
|                                 _recoveryKeyInputLoading = true;
 | |
|                               });
 | |
|                               try {
 | |
|                                 final key = _recoveryKeyTextEditingController
 | |
|                                     .text
 | |
|                                     .trim();
 | |
|                                 if (key.isEmpty) return;
 | |
|                                 await bootstrap.newSsssKey!.unlock(
 | |
|                                   keyOrPassphrase: key,
 | |
|                                 );
 | |
|                                 await bootstrap.openExistingSsss();
 | |
|                                 Logs().d('SSSS unlocked');
 | |
|                                 if (bootstrap.encryption.crossSigning.enabled) {
 | |
|                                   Logs().v(
 | |
|                                     'Cross signing is already enabled. Try to self-sign',
 | |
|                                   );
 | |
|                                   try {
 | |
|                                     await bootstrap
 | |
|                                         .client.encryption!.crossSigning
 | |
|                                         .selfSign(recoveryKey: key);
 | |
|                                     Logs().d('Successful selfsigned');
 | |
|                                   } catch (e, s) {
 | |
|                                     Logs().e(
 | |
|                                       'Unable to self sign with recovery key after successfully open existing SSSS',
 | |
|                                       e,
 | |
|                                       s,
 | |
|                                     );
 | |
|                                   }
 | |
|                                 }
 | |
|                               } on InvalidPassphraseException catch (e) {
 | |
|                                 setState(
 | |
|                                   () => _recoveryKeyInputError =
 | |
|                                       e.toLocalizedString(context),
 | |
|                                 );
 | |
|                               } on FormatException catch (_) {
 | |
|                                 setState(
 | |
|                                   () => _recoveryKeyInputError =
 | |
|                                       L10n.of(context).wrongRecoveryKey,
 | |
|                                 );
 | |
|                               } catch (e, s) {
 | |
|                                 ErrorReporter(
 | |
|                                   context,
 | |
|                                   'Unable to open SSSS with recovery key',
 | |
|                                 ).onErrorCallback(e, s);
 | |
|                                 setState(
 | |
|                                   () => _recoveryKeyInputError =
 | |
|                                       e.toLocalizedString(context),
 | |
|                                 );
 | |
|                               } finally {
 | |
|                                 setState(
 | |
|                                   () => _recoveryKeyInputLoading = false,
 | |
|                                 );
 | |
|                               }
 | |
|                             },
 | |
|                     ),
 | |
|                     const SizedBox(height: 16),
 | |
|                     Row(
 | |
|                       children: [
 | |
|                         const Expanded(child: Divider()),
 | |
|                         Padding(
 | |
|                           padding: const EdgeInsets.all(12.0),
 | |
|                           child: Text(L10n.of(context).or),
 | |
|                         ),
 | |
|                         const Expanded(child: Divider()),
 | |
|                       ],
 | |
|                     ),
 | |
|                     const SizedBox(height: 16),
 | |
|                     ElevatedButton.icon(
 | |
|                       icon: const Icon(Icons.cast_connected_outlined),
 | |
|                       label: Text(L10n.of(context).transferFromAnotherDevice),
 | |
|                       onPressed: _recoveryKeyInputLoading
 | |
|                           ? null
 | |
|                           : () async {
 | |
|                               final consent = await showOkCancelAlertDialog(
 | |
|                                 context: context,
 | |
|                                 title: L10n.of(context).verifyOtherDevice,
 | |
|                                 message: L10n.of(context)
 | |
|                                     .verifyOtherDeviceDescription,
 | |
|                                 okLabel: L10n.of(context).ok,
 | |
|                                 cancelLabel: L10n.of(context).cancel,
 | |
|                               );
 | |
|                               if (consent != OkCancelResult.ok) return;
 | |
|                               final req = await showFutureLoadingDialog(
 | |
|                                 context: context,
 | |
|                                 delay: false,
 | |
|                                 future: () async {
 | |
|                                   await widget.client.updateUserDeviceKeys();
 | |
|                                   return widget.client
 | |
|                                       .userDeviceKeys[widget.client.userID!]!
 | |
|                                       .startVerification();
 | |
|                                 },
 | |
|                               );
 | |
|                               if (req.error != null) return;
 | |
|                               await KeyVerificationDialog(request: req.result!)
 | |
|                                   .show(context);
 | |
|                               Navigator.of(context, rootNavigator: false).pop();
 | |
|                             },
 | |
|                     ),
 | |
|                     const SizedBox(height: 16),
 | |
|                     ElevatedButton.icon(
 | |
|                       style: ElevatedButton.styleFrom(
 | |
|                         backgroundColor: theme.colorScheme.errorContainer,
 | |
|                         foregroundColor: theme.colorScheme.onErrorContainer,
 | |
|                         iconColor: theme.colorScheme.onErrorContainer,
 | |
|                       ),
 | |
|                       icon: const Icon(Icons.delete_outlined),
 | |
|                       label: Text(L10n.of(context).recoveryKeyLost),
 | |
|                       onPressed: _recoveryKeyInputLoading
 | |
|                           ? null
 | |
|                           : () async {
 | |
|                               if (OkCancelResult.ok ==
 | |
|                                   await showOkCancelAlertDialog(
 | |
|                                     useRootNavigator: false,
 | |
|                                     context: context,
 | |
|                                     title: L10n.of(context).recoveryKeyLost,
 | |
|                                     message: L10n.of(context).wipeChatBackup,
 | |
|                                     okLabel: L10n.of(context).ok,
 | |
|                                     cancelLabel: L10n.of(context).cancel,
 | |
|                                     isDestructive: true,
 | |
|                                   )) {
 | |
|                                 setState(() => _createBootstrap(true));
 | |
|                               }
 | |
|                             },
 | |
|                     ),
 | |
|                   ],
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
|           );
 | |
|         case BootstrapState.askWipeCrossSigning:
 | |
|           WidgetsBinding.instance.addPostFrameCallback(
 | |
|             (_) => bootstrap.wipeCrossSigning(_wipe!),
 | |
|           );
 | |
|           break;
 | |
|         case BootstrapState.askSetupCrossSigning:
 | |
|           WidgetsBinding.instance.addPostFrameCallback(
 | |
|             (_) => bootstrap.askSetupCrossSigning(
 | |
|               setupMasterKey: true,
 | |
|               setupSelfSigningKey: true,
 | |
|               setupUserSigningKey: true,
 | |
|             ),
 | |
|           );
 | |
|           break;
 | |
|         case BootstrapState.askWipeOnlineKeyBackup:
 | |
|           WidgetsBinding.instance.addPostFrameCallback(
 | |
|             (_) => bootstrap.wipeOnlineKeyBackup(_wipe!),
 | |
|           );
 | |
| 
 | |
|           break;
 | |
|         case BootstrapState.askSetupOnlineKeyBackup:
 | |
|           WidgetsBinding.instance.addPostFrameCallback(
 | |
|             (_) => bootstrap.askSetupOnlineKeyBackup(true),
 | |
|           );
 | |
|           break;
 | |
|         case BootstrapState.error:
 | |
|           titleText = L10n.of(context).oopsSomethingWentWrong;
 | |
|           body = const Icon(Icons.error_outline, color: Colors.red, size: 80);
 | |
|           buttons.add(
 | |
|             ElevatedButton(
 | |
|               onPressed: () =>
 | |
|                   Navigator.of(context, rootNavigator: false).pop<bool>(false),
 | |
|               child: Text(L10n.of(context).close),
 | |
|             ),
 | |
|           );
 | |
|           break;
 | |
|         case BootstrapState.done:
 | |
|           titleText = L10n.of(context).everythingReady;
 | |
|           body = Column(
 | |
|             mainAxisSize: MainAxisSize.min,
 | |
|             children: [
 | |
|               const Icon(
 | |
|                 Icons.check_circle_rounded,
 | |
|                 size: 120,
 | |
|                 color: Colors.green,
 | |
|               ),
 | |
|               const SizedBox(height: 16),
 | |
|               Text(
 | |
|                 L10n.of(context).yourChatBackupHasBeenSetUp,
 | |
|                 style: const TextStyle(fontSize: 20),
 | |
|               ),
 | |
|               const SizedBox(height: 16),
 | |
|             ],
 | |
|           );
 | |
|           buttons.add(
 | |
|             ElevatedButton(
 | |
|               onPressed: () =>
 | |
|                   Navigator.of(context, rootNavigator: false).pop<bool>(false),
 | |
|               child: Text(L10n.of(context).close),
 | |
|             ),
 | |
|           );
 | |
|           break;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     return Scaffold(
 | |
|       appBar: AppBar(
 | |
|         leading: Center(
 | |
|           child: CloseButton(
 | |
|             onPressed: () =>
 | |
|                 Navigator.of(context, rootNavigator: false).pop<bool>(true),
 | |
|           ),
 | |
|         ),
 | |
|         title: Text(titleText ?? L10n.of(context).loadingPleaseWait),
 | |
|       ),
 | |
|       body: Center(
 | |
|         child: Padding(
 | |
|           padding: const EdgeInsets.all(20.0),
 | |
|           child: Column(
 | |
|             mainAxisSize: MainAxisSize.min,
 | |
|             crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|             children: [
 | |
|               body,
 | |
|               const SizedBox(height: 8),
 | |
|               ...buttons,
 | |
|             ],
 | |
|           ),
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |