diff --git a/lib/config/routes.dart b/lib/config/routes.dart index a6d2d70ce..9b4b90445 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -7,7 +7,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/archive/archive.dart'; -import 'package:fluffychat/pages/bootstrap/bootstrap_dialog.dart'; +import 'package:fluffychat/pages/bootstrap/bootstrap_page.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat_access_settings/chat_access_settings_controller.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; @@ -107,11 +107,8 @@ abstract class AppRoutes { GoRoute( path: '/backup', redirect: loggedOutRedirect, - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - BootstrapDialog(wipe: state.uri.queryParameters['wipe'] == 'true'), - ), + pageBuilder: (context, state) => + defaultPageBuilder(context, state, BootstrapPage()), ), ShellRoute( // Never use a transition on the shell route. Changing the PageBuilder diff --git a/lib/config/themes.dart b/lib/config/themes.dart index 122eef918..42d83dc9e 100644 --- a/lib/config/themes.dart +++ b/lib/config/themes.dart @@ -74,7 +74,7 @@ abstract class FluffyThemes { ), inputDecorationTheme: InputDecorationTheme( border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppConfig.borderRadius), + borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), ), contentPadding: const EdgeInsets.all(12), ), diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 18bf66dd5..9fd1d34f5 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -2776,5 +2776,45 @@ "removeAdminRights": "Remove admin rights", "powerLevel": "Power level", "setPowerLevelDescription": "Power levels define what a member is allowed to do in this room and usually range between 0 and 100.", - "owner": "Owner" + "owner": "Owner", + "restoreBootstrapEmptyDevicesDescription": "Please enter your passphrase or recovery key to verify this device and get access to your encrypted message backup.", + "@restoreBootstrapEmptyDevicesDescription": { + "type": "String", + "placeholders": {} + }, + "restoreBootstrapDevicesDescription": "Verify this device with one of your active sessions to get access to your encrypted message backup:", + "@restoreBootstrapDevicesDescription": { + "type": "String", + "placeholders": {} + }, + "restoreBootstrapAlternativeDescription": "Alternatively, you can use your passphrase or recovery key to verify your device and unlock your encrypted message backup:", + "@restoreBootstrapAlternativeDescription": { + "type": "String", + "placeholders": {} + }, + "restoreBootstrapHintText": "Passphrase or recovery key", + "@restoreBootstrapHintText": { + "type": "String", + "placeholders": {} + }, + "resetAccount": "Reset account", + "@resetAccount": { + "type": "String", + "placeholders": {} + }, + "restoreCryptoIdentity": "Restore Crypto Identity", + "@restoreCryptoIdentity": { + "type": "String", + "placeholders": {} + }, + "resetCryptoIdentity": "Reset Crypto Identity", + "@resetCryptoIdentity": { + "type": "String", + "placeholders": {} + }, + "setUpCryptoIdentity": "Set Up Crypto Identity", + "@setUpCryptoIdentity": { + "type": "String", + "placeholders": {} + } } \ No newline at end of file diff --git a/lib/pages/bootstrap/bootstrap_dialog.dart b/lib/pages/bootstrap/bootstrap_dialog.dart deleted file mode 100644 index 88d30813f..000000000 --- a/lib/pages/bootstrap/bootstrap_dialog.dart +++ /dev/null @@ -1,587 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:go_router/go_router.dart'; -import 'package:matrix/encryption.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/utils/error_reporter.dart'; -import 'package:fluffychat/utils/fluffy_share.dart'; -import 'package:fluffychat/utils/localized_exception_extension.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/utils/sync_status_localization.dart'; -import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; -import 'package:fluffychat/widgets/future_loading_dialog.dart'; -import 'package:fluffychat/widgets/layouts/login_scaffold.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import '../key_verification/key_verification_dialog.dart'; - -class BootstrapDialog extends StatefulWidget { - final bool wipe; - - const BootstrapDialog({super.key, this.wipe = false}); - - @override - BootstrapDialogState createState() => BootstrapDialogState(); -} - -class BootstrapDialogState extends State { - final TextEditingController _recoveryKeyTextEditingController = - TextEditingController(); - - 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; - } - - late final Client client; - - @override - void initState() { - super.initState(); - client = Matrix.of(context).client; - _createBootstrap(widget.wipe); - } - - Future _cancelAction() async { - final consent = await showOkCancelAlertDialog( - context: context, - title: L10n.of(context).skipChatBackup, - message: L10n.of(context).skipChatBackupWarning, - okLabel: L10n.of(context).skip, - isDestructive: true, - ); - if (consent != OkCancelResult.ok) return; - if (!mounted) return; - _goBackAction(false); - } - - void _goBackAction(bool success) { - if (success) _decryptLastEvents(); - - context.canPop() ? context.pop(success) : context.go('/rooms'); - } - - void _decryptLastEvents() { - for (final room in client.rooms) { - final event = room.lastEvent; - if (event != null && - event.type == EventTypes.Encrypted && - event.messageType == MessageTypes.BadEncrypted && - event.content['can_request_session'] == true) { - final sessionId = event.content.tryGet('session_id'); - final senderKey = event.content.tryGet('sender_key'); - if (sessionId != null && senderKey != null) { - room.client.encryption?.keyManager.maybeAutoRequest( - room.id, - sessionId, - senderKey, - ); - } - } - } - } - - Future _createBootstrap(bool wipe) async { - await client.roomsLoading; - await client.accountDataLoading; - await client.userDeviceKeysLoading; - while (client.prevBatch == null) { - await client.onSyncStatus.stream.first; - } - await client.updateUserDeviceKeys(); - _wipe = wipe; - titleText = null; - _recoveryKeyStored = false; - bootstrap = 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); - final bootstrap = this.bootstrap; - if (bootstrap == null) { - return LoginScaffold( - appBar: AppBar( - centerTitle: true, - leading: CloseButton(onPressed: _cancelAction), - title: Text(L10n.of(context).loadingMessages), - ), - body: Center( - child: StreamBuilder( - stream: client.onSyncStatus.stream, - builder: (context, snapshot) { - final status = snapshot.data; - return Column( - mainAxisAlignment: .center, - children: [ - CircularProgressIndicator.adaptive(value: status?.progress), - if (status != null) Text(status.calcLocalizedString(context)), - ], - ); - }, - ), - ), - ); - } - - _wipe ??= widget.wipe; - final buttons = []; - Widget body = const Center(child: 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 LoginScaffold( - appBar: AppBar( - centerTitle: true, - leading: CloseButton(onPressed: _cancelAction), - 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) - Semantics( - identifier: 'store_in_secure_storage', - child: 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), - Semantics( - identifier: 'copy_to_clipboard', - child: 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 LoginScaffold( - appBar: AppBar( - centerTitle: true, - leading: CloseButton(onPressed: _cancelAction), - title: Text(L10n.of(context).setupChatBackup), - ), - 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', - ); - await bootstrap - .client - .encryption! - .crossSigning - .selfSign(recoveryKey: key); - Logs().d('Successful selfsigned'); - } - } 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 client.updateUserDeviceKeys(); - return client.userDeviceKeys[client.userID!]! - .startVerification(); - }, - ); - if (req.error != null) return; - final success = await KeyVerificationDialog( - request: req.result!, - ).show(context); - if (success != true) return; - if (!mounted) return; - - final result = await showFutureLoadingDialog( - context: context, - future: () async { - final allCached = - await client.encryption!.keyManager - .isCached() && - await client.encryption!.crossSigning - .isCached(); - if (!allCached) { - await client - .encryption! - .ssss - .onSecretStored - .stream - .first; - } - return; - }, - ); - if (!mounted) return; - if (!result.isError) _goBackAction(true); - }, - ), - 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: () => _goBackAction(false), - child: Text(L10n.of(context).close), - ), - ); - break; - case BootstrapState.done: - titleText = L10n.of(context).everythingReady; - body = Column( - 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: () => _goBackAction(true), - child: Text(L10n.of(context).close), - ), - ); - break; - } - } - - return LoginScaffold( - appBar: AppBar( - leading: CloseButton(onPressed: _cancelAction), - title: Text(titleText ?? L10n.of(context).loadingPleaseWait), - ), - body: Center( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .stretch, - children: [body, const SizedBox(height: 8), ...buttons], - ), - ), - ), - ); - } -} diff --git a/lib/pages/bootstrap/bootstrap_page.dart b/lib/pages/bootstrap/bootstrap_page.dart new file mode 100644 index 000000000..5109243ae --- /dev/null +++ b/lib/pages/bootstrap/bootstrap_page.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/bootstrap/view_model/bootstrap_view_model.dart'; +import 'package:fluffychat/pages/bootstrap/widgets/new_passphrase_view.dart'; +import 'package:fluffychat/pages/bootstrap/widgets/restore_bootstrap_view.dart'; +import 'package:fluffychat/pages/bootstrap/widgets/store_recovery_key_view.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/device_extension.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; +import 'package:fluffychat/widgets/layouts/login_scaffold.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/view_model_builder.dart'; + +class BootstrapPage extends StatelessWidget { + const BootstrapPage({super.key}); + + @override + Widget build(BuildContext context) { + return ViewModelBuilder( + create: () => BootstrapViewModel(client: Matrix.of(context).client), + builder: (context, viewModel, _) { + final cryptoIdentityState = viewModel.value.cryptoIdentityState; + + final title = cryptoIdentityState == null + ? L10n.of(context).loadingPleaseWait + : cryptoIdentityState.initialized && !viewModel.value.reset + ? L10n.of(context).restoreCryptoIdentity + : viewModel.value.reset + ? L10n.of(context).resetCryptoIdentity + : L10n.of(context).setUpCryptoIdentity; + + return LoginScaffold( + appBar: AppBar( + leading: CloseButton( + onPressed: () async { + final consent = await showOkCancelAlertDialog( + context: context, + title: L10n.of(context).skipChatBackup, + message: L10n.of(context).skipChatBackupWarning, + okLabel: L10n.of(context).skip, + isDestructive: true, + ); + if (consent != OkCancelResult.ok) return; + if (!context.mounted) return; + context.go('/rooms'); + }, + ), + title: Text(title), + ), + body: cryptoIdentityState == null + ? Center(child: CircularProgressIndicator.adaptive()) + : !cryptoIdentityState.initialized || viewModel.value.reset + ? viewModel.value.recoveryKey == null + ? NewPassphraseView() + : StoreRecoveryKeyView() + : RestoreBootstrapView( + onToggleObscureText: viewModel.toggleObscureText, + unlockWithRecoveryKey: () async { + final success = await viewModel.unlock(); + if (!success) return; + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Device has been verified and message backup is unlocked!', + ), + ), + ); + context.go('/rooms'); + }, + recoveryKeyInputController: + viewModel.enterPassphraseOrRecovController, + obscureText: viewModel.value.obscureText, + isLoading: viewModel.value.isLoading, + errorText: viewModel.value.unlockWithError?.toLocalizedString( + context, + ), + onResetAccount: () async { + final consent = await showOkCancelAlertDialog( + context: context, + title: L10n.of(context).warning, + message: + 'When you reset your account you will lose the access to your old messages forever. All your current devices need to be verified again. Please only perform this action when you have no other devices left to verify your session and you have lost your recovery key and passphrase!', + isDestructive: true, + okLabel: L10n.of(context).resetAccount, + ); + if (consent != OkCancelResult.ok) return; + if (!context.mounted) return; + viewModel.startResetAccount(); + }, + devices: + viewModel.value.connectedDevices + ?.map( + (device) => ( + title: device.displayname, + lastActive: device.lastActive, + icon: device.icon, + ), + ) + .toList() ?? + [], + ), + ); + }, + ); + } +} diff --git a/lib/pages/bootstrap/view_model/bootstrap_state.dart b/lib/pages/bootstrap/view_model/bootstrap_state.dart new file mode 100644 index 000000000..fb02bf4f0 --- /dev/null +++ b/lib/pages/bootstrap/view_model/bootstrap_state.dart @@ -0,0 +1,14 @@ +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; + +class BootstrapViewModelState { + String? recoveryKey; + bool isLoading = false; + Object? unlockWithError; + ({bool connected, bool initialized})? cryptoIdentityState; + bool reset = false; + KeyVerification? keyVerification; + List? connectedDevices; + bool recoveryKeyStored = false; + bool obscureText = true; +} diff --git a/lib/pages/bootstrap/view_model/bootstrap_view_model.dart b/lib/pages/bootstrap/view_model/bootstrap_view_model.dart new file mode 100644 index 000000000..2de31ac5e --- /dev/null +++ b/lib/pages/bootstrap/view_model/bootstrap_view_model.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; + +import 'bootstrap_state.dart'; + +class BootstrapViewModel extends ValueNotifier { + final Client client; + + final TextEditingController enterPassphraseOrRecovController = + TextEditingController(); + final TextEditingController newPassphraseController = TextEditingController(); + final TextEditingController repeatPassphraseController = + TextEditingController(); + + BootstrapViewModel({required this.client}) + : super(BootstrapViewModelState()) { + _init(); + } + + Future _init() async { + final state = value.cryptoIdentityState = await client + .getCryptoIdentityState(); + if (state.initialized) { + if (state.connected) return notifyListeners(); + + await client.updateUserDeviceKeys(); + + final devices = value.connectedDevices = + client.userDeviceKeys[client.userID!]?.deviceKeys.values + .where( + (device) => device.hasValidSignatureChain( + verifiedByTheirMasterKey: true, + ), + ) + .toList() ?? + []; + if (devices.isNotEmpty) { + value.keyVerification = await client.userDeviceKeys[client.userID!]! + .startVerification(); + } + } + notifyListeners(); + } + + Future setOrSkipPassphrase(String? passphrase) async { + value.isLoading = true; + notifyListeners(); + + value.recoveryKey = await client.initCryptoIdentity(passphrase: passphrase); + notifyListeners(); + } + + Future unlock() async { + final key = enterPassphraseOrRecovController.text.trim(); + if (key.isEmpty) return false; + + value.unlockWithError = null; + value.isLoading = true; + notifyListeners(); + try { + await client.restoreCryptoIdentity(key); + value.isLoading = false; + value.cryptoIdentityState = await client.getCryptoIdentityState(); + notifyListeners(); + return true; + } catch (e, s) { + Logs().d('Unable to unlock', e, s); + value.isLoading = false; + value.unlockWithError = e; + notifyListeners(); + return false; + } + } + + void setRecoveryKeyStored() { + value.recoveryKeyStored = true; + notifyListeners(); + } + + void toggleObscureText() { + value.obscureText = !value.obscureText; + notifyListeners(); + } + + void startResetAccount() { + value.reset = true; + notifyListeners(); + } +} diff --git a/lib/pages/bootstrap/widgets/new_passphrase_view.dart b/lib/pages/bootstrap/widgets/new_passphrase_view.dart new file mode 100644 index 000000000..3f55f46f8 --- /dev/null +++ b/lib/pages/bootstrap/widgets/new_passphrase_view.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; + +class NewPassphraseView extends StatelessWidget { + const NewPassphraseView({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.all(16.0), + child: ListView( + children: [ + Text( + 'FluffyChat uses end to end encryption. To not lose your messages, please choose a strong passphrase to secure your crypto identity and your encrypted message backup.', + textAlign: .center, + ), + const SizedBox(height: 16), + TextField( + decoration: InputDecoration( + suffixIcon: IconButton( + icon: Icon(Icons.visibility_off_outlined), + onPressed: () {}, + ), + hintText: 'New passphrase', + ), + ), + const SizedBox(height: 16), + TextField(decoration: InputDecoration(hintText: 'Repeat passphrase')), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () {}, + child: Text(L10n.of(context).continueText), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + child: Text(L10n.of(context).skip), + ), + const SizedBox(height: 16), + _PassphraseCheckListTile( + checked: true, + label: 'At least 12 characters long.', + ), + const SizedBox(height: 16), + _PassphraseCheckListTile( + checked: false, + label: 'Contains uppercase and lowercase characters.', + ), + const SizedBox(height: 16), + _PassphraseCheckListTile( + checked: false, + label: 'Contains special characters.', + ), + const SizedBox(height: 16), + _PassphraseCheckListTile( + checked: true, + label: 'Contains one numbers.', + ), + ], + ), + ); + } +} + +class _PassphraseCheckListTile extends StatelessWidget { + final String label; + final bool checked; + const _PassphraseCheckListTile({required this.label, required this.checked}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + spacing: 8.0, + children: [ + Icon( + checked ? Icons.check_circle_outlined : Icons.circle_outlined, + color: checked + ? theme.brightness == Brightness.light + ? Colors.green.shade800 + : Colors.green.shade300 + : theme.colorScheme.error, + size: 20, + ), + Text(label, style: TextStyle(fontSize: 12)), + ], + ); + } +} diff --git a/lib/pages/bootstrap/widgets/restore_bootstrap_view.dart b/lib/pages/bootstrap/widgets/restore_bootstrap_view.dart new file mode 100644 index 000000000..cd3bb35f0 --- /dev/null +++ b/lib/pages/bootstrap/widgets/restore_bootstrap_view.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/utils/date_time_extension.dart'; + +class RestoreBootstrapView extends StatelessWidget { + final List<({String title, DateTime lastActive, IconData icon})> devices; + final TextEditingController recoveryKeyInputController; + final bool obscureText, isLoading; + final VoidCallback onToggleObscureText, unlockWithRecoveryKey, onResetAccount; + final String? errorText; + + const RestoreBootstrapView({ + super.key, + required this.devices, + required this.recoveryKeyInputController, + required this.obscureText, + required this.onToggleObscureText, + required this.unlockWithRecoveryKey, + required this.isLoading, + this.errorText, + required this.onResetAccount, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + spacing: 16.0, + crossAxisAlignment: .stretch, + children: [ + Text( + devices.isEmpty + ? L10n.of(context).restoreBootstrapEmptyDevicesDescription + : L10n.of(context).restoreBootstrapDevicesDescription, + textAlign: .center, + ), + Material( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: 128), + child: ListView.builder( + shrinkWrap: true, + itemCount: devices.length, + itemBuilder: (context, i) => ListTile( + leading: CircleAvatar( + backgroundColor: theme.colorScheme.surfaceContainer, + child: Icon(devices[i].icon), + ), + title: Text(devices[i].title), + subtitle: Text( + L10n.of(context).lastActiveAgo( + devices[i].lastActive.localizedTime(context), + ), + ), + ), + ), + ), + ), + + if (devices.isNotEmpty) ...[ + Divider(height: 32), + Text( + L10n.of(context).restoreBootstrapAlternativeDescription, + textAlign: .center, + ), + ], + TextField( + readOnly: isLoading, + obscureText: obscureText, + controller: recoveryKeyInputController, + decoration: InputDecoration( + hintText: L10n.of(context).restoreBootstrapHintText, + prefixIcon: IconButton( + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: onToggleObscureText, + ), + errorText: errorText, + errorMaxLines: 4, + suffixIcon: isLoading + ? SizedBox.square( + dimension: 32, + child: Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ), + ) + : IconButton( + icon: Icon(Icons.send_outlined), + onPressed: unlockWithRecoveryKey, + ), + ), + ), + TextButton( + onPressed: onResetAccount, + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + child: Text(L10n.of(context).resetAccount), + ), + ], + ), + ); + } +} diff --git a/lib/pages/bootstrap/widgets/store_recovery_key_view.dart b/lib/pages/bootstrap/widgets/store_recovery_key_view.dart new file mode 100644 index 000000000..2d4109fdf --- /dev/null +++ b/lib/pages/bootstrap/widgets/store_recovery_key_view.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class StoreRecoveryKeyView extends StatelessWidget { + const StoreRecoveryKeyView({super.key}); + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/lib/pages/sign_in/view_model/sign_in_state.dart b/lib/pages/sign_in/view_model/sign_in_state.dart index 84213152e..c6e2f9c2d 100644 --- a/lib/pages/sign_in/view_model/sign_in_state.dart +++ b/lib/pages/sign_in/view_model/sign_in_state.dart @@ -3,30 +3,9 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/pages/sign_in/view_model/model/public_homeserver_data.dart'; class SignInState { - final PublicHomeserverData? selectedHomeserver; - final AsyncSnapshot> publicHomeservers; - final List filteredPublicHomeservers; - final AsyncSnapshot loginLoading; - - const SignInState({ - this.selectedHomeserver, - this.publicHomeservers = const AsyncSnapshot.nothing(), - this.loginLoading = const AsyncSnapshot.nothing(), - this.filteredPublicHomeservers = const [], - }); - - SignInState copyWith({ - PublicHomeserverData? selectedHomeserver, - AsyncSnapshot>? publicHomeservers, - AsyncSnapshot? loginLoading, - List? filteredPublicHomeservers, - }) { - return SignInState( - selectedHomeserver: selectedHomeserver ?? this.selectedHomeserver, - publicHomeservers: publicHomeservers ?? this.publicHomeservers, - loginLoading: loginLoading ?? this.loginLoading, - filteredPublicHomeservers: - filteredPublicHomeservers ?? this.filteredPublicHomeservers, - ); - } + PublicHomeserverData? selectedHomeserver; + AsyncSnapshot> publicHomeservers = + const AsyncSnapshot.nothing(); + List filteredPublicHomeservers = []; + AsyncSnapshot loginLoading = const AsyncSnapshot.nothing(); } diff --git a/lib/pages/sign_in/view_model/sign_in_view_model.dart b/lib/pages/sign_in/view_model/sign_in_view_model.dart index cb4a41ae8..afb53ea8b 100644 --- a/lib/pages/sign_in/view_model/sign_in_view_model.dart +++ b/lib/pages/sign_in/view_model/sign_in_view_model.dart @@ -45,14 +45,13 @@ class SignInViewModel extends ValueNotifier { )) { filteredPublicHomeservers.add(PublicHomeserverData(name: filterText)); } - - value = value.copyWith( - filteredPublicHomeservers: filteredPublicHomeservers, - ); + value.filteredPublicHomeservers = filteredPublicHomeservers; + notifyListeners(); } Future refreshPublicHomeservers() async { - value = value.copyWith(publicHomeservers: AsyncSnapshot.waiting()); + notifyListeners(); + value.publicHomeservers = AsyncSnapshot.waiting(); final defaultHomeserverData = PublicHomeserverData( name: AppSettings.defaultHomeserver.value, ); @@ -82,30 +81,31 @@ class SignInViewModel extends ValueNotifier { publicHomeservers.insert(0, defaultHomeserverData); } - value = value.copyWith( - selectedHomeserver: value.selectedHomeserver ?? publicHomeservers.first, - publicHomeservers: AsyncSnapshot.withData( - ConnectionState.done, - publicHomeservers, - ), + value.selectedHomeserver = + value.selectedHomeserver ?? publicHomeservers.first; + value.publicHomeservers = AsyncSnapshot.withData( + ConnectionState.done, + publicHomeservers, ); + notifyListeners(); } catch (e, s) { Logs().w('Unable to fetch public homeservers...', e, s); - value = value.copyWith( - selectedHomeserver: defaultHomeserverData, - publicHomeservers: AsyncSnapshot.withData(ConnectionState.done, [ - defaultHomeserverData, - ]), - ); + value.selectedHomeserver = defaultHomeserverData; + value.publicHomeservers = AsyncSnapshot.withData(ConnectionState.done, [ + defaultHomeserverData, + ]); + notifyListeners(); } _filterHomeservers(); } void selectHomeserver(PublicHomeserverData? publicHomeserverData) { - value = value.copyWith(selectedHomeserver: publicHomeserverData); + value.selectedHomeserver = publicHomeserverData; + notifyListeners(); } void setLoginLoading(AsyncSnapshot loginLoading) { - value = value.copyWith(loginLoading: loginLoading); + value.loginLoading = loginLoading; + notifyListeners(); } }