Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
krille-chan
8520bb4550
feat: better crypto setup with custom passphrase 2026-03-05 09:08:13 +01:00
12 changed files with 502 additions and 640 deletions

View file

@ -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

View file

@ -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),
),

View file

@ -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": {}
}
}

View file

@ -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<BootstrapDialog> {
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<void> _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<String>('session_id');
final senderKey = event.content.tryGet<String>('sender_key');
if (sessionId != null && senderKey != null) {
room.client.encryption?.keyManager.maybeAutoRequest(
room.id,
sessionId,
senderKey,
);
}
}
}
}
Future<void> _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>[];
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],
),
),
),
);
}
}

View file

@ -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() ??
[],
),
);
},
);
}
}

View file

@ -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<DeviceKeys>? connectedDevices;
bool recoveryKeyStored = false;
bool obscureText = true;
}

View file

@ -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<BootstrapViewModelState> {
final Client client;
final TextEditingController enterPassphraseOrRecovController =
TextEditingController();
final TextEditingController newPassphraseController = TextEditingController();
final TextEditingController repeatPassphraseController =
TextEditingController();
BootstrapViewModel({required this.client})
: super(BootstrapViewModelState()) {
_init();
}
Future<void> _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<void> setOrSkipPassphrase(String? passphrase) async {
value.isLoading = true;
notifyListeners();
value.recoveryKey = await client.initCryptoIdentity(passphrase: passphrase);
notifyListeners();
}
Future<bool> 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();
}
}

View file

@ -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)),
],
);
}
}

View file

@ -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),
),
],
),
);
}
}

View file

@ -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();
}
}

View file

@ -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<List<PublicHomeserverData>> publicHomeservers;
final List<PublicHomeserverData> filteredPublicHomeservers;
final AsyncSnapshot<bool> loginLoading;
const SignInState({
this.selectedHomeserver,
this.publicHomeservers = const AsyncSnapshot.nothing(),
this.loginLoading = const AsyncSnapshot.nothing(),
this.filteredPublicHomeservers = const [],
});
SignInState copyWith({
PublicHomeserverData? selectedHomeserver,
AsyncSnapshot<List<PublicHomeserverData>>? publicHomeservers,
AsyncSnapshot<bool>? loginLoading,
List<PublicHomeserverData>? filteredPublicHomeservers,
}) {
return SignInState(
selectedHomeserver: selectedHomeserver ?? this.selectedHomeserver,
publicHomeservers: publicHomeservers ?? this.publicHomeservers,
loginLoading: loginLoading ?? this.loginLoading,
filteredPublicHomeservers:
filteredPublicHomeservers ?? this.filteredPublicHomeservers,
);
}
PublicHomeserverData? selectedHomeserver;
AsyncSnapshot<List<PublicHomeserverData>> publicHomeservers =
const AsyncSnapshot.nothing();
List<PublicHomeserverData> filteredPublicHomeservers = [];
AsyncSnapshot<bool> loginLoading = const AsyncSnapshot.nothing();
}

View file

@ -45,14 +45,13 @@ class SignInViewModel extends ValueNotifier<SignInState> {
)) {
filteredPublicHomeservers.add(PublicHomeserverData(name: filterText));
}
value = value.copyWith(
filteredPublicHomeservers: filteredPublicHomeservers,
);
value.filteredPublicHomeservers = filteredPublicHomeservers;
notifyListeners();
}
Future<void> 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<SignInState> {
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<bool> loginLoading) {
value = value.copyWith(loginLoading: loginLoading);
value.loginLoading = loginLoading;
notifyListeners();
}
}