// SPDX-FileCopyrightText: 2019-Present Christian Kußowski // SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat // // SPDX-License-Identifier: AGPL-3.0-or-later import 'package:file_picker/file_picker.dart'; import 'package:tatchat/utils/error_reporter.dart'; import 'package:tatchat/utils/localized_exception_extension.dart'; import 'package:tatchat/utils/platform_infos.dart'; import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:go_router/go_router.dart'; import 'package:material_ui/material_ui.dart'; import 'package:matrix/encryption.dart'; import 'package:matrix/matrix.dart'; import 'bootstrap_state.dart'; class BootstrapViewModel extends ValueNotifier { final Client client; final bool reset; final TextEditingController enterPassphraseOrRecovController = TextEditingController(); final TextEditingController newPassphraseController = TextEditingController(); final TextEditingController repeatPassphraseController = TextEditingController(); final ScrollController devicesScrollController = ScrollController(); BootstrapViewModel({required this.client, required this.reset}) : super(BootstrapViewModelState()..reset = reset) { _init(); } @override void dispose() { _cancelKeyVerification(); enterPassphraseOrRecovController.dispose(); newPassphraseController.dispose(); repeatPassphraseController.dispose(); devicesScrollController.dispose(); super.dispose(); } void _checkCanCreatePassphrase([_]) { final passphrase = newPassphraseController.text; value.newPassphraseEqualsRepeatPassphrase = passphrase.isNotEmpty && passphrase == repeatPassphraseController.text; value.newPassphraseLongEnough = passphrase.length >= 12; value.newPassphraseUpperAndLowerCase = passphrase.contains(RegExp(r'[A-Z]')) && passphrase.contains(RegExp(r'[a-z]')); value.newPassphraseSpecialCharacters = passphrase.contains( RegExp(r'[!@#$%^&*(),.?":{}|<>]'), ); value.newPassphraseNumbers = passphrase.contains(RegExp(r'\d')); notifyListeners(); } Future retryKeyVerification() async { value.noSecretsreceived = false; value.keyVerification = await client.userDeviceKeys[client.userID!]! .startVerification(); value.keyVerification?.onUpdate = _onKeyVerificationUpdate; notifyListeners(); } Future _init() async { final state = value.cryptoIdentityState = await client .getCryptoIdentityState(); newPassphraseController.addListener(_checkCanCreatePassphrase); repeatPassphraseController.addListener(_checkCanCreatePassphrase); enterPassphraseOrRecovController.addListener( _passphraseOrRecoveryKeyEntered, ); 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(); value.keyVerification?.onUpdate = _onKeyVerificationUpdate; } if (supportsSecureStorage) { try { final keyFromSecureStorage = await FlutterSecureStorage().read( key: _secureStorageKey, ); if (keyFromSecureStorage != null) { enterPassphraseOrRecovController.text = keyFromSecureStorage; } } catch (e, s) { Logs().e('Unable to read key from secure storage', e, s); } } } notifyListeners(); } void _passphraseOrRecoveryKeyEntered() { final passphraseOrRecoveryKeyEntered = enterPassphraseOrRecovController.text.isNotEmpty; if (value.passphraseOrRecoveryKeyEntered != passphraseOrRecoveryKeyEntered) { value.passphraseOrRecoveryKeyEntered = passphraseOrRecoveryKeyEntered; notifyListeners(); } } Future _onKeyVerificationUpdate() async { if (value.keyVerification?.state == KeyVerificationState.done) { value.waitingForSecrets = true; value.noSecretsreceived = false; notifyListeners(); value.cryptoIdentityState = await client.getCryptoIdentityState(); var tries = 0; const max = 10; while (value.cryptoIdentityState?.connected != true) { Logs().d('Waiting for secrets... [$tries/$max]'); if (tries >= max) return; await Future.delayed(const Duration(seconds: 1)); value.cryptoIdentityState = await client.getCryptoIdentityState(); tries++; } if (value.cryptoIdentityState?.connected != true) { value.waitingForSecrets = false; value.noSecretsreceived = true; } } notifyListeners(); } Future setOrSkipPassphrase( String? passphrase, BuildContext context, ) async { value.isLoading = true; notifyListeners(); try { value.recoveryKey = await client.initCryptoIdentity( passphrase: passphrase, wipeCrossSigning: !reset, wipeKeyBackup: !reset, wipeSecureStorage: !reset, setupOnlineKeyBackup: !reset, setupMasterKey: !reset, setupSelfSigningKey: !reset, setupUserSigningKey: !reset, ); } catch (e, s) { if (!context.mounted) return; ErrorReporter( context, 'Unable to init crypto identity', ).onErrorCallback(e, s); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context)))); value.isLoading = false; } notifyListeners(); } void _cancelKeyVerification() { final keyVerification = value.keyVerification; if (keyVerification != null && keyVerification.state != KeyVerificationState.done && keyVerification.state != KeyVerificationState.error) { keyVerification.cancel(); } } bool get supportsSecureStorage => PlatformInfos.isMobile || PlatformInfos.isDesktop; Future unlock(BuildContext context) async { final key = enterPassphraseOrRecovController.text.trim(); if (key.isEmpty) return; _cancelKeyVerification(); value.unlockWithError = null; value.isLoading = true; notifyListeners(); try { await client.restoreCryptoIdentity(key); value.isLoading = false; value.cryptoIdentityState = await client.getCryptoIdentityState(); notifyListeners(); return; } catch (e, s) { if (e is! InvalidPassphraseException) { const errorMessage = 'Unexpected error on unlock passphrase'; if (context.mounted) { ErrorReporter(context, errorMessage).onErrorCallback(e, s); } else { Logs().wtf(errorMessage, e, s); } } value.isLoading = false; value.unlockWithError = e; notifyListeners(); if (supportsSecureStorage) { await FlutterSecureStorage().delete(key: _secureStorageKey); } return; } } void goToRoomsPageAfterSuccess(BuildContext context) { for (final room in client.rooms) { final lastEvent = room.lastEvent; if (lastEvent == null || lastEvent.messageType != MessageTypes.BadEncrypted || lastEvent.content['can_request_session'] != true) { continue; } final sessionId = lastEvent.content.tryGet('session_id'); final senderKey = lastEvent.content.tryGet('sender_key'); if (sessionId != null && senderKey != null) { client.encryption?.keyManager.maybeAutoRequest( room.id, sessionId, senderKey, ); } } context.go('/rooms'); } void toggleObscureText() { value.obscureText = !value.obscureText; notifyListeners(); } void startResetAccount() { value.reset = true; notifyListeners(); } String get _secureStorageKey => 'ssss_recovery_key_${client.userID}'; Future openRecoveryKeyFile(BuildContext context) async { final result = await FilePicker.pickFile( allowedExtensions: ['txt'], type: FileType.custom, ); final file = result?.xFile; if (file == null) return; try { final key = await file.readAsString(); enterPassphraseOrRecovController.text = key; } catch (e, s) { Logs().d('Unable to read recovery key file', e, s); if (context.mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context)))); } } if (context.mounted) await unlock(context); } Future toggleRecoveryKeyDownloaded( bool? downloaded, BuildContext context, ) async { final path = await FilePicker.saveFile( fileName: 'FluffyChat-Recovery-Key-${DateTime.now().toIso8601String()}.txt', bytes: Uint8List.fromList(value.recoveryKey!.codeUnits), ); if (path == null) return; value.recoveryKeyDownloaded = downloaded == true; notifyListeners(); } Future toggleRecoveryKeyStoredInSecureStorage(bool? stored) async { if (stored == true) { await FlutterSecureStorage().write( key: _secureStorageKey, value: value.recoveryKey, ); } else { await FlutterSecureStorage().delete(key: _secureStorageKey); } value.recoveryKeyStoredInSecureStorage = stored == true; notifyListeners(); } }