Initial commit
This commit is contained in:
96
lib/pages/bootstrap/bootstrap_page.dart
Normal file
96
lib/pages/bootstrap/bootstrap_page.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/bootstrap/view_model/bootstrap_view_model.dart';
|
||||
import 'package:tatchat/pages/bootstrap/widgets/new_passphrase_view.dart';
|
||||
import 'package:tatchat/pages/bootstrap/widgets/restore_bootstrap_view.dart';
|
||||
import 'package:tatchat/pages/bootstrap/widgets/store_recovery_key_view.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
|
||||
import 'package:tatchat/widgets/layouts/login_scaffold.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:tatchat/widgets/view_model_builder.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class BootstrapPage extends StatelessWidget {
|
||||
final bool reset;
|
||||
const BootstrapPage({required this.reset, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return ViewModelBuilder(
|
||||
create: () =>
|
||||
BootstrapViewModel(client: Matrix.of(context).client, reset: reset),
|
||||
builder: (context, viewModel, _) {
|
||||
final cryptoIdentityState = viewModel.value.cryptoIdentityState;
|
||||
if (cryptoIdentityState?.connected == true && !reset) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => viewModel.goToRoomsPageAfterSuccess(context),
|
||||
);
|
||||
}
|
||||
|
||||
final title = cryptoIdentityState == null
|
||||
? L10n.of(context).loadingPleaseWait
|
||||
: cryptoIdentityState.initialized && !viewModel.value.reset
|
||||
? L10n.of(context).restoreCryptoIdentity
|
||||
: viewModel.value.reset
|
||||
? viewModel.value.recoveryKey != null
|
||||
? L10n.of(context).youAreReadyToStart
|
||||
: L10n.of(context).resetCryptoIdentity
|
||||
: L10n.of(context).setUpCryptoIdentity;
|
||||
|
||||
return LoginScaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: viewModel.value.recoveryKey != null
|
||||
? null
|
||||
: CloseButton(
|
||||
onPressed: () async {
|
||||
if (!reset) {
|
||||
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),
|
||||
actions: [
|
||||
if (viewModel.value.recoveryKey != null)
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
onPressed: () => context.go('/rooms'),
|
||||
child: Text(L10n.of(context).continueText),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
body: cryptoIdentityState == null
|
||||
? Center(child: CircularProgressIndicator.adaptive())
|
||||
: !cryptoIdentityState.initialized || viewModel.value.reset
|
||||
? viewModel.value.recoveryKey == null
|
||||
? NewPassphraseView(viewModel)
|
||||
: StoreRecoveryKeyView(viewModel)
|
||||
: RestoreBootstrapView(viewModel),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/pages/bootstrap/view_model/bootstrap_state.dart
Normal file
35
lib/pages/bootstrap/view_model/bootstrap_state.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:matrix/encryption.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class BootstrapViewModelState {
|
||||
String? recoveryKey;
|
||||
bool isLoading = false;
|
||||
Object? unlockWithError;
|
||||
({
|
||||
bool connected,
|
||||
bool crossSigningEnabled,
|
||||
bool initialized,
|
||||
bool keyBackupEnabled,
|
||||
})?
|
||||
cryptoIdentityState;
|
||||
bool reset = false;
|
||||
KeyVerification? keyVerification;
|
||||
List<DeviceKeys>? connectedDevices;
|
||||
bool obscureText = true;
|
||||
bool waitingForSecrets = false;
|
||||
bool noSecretsreceived = false;
|
||||
|
||||
bool newPassphraseEqualsRepeatPassphrase = false;
|
||||
bool newPassphraseLongEnough = false;
|
||||
bool newPassphraseUpperAndLowerCase = false;
|
||||
bool newPassphraseSpecialCharacters = false;
|
||||
bool newPassphraseNumbers = false;
|
||||
bool passphraseOrRecoveryKeyEntered = false;
|
||||
bool recoveryKeyDownloaded = false;
|
||||
bool recoveryKeyStoredInSecureStorage = false;
|
||||
}
|
||||
302
lib/pages/bootstrap/view_model/bootstrap_view_model.dart
Normal file
302
lib/pages/bootstrap/view_model/bootstrap_view_model.dart
Normal file
@@ -0,0 +1,302 @@
|
||||
// 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<BootstrapViewModelState> {
|
||||
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<void> retryKeyVerification() async {
|
||||
value.noSecretsreceived = false;
|
||||
value.keyVerification = await client.userDeviceKeys[client.userID!]!
|
||||
.startVerification();
|
||||
value.keyVerification?.onUpdate = _onKeyVerificationUpdate;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> 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<void> 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<String>('session_id');
|
||||
final senderKey = lastEvent.content.tryGet<String>('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<void> 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<void> 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<void> 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();
|
||||
}
|
||||
}
|
||||
129
lib/pages/bootstrap/widgets/new_passphrase_view.dart
Normal file
129
lib/pages/bootstrap/widgets/new_passphrase_view.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/bootstrap/view_model/bootstrap_view_model.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class NewPassphraseView extends StatelessWidget {
|
||||
final BootstrapViewModel viewModel;
|
||||
|
||||
const NewPassphraseView(this.viewModel, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final canCreatePassphrase =
|
||||
viewModel.value.newPassphraseEqualsRepeatPassphrase &&
|
||||
viewModel.value.newPassphraseNumbers &&
|
||||
viewModel.value.newPassphraseSpecialCharacters &&
|
||||
viewModel.value.newPassphraseUpperAndLowerCase &&
|
||||
viewModel.value.newPassphraseLongEnough;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
Text(L10n.of(context).newPassphraseDescription, textAlign: .center),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
obscureText: viewModel.value.obscureText,
|
||||
readOnly: viewModel.value.isLoading,
|
||||
controller: viewModel.newPassphraseController,
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
viewModel.value.obscureText
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined,
|
||||
),
|
||||
onPressed: viewModel.toggleObscureText,
|
||||
),
|
||||
hintText: L10n.of(context).newPassphrase,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
obscureText: viewModel.value.obscureText,
|
||||
readOnly: viewModel.value.isLoading,
|
||||
controller: viewModel.repeatPassphraseController,
|
||||
decoration: InputDecoration(
|
||||
hintText: L10n.of(context).repeatPassphrase,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: canCreatePassphrase && !viewModel.value.isLoading
|
||||
? () => viewModel.setOrSkipPassphrase(
|
||||
viewModel.newPassphraseController.text,
|
||||
context,
|
||||
)
|
||||
: null,
|
||||
child: viewModel.value.isLoading
|
||||
? CircularProgressIndicator.adaptive()
|
||||
: Text(L10n.of(context).continueText),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: viewModel.value.isLoading
|
||||
? null
|
||||
: () => viewModel.setOrSkipPassphrase(null, context),
|
||||
style: TextButton.styleFrom(foregroundColor: theme.colorScheme.error),
|
||||
child: Text(L10n.of(context).skip),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: viewModel.value.newPassphraseEqualsRepeatPassphrase,
|
||||
label: L10n.of(context).passphrasesMatch,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: viewModel.value.newPassphraseLongEnough,
|
||||
label: L10n.of(context).passphraseLengthRequirement,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: viewModel.value.newPassphraseUpperAndLowerCase,
|
||||
label: L10n.of(context).passphraseUpperAndLowerCaseRequirement,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: viewModel.value.newPassphraseSpecialCharacters,
|
||||
label: L10n.of(context).passphraseSpecialCharactersRequirement,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: viewModel.value.newPassphraseNumbers,
|
||||
label: L10n.of(context).passphraseNumberRequirement,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
198
lib/pages/bootstrap/widgets/restore_bootstrap_view.dart
Normal file
198
lib/pages/bootstrap/widgets/restore_bootstrap_view.dart
Normal file
@@ -0,0 +1,198 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/bootstrap/view_model/bootstrap_view_model.dart';
|
||||
import 'package:tatchat/pages/key_verification/key_verification_dialog.dart';
|
||||
import 'package:tatchat/utils/date_time_extension.dart';
|
||||
import 'package:tatchat/utils/localized_exception_extension.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/device_extension.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/encryption/utils/key_verification.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class RestoreBootstrapView extends StatelessWidget {
|
||||
final BootstrapViewModel viewModel;
|
||||
|
||||
const RestoreBootstrapView(this.viewModel, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final keyVerification = viewModel.value.keyVerification;
|
||||
if (keyVerification != null) {
|
||||
Logs().v('Key verification state:', keyVerification.state);
|
||||
if (keyVerification.state == KeyVerificationState.askSas) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
KeyVerificationDialog(request: keyVerification).show(context);
|
||||
});
|
||||
}
|
||||
}
|
||||
final devices =
|
||||
viewModel.value.connectedDevices
|
||||
?.map(
|
||||
(device) => (
|
||||
title: device.displayname,
|
||||
lastActive: device.lastActive,
|
||||
icon: device.icon,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
if (keyVerification != null) ...[
|
||||
ListTile(
|
||||
leading:
|
||||
(keyVerification.state == KeyVerificationState.error ||
|
||||
viewModel.value.noSecretsreceived)
|
||||
? IconButton(
|
||||
onPressed: viewModel.value.isLoading
|
||||
? null
|
||||
: viewModel.retryKeyVerification,
|
||||
tooltip: L10n.of(context).tryAgain,
|
||||
icon: Icon(Icons.refresh_outlined),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
minLeadingWidth: 40,
|
||||
title: Text(
|
||||
viewModel.value.waitingForSecrets
|
||||
? L10n.of(context).waitingForKeys
|
||||
: viewModel.value.noSecretsreceived
|
||||
? L10n.of(context).noKeysTransmitted
|
||||
: L10n.of(context).restoreBootstrapDevicesDescription,
|
||||
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Material(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: 128),
|
||||
child: Scrollbar(
|
||||
thumbVisibility: true,
|
||||
trackVisibility: true,
|
||||
controller: viewModel.devicesScrollController,
|
||||
child: ListView.builder(
|
||||
controller: viewModel.devicesScrollController,
|
||||
shrinkWrap: true,
|
||||
itemCount: devices.length,
|
||||
itemBuilder: (context, i) => ListTile(
|
||||
leading: CircleAvatar(
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
child: Icon(devices[i].icon),
|
||||
),
|
||||
title: Text(
|
||||
devices[i].title,
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
L10n.of(context).lastActiveAgo(
|
||||
devices[i].lastActive.localizedTime(context),
|
||||
),
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Divider(height: 64)),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(L10n.of(context).or),
|
||||
),
|
||||
Expanded(child: Divider(height: 64)),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
Text(
|
||||
L10n.of(context).restoreBootstrapEmptyDevicesDescription,
|
||||
textAlign: .center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
TextField(
|
||||
readOnly: viewModel.value.isLoading,
|
||||
obscureText: viewModel.value.obscureText,
|
||||
controller: viewModel.enterPassphraseOrRecovController,
|
||||
minLines: 1,
|
||||
maxLines: viewModel.value.obscureText ? 1 : 4,
|
||||
onSubmitted: (_) => viewModel.unlock(context),
|
||||
decoration: InputDecoration(
|
||||
hintText: L10n.of(context).passphraseOrKey,
|
||||
prefixIcon: IconButton(
|
||||
icon: Icon(
|
||||
viewModel.value.obscureText
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: viewModel.toggleObscureText,
|
||||
),
|
||||
errorText: viewModel.value.unlockWithError?.toLocalizedString(
|
||||
context,
|
||||
),
|
||||
errorMaxLines: 4,
|
||||
suffixIcon: viewModel.value.isLoading
|
||||
? SizedBox.square(
|
||||
dimension: 32,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius / 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: viewModel.value.passphraseOrRecoveryKeyEntered
|
||||
? () => viewModel.unlock(context)
|
||||
: () => viewModel.openRecoveryKeyFile(context),
|
||||
child: Text(
|
||||
viewModel.value.passphraseOrRecoveryKeyEntered
|
||||
? L10n.of(context).unlock
|
||||
: L10n.of(context).openFile,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final consent = await showOkCancelAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context).warning,
|
||||
message: L10n.of(context).resetAccountWarning,
|
||||
isDestructive: true,
|
||||
okLabel: L10n.of(context).resetAccount,
|
||||
);
|
||||
if (consent != OkCancelResult.ok) return;
|
||||
if (!context.mounted) return;
|
||||
viewModel.startResetAccount();
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: theme.colorScheme.error),
|
||||
child: Text(L10n.of(context).resetAccount),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
63
lib/pages/bootstrap/widgets/store_recovery_key_view.dart
Normal file
63
lib/pages/bootstrap/widgets/store_recovery_key_view.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/bootstrap/view_model/bootstrap_view_model.dart';
|
||||
import 'package:tatchat/utils/tatu_share.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class StoreRecoveryKeyView extends StatelessWidget {
|
||||
final BootstrapViewModel viewModel;
|
||||
const StoreRecoveryKeyView(this.viewModel, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
Text(L10n.of(context).storeRecoveryKeyDescription),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
// TODO: Dispose this
|
||||
controller: TextEditingController(text: viewModel.value.recoveryKey),
|
||||
readOnly: true,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(Icons.copy_outlined),
|
||||
onPressed: () =>
|
||||
TatuShare.share(viewModel.value.recoveryKey!, context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CheckboxListTile.adaptive(
|
||||
value: viewModel.value.recoveryKeyDownloaded,
|
||||
onChanged: (copied) =>
|
||||
viewModel.toggleRecoveryKeyDownloaded(copied, context),
|
||||
title: Text(L10n.of(context).saveAsFile),
|
||||
),
|
||||
if (viewModel.supportsSecureStorage)
|
||||
CheckboxListTile.adaptive(
|
||||
value: viewModel.value.recoveryKeyStoredInSecureStorage,
|
||||
onChanged: viewModel.toggleRecoveryKeyStoredInSecureStorage,
|
||||
title: Text(_getSecureStorageLocalizedName(context)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getSecureStorageLocalizedName(BuildContext context) {
|
||||
if (PlatformInfos.isAndroid) {
|
||||
return L10n.of(context).storeInAndroidKeystore;
|
||||
}
|
||||
if (PlatformInfos.isIOS || PlatformInfos.isMacOS) {
|
||||
return L10n.of(context).storeInAppleKeyChain;
|
||||
}
|
||||
return L10n.of(context).storeSecurlyOnThisDevice;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user