Skip to content

Commit

Permalink
feat: Added the ability to export a given backup. Fixes #3.
Browse files Browse the repository at this point in the history
  • Loading branch information
Skyost committed Oct 28, 2024
1 parent 61b58f4 commit 5a7ce70
Show file tree
Hide file tree
Showing 10 changed files with 137 additions and 61 deletions.
9 changes: 9 additions & 0 deletions lib/i18n/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@
"deleteBackupConfirmationDialog": {
"title": "Delete backup",
"message": "Do you want to delete this backup ?"
},
"exportBackupDialog": {
"subject": "Export backup",
"text": "Export the backup to save it or to share it."
},
"button": {
"export": "Export",
"delete": "Delete",
"restore": "Restore"
}
}
},
Expand Down
9 changes: 9 additions & 0 deletions lib/i18n/fr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@
"deleteBackupConfirmationDialog": {
"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."
},
"button": {
"export": "Exporter",
"delete": "Supprimer",
"restore": "Restaurer"
}
}
},
Expand Down
16 changes: 8 additions & 8 deletions lib/model/app_unlock/method.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,20 @@ import 'package:open_authenticator/widgets/dialog/text_input_dialog.dart';
sealed class AppUnlockMethod {
/// Tries to unlock the app.
/// [context] is required so that we can interact with the user.
Future<Result> tryUnlock(BuildContext context, AsyncNotifierProviderRef ref, UnlockReason reason);
Future<Result> tryUnlock(BuildContext context, Ref ref, UnlockReason reason);

/// Triggered when this method has been chosen has the app unlock method.
/// [unlockResult] is the result of the [tryUnlock] call.
Future<void> onMethodChosen(AsyncNotifierProviderRef ref, {ResultSuccess? enableResult}) => Future.value();
Future<void> onMethodChosen(Ref ref, {ResultSuccess? enableResult}) => Future.value();

/// Triggered when a new method will be used for app unlocking.
Future<void> onMethodChanged(AsyncNotifierProviderRef ref, {ResultSuccess? disableResult}) => Future.value();
Future<void> onMethodChanged(Ref ref, {ResultSuccess? disableResult}) => Future.value();
}

/// Local authentication.
class LocalAuthenticationAppUnlockMethod extends AppUnlockMethod {
@override
Future<Result> tryUnlock(BuildContext context, AsyncNotifierProviderRef ref, UnlockReason reason) async {
Future<Result> tryUnlock(BuildContext context, Ref ref, UnlockReason reason) async {
LocalAuthentication auth = LocalAuthentication();
if (!(await auth.isDeviceSupported())) {
return ResultError();
Expand Down Expand Up @@ -71,7 +71,7 @@ class LocalAuthenticationAppUnlockMethod extends AppUnlockMethod {
/// Enter master password.
class MasterPasswordAppUnlockMethod extends AppUnlockMethod {
@override
Future<Result> tryUnlock(BuildContext context, AsyncNotifierProviderRef ref, UnlockReason reason) async {
Future<Result> tryUnlock(BuildContext context, Ref ref, UnlockReason reason) async {
if (reason != UnlockReason.openApp && reason != UnlockReason.sensibleAction) {
TotpList totps = await ref.read(totpRepositoryProvider.future);
if (totps.isEmpty) {
Expand Down Expand Up @@ -107,15 +107,15 @@ class MasterPasswordAppUnlockMethod extends AppUnlockMethod {
}

@override
Future<void> onMethodChosen(AsyncNotifierProviderRef ref, {ResultSuccess? enableResult}) async {
Future<void> onMethodChosen(Ref ref, {ResultSuccess? enableResult}) async {
String? password = enableResult?.valueOrNull;
if (await ref.read(passwordSignatureVerificationMethodProvider.notifier).enable(password)) {
await ref.read(cryptoStoreProvider.notifier).deleteFromLocalStorage();
}
}

@override
Future<void> onMethodChanged(AsyncNotifierProviderRef ref, {ResultSuccess? disableResult}) async {
Future<void> onMethodChanged(Ref ref, {ResultSuccess? disableResult}) async {
await ref.read(passwordSignatureVerificationMethodProvider.notifier).disable();
await ref.read(cryptoStoreProvider.notifier).saveCurrentOnLocalStorage(checkSettings: false);
}
Expand All @@ -124,7 +124,7 @@ class MasterPasswordAppUnlockMethod extends AppUnlockMethod {
/// No unlock.
class NoneAppUnlockMethod extends AppUnlockMethod {
@override
Future<Result> tryUnlock(BuildContext context, AsyncNotifierProviderRef ref, UnlockReason reason) => Future.value(const ResultSuccess());
Future<Result> tryUnlock(BuildContext context, Ref ref, UnlockReason reason) => Future.value(const ResultSuccess());
}

/// Configures the unlock reason for [UnlockChallenge]s.
Expand Down
12 changes: 6 additions & 6 deletions lib/model/backup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,21 +75,21 @@ class Backup implements Comparable<Backup> {
static const String kPasswordSignatureKey = 'passwordSignature';

/// The Riverpod ref.
final AsyncNotifierProviderRef _ref;
final Ref _ref;

/// The backup time.
final DateTime dateTime;

/// Creates a new backup instance.
Backup._({
required AsyncNotifierProviderRef ref,
required Ref ref,
required this.dateTime,
}) : _ref = ref;

/// Restore this backup.
Future<Result> restore(String password) async {
try {
File file = await _getBackupPath();
File file = await getBackupPath();
if (!file.existsSync()) {
throw _BackupFileDoesNotExistException(path: file.path);
}
Expand Down Expand Up @@ -146,7 +146,7 @@ class Backup implements Comparable<Backup> {
toBackup.add(decryptedTotp ?? totp);
}
HmacSecretKey hmacSecretKey = await HmacSecretKey.importRawKey(await newStore.key.exportRawKey(), Hash.sha256);
File file = await _getBackupPath(createDirectory: true);
File file = await getBackupPath(createDirectory: true);
file.writeAsString(jsonEncode({
kPasswordSignatureKey: base64.encode(await hmacSecretKey.signBytes(utf8.encode(password))),
kSaltKey: base64.encode(newStore.salt.value),
Expand All @@ -164,7 +164,7 @@ class Backup implements Comparable<Backup> {
/// Deletes this backup.
Future<Result> delete() async {
try {
File file = await _getBackupPath();
File file = await getBackupPath();
if (file.existsSync()) {
file.deleteSync();
}
Expand All @@ -179,7 +179,7 @@ class Backup implements Comparable<Backup> {
}

/// Returns the backup path (TOTPs and salt).
Future<File> _getBackupPath({bool createDirectory = false}) async {
Future<File> getBackupPath({bool createDirectory = false}) async {
Directory directory = await BackupStore._getBackupsDirectory(create: createDirectory);
return File(join(directory.path, '${dateTime.millisecondsSinceEpoch}.bak'));
}
Expand Down
129 changes: 82 additions & 47 deletions lib/pages/settings/entries/manage_backups.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:open_authenticator/i18n/translations.g.dart';
import 'package:open_authenticator/model/backup.dart';
import 'package:open_authenticator/utils/platform.dart';
import 'package:open_authenticator/utils/result.dart';
import 'package:open_authenticator/widgets/centered_circular_progress_indicator.dart';
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/waiting_overlay.dart';
import 'package:share_plus/share_plus.dart';

/// Allows the user to restore a backup.
class ManageBackupSettingsEntryWidget extends ConsumerWidget {
Expand Down Expand Up @@ -47,35 +50,31 @@ class _RestoreBackupDialog extends ConsumerStatefulWidget {

/// The restore backup dialog state.
class _RestoreBackupDialogState extends ConsumerState<_RestoreBackupDialog> {
/// The list global key.
late GlobalKey listKey = GlobalKey();

@override
Widget build(BuildContext context) {
DateFormat formatter = DateFormat(_RestoreBackupDialog.kDateFormat);
AsyncValue<List<Backup>> backups = ref.watch(backupStoreProvider);
Widget content;
switch (backups) {
case AsyncData(:final value):
content = ListView(
shrinkWrap: true,
children: [
for (Backup backup in value)
ListTile(
title: Text(formatter.format(backup.dateTime)),
onLongPress: currentPlatform.isDesktop ? null : () => deleteBackup(backup),
contentPadding: EdgeInsets.zero,
trailing: currentPlatform.isDesktop
? Row(
mainAxisSize: MainAxisSize.min,
children: [
createRestoreButton(backup),
IconButton(
onPressed: () => deleteBackup(backup),
icon: const Icon(Icons.delete),
),
],
)
: createRestoreButton(backup),
),
],
content = SizedBox(
width: MediaQuery.of(context).size.width,
child: ListView(
key: listKey,
shrinkWrap: true,
children: [
for (Backup backup in value)
ExpandListTile(
title: Text(
formatter.format(backup.dateTime),
),
children: createBackupActions(backup),
),
],
),
);
break;
case AsyncError(:final error):
Expand All @@ -102,32 +101,68 @@ class _RestoreBackupDialogState extends ConsumerState<_RestoreBackupDialog> {
);
}

/// Creates the button that allows to restore the given [backup].
Widget createRestoreButton(Backup backup) => IconButton(
onPressed: () async {
String? password = await TextInputDialog.prompt(
context,
title: translations.settings.backups.manageBackups.restoreBackupPasswordDialog.title,
message: translations.settings.backups.manageBackups.restoreBackupPasswordDialog.message,
password: true,
);
if (password == null || !mounted) {
return;
}
Result result = await showWaitingOverlay(
context,
future: backup.restore(password),
);
if (mounted) {
context.showSnackBarForResult(result);
Navigator.pop(context);
}
},
icon: const Icon(Icons.upload),
);
/// Creates the buttons to interact with a given [backup].
List<Widget> createBackupActions(Backup backup) => [
ListTile(
dense: true,
onTap: () => restoreBackup(backup),
title: Text(translations.settings.backups.manageBackups.button.restore),
leading: const Icon(Icons.upload),
),
ListTile(
dense: true,
onTap: () => exportBackup(backup),
title: Text(translations.settings.backups.manageBackups.button.export),
leading: const Icon(Icons.share),
),
ListTile(
dense: true,
onTap: () => deleteBackup(backup),
title: Text(translations.settings.backups.manageBackups.button.delete),
leading: const Icon(Icons.delete),
),
];

/// Asks the user for the given [backup] restoring.
Future<void> restoreBackup(Backup backup) async {
String? password = await TextInputDialog.prompt(
context,
title: translations.settings.backups.manageBackups.restoreBackupPasswordDialog.title,
message: translations.settings.backups.manageBackups.restoreBackupPasswordDialog.message,
password: true,
);
if (password == null || !mounted) {
return;
}
Result result = await showWaitingOverlay(
context,
future: backup.restore(password),
);
if (mounted) {
context.showSnackBarForResult(result);
Navigator.pop(context);
}
}

/// Asks the user for the given [backup] export.
Future<void> exportBackup(Backup backup) async {
RenderBox? box = listKey.currentContext?.findRenderObject() as RenderBox?;
File file = await backup.getBackupPath();
await Share.shareXFiles(
[
XFile(
file.path,
mimeType: 'application/json',
),
],
subject: translations.settings.backups.manageBackups.exportBackupDialog.subject,
text: translations.settings.backups.manageBackups.exportBackupDialog.text,
sharePositionOrigin: box == null ? Rect.zero : (box.localToGlobal(Offset.zero) & box.size),
);
}

/// Asks the user for the given [backup] deletion.
void deleteBackup(Backup backup) async {
Future<void> deleteBackup(Backup backup) async {
bool result = await ConfirmationDialog.ask(
context,
title: translations.settings.backups.manageBackups.deleteBackupConfirmationDialog.title,
Expand Down
2 changes: 2 additions & 0 deletions macos/Flutter/GeneratedPluginRegistrant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import path_provider_foundation
import purchases_flutter
import rate_my_app
import screen_retriever
import share_plus
import shared_preferences_foundation
import simple_secure_storage_darwin
import sqlite3_flutter_libs
Expand All @@ -37,6 +38,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PurchasesFlutterPlugin.register(with: registry.registrar(forPlugin: "PurchasesFlutterPlugin"))
SwiftRateMyAppPlugin.register(with: registry.registrar(forPlugin: "SwiftRateMyAppPlugin"))
ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SimpleSecureStoragePlugin.register(with: registry.registrar(forPlugin: "SimpleSecureStoragePlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
Expand Down
16 changes: 16 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0+4"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: "3af2cda1752e5c24f2fc04b6083b40f013ffe84fb90472f30c6499a9213d5442"
url: "https://pub.dev"
source: hosted
version: "10.1.1"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: c57c0bbfec7142e3a0f55633be504b796af72e60e3c791b44d5a017b985f7a48
url: "https://pub.dev"
source: hosted
version: "5.0.1"
shared_preferences:
dependency: "direct main"
description:
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ dependencies:
hashlib: ^1.21.0
hashlib_codecs: ^2.6.0
scrollable_positioned_list: ^0.3.8
share_plus: ^10.1.1

dev_dependencies:
# The "flutter_lints" package below contains a set of recommended lints to
Expand Down
3 changes: 3 additions & 0 deletions windows/flutter/generated_plugin_registrant.cc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <local_auth_windows/local_auth_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <simple_secure_storage_windows/simple_secure_storage_windows_plugin_c_api.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
Expand All @@ -31,6 +32,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
ScreenRetrieverPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
SimpleSecureStorageWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SimpleSecureStorageWindowsPluginCApi"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
Expand Down
1 change: 1 addition & 0 deletions windows/flutter/generated_plugins.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
firebase_core
local_auth_windows
screen_retriever
share_plus
simple_secure_storage_windows
sqlite3_flutter_libs
url_launcher_windows
Expand Down

0 comments on commit 5a7ce70

Please sign in to comment.