use new location
This commit is contained in:
@@ -18,7 +18,6 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
|||||||
@@ -1297,7 +1297,7 @@ mixin $ProfileBackupListRoute on GoRouteData {
|
|||||||
mixin $RestoreProfileRoute on GoRouteData {
|
mixin $RestoreProfileRoute on GoRouteData {
|
||||||
static RestoreProfileRoute _fromState(GoRouterState state) =>
|
static RestoreProfileRoute _fromState(GoRouterState state) =>
|
||||||
RestoreProfileRoute(
|
RestoreProfileRoute(
|
||||||
backupFilePath: state.uri.queryParameters['backup-file-path']!,
|
backupFileUri: state.uri.queryParameters['backup-file-uri']!,
|
||||||
);
|
);
|
||||||
|
|
||||||
RestoreProfileRoute get _self => this as RestoreProfileRoute;
|
RestoreProfileRoute get _self => this as RestoreProfileRoute;
|
||||||
@@ -1305,7 +1305,7 @@ mixin $RestoreProfileRoute on GoRouteData {
|
|||||||
@override
|
@override
|
||||||
String get location => GoRouteData.$location(
|
String get location => GoRouteData.$location(
|
||||||
'/profiles/restore',
|
'/profiles/restore',
|
||||||
queryParams: {'backup-file-path': _self.backupFilePath},
|
queryParams: {'backup-file-uri': _self.backupFileUri},
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -83,13 +83,13 @@ class BackupProfileRoute extends GoRouteData with $BackupProfileRoute {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class RestoreProfileRoute extends GoRouteData with $RestoreProfileRoute {
|
class RestoreProfileRoute extends GoRouteData with $RestoreProfileRoute {
|
||||||
final String backupFilePath;
|
final String backupFileUri;
|
||||||
|
|
||||||
const RestoreProfileRoute({required this.backupFilePath});
|
const RestoreProfileRoute({required this.backupFileUri});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, GoRouterState state) {
|
Widget build(BuildContext context, GoRouterState state) {
|
||||||
return ProfileRestoreScreen(backupFile: File(backupFilePath));
|
return ProfileRestoreScreen(backupFileUri: Uri.parse(backupFileUri));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/core/routing/routes.dart';
|
||||||
import 'package:weblibre/domain/entities/profile.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/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/features/user/domain/services/user_backup.dart';
|
||||||
import 'package:weblibre/utils/ui_helper.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
|
backupFuture.value = ref
|
||||||
.read(userBackupServiceProvider.notifier)
|
.read(userBackupServiceProvider.notifier)
|
||||||
.createUserBackup(
|
.createUserBackup(
|
||||||
|
|||||||
@@ -18,11 +18,13 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'package:flutter/material.dart';
|
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: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/providers/format.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/user/domain/providers.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/features/user/domain/services/user_backup.dart';
|
||||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||||
|
|
||||||
@@ -33,67 +35,128 @@ final _filenamePattern = RegExp(
|
|||||||
class ProfileBackupListScreen extends HookConsumerWidget {
|
class ProfileBackupListScreen extends HookConsumerWidget {
|
||||||
const ProfileBackupListScreen({super.key});
|
const ProfileBackupListScreen({super.key});
|
||||||
|
|
||||||
|
Future<void> _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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final dirUri = ref.watch(backupDirectoryUriProvider);
|
||||||
final backupListAsync = ref.watch(backupListProvider);
|
final backupListAsync = ref.watch(backupListProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Backups')),
|
appBar: AppBar(
|
||||||
body: SafeArea(
|
title: const Text('Backups'),
|
||||||
child: backupListAsync.when(
|
actions: [
|
||||||
data: (backupList) {
|
IconButton(
|
||||||
return ListView.builder(
|
icon: const Icon(MdiIcons.folderCog),
|
||||||
itemCount: backupList.length,
|
tooltip: 'Change backup directory',
|
||||||
itemBuilder: (context, index) {
|
onPressed: () => _pickDirectory(ref),
|
||||||
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);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
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()),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,6 @@
|
|||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.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 }
|
enum RestoreTarget { createOrOverride, createNew }
|
||||||
|
|
||||||
class ProfileRestoreScreen extends HookConsumerWidget {
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -149,7 +147,7 @@ class ProfileRestoreScreen extends HookConsumerWidget {
|
|||||||
ref
|
ref
|
||||||
.read(userBackupServiceProvider.notifier)
|
.read(userBackupServiceProvider.notifier)
|
||||||
.restoreAndCreateOrOverride(
|
.restoreAndCreateOrOverride(
|
||||||
backupFile,
|
backupFileUri,
|
||||||
password: passwordTextController.text,
|
password: passwordTextController.text,
|
||||||
confirmOverrideCallback: () {
|
confirmOverrideCallback: () {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -163,7 +161,7 @@ class ProfileRestoreScreen extends HookConsumerWidget {
|
|||||||
ref
|
ref
|
||||||
.read(userBackupServiceProvider.notifier)
|
.read(userBackupServiceProvider.notifier)
|
||||||
.restoreAndCreateNew(
|
.restoreAndCreateNew(
|
||||||
backupFile,
|
backupFileUri,
|
||||||
profileName: nameTextController.text,
|
profileName: nameTextController.text,
|
||||||
password: passwordTextController.text,
|
password: passwordTextController.text,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,16 +17,16 @@
|
|||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:exceptions/exceptions.dart';
|
import 'package:exceptions/exceptions.dart';
|
||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:riverpod/riverpod.dart';
|
import 'package:riverpod/riverpod.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.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/core/filesystem.dart';
|
||||||
import 'package:weblibre/domain/entities/profile.dart';
|
import 'package:weblibre/domain/entities/profile.dart';
|
||||||
import 'package:weblibre/features/user/data/providers.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/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/engine_settings.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/profile.dart';
|
import 'package:weblibre/features/user/domain/repositories/profile.dart';
|
||||||
@@ -78,9 +78,9 @@ Future<Profile> selectedProfile(Ref ref) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Riverpod()
|
@Riverpod()
|
||||||
Future<List<File>> backupList(Ref ref) {
|
Future<List<SafDocumentFile>> backupList(Ref ref) async {
|
||||||
return ref
|
final dirUri = ref.watch(backupDirectoryUriProvider);
|
||||||
.watch(userBackupServiceProvider.notifier)
|
if (dirUri == null) return [];
|
||||||
.getBackupListStream()
|
|
||||||
.toList();
|
return ref.watch(userBackupServiceProvider.notifier).getBackupList(dirUri);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,11 +167,13 @@ final backupListProvider = BackupListProvider._();
|
|||||||
final class BackupListProvider
|
final class BackupListProvider
|
||||||
extends
|
extends
|
||||||
$FunctionalProvider<
|
$FunctionalProvider<
|
||||||
AsyncValue<List<File>>,
|
AsyncValue<List<SafDocumentFile>>,
|
||||||
List<File>,
|
List<SafDocumentFile>,
|
||||||
FutureOr<List<File>>
|
FutureOr<List<SafDocumentFile>>
|
||||||
>
|
>
|
||||||
with $FutureModifier<List<File>>, $FutureProvider<List<File>> {
|
with
|
||||||
|
$FutureModifier<List<SafDocumentFile>>,
|
||||||
|
$FutureProvider<List<SafDocumentFile>> {
|
||||||
BackupListProvider._()
|
BackupListProvider._()
|
||||||
: super(
|
: super(
|
||||||
from: null,
|
from: null,
|
||||||
@@ -188,13 +190,14 @@ final class BackupListProvider
|
|||||||
|
|
||||||
@$internal
|
@$internal
|
||||||
@override
|
@override
|
||||||
$FutureProviderElement<List<File>> $createElement($ProviderPointer pointer) =>
|
$FutureProviderElement<List<SafDocumentFile>> $createElement(
|
||||||
$FutureProviderElement(pointer);
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<List<File>> create(Ref ref) {
|
FutureOr<List<SafDocumentFile>> create(Ref ref) {
|
||||||
return backupList(ref);
|
return backupList(ref);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$backupListHash() => r'6fdfbb5293df11aa37ed74f4fcf9bd78591d770f';
|
String _$backupListHash() => r'cdce1b5f195f0c9b72132f7da0c9d173dfc119eb';
|
||||||
|
|||||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BackupDirectoryUri, Uri?> {
|
||||||
|
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<Uri?>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$backupDirectoryUriHash() =>
|
||||||
|
r'4e5f4e7bde90b2a92c559afe8774ed5ee503277d';
|
||||||
|
|
||||||
|
abstract class _$BackupDirectoryUri extends $Notifier<Uri?> {
|
||||||
|
Uri? build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<Uri?, Uri?>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<Uri?, Uri?>,
|
||||||
|
Uri?,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,10 +23,14 @@ import 'package:convert/convert.dart';
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.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:secure_archive/secure_archive.dart';
|
||||||
import 'package:weblibre/core/filesystem.dart';
|
import 'package:weblibre/core/filesystem.dart';
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
import 'package:weblibre/domain/entities/profile.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';
|
import 'package:weblibre/features/user/domain/repositories/profile.dart';
|
||||||
|
|
||||||
part 'user_backup.g.dart';
|
part 'user_backup.g.dart';
|
||||||
@@ -35,29 +39,22 @@ part 'user_backup.g.dart';
|
|||||||
class UserBackupService extends _$UserBackupService {
|
class UserBackupService extends _$UserBackupService {
|
||||||
static final dateFormatter = FixedDateTimeFormatter('YYYY-MM-DD_hhmmss');
|
static final dateFormatter = FixedDateTimeFormatter('YYYY-MM-DD_hhmmss');
|
||||||
|
|
||||||
Future<Directory> getBackupDirectory() async {
|
static final _safUtil = SafUtil();
|
||||||
return Directory(
|
static final _safStream = SafStream();
|
||||||
p.join(
|
|
||||||
await getExternalStorageDirectory().then(
|
Uri _requireBackupDirectoryUri() {
|
||||||
(dir) => Directory(
|
final uri = ref.read(backupDirectoryUriProvider);
|
||||||
dir!.path.replaceFirst('/data/', '/media/'),
|
if (uri == null) {
|
||||||
).parent.path,
|
throw Exception('No backup directory configured');
|
||||||
),
|
}
|
||||||
'Backup',
|
return uri;
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<File> getBackupListStream() async* {
|
Future<List<SafDocumentFile>> getBackupList(Uri dirUri) async {
|
||||||
final backupDirectory = await getBackupDirectory();
|
final files = await _safUtil.list(dirUri.toString());
|
||||||
|
return files
|
||||||
if (!await backupDirectory.exists()) return;
|
.where((f) => !f.isDir && f.name.endsWith('.weblibre'))
|
||||||
|
.toList();
|
||||||
await for (final entity in backupDirectory.list(recursive: true)) {
|
|
||||||
if (entity is File) {
|
|
||||||
yield entity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> createUserBackup(
|
Future<bool> createUserBackup(
|
||||||
@@ -65,40 +62,62 @@ class UserBackupService extends _$UserBackupService {
|
|||||||
required String password,
|
required String password,
|
||||||
required bool integrityCheck,
|
required bool integrityCheck,
|
||||||
}) async {
|
}) async {
|
||||||
|
final dirUri = _requireBackupDirectoryUri();
|
||||||
final timestamp = dateFormatter.encode(DateTime.now());
|
final timestamp = dateFormatter.encode(DateTime.now());
|
||||||
|
final fileName = 'backup_${profile.name}_$timestamp.weblibre';
|
||||||
|
|
||||||
final outputFile = File(
|
final tempDir = await getTemporaryDirectory();
|
||||||
p.join(
|
final tempFile = File(p.join(tempDir.path, fileName));
|
||||||
await getBackupDirectory().then((dir) => dir.path),
|
|
||||||
'backup_${profile.name}_$timestamp.weblibre',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await outputFile.parent.create(recursive: true);
|
try {
|
||||||
|
final backup = SecureArchivePack(
|
||||||
|
outputFile: tempFile,
|
||||||
|
sourceDirectory: filesystem.getProfileDir(profile.uuidValue),
|
||||||
|
argon2Params: Argon2Params.memoryConstrained(),
|
||||||
|
);
|
||||||
|
|
||||||
final backup = SecureArchivePack(
|
await backup.pack(password, integrityCheck: integrityCheck);
|
||||||
outputFile: outputFile,
|
|
||||||
sourceDirectory: filesystem.getProfileDir(profile.uuidValue),
|
|
||||||
argon2Params: Argon2Params.memoryConstrained(),
|
|
||||||
);
|
|
||||||
|
|
||||||
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<bool> restoreAndCreateNew(
|
Future<bool> restoreAndCreateNew(
|
||||||
File backupFile, {
|
Uri backupFileUri, {
|
||||||
required String profileName,
|
required String profileName,
|
||||||
required String password,
|
required String password,
|
||||||
}) async {
|
}) async {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final tempFile = File(p.join(tempDir.path, 'restore_temp.weblibre'));
|
||||||
|
|
||||||
final outputDirectory = Directory(
|
final outputDirectory = Directory(
|
||||||
p.join(filesystem.profilesDir.path, p.basename(backupFile.path)),
|
p.join(filesystem.profilesDir.path, 'restore_temp'),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await _safStream.copyToLocalFile(backupFileUri.toString(), tempFile.path);
|
||||||
|
|
||||||
final backup = SecureArchiveUnpack(
|
final backup = SecureArchiveUnpack(
|
||||||
inputFile: backupFile,
|
inputFile: tempFile,
|
||||||
outputDirectory: outputDirectory,
|
outputDirectory: outputDirectory,
|
||||||
argon2Params: Argon2Params.memoryConstrained(),
|
argon2Params: Argon2Params.memoryConstrained(),
|
||||||
);
|
);
|
||||||
@@ -114,6 +133,17 @@ class UserBackupService extends _$UserBackupService {
|
|||||||
ref.invalidate(profileRepositoryProvider);
|
ref.invalidate(profileRepositoryProvider);
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} 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 {
|
try {
|
||||||
if (await outputDirectory.exists()) {
|
if (await outputDirectory.exists()) {
|
||||||
await outputDirectory.delete(recursive: true);
|
await outputDirectory.delete(recursive: true);
|
||||||
@@ -129,17 +159,22 @@ class UserBackupService extends _$UserBackupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> restoreAndCreateOrOverride(
|
Future<bool> restoreAndCreateOrOverride(
|
||||||
File backupFile, {
|
Uri backupFileUri, {
|
||||||
required String password,
|
required String password,
|
||||||
required FutureOr<bool?> Function() confirmOverrideCallback,
|
required FutureOr<bool?> Function() confirmOverrideCallback,
|
||||||
}) async {
|
}) async {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final tempFile = File(p.join(tempDir.path, 'restore_temp.weblibre'));
|
||||||
|
|
||||||
final outputDirectory = Directory(
|
final outputDirectory = Directory(
|
||||||
p.join(filesystem.profilesDir.path, p.basename(backupFile.path)),
|
p.join(filesystem.profilesDir.path, 'restore_temp'),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await _safStream.copyToLocalFile(backupFileUri.toString(), tempFile.path);
|
||||||
|
|
||||||
final backup = SecureArchiveUnpack(
|
final backup = SecureArchiveUnpack(
|
||||||
inputFile: backupFile,
|
inputFile: tempFile,
|
||||||
outputDirectory: outputDirectory,
|
outputDirectory: outputDirectory,
|
||||||
argon2Params: Argon2Params.memoryConstrained(),
|
argon2Params: Argon2Params.memoryConstrained(),
|
||||||
);
|
);
|
||||||
@@ -177,6 +212,17 @@ class UserBackupService extends _$UserBackupService {
|
|||||||
ref.invalidate(profileRepositoryProvider);
|
ref.invalidate(profileRepositoryProvider);
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} 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 {
|
try {
|
||||||
if (await outputDirectory.exists()) {
|
if (await outputDirectory.exists()) {
|
||||||
await outputDirectory.delete(recursive: true);
|
await outputDirectory.delete(recursive: true);
|
||||||
@@ -191,6 +237,55 @@ class UserBackupService extends _$UserBackupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> 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
|
@override
|
||||||
void build() {}
|
void build() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ final class UserBackupServiceProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$userBackupServiceHash() => r'fb0b29075da3b32135d6309ef1bb7a7f119a3e25';
|
String _$userBackupServiceHash() => r'74cd6fd9818a8350a91b1225d904e23f48bc9e30';
|
||||||
|
|
||||||
abstract class _$UserBackupService extends $Notifier<void> {
|
abstract class _$UserBackupService extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ dependencies:
|
|||||||
riverpod_annotation: ^4.0.2
|
riverpod_annotation: ^4.0.2
|
||||||
rss_dart: ^1.0.14
|
rss_dart: ^1.0.14
|
||||||
rxdart: ^0.28.0
|
rxdart: ^0.28.0
|
||||||
|
saf_stream: ^2.0.0
|
||||||
|
saf_util: ^2.0.0
|
||||||
secure_archive:
|
secure_archive:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/FaFre/secure_archive.git
|
url: https://github.com/FaFre/secure_archive.git
|
||||||
|
|||||||
Reference in New Issue
Block a user