diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart
index fa4576ca..89f0129e 100644
--- a/app/lib/core/routing/routes.dart
+++ b/app/lib/core/routing/routes.dart
@@ -18,7 +18,6 @@
* along with this program. If not, see .
*/
import 'dart:convert';
-import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart
index 8dbf6502..acb7f9c4 100644
--- a/app/lib/core/routing/routes.g.dart
+++ b/app/lib/core/routing/routes.g.dart
@@ -1297,7 +1297,7 @@ mixin $ProfileBackupListRoute on GoRouteData {
mixin $RestoreProfileRoute on GoRouteData {
static RestoreProfileRoute _fromState(GoRouterState state) =>
RestoreProfileRoute(
- backupFilePath: state.uri.queryParameters['backup-file-path']!,
+ backupFileUri: state.uri.queryParameters['backup-file-uri']!,
);
RestoreProfileRoute get _self => this as RestoreProfileRoute;
@@ -1305,7 +1305,7 @@ mixin $RestoreProfileRoute on GoRouteData {
@override
String get location => GoRouteData.$location(
'/profiles/restore',
- queryParams: {'backup-file-path': _self.backupFilePath},
+ queryParams: {'backup-file-uri': _self.backupFileUri},
);
@override
diff --git a/app/lib/core/routing/routes.profiles.dart b/app/lib/core/routing/routes.profiles.dart
index e1f4ab4f..fdcb42d1 100644
--- a/app/lib/core/routing/routes.profiles.dart
+++ b/app/lib/core/routing/routes.profiles.dart
@@ -83,13 +83,13 @@ class BackupProfileRoute extends GoRouteData with $BackupProfileRoute {
}
class RestoreProfileRoute extends GoRouteData with $RestoreProfileRoute {
- final String backupFilePath;
+ final String backupFileUri;
- const RestoreProfileRoute({required this.backupFilePath});
+ const RestoreProfileRoute({required this.backupFileUri});
@override
Widget build(BuildContext context, GoRouterState state) {
- return ProfileRestoreScreen(backupFile: File(backupFilePath));
+ return ProfileRestoreScreen(backupFileUri: Uri.parse(backupFileUri));
}
}
diff --git a/app/lib/features/user/domain/presentation/screens/profile_backup.dart b/app/lib/features/user/domain/presentation/screens/profile_backup.dart
index 0b54e652..1867afb4 100644
--- a/app/lib/features/user/domain/presentation/screens/profile_backup.dart
+++ b/app/lib/features/user/domain/presentation/screens/profile_backup.dart
@@ -22,9 +22,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:saf_util/saf_util.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/domain/entities/profile.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/password_confirmation_dialog.dart';
+import 'package:weblibre/features/user/domain/providers/backup_directory.dart';
import 'package:weblibre/features/user/domain/services/user_backup.dart';
import 'package:weblibre/utils/ui_helper.dart';
@@ -156,6 +158,17 @@ class ProfileBackupScreen extends HookConsumerWidget {
}
}
+ if (ref.read(backupDirectoryUriProvider) == null) {
+ final dir = await SafUtil().pickDirectory(
+ writePermission: true,
+ persistablePermission: true,
+ );
+ if (dir == null) return;
+ ref
+ .read(backupDirectoryUriProvider.notifier)
+ .set(Uri.parse(dir.uri));
+ }
+
backupFuture.value = ref
.read(userBackupServiceProvider.notifier)
.createUserBackup(
diff --git a/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart b/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart
index 38dba97d..46863d32 100644
--- a/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart
+++ b/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart
@@ -18,11 +18,13 @@
* along with this program. If not, see .
*/
import 'package:flutter/material.dart';
+import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:path/path.dart' as p;
+import 'package:saf_util/saf_util.dart';
import 'package:weblibre/core/providers/format.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/user/domain/providers.dart';
+import 'package:weblibre/features/user/domain/providers/backup_directory.dart';
import 'package:weblibre/features/user/domain/services/user_backup.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
@@ -33,67 +35,128 @@ final _filenamePattern = RegExp(
class ProfileBackupListScreen extends HookConsumerWidget {
const ProfileBackupListScreen({super.key});
+ Future _pickDirectory(WidgetRef ref) async {
+ final dir = await SafUtil().pickDirectory(
+ writePermission: true,
+ persistablePermission: true,
+ );
+
+ if (dir != null) {
+ final dirUri = Uri.parse(dir.uri);
+ ref.read(backupDirectoryUriProvider.notifier).set(dirUri);
+
+ final migrated = await ref
+ .read(userBackupServiceProvider.notifier)
+ .migrateOldBackups(dirUri);
+
+ if (migrated > 0) {
+ ref.invalidate(backupListProvider);
+ }
+ }
+ }
+
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final dirUri = ref.watch(backupDirectoryUriProvider);
final backupListAsync = ref.watch(backupListProvider);
return Scaffold(
- appBar: AppBar(title: const Text('Backups')),
- body: SafeArea(
- child: backupListAsync.when(
- data: (backupList) {
- return ListView.builder(
- itemCount: backupList.length,
- itemBuilder: (context, index) {
- final file = backupList[index];
- final match = _filenamePattern.firstMatch(
- p.basename(file.path),
- );
-
- if (match != null) {
- final profileName = match.group(1)!;
- final datePart = match.group(2)!;
-
- // Reparse into DateTime
- final dateTime = UserBackupService.dateFormatter.decode(
- datePart,
- );
-
- return ListTile(
- key: ValueKey(file.path),
- title: Text(profileName),
- subtitle: Text(
- ref.read(formatProvider.notifier).fullDateTime(dateTime),
- ),
- onTap: () async {
- await RestoreProfileRoute(
- backupFilePath: file.path,
- ).push(context);
- },
- );
- } else {
- return ListTile(
- key: ValueKey(file.path),
- title: Text(p.basename(file.path)),
- onTap: () async {
- await RestoreProfileRoute(
- backupFilePath: file.path,
- ).push(context);
- },
- );
- }
- },
- );
- },
- error: (error, stackTrace) => FailureWidget(
- title: 'Failed to get backups',
- exception: error,
- onRetry: () {
- ref.invalidate(backupListProvider);
- },
+ appBar: AppBar(
+ title: const Text('Backups'),
+ actions: [
+ IconButton(
+ icon: const Icon(MdiIcons.folderCog),
+ tooltip: 'Change backup directory',
+ onPressed: () => _pickDirectory(ref),
),
- loading: () => const Center(child: CircularProgressIndicator()),
- ),
+ ],
+ ),
+ body: SafeArea(
+ child: dirUri == null
+ ? Center(
+ child: Padding(
+ padding: const EdgeInsets.all(32.0),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(MdiIcons.folderOpen, size: 64),
+ const SizedBox(height: 16),
+ const Text(
+ 'Select a directory to store your backups.',
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ 'Choose a location outside of the app to keep your backups safe across reinstalls.',
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 24),
+ FilledButton.icon(
+ icon: const Icon(MdiIcons.folderPlus),
+ label: const Text('Select Backup Directory'),
+ onPressed: () => _pickDirectory(ref),
+ ),
+ ],
+ ),
+ ),
+ )
+ : backupListAsync.when(
+ data: (backupList) {
+ if (backupList.isEmpty) {
+ return const Center(child: Text('No backups found'));
+ }
+
+ return ListView.builder(
+ itemCount: backupList.length,
+ itemBuilder: (context, index) {
+ final file = backupList[index];
+ final match = _filenamePattern.firstMatch(file.name);
+
+ if (match != null) {
+ final profileName = match.group(1)!;
+ final datePart = match.group(2)!;
+
+ final dateTime = UserBackupService.dateFormatter.decode(
+ datePart,
+ );
+
+ return ListTile(
+ key: ValueKey(file.uri),
+ title: Text(profileName),
+ subtitle: Text(
+ ref
+ .read(formatProvider.notifier)
+ .fullDateTime(dateTime),
+ ),
+ onTap: () async {
+ await RestoreProfileRoute(
+ backupFileUri: file.uri,
+ ).push(context);
+ },
+ );
+ } else {
+ return ListTile(
+ key: ValueKey(file.uri),
+ title: Text(file.name),
+ onTap: () async {
+ await RestoreProfileRoute(
+ backupFileUri: file.uri,
+ ).push(context);
+ },
+ );
+ }
+ },
+ );
+ },
+ error: (error, stackTrace) => FailureWidget(
+ title: 'Failed to get backups',
+ exception: error,
+ onRetry: () {
+ ref.invalidate(backupListProvider);
+ },
+ ),
+ loading: () => const Center(child: CircularProgressIndicator()),
+ ),
),
);
}
diff --git a/app/lib/features/user/domain/presentation/screens/profile_restore.dart b/app/lib/features/user/domain/presentation/screens/profile_restore.dart
index b79d48f8..539581f2 100644
--- a/app/lib/features/user/domain/presentation/screens/profile_restore.dart
+++ b/app/lib/features/user/domain/presentation/screens/profile_restore.dart
@@ -17,8 +17,6 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
-import 'dart:io';
-
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
@@ -32,9 +30,9 @@ import 'package:weblibre/utils/ui_helper.dart';
enum RestoreTarget { createOrOverride, createNew }
class ProfileRestoreScreen extends HookConsumerWidget {
- final File backupFile;
+ final Uri backupFileUri;
- const ProfileRestoreScreen({super.key, required this.backupFile});
+ const ProfileRestoreScreen({super.key, required this.backupFileUri});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -149,7 +147,7 @@ class ProfileRestoreScreen extends HookConsumerWidget {
ref
.read(userBackupServiceProvider.notifier)
.restoreAndCreateOrOverride(
- backupFile,
+ backupFileUri,
password: passwordTextController.text,
confirmOverrideCallback: () {
if (context.mounted) {
@@ -163,7 +161,7 @@ class ProfileRestoreScreen extends HookConsumerWidget {
ref
.read(userBackupServiceProvider.notifier)
.restoreAndCreateNew(
- backupFile,
+ backupFileUri,
profileName: nameTextController.text,
password: passwordTextController.text,
),
diff --git a/app/lib/features/user/domain/providers.dart b/app/lib/features/user/domain/providers.dart
index d492dc98..8ded41ca 100644
--- a/app/lib/features/user/domain/providers.dart
+++ b/app/lib/features/user/domain/providers.dart
@@ -17,16 +17,16 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
-import 'dart:io';
-
import 'package:exceptions/exceptions.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'package:saf_util/saf_util_platform_interface.dart';
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/domain/entities/profile.dart';
import 'package:weblibre/features/user/data/providers.dart';
import 'package:weblibre/features/user/domain/entities/fingerprint_overrides.dart';
+import 'package:weblibre/features/user/domain/providers/backup_directory.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/profile.dart';
@@ -78,9 +78,9 @@ Future selectedProfile(Ref ref) async {
}
@Riverpod()
-Future> backupList(Ref ref) {
- return ref
- .watch(userBackupServiceProvider.notifier)
- .getBackupListStream()
- .toList();
+Future> backupList(Ref ref) async {
+ final dirUri = ref.watch(backupDirectoryUriProvider);
+ if (dirUri == null) return [];
+
+ return ref.watch(userBackupServiceProvider.notifier).getBackupList(dirUri);
}
diff --git a/app/lib/features/user/domain/providers.g.dart b/app/lib/features/user/domain/providers.g.dart
index 9263fc0a..9f621e21 100644
--- a/app/lib/features/user/domain/providers.g.dart
+++ b/app/lib/features/user/domain/providers.g.dart
@@ -167,11 +167,13 @@ final backupListProvider = BackupListProvider._();
final class BackupListProvider
extends
$FunctionalProvider<
- AsyncValue>,
- List,
- FutureOr>
+ AsyncValue>,
+ List,
+ FutureOr>
>
- with $FutureModifier>, $FutureProvider> {
+ with
+ $FutureModifier>,
+ $FutureProvider> {
BackupListProvider._()
: super(
from: null,
@@ -188,13 +190,14 @@ final class BackupListProvider
@$internal
@override
- $FutureProviderElement> $createElement($ProviderPointer pointer) =>
- $FutureProviderElement(pointer);
+ $FutureProviderElement> $createElement(
+ $ProviderPointer pointer,
+ ) => $FutureProviderElement(pointer);
@override
- FutureOr> create(Ref ref) {
+ FutureOr> create(Ref ref) {
return backupList(ref);
}
}
-String _$backupListHash() => r'6fdfbb5293df11aa37ed74f4fcf9bd78591d770f';
+String _$backupListHash() => r'cdce1b5f195f0c9b72132f7da0c9d173dfc119eb';
diff --git a/app/lib/features/user/domain/providers/backup_directory.dart b/app/lib/features/user/domain/providers/backup_directory.dart
new file mode 100644
index 00000000..19a152a0
--- /dev/null
+++ b/app/lib/features/user/domain/providers/backup_directory.dart
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:riverpod/experimental/persist.dart';
+import 'package:riverpod_annotation/experimental/persist.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'package:weblibre/features/user/data/providers.dart';
+
+part 'backup_directory.g.dart';
+
+@Riverpod(keepAlive: true)
+class BackupDirectoryUri extends _$BackupDirectoryUri {
+ // ignore: use_setters_to_change_properties
+ void set(Uri? value) => state = value;
+
+ @override
+ Uri? build() {
+ persist(
+ ref.watch(riverpodDatabaseStorageProvider),
+ key: 'BackupDirectoryUri',
+ encode: (state) => state?.toString() ?? '',
+ decode: (encoded) => encoded.isEmpty ? null : Uri.parse(encoded),
+ );
+
+ return stateOrNull;
+ }
+}
diff --git a/app/lib/features/user/domain/providers/backup_directory.g.dart b/app/lib/features/user/domain/providers/backup_directory.g.dart
new file mode 100644
index 00000000..b516be5a
--- /dev/null
+++ b/app/lib/features/user/domain/providers/backup_directory.g.dart
@@ -0,0 +1,63 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'backup_directory.dart';
+
+// **************************************************************************
+// RiverpodGenerator
+// **************************************************************************
+
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint, type=warning
+
+@ProviderFor(BackupDirectoryUri)
+final backupDirectoryUriProvider = BackupDirectoryUriProvider._();
+
+final class BackupDirectoryUriProvider
+ extends $NotifierProvider {
+ BackupDirectoryUriProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'backupDirectoryUriProvider',
+ isAutoDispose: false,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$backupDirectoryUriHash();
+
+ @$internal
+ @override
+ BackupDirectoryUri create() => BackupDirectoryUri();
+
+ /// {@macro riverpod.override_with_value}
+ Override overrideWithValue(Uri? value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider(value),
+ );
+ }
+}
+
+String _$backupDirectoryUriHash() =>
+ r'4e5f4e7bde90b2a92c559afe8774ed5ee503277d';
+
+abstract class _$BackupDirectoryUri extends $Notifier {
+ Uri? build();
+ @$mustCallSuper
+ @override
+ void runBuild() {
+ final ref = this.ref as $Ref;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier,
+ Uri?,
+ Object?,
+ Object?
+ >;
+ element.handleCreate(ref, build);
+ }
+}
diff --git a/app/lib/features/user/domain/services/user_backup.dart b/app/lib/features/user/domain/services/user_backup.dart
index b2f7f50d..e49239c8 100644
--- a/app/lib/features/user/domain/services/user_backup.dart
+++ b/app/lib/features/user/domain/services/user_backup.dart
@@ -23,10 +23,14 @@ import 'package:convert/convert.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'package:saf_stream/saf_stream.dart';
+import 'package:saf_util/saf_util.dart';
+import 'package:saf_util/saf_util_platform_interface.dart';
import 'package:secure_archive/secure_archive.dart';
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/domain/entities/profile.dart';
+import 'package:weblibre/features/user/domain/providers/backup_directory.dart';
import 'package:weblibre/features/user/domain/repositories/profile.dart';
part 'user_backup.g.dart';
@@ -35,29 +39,22 @@ part 'user_backup.g.dart';
class UserBackupService extends _$UserBackupService {
static final dateFormatter = FixedDateTimeFormatter('YYYY-MM-DD_hhmmss');
- Future getBackupDirectory() async {
- return Directory(
- p.join(
- await getExternalStorageDirectory().then(
- (dir) => Directory(
- dir!.path.replaceFirst('/data/', '/media/'),
- ).parent.path,
- ),
- 'Backup',
- ),
- );
+ static final _safUtil = SafUtil();
+ static final _safStream = SafStream();
+
+ Uri _requireBackupDirectoryUri() {
+ final uri = ref.read(backupDirectoryUriProvider);
+ if (uri == null) {
+ throw Exception('No backup directory configured');
+ }
+ return uri;
}
- Stream getBackupListStream() async* {
- final backupDirectory = await getBackupDirectory();
-
- if (!await backupDirectory.exists()) return;
-
- await for (final entity in backupDirectory.list(recursive: true)) {
- if (entity is File) {
- yield entity;
- }
- }
+ Future> getBackupList(Uri dirUri) async {
+ final files = await _safUtil.list(dirUri.toString());
+ return files
+ .where((f) => !f.isDir && f.name.endsWith('.weblibre'))
+ .toList();
}
Future createUserBackup(
@@ -65,40 +62,62 @@ class UserBackupService extends _$UserBackupService {
required String password,
required bool integrityCheck,
}) async {
+ final dirUri = _requireBackupDirectoryUri();
final timestamp = dateFormatter.encode(DateTime.now());
+ final fileName = 'backup_${profile.name}_$timestamp.weblibre';
- final outputFile = File(
- p.join(
- await getBackupDirectory().then((dir) => dir.path),
- 'backup_${profile.name}_$timestamp.weblibre',
- ),
- );
+ final tempDir = await getTemporaryDirectory();
+ final tempFile = File(p.join(tempDir.path, fileName));
- await outputFile.parent.create(recursive: true);
+ try {
+ final backup = SecureArchivePack(
+ outputFile: tempFile,
+ sourceDirectory: filesystem.getProfileDir(profile.uuidValue),
+ argon2Params: Argon2Params.memoryConstrained(),
+ );
- final backup = SecureArchivePack(
- outputFile: outputFile,
- sourceDirectory: filesystem.getProfileDir(profile.uuidValue),
- argon2Params: Argon2Params.memoryConstrained(),
- );
+ await backup.pack(password, integrityCheck: integrityCheck);
- await backup.pack(password, integrityCheck: integrityCheck);
+ await _safStream.pasteLocalFile(
+ tempFile.path,
+ dirUri.toString(),
+ fileName,
+ 'application/octet-stream',
+ );
- return true;
+ return true;
+ } finally {
+ try {
+ if (await tempFile.exists()) {
+ await tempFile.delete();
+ }
+ } catch (e, s) {
+ logger.w(
+ 'Failed to cleanup temporary backup file: ${tempFile.path}',
+ error: e,
+ stackTrace: s,
+ );
+ }
+ }
}
Future restoreAndCreateNew(
- File backupFile, {
+ Uri backupFileUri, {
required String profileName,
required String password,
}) async {
+ final tempDir = await getTemporaryDirectory();
+ final tempFile = File(p.join(tempDir.path, 'restore_temp.weblibre'));
+
final outputDirectory = Directory(
- p.join(filesystem.profilesDir.path, p.basename(backupFile.path)),
+ p.join(filesystem.profilesDir.path, 'restore_temp'),
);
try {
+ await _safStream.copyToLocalFile(backupFileUri.toString(), tempFile.path);
+
final backup = SecureArchiveUnpack(
- inputFile: backupFile,
+ inputFile: tempFile,
outputDirectory: outputDirectory,
argon2Params: Argon2Params.memoryConstrained(),
);
@@ -114,6 +133,17 @@ class UserBackupService extends _$UserBackupService {
ref.invalidate(profileRepositoryProvider);
return true;
} finally {
+ try {
+ if (await tempFile.exists()) {
+ await tempFile.delete();
+ }
+ } catch (e, s) {
+ logger.w(
+ 'Failed to cleanup temporary restore file: ${tempFile.path}',
+ error: e,
+ stackTrace: s,
+ );
+ }
try {
if (await outputDirectory.exists()) {
await outputDirectory.delete(recursive: true);
@@ -129,17 +159,22 @@ class UserBackupService extends _$UserBackupService {
}
Future restoreAndCreateOrOverride(
- File backupFile, {
+ Uri backupFileUri, {
required String password,
required FutureOr Function() confirmOverrideCallback,
}) async {
+ final tempDir = await getTemporaryDirectory();
+ final tempFile = File(p.join(tempDir.path, 'restore_temp.weblibre'));
+
final outputDirectory = Directory(
- p.join(filesystem.profilesDir.path, p.basename(backupFile.path)),
+ p.join(filesystem.profilesDir.path, 'restore_temp'),
);
try {
+ await _safStream.copyToLocalFile(backupFileUri.toString(), tempFile.path);
+
final backup = SecureArchiveUnpack(
- inputFile: backupFile,
+ inputFile: tempFile,
outputDirectory: outputDirectory,
argon2Params: Argon2Params.memoryConstrained(),
);
@@ -177,6 +212,17 @@ class UserBackupService extends _$UserBackupService {
ref.invalidate(profileRepositoryProvider);
return true;
} finally {
+ try {
+ if (await tempFile.exists()) {
+ await tempFile.delete();
+ }
+ } catch (e, s) {
+ logger.w(
+ 'Failed to cleanup temporary restore file: ${tempFile.path}',
+ error: e,
+ stackTrace: s,
+ );
+ }
try {
if (await outputDirectory.exists()) {
await outputDirectory.delete(recursive: true);
@@ -191,6 +237,55 @@ class UserBackupService extends _$UserBackupService {
}
}
+ Future migrateOldBackups(Uri newDirUri) async {
+ try {
+ final oldDir = Directory(
+ p.join(
+ await getExternalStorageDirectory().then(
+ (dir) => Directory(
+ dir!.path.replaceFirst('/data/', '/media/'),
+ ).parent.path,
+ ),
+ 'Backup',
+ ),
+ );
+
+ if (!await oldDir.exists()) return 0;
+
+ var count = 0;
+ await for (final entity in oldDir.list()) {
+ if (entity is File && entity.path.endsWith('.weblibre')) {
+ try {
+ await _safStream.pasteLocalFile(
+ entity.path,
+ newDirUri.toString(),
+ p.basename(entity.path),
+ 'application/octet-stream',
+ );
+ await entity.delete();
+ count++;
+ } catch (e, s) {
+ logger.w(
+ 'Failed to migrate backup: ${entity.path}',
+ error: e,
+ stackTrace: s,
+ );
+ }
+ }
+ }
+
+ // Clean up old directory if empty
+ if (await oldDir.list().isEmpty) {
+ await oldDir.delete();
+ }
+
+ return count;
+ } catch (e, s) {
+ logger.w('Failed to migrate old backups', error: e, stackTrace: s);
+ return 0;
+ }
+ }
+
@override
void build() {}
}
diff --git a/app/lib/features/user/domain/services/user_backup.g.dart b/app/lib/features/user/domain/services/user_backup.g.dart
index eb582181..9ce1b419 100644
--- a/app/lib/features/user/domain/services/user_backup.g.dart
+++ b/app/lib/features/user/domain/services/user_backup.g.dart
@@ -41,7 +41,7 @@ final class UserBackupServiceProvider
}
}
-String _$userBackupServiceHash() => r'fb0b29075da3b32135d6309ef1bb7a7f119a3e25';
+String _$userBackupServiceHash() => r'74cd6fd9818a8350a91b1225d904e23f48bc9e30';
abstract class _$UserBackupService extends $Notifier {
void build();
diff --git a/app/pubspec.yaml b/app/pubspec.yaml
index 102e667c..73e57017 100644
--- a/app/pubspec.yaml
+++ b/app/pubspec.yaml
@@ -70,6 +70,8 @@ dependencies:
riverpod_annotation: ^4.0.2
rss_dart: ^1.0.14
rxdart: ^0.28.0
+ saf_stream: ^2.0.0
+ saf_util: ^2.0.0
secure_archive:
git:
url: https://github.com/FaFre/secure_archive.git