From 296226760ff7410ab430ad4ebd784b3c817e4f9d Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 23 Mar 2026 13:47:21 +0100 Subject: [PATCH] use new location --- app/lib/core/routing/routes.dart | 1 - app/lib/core/routing/routes.g.dart | 4 +- app/lib/core/routing/routes.profiles.dart | 6 +- .../presentation/screens/profile_backup.dart | 13 ++ .../screens/profile_backup_list.dart | 175 +++++++++++------ .../presentation/screens/profile_restore.dart | 10 +- app/lib/features/user/domain/providers.dart | 14 +- app/lib/features/user/domain/providers.g.dart | 19 +- .../domain/providers/backup_directory.dart | 43 +++++ .../domain/providers/backup_directory.g.dart | 63 +++++++ .../user/domain/services/user_backup.dart | 177 ++++++++++++++---- .../user/domain/services/user_backup.g.dart | 2 +- app/pubspec.yaml | 2 + 13 files changed, 404 insertions(+), 125 deletions(-) create mode 100644 app/lib/features/user/domain/providers/backup_directory.dart create mode 100644 app/lib/features/user/domain/providers/backup_directory.g.dart 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