Skip to content

Commit

Permalink
feat: Improved backup manager.
Browse files Browse the repository at this point in the history
  • Loading branch information
Skyost committed Jan 15, 2025
1 parent f1d51c6 commit 6f0e559
Show file tree
Hide file tree
Showing 6 changed files with 181 additions and 49 deletions.
12 changes: 8 additions & 4 deletions lib/i18n/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,18 @@
"title": "Delete backup",
"message": "Do you want to delete this backup ?"
},
"exportBackupDialog": {
"shareBackupDialog": {
"subject": "Export backup",
"text": "Export the backup to save it or to share it."
"text": "Share the backup."
},
"importBackupDialogTitle": "Import a backup",
"exportBackupDialogTitle": "Export a backup",
"button": {
"import": "Import a backup",
"restore": "Restore",
"share": "Share",
"export": "Export",
"delete": "Delete",
"restore": "Restore"
"delete": "Delete"
}
}
},
Expand Down
14 changes: 9 additions & 5 deletions lib/i18n/fr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,18 @@
"title": "Supprimer la sauvegarde",
"message": "Voulez-vous vraiment supprimer cette sauvegarde ?"
},
"exportBackupDialog": {
"subject": "Exporter la sauvegarde",
"text": "Exporter la sauvegarde pour l'enregistrer ou la partager."
"shareBackupDialog": {
"subject": "Partager la sauvegarde",
"text": "Partager la sauvegarde."
},
"importBackupDialogTitle": "Importer une sauvegarde",
"exportBackupDialogTitle": "Exporter une sauvegarde",
"button": {
"import": "Importer une sauvegarde",
"restore": "Restaurer",
"share": "share",
"export": "Exporter",
"delete": "Supprimer",
"restore": "Restaurer"
"delete": "Supprimer"
}
}
},
Expand Down
57 changes: 46 additions & 11 deletions lib/model/backup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,23 @@ final backupStoreProvider = AsyncNotifierProvider<BackupStore, List<Backup>>(Bac

/// Contains all backups.
class BackupStore extends AsyncNotifier<List<Backup>> {
/// The backup filename regex.
static const String _kBackupFilenameRegex = r'\d{10}\.bak';

@override
FutureOr<List<Backup>> build() => _listBackups();

/// Imports the [backupFile].
Future<Result<Backup>> import(File backupFile) async {
if (!Backup.isValidBackup(backupFile)) {
return ResultError(exception: _InvalidBackupContentException());
}
DateTime? dateTime = _fromBackupFilename(backupFile);
Backup backup = Backup._(ref: ref, dateTime: dateTime ?? DateTime.now());
state = AsyncData([...(await future), backup]..sort());
return ResultSuccess(value: backup);
}

/// Do a backup with the given password.
Future<Result<Backup>> doBackup(String password) async {
Backup backup = Backup._(ref: ref, dateTime: DateTime.now());
Expand All @@ -37,23 +51,32 @@ class BackupStore extends AsyncNotifier<List<Backup>> {
/// Lists available backups.
Future<List<Backup>> _listBackups() async {
List<Backup> result = [];
Directory directory = await _getBackupsDirectory();
Directory directory = await getBackupsDirectory();
if (!directory.existsSync()) {
return result;
}
RegExp backupRegex = RegExp(r'\d{10}\.bak');
for (FileSystemEntity entity in directory.listSync(followLinks: false)) {
String name = entity.uri.pathSegments.last;
if (backupRegex.hasMatch(name)) {
DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(int.parse(name.substring(0, name.length - '.bak'.length)));
DateTime? dateTime = _fromBackupFilename(entity);
if (dateTime != null) {
result.add(Backup._(ref: ref, dateTime: dateTime));
}
}
return result..sort((a, b) => a.dateTime.compareTo(b.dateTime));
return result..sort();
}

/// Constructs a [DateTime] from a [file], if possible.
DateTime? _fromBackupFilename(FileSystemEntity file) {
RegExp backupRegex = RegExp(_kBackupFilenameRegex);
String filename = file.uri.pathSegments.last;
if (backupRegex.hasMatch(filename)) {
DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(int.parse(filename.substring(0, filename.length - '.bak'.length)));
return dateTime;
}
return null;
}

/// Returns the backup directory.
static Future<Directory> _getBackupsDirectory({bool create = false}) async {
static Future<Directory> getBackupsDirectory({bool create = false}) async {
String name = '${App.appName} Backups${kDebugMode ? ' (Debug)' : ''}';
Directory directory = Directory(join((await getApplicationDocumentsDirectory()).path, name));
if (create && !directory.existsSync()) {
Expand Down Expand Up @@ -86,6 +109,15 @@ class Backup implements Comparable<Backup> {
required this.dateTime,
}) : _ref = ref;

/// Returns whether the given [file] is a valid backup file.
static bool isValidBackup(File file) {
if (!file.existsSync()) {
return false;
}
Map<String, dynamic> jsonData = jsonDecode(file.readAsStringSync());
return jsonData[kTotpsKey] is List && jsonData[kSaltKey] is String && jsonData[kPasswordSignatureKey] is String;
}

/// Restore this backup.
Future<Result> restore(String password) async {
try {
Expand All @@ -94,11 +126,11 @@ class Backup implements Comparable<Backup> {
throw _BackupFileDoesNotExistException(path: file.path);
}

Map<String, dynamic> jsonData = jsonDecode(file.readAsStringSync());
if (jsonData[kTotpsKey] is! List || jsonData[kSaltKey] is! String || jsonData[kPasswordSignatureKey] is! String) {
if (!isValidBackup(file)) {
throw _InvalidBackupContentException();
}

Map<String, dynamic> jsonData = jsonDecode(file.readAsStringSync());
CryptoStore cryptoStore = await CryptoStore.fromPassword(password, Salt.fromRawValue(value: base64.decode(jsonData[kSaltKey])));
HmacSecretKey hmacSecretKey = await HmacSecretKey.importRawKey(await cryptoStore.key.exportRawKey(), Hash.sha256);
if (!(await hmacSecretKey.verifyBytes(base64.decode(jsonData[kPasswordSignatureKey]), utf8.encode(password)))) {
Expand Down Expand Up @@ -180,10 +212,13 @@ class Backup implements Comparable<Backup> {
}
}

/// Returns the backup filename.
String get filename => '${dateTime.millisecondsSinceEpoch}.bak';

/// Returns the backup path (TOTPs and salt).
Future<File> getBackupPath({bool createDirectory = false}) async {
Directory directory = await BackupStore._getBackupsDirectory(create: createDirectory);
return File(join(directory.path, '${dateTime.millisecondsSinceEpoch}.bak'));
Directory directory = await BackupStore.getBackupsDirectory(create: createDirectory);
return File(join(directory.path, filename));
}

@override
Expand Down
114 changes: 97 additions & 17 deletions lib/pages/settings/entries/manage_backups.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';

import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
Expand All @@ -10,6 +13,7 @@ import 'package:open_authenticator/widgets/centered_circular_progress_indicator.
import 'package:open_authenticator/widgets/dialog/confirmation_dialog.dart';
import 'package:open_authenticator/widgets/dialog/text_input_dialog.dart';
import 'package:open_authenticator/widgets/list/expand_list_tile.dart';
import 'package:open_authenticator/widgets/list/list_tile_padding.dart';
import 'package:open_authenticator/widgets/waiting_overlay.dart';
import 'package:share_plus/share_plus.dart';

Expand All @@ -28,7 +32,7 @@ class ManageBackupSettingsEntryWidget extends ConsumerWidget {
leading: const Icon(Icons.access_time),
title: Text(translations.settings.backups.manageBackups.title),
subtitle: Text(translations.settings.backups.manageBackups.subtitle(n: backupCount)),
enabled: backups.hasValue && backupCount > 0,
enabled: backups.hasValue,
onTap: () {
showDialog(
context: context,
Expand Down Expand Up @@ -66,6 +70,16 @@ class _RestoreBackupDialogState extends ConsumerState<_RestoreBackupDialog> {
key: listKey,
shrinkWrap: true,
children: [
if (value.isEmpty)
ListTilePadding(
top: 20,
bottom: 20,
child: Text(
translations.settings.backups.manageBackups.subtitle(n: 0),
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic),
),
),
for (Backup backup in value)
ExpandListTile(
title: Text(
Expand Down Expand Up @@ -93,6 +107,10 @@ class _RestoreBackupDialogState extends ConsumerState<_RestoreBackupDialog> {
child: content,
),
actions: [
TextButton(
onPressed: importBackup,
child: Text(translations.settings.backups.manageBackups.button.import),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
Expand All @@ -109,11 +127,17 @@ class _RestoreBackupDialogState extends ConsumerState<_RestoreBackupDialog> {
title: Text(translations.settings.backups.manageBackups.button.restore),
leading: const Icon(Icons.upload),
),
ListTile(
dense: true,
onTap: () => shareBackup(backup),
title: Text(translations.settings.backups.manageBackups.button.share),
leading: const Icon(Icons.share),
),
ListTile(
dense: true,
onTap: () => exportBackup(backup),
title: Text(translations.settings.backups.manageBackups.button.export),
leading: const Icon(Icons.share),
leading: const Icon(Icons.import_export),
),
ListTile(
dense: true,
Expand All @@ -123,6 +147,40 @@ class _RestoreBackupDialogState extends ConsumerState<_RestoreBackupDialog> {
),
];

/// Allows to import a backup.
Future<void> importBackup() async {
Result result = ResultCancelled();
try {
FilePickerResult? filePickerResult = await showWaitingOverlay(
context,
future: (() async {
Directory directory = await BackupStore.getBackupsDirectory(create: true);
return FilePicker.platform.pickFiles(
dialogTitle: translations.settings.backups.manageBackups.importBackupDialogTitle,
initialDirectory: directory.path,
type: FileType.custom,
allowedExtensions: ['bak'],
lockParentWindow: true,
);
})(),
);
String? backupFilePath = filePickerResult?.files.firstOrNull?.path;
if (backupFilePath == null || !mounted) {
return;
}
result = await showWaitingOverlay(
context,
future: ref.read(backupStoreProvider.notifier).import(File(backupFilePath)),
);
} catch (ex, stacktrace) {
result = ResultError(exception: ex, stacktrace: stacktrace);
} finally {
if (mounted) {
context.showSnackBarForResult(result);
}
}
}

/// Asks the user for the given [backup] restoring.
Future<void> restoreBackup(Backup backup) async {
String? password = await TextInputDialog.prompt(
Expand All @@ -144,8 +202,8 @@ class _RestoreBackupDialogState extends ConsumerState<_RestoreBackupDialog> {
}
}

/// Asks the user for the given [backup] export.
Future<void> exportBackup(Backup backup) async {
/// Asks the user for the given [backup] share.
Future<void> shareBackup(Backup backup) async {
RenderBox? box = listKey.currentContext?.findRenderObject() as RenderBox?;
File file = await backup.getBackupPath();
await Share.shareXFiles(
Expand All @@ -155,12 +213,45 @@ class _RestoreBackupDialogState extends ConsumerState<_RestoreBackupDialog> {
mimeType: 'application/json',
),
],
subject: translations.settings.backups.manageBackups.exportBackupDialog.subject,
text: translations.settings.backups.manageBackups.exportBackupDialog.text,
subject: translations.settings.backups.manageBackups.shareBackupDialog.subject,
text: translations.settings.backups.manageBackups.shareBackupDialog.text,
sharePositionOrigin: box == null ? Rect.zero : (box.localToGlobal(Offset.zero) & box.size),
);
}

/// Asks the user for the given [backup] export.
Future<void> exportBackup(Backup backup) async {
Result result = ResultCancelled();
try {
String? outputFilePath = await showWaitingOverlay(
context,
future: (() async {
Directory directory = await BackupStore.getBackupsDirectory(create: true);
return FilePicker.platform.saveFile(
dialogTitle: translations.settings.backups.manageBackups.exportBackupDialogTitle,
initialDirectory: directory.path,
fileName: backup.filename,
bytes: Uint8List(0),
allowedExtensions: ['.bak'],
lockParentWindow: true,
);
})(),
);
if (outputFilePath == null) {
return;
}
File backupFile = await backup.getBackupPath();
backupFile.copySync(outputFilePath);
result = ResultSuccess();
} catch (ex, stacktrace) {
result = ResultError(exception: ex, stacktrace: stacktrace);
} finally {
if (mounted) {
context.showSnackBarForResult(result);
}
}
}

/// Asks the user for the given [backup] deletion.
Future<void> deleteBackup(Backup backup) async {
bool result = await ConfirmationDialog.ask(
Expand All @@ -174,17 +265,6 @@ class _RestoreBackupDialogState extends ConsumerState<_RestoreBackupDialog> {
Result deleteResult = await backup.delete();
if (mounted) {
context.showSnackBarForResult(deleteResult);
if (deleteResult is ResultSuccess) {
await closeIfNoRemainingBackup();
}
}
}

/// Closes this dialog if there is no remaining backup.
Future<void> closeIfNoRemainingBackup() async {
List<Backup> backups = await ref.read(backupStoreProvider.future);
if (backups.isEmpty && mounted) {
Navigator.pop(context);
}
}
}
Loading

0 comments on commit 6f0e559

Please sign in to comment.