prepare for multiple apps
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
class AuthException implements Exception {
|
||||
final String message;
|
||||
|
||||
AuthException(this.message);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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:exceptions/exceptions.dart';
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
|
||||
class FingerprintOverrides with FastEquatable {
|
||||
static final pattern = RegExp('([+-])([a-zA-Z_][a-zA-Z0-9_]{1,64})');
|
||||
|
||||
final bool? allTargets;
|
||||
final Map<String, bool> targets;
|
||||
|
||||
FingerprintOverrides(this.allTargets, this.targets);
|
||||
|
||||
//Monitor https://searchfox.org/firefox-main/source/toolkit/components/resistfingerprinting/RFPTargetsDefault.inc
|
||||
FingerprintOverrides.defaults()
|
||||
: this(false, {
|
||||
'CanvasRandomization': true,
|
||||
'EfficientCanvasRandomization': true,
|
||||
'FontVisibilityLangPack': true,
|
||||
'JSMathFdlibm': true,
|
||||
'ScreenAvailToResolution': true,
|
||||
'NavigatorHWConcurrencyTiered': true,
|
||||
'MaxTouchPointsCollapse': true,
|
||||
});
|
||||
|
||||
FingerprintOverrides.hardenedDefaults()
|
||||
: this(false, {
|
||||
'TouchEvents': true,
|
||||
'PointerEvents': true,
|
||||
'KeyboardEvents': true,
|
||||
'ScreenOrientation': true,
|
||||
'SpeechSynthesis': true,
|
||||
'CSSPrefersReducedMotion': true,
|
||||
'CSSPrefersContrast': true,
|
||||
'CanvasRandomization': true,
|
||||
'CanvasExtractionFromThirdPartiesIsBlocked': true,
|
||||
'JSLocale': true,
|
||||
'NavigatorAppVersion': true,
|
||||
'NavigatorBuildID': true,
|
||||
'NavigatorHWConcurrency': true,
|
||||
'NavigatorOscpu': true,
|
||||
'NavigatorPlatform': true,
|
||||
'NavigatorUserAgent': true,
|
||||
'PointerId': true,
|
||||
'StreamVideoFacingMode': true,
|
||||
'JSDateTimeUTC': true,
|
||||
'JSMathFdlibm': true,
|
||||
'Gamepad': true,
|
||||
'HttpUserAgent': true,
|
||||
'WindowOuterSize': true,
|
||||
'WindowScreenXY': true,
|
||||
'WindowInnerScreenXY': true,
|
||||
'ScreenPixelDepth': true,
|
||||
'ScreenRect': true,
|
||||
'ScreenAvailRect': true,
|
||||
'VideoElementMozFrames': true,
|
||||
'VideoElementMozFrameDelay': true,
|
||||
'VideoElementPlaybackQuality': true,
|
||||
'ReduceTimerPrecision': true,
|
||||
'WidgetEvents': true,
|
||||
'MediaDevices': true,
|
||||
'MediaCapabilities': true,
|
||||
'AudioSampleRate': true,
|
||||
'NetworkConnection': true,
|
||||
'WindowDevicePixelRatio': true,
|
||||
'MouseEventScreenPoint': true,
|
||||
'FontVisibilityBaseSystem': true,
|
||||
'FontVisibilityLangPack': true,
|
||||
'DeviceSensors': true,
|
||||
'RoundWindowSize': true,
|
||||
'UseStandinsForNativeColors': true,
|
||||
'AudioContext': true,
|
||||
'MediaError': true,
|
||||
'DOMStyleOsxFontSmoothing': true,
|
||||
'CSSDeviceSize': true,
|
||||
'CSSColorInfo': true,
|
||||
'CSSResolution': true,
|
||||
'CSSPrefersReducedTransparency': true,
|
||||
'CSSInvertedColors': true,
|
||||
'CSSVideoDynamicRange': true,
|
||||
'CSSPointerCapabilities': true,
|
||||
'WebGLRenderCapability': true,
|
||||
'WebGLRenderInfo': true,
|
||||
'SiteSpecificZoom': true,
|
||||
'FontVisibilityRestrictGenerics': true,
|
||||
'WebVTT': true,
|
||||
'WebGPULimits': true,
|
||||
'WebGPUIsFallbackAdapter': true,
|
||||
'WebGPUSubgroupSizes': true,
|
||||
'JSLocalePrompt': true,
|
||||
'ScreenAvailToResolution': true,
|
||||
'UseHardcodedFontSubstitutes': true,
|
||||
'DiskStorageLimit': true,
|
||||
'WebCodecs': true,
|
||||
'MaxTouchPoints': true,
|
||||
'MaxTouchPointsCollapse': true,
|
||||
'NavigatorHWConcurrencyTiered': true,
|
||||
});
|
||||
|
||||
static Result<FingerprintOverrides> parse(
|
||||
String input,
|
||||
Set<String> availableTargets,
|
||||
) {
|
||||
final cleaned = input.replaceAll(RegExp(r'\s'), '');
|
||||
if (cleaned.isEmpty) return Result.success(FingerprintOverrides(null, {}));
|
||||
|
||||
bool? allTargets;
|
||||
final targets = <String, bool>{};
|
||||
|
||||
for (final word in cleaned.split(',')) {
|
||||
final match = pattern.firstMatch(word);
|
||||
if (match == null) {
|
||||
return Result.failure(
|
||||
const ErrorMessage(source: 'FpParser', message: 'Invalid Override'),
|
||||
);
|
||||
}
|
||||
|
||||
final enabled = match.group(1) != '-';
|
||||
final name = match.group(2)!;
|
||||
|
||||
if (name == 'AllTargets') {
|
||||
allTargets = enabled;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!availableTargets.contains(name)) {
|
||||
return Result.failure(
|
||||
const ErrorMessage(
|
||||
source: 'FpParser',
|
||||
message: 'Invalid target name',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
targets[name] = enabled;
|
||||
}
|
||||
|
||||
return Result.success(FingerprintOverrides(allTargets, targets));
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final sb = StringBuffer();
|
||||
|
||||
if (allTargets != null) {
|
||||
sb.write('${allTargets! ? '+' : '-'}AllTargets');
|
||||
if (targets.isNotEmpty) {
|
||||
sb.write(',');
|
||||
}
|
||||
}
|
||||
|
||||
sb.write(
|
||||
targets.entries.map((e) => (e.value ? '+' : '-') + e.key).join(','),
|
||||
);
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
FingerprintOverrides copyWithAllTargetsEnabled(bool value) {
|
||||
return FingerprintOverrides(
|
||||
value,
|
||||
Map.fromEntries(targets.entries.where((e) => e.value != value)),
|
||||
);
|
||||
}
|
||||
|
||||
FingerprintOverrides copyWithTarget(String name, bool value) {
|
||||
if (value && allTargets == true) {
|
||||
return this;
|
||||
}
|
||||
|
||||
return FingerprintOverrides(allTargets, {...targets, name: value});
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [allTargets, targets];
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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:flutter/material.dart';
|
||||
|
||||
/// Dialog to confirm profile/user deletion.
|
||||
/// Returns true if user confirms deletion, false if cancelled, null if dismissed.
|
||||
Future<bool?> showDeleteProfileDialog(BuildContext context) {
|
||||
return showDialog<bool?>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
icon: const Icon(Icons.warning),
|
||||
title: const Text('Delete User'),
|
||||
content: const Text(
|
||||
'Are you sure you want to delete this User including all data?',
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context, false);
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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:flutter/material.dart';
|
||||
|
||||
/// Dialog to confirm overriding existing profile during restore.
|
||||
/// Returns true if user confirms override, false if cancelled, null if dismissed.
|
||||
Future<bool?> showOverrideProfileDialog(BuildContext context) {
|
||||
return showDialog<bool?>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
icon: const Icon(Icons.warning),
|
||||
title: const Text('Override User'),
|
||||
content: const Text(
|
||||
'Are you sure you want to override the exisiting User?',
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context, false);
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
child: const Text('Override'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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:flutter/material.dart';
|
||||
|
||||
/// Dialog to confirm password during backup creation.
|
||||
/// Returns the entered password string if confirmed, null if cancelled or dismissed.
|
||||
Future<String?> showPasswordConfirmationDialog(BuildContext context) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final controller = TextEditingController();
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Password Confirmation'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
enableIMEPersonalizedLearning: false,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(controller.text);
|
||||
},
|
||||
child: const Text('Confirm'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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:flutter/material.dart';
|
||||
|
||||
/// Shows a confirmation dialog for quitting the browser.
|
||||
///
|
||||
/// Returns true if the user confirms, false if cancelled, null if dismissed.
|
||||
Future<bool?> showQuitBrowserDialog(BuildContext context) {
|
||||
return showDialog<bool?>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
icon: const Icon(Icons.warning),
|
||||
title: const Text('Quit Browser'),
|
||||
content: const Text(
|
||||
'This will properly shutdown the browser and clear private tabs',
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context, false);
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
child: const Text('Quit'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* 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 'dart:convert';
|
||||
|
||||
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:weblibre/core/filesystem.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/domain/entities/profile.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/utils/profile_switch_handler.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/profile.dart';
|
||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
|
||||
/// Bottom sheet widget to select a user profile.
|
||||
class SelectProfileDialog extends HookConsumerWidget {
|
||||
const SelectProfileDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final usersAsync = ref.watch(profileRepositoryProvider);
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Select user',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
usersAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (profiles) => Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 24,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
...profiles.map(
|
||||
(profile) => _ProfileAvatar(
|
||||
profile: profile,
|
||||
isActive: filesystem.selectedProfile == profile.uuidValue,
|
||||
onTap: () async {
|
||||
await handleSwitchProfile(context, ref, profile);
|
||||
},
|
||||
onLongPress: () async {
|
||||
await EditProfileRoute(
|
||||
profile: jsonEncode(profile.toJson()),
|
||||
).push(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
_AddProfileAvatar(
|
||||
onTap: () async {
|
||||
await CreateProfileRoute().push(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
error: (error, stackTrace) => Center(
|
||||
child: FailureWidget(
|
||||
title: 'Failed to load Profiles',
|
||||
exception: error,
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
await ProfileListRoute().push(context);
|
||||
},
|
||||
icon: const Icon(MdiIcons.accountGroup),
|
||||
label: const Text('Manage Profiles'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileAvatar extends StatelessWidget {
|
||||
final Profile profile;
|
||||
final bool isActive;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
const _ProfileAvatar({
|
||||
required this.profile,
|
||||
required this.isActive,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: isActive
|
||||
? colorScheme.primary
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 24,
|
||||
color: isActive
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
profile.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddProfileAvatar extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _AddProfileAvatar({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: colorScheme.outline),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
size: 24,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
'Add user',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Shows a confirmation dialog for switching user profiles.
|
||||
///
|
||||
/// Returns `true` if the user confirms the switch, or null if dismissed.
|
||||
Future<bool?> showSwitchProfileDialog(
|
||||
BuildContext context, {
|
||||
required String profileName,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
icon: const Icon(Icons.warning),
|
||||
title: const Text('Switch User'),
|
||||
content: Text(
|
||||
"Switching to User '$profileName' will require a restart of the Browser.",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.pop(false);
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.pop(true);
|
||||
},
|
||||
child: const Text('Switch Profile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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:fancy_password_field/fancy_password_field.dart';
|
||||
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';
|
||||
|
||||
class ProfileBackupScreen extends HookConsumerWidget {
|
||||
final Profile profile;
|
||||
|
||||
const ProfileBackupScreen({super.key, required this.profile});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final formKey = useMemoized(() => GlobalKey<FormState>());
|
||||
|
||||
final passwordTextController = useTextEditingController();
|
||||
final passwordController = useMemoized(() => FancyPasswordController());
|
||||
|
||||
final integrityVerification = useState(true);
|
||||
final skipCaches = useState(false);
|
||||
final skipPasswordConfirmation = useState(false);
|
||||
|
||||
final backupFuture = useState<Future<bool>?>(null);
|
||||
final backupState = useFuture(backupFuture.value);
|
||||
|
||||
useEffect(() {
|
||||
if (backupState.hasError) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
showErrorMessage(context, backupState.error!.toString());
|
||||
});
|
||||
} else if (backupState.hasData) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
showInfoMessage(context, 'Backup created successfully');
|
||||
ProfileListRoute().go(context);
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [backupState.hasError, backupState.hasData, backupState.error]);
|
||||
|
||||
final disableInteraction =
|
||||
backupState.connectionState == ConnectionState.waiting;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Create Backup')),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: ListView(
|
||||
children: [
|
||||
FancyPasswordField(
|
||||
controller: passwordTextController,
|
||||
enabled: !disableInteraction,
|
||||
passwordController: passwordController,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
enableIMEPersonalizedLearning: false,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
),
|
||||
validationRules: {MinCharactersValidationRule(5)},
|
||||
validator: (value) {
|
||||
//Make sure since onChange is sometimes unreliable
|
||||
passwordController.onChange(value ?? '');
|
||||
|
||||
return passwordController.areAllRulesValidated
|
||||
? null
|
||||
: 'Not Validated';
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: integrityVerification.value,
|
||||
onChanged: disableInteraction
|
||||
? null
|
||||
: (value) {
|
||||
integrityVerification.value = value;
|
||||
},
|
||||
title: const Text('Verify Backup Integrity'),
|
||||
subtitle: const Text(
|
||||
'Automatically check that backups are complete and restorable',
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: skipCaches.value,
|
||||
onChanged: disableInteraction
|
||||
? null
|
||||
: (value) {
|
||||
skipCaches.value = value;
|
||||
},
|
||||
title: const Text('Skip Cache Directories'),
|
||||
subtitle: const Text(
|
||||
'Leave out temporary browser caches like page, icon, and thumbnail data to keep backups smaller',
|
||||
),
|
||||
),
|
||||
ExpansionTile(
|
||||
enabled: !disableInteraction,
|
||||
childrenPadding: EdgeInsets.zero,
|
||||
tilePadding: EdgeInsets.zero,
|
||||
title: const Text('Advanced'),
|
||||
children: [
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: skipPasswordConfirmation.value,
|
||||
onChanged: disableInteraction
|
||||
? null
|
||||
: (value) {
|
||||
skipPasswordConfirmation.value = value;
|
||||
},
|
||||
title: const Text('Skip Password Confirmation Prompt'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (disableInteraction)
|
||||
const Column(
|
||||
children: [
|
||||
LinearProgressIndicator(),
|
||||
Text('Creating Backup'),
|
||||
],
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
icon: const Icon(MdiIcons.safe),
|
||||
onPressed: () async {
|
||||
if (formKey.currentState?.validate() ?? false) {
|
||||
if (!skipPasswordConfirmation.value) {
|
||||
final confirmation =
|
||||
await showPasswordConfirmationDialog(context);
|
||||
|
||||
if (confirmation != passwordTextController.text) {
|
||||
if (context.mounted) {
|
||||
showErrorMessage(
|
||||
context,
|
||||
'Passwords do not match',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
profile,
|
||||
password: passwordTextController.text,
|
||||
integrityCheck: integrityVerification.value,
|
||||
skipCaches: skipCaches.value,
|
||||
);
|
||||
}
|
||||
},
|
||||
label: const Text('Backup'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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:flutter/material.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/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';
|
||||
|
||||
final _filenamePattern = RegExp(
|
||||
r'^backup_(?<profile>.+?)_(?<timestamp>\d{4}-\d{2}-\d{2}_\d{6})\.weblibre$',
|
||||
);
|
||||
|
||||
class ProfileBackupListScreen extends HookConsumerWidget {
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final dirUri = ref.watch(backupDirectoryUriProvider);
|
||||
final backupListAsync = ref.watch(backupListProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Backups'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(MdiIcons.folderCog),
|
||||
tooltip: 'Change backup directory',
|
||||
onPressed: () => _pickDirectory(ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
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()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* 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 'dart:convert';
|
||||
|
||||
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:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/filesystem.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/domain/entities/profile.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
|
||||
import 'package:weblibre/features/user/data/models/auth_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/dialogs/delete_profile_dialog.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/utils/profile_switch_handler.dart';
|
||||
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/profile.dart';
|
||||
import 'package:weblibre/features/user/domain/services/local_authentication.dart';
|
||||
import 'package:weblibre/utils/form_validators.dart';
|
||||
|
||||
const _timeoutOptions = <DropdownMenuItem<Duration?>>[
|
||||
DropdownMenuItem(value: Duration(minutes: 1), child: Text('1 minute')),
|
||||
DropdownMenuItem(value: Duration(minutes: 5), child: Text('5 minutes')),
|
||||
DropdownMenuItem(value: Duration(minutes: 15), child: Text('15 minutes')),
|
||||
DropdownMenuItem(value: Duration(hours: 1), child: Text('1 hour')),
|
||||
];
|
||||
|
||||
class ProfileEditScreen extends HookConsumerWidget {
|
||||
final Profile? profile;
|
||||
|
||||
const ProfileEditScreen({super.key, required this.profile});
|
||||
|
||||
Future<void> _handleSave(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
GlobalKey<FormState> formKey,
|
||||
String name,
|
||||
AuthSettings authSettings,
|
||||
) async {
|
||||
if (!(formKey.currentState?.validate() ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Require biometric confirmation when enabling/changing auth
|
||||
if (profile != null &&
|
||||
(profile!.authSettings.authenticationRequired ||
|
||||
authSettings.authenticationRequired)) {
|
||||
final authResult = await ref
|
||||
.read(localAuthenticationServiceProvider.notifier)
|
||||
.authenticate(
|
||||
authKey: profileAccessAuthKey(profile!.id),
|
||||
localizedReason: 'Require authentication for profile',
|
||||
);
|
||||
|
||||
if (!authResult) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (profile != null) {
|
||||
await ref
|
||||
.read(profileRepositoryProvider.notifier)
|
||||
.updateProfileMetadata(
|
||||
profile!.copyWith(name: name, authSettings: authSettings),
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
} else {
|
||||
await ref
|
||||
.read(profileRepositoryProvider.notifier)
|
||||
.createProfile(name: name, authSettings: authSettings);
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final formKey = useMemoized(() => GlobalKey<FormState>());
|
||||
final nameTextController = useTextEditingController(text: profile?.name);
|
||||
final authSettings = useState(
|
||||
profile?.authSettings ?? AuthSettings.withDefaults(),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: (profile != null)
|
||||
? const Text('Edit User')
|
||||
: const Text('Create User'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
await _handleSave(
|
||||
context,
|
||||
ref,
|
||||
formKey,
|
||||
nameTextController.text,
|
||||
authSettings.value,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.check),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: nameTextController,
|
||||
decoration: const InputDecoration(
|
||||
label: Text('Name'),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
),
|
||||
validator: validateProfileName,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_AuthSection(
|
||||
authSettings: authSettings.value,
|
||||
onAuthSettingsChanged: (newSettings) {
|
||||
authSettings.value = newSettings;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (profile != null) ...[
|
||||
_ProfileActionsSection(profile: profile!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthSection extends StatelessWidget {
|
||||
final AuthSettings authSettings;
|
||||
final ValueChanged<AuthSettings> onAuthSettingsChanged;
|
||||
|
||||
const _AuthSection({
|
||||
required this.authSettings,
|
||||
required this.onAuthSettingsChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SettingSection(name: 'Authentication'),
|
||||
SwitchListTile.adaptive(
|
||||
value: authSettings.authenticationRequired,
|
||||
title: const Text('Require Authentication'),
|
||||
subtitle: const Text(
|
||||
'Lock this profile when switching away from the app',
|
||||
),
|
||||
secondary: const Icon(MdiIcons.fingerprint),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) {
|
||||
onAuthSettingsChanged(
|
||||
authSettings.copyWith.authenticationRequired(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (authSettings.authenticationRequired) ...[
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ListTile(
|
||||
title: Text('Auto-lock Behavior'),
|
||||
subtitle: Text('Choose when to lock the profile'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(MdiIcons.lockClock),
|
||||
),
|
||||
RadioGroup<AutoLockMode>(
|
||||
groupValue: authSettings.autoLockMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
onAuthSettingsChanged(
|
||||
authSettings.copyWith.autoLockMode(value),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile.adaptive(
|
||||
value: AutoLockMode.background,
|
||||
title: Text('Lock on Background'),
|
||||
subtitle: Text(
|
||||
'Lock immediately when app goes to background',
|
||||
),
|
||||
),
|
||||
RadioListTile.adaptive(
|
||||
value: AutoLockMode.timeout,
|
||||
title: Text('Lock After Timeout'),
|
||||
subtitle: Text('Lock after a period of inactivity'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (authSettings.autoLockMode == AutoLockMode.timeout)
|
||||
ListTile(
|
||||
title: const Text('Timeout Duration'),
|
||||
subtitle: const Text('How long to wait before locking'),
|
||||
leading: const Icon(MdiIcons.timerOutline),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
trailing: DropdownButton<Duration?>(
|
||||
value: authSettings.timeout,
|
||||
items: _timeoutOptions,
|
||||
underline: const SizedBox.shrink(),
|
||||
onChanged: (Duration? value) {
|
||||
if (value != null) {
|
||||
onAuthSettingsChanged(authSettings.copyWith.timeout(value));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileActionsSection extends ConsumerWidget {
|
||||
final Profile profile;
|
||||
|
||||
const _ProfileActionsSection({required this.profile});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SettingSection(name: 'Profile Actions'),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
label: const Text('Backup'),
|
||||
icon: const Icon(MdiIcons.safe),
|
||||
onPressed: () async {
|
||||
await BackupProfileRoute(
|
||||
profile: jsonEncode(profile.toJson()),
|
||||
).push(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (filesystem.selectedProfile != profile.uuidValue)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
label: const Text('Switch to this Profile'),
|
||||
icon: const Icon(MdiIcons.accountSwitch),
|
||||
onPressed: () async {
|
||||
await handleSwitchProfile(context, ref, profile);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (filesystem.selectedProfile != profile.uuidValue)
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.error),
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
iconColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
label: const Text('Delete'),
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () async {
|
||||
final result = await showDeleteProfileDialog(context);
|
||||
|
||||
if (result == true) {
|
||||
await ref
|
||||
.read(profileRepositoryProvider.notifier)
|
||||
.deleteProfile(profile.uuidValue.uuid);
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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 'dart:convert';
|
||||
|
||||
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:weblibre/core/filesystem.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/profile.dart';
|
||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
|
||||
class ProfileListScreen extends HookConsumerWidget {
|
||||
const ProfileListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final usersAsync = ref.watch(profileRepositoryProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Users'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
await ProfileBackupListRoute().push(context);
|
||||
},
|
||||
icon: const Icon(MdiIcons.backupRestore),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: usersAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (profiles) => ListView.builder(
|
||||
itemCount: profiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final profile = profiles[index];
|
||||
final isSelected =
|
||||
filesystem.selectedProfile == profile.uuidValue;
|
||||
|
||||
return ListTile(
|
||||
enabled: !isSelected,
|
||||
leading: const Icon(Icons.person),
|
||||
title: Text(profile.name),
|
||||
subtitle: isSelected ? const Text('Active') : null,
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
await EditProfileRoute(
|
||||
profile: jsonEncode(profile.toJson()),
|
||||
).push(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
error: (error, stackTrace) => Center(
|
||||
child: FailureWidget(
|
||||
title: 'Failed to load Profiles',
|
||||
exception: error,
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () async {
|
||||
await CreateProfileRoute().push(context);
|
||||
},
|
||||
child: const Icon(Icons.person_add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* 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: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:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/dialogs/override_profile_dialog.dart';
|
||||
import 'package:weblibre/features/user/domain/services/user_backup.dart';
|
||||
import 'package:weblibre/utils/form_validators.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
enum RestoreTarget { createOrOverride, createNew }
|
||||
|
||||
class ProfileRestoreScreen extends HookConsumerWidget {
|
||||
final Uri backupFileUri;
|
||||
|
||||
const ProfileRestoreScreen({super.key, required this.backupFileUri});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final formKey = useMemoized(() => GlobalKey<FormState>());
|
||||
|
||||
final passwordTextController = useTextEditingController();
|
||||
final nameTextController = useTextEditingController();
|
||||
|
||||
final restoreFuture = useState<Future<bool>?>(null);
|
||||
final restoreState = useFuture(restoreFuture.value);
|
||||
|
||||
final restoreTarget = useState(RestoreTarget.createNew);
|
||||
|
||||
useEffect(() {
|
||||
if (restoreState.hasError) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
showErrorMessage(context, restoreState.error!.toString());
|
||||
});
|
||||
} else if (restoreState.hasData) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
showInfoMessage(context, 'Backup restored successfully');
|
||||
ProfileListRoute().go(context);
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [restoreState.hasError, restoreState.hasData, restoreState.error]);
|
||||
|
||||
final disableInteraction =
|
||||
restoreState.connectionState == ConnectionState.waiting;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Restore Backup')),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: ListView(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: passwordTextController,
|
||||
enabled: !disableInteraction,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
enableIMEPersonalizedLearning: false,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
),
|
||||
validator: (value) {
|
||||
return validateRequired(
|
||||
value,
|
||||
message: 'Password required',
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
RadioGroup(
|
||||
groupValue: restoreTarget.value,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
restoreTarget.value = value;
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile(
|
||||
enabled: !disableInteraction,
|
||||
value: RestoreTarget.createNew,
|
||||
title: const Text('Create New User'),
|
||||
subtitle: const Text('Restore backup as a new user'),
|
||||
),
|
||||
RadioListTile(
|
||||
enabled: !disableInteraction,
|
||||
value: RestoreTarget.createOrOverride,
|
||||
title: const Text('Restore & Replace'),
|
||||
subtitle: const Text(
|
||||
'Restore backup and overwrite existing user if present',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (restoreTarget.value == RestoreTarget.createNew)
|
||||
TextFormField(
|
||||
controller: nameTextController,
|
||||
enabled: !disableInteraction,
|
||||
decoration: const InputDecoration(
|
||||
label: Text('Name'),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
),
|
||||
validator: validateProfileName,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (disableInteraction)
|
||||
const Column(
|
||||
children: [
|
||||
LinearProgressIndicator(),
|
||||
Text('Restoring Backup'),
|
||||
],
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
icon: const Icon(MdiIcons.backupRestore),
|
||||
onPressed: () {
|
||||
if (formKey.currentState?.validate() ?? false) {
|
||||
restoreFuture.value = switch (restoreTarget.value) {
|
||||
RestoreTarget.createOrOverride =>
|
||||
ref
|
||||
.read(userBackupServiceProvider.notifier)
|
||||
.restoreAndCreateOrOverride(
|
||||
backupFileUri,
|
||||
password: passwordTextController.text,
|
||||
confirmOverrideCallback: () {
|
||||
if (context.mounted) {
|
||||
return showOverrideProfileDialog(context);
|
||||
} else {
|
||||
throw Exception('Override failed');
|
||||
}
|
||||
},
|
||||
),
|
||||
RestoreTarget.createNew =>
|
||||
ref
|
||||
.read(userBackupServiceProvider.notifier)
|
||||
.restoreAndCreateNew(
|
||||
backupFileUri,
|
||||
profileName: nameTextController.text,
|
||||
password: passwordTextController.text,
|
||||
),
|
||||
};
|
||||
}
|
||||
},
|
||||
label: const Text('Restore'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/filesystem.dart';
|
||||
import 'package:weblibre/domain/entities/profile.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/dialogs/switch_profile_dialog.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/profile.dart';
|
||||
import 'package:weblibre/utils/exit_app.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
/// Handles the profile switching flow with confirmation dialog.
|
||||
///
|
||||
/// This function:
|
||||
/// - Checks if the profile is already active
|
||||
/// - Shows a confirmation dialog with browser restart warning
|
||||
/// - Switches to the selected profile and exits the app
|
||||
Future<void> handleSwitchProfile(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Profile profile,
|
||||
) async {
|
||||
final isSelected = filesystem.selectedProfile == profile.uuidValue;
|
||||
|
||||
// Don't allow switching to the already active profile
|
||||
if (isSelected) {
|
||||
if (context.mounted) {
|
||||
ui_helper.showInfoMessage(context, 'This profile is already active');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
final shouldSwitch = await showSwitchProfileDialog(
|
||||
context,
|
||||
profileName: profile.name,
|
||||
);
|
||||
|
||||
if (shouldSwitch == true) {
|
||||
await ref
|
||||
.read(profileRepositoryProvider.notifier)
|
||||
.switchProfile(profile.id);
|
||||
await exitApp(ref.container);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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 'dart:async';
|
||||
|
||||
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:weblibre/features/user/domain/providers/profile_auth.dart';
|
||||
import 'package:weblibre/presentation/hooks/on_initialization.dart';
|
||||
|
||||
class LockScreen extends HookConsumerWidget {
|
||||
const LockScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isAuthenticating = useState(false);
|
||||
final didAutoAuthenticate = useRef(false);
|
||||
|
||||
Future<void> authenticate() async {
|
||||
if (isAuthenticating.value) return;
|
||||
|
||||
isAuthenticating.value = true;
|
||||
|
||||
try {
|
||||
await ref.read(profileAuthStateProvider.notifier).authenticate();
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
isAuthenticating.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useOnInitialization(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!didAutoAuthenticate.value) {
|
||||
didAutoAuthenticate.value = true;
|
||||
unawaited(authenticate());
|
||||
}
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(MdiIcons.lock, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Profile is locked'),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(minimumSize: const Size(160, 40)),
|
||||
icon: const Icon(MdiIcons.fingerprint),
|
||||
label: Text(isAuthenticating.value ? 'Unlocking...' : 'Unlock'),
|
||||
onPressed: isAuthenticating.value ? null : authenticate,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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: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';
|
||||
import 'package:weblibre/features/user/domain/services/fingerprinting.dart';
|
||||
import 'package:weblibre/features/user/domain/services/user_backup.dart';
|
||||
|
||||
part 'providers.g.dart';
|
||||
|
||||
@Riverpod()
|
||||
Stream<double> iconCacheSizeMegabytes(Ref ref) {
|
||||
final repository = ref.watch(userDatabaseProvider);
|
||||
return repository.cacheDao.getIconCacheSize().watchSingle();
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
bool incognitoModeEnabled(Ref ref) {
|
||||
return ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(value) => value.deleteBrowsingDataOnQuit != null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
Future<Result<FingerprintOverrides>> fingerprintOverrideSettings(
|
||||
Ref ref,
|
||||
) async {
|
||||
final fingerprintTargets = await ref.watch(fingerprintTargetsProvider.future);
|
||||
final fingerprintTargetSet = fingerprintTargets.map((e) => e.name).toSet();
|
||||
|
||||
final overrides = ref.watch(
|
||||
engineSettingsWithDefaultsProvider.select(
|
||||
(settings) =>
|
||||
settings.fingerprintingProtectionOverrides.mapNotNull(
|
||||
(settings) =>
|
||||
FingerprintOverrides.parse(settings, fingerprintTargetSet),
|
||||
) ??
|
||||
Result.success(FingerprintOverrides.defaults()),
|
||||
),
|
||||
);
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<Profile> selectedProfile(Ref ref) async {
|
||||
final profiles = await ref.watch(profileRepositoryProvider.future);
|
||||
return profiles.firstWhere((p) => p.uuidValue == filesystem.selectedProfile);
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
Future<List<SafDocumentFile>> backupList(Ref ref) async {
|
||||
final dirUri = ref.watch(backupDirectoryUriProvider);
|
||||
if (dirUri == null) return [];
|
||||
|
||||
return ref.watch(userBackupServiceProvider.notifier).getBackupList(dirUri);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'providers.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(iconCacheSizeMegabytes)
|
||||
final iconCacheSizeMegabytesProvider = IconCacheSizeMegabytesProvider._();
|
||||
|
||||
final class IconCacheSizeMegabytesProvider
|
||||
extends $FunctionalProvider<AsyncValue<double>, double, Stream<double>>
|
||||
with $FutureModifier<double>, $StreamProvider<double> {
|
||||
IconCacheSizeMegabytesProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'iconCacheSizeMegabytesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$iconCacheSizeMegabytesHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$StreamProviderElement<double> $createElement($ProviderPointer pointer) =>
|
||||
$StreamProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Stream<double> create(Ref ref) {
|
||||
return iconCacheSizeMegabytes(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$iconCacheSizeMegabytesHash() =>
|
||||
r'5d7f5f6485060b08ce4fd8fa634f07bf8bfdbd2d';
|
||||
|
||||
@ProviderFor(incognitoModeEnabled)
|
||||
final incognitoModeEnabledProvider = IncognitoModeEnabledProvider._();
|
||||
|
||||
final class IncognitoModeEnabledProvider
|
||||
extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
IncognitoModeEnabledProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'incognitoModeEnabledProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$incognitoModeEnabledHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return incognitoModeEnabled(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$incognitoModeEnabledHash() =>
|
||||
r'36957b70a5261f9d3ad228e07cc8dd5c8f616082';
|
||||
|
||||
@ProviderFor(fingerprintOverrideSettings)
|
||||
final fingerprintOverrideSettingsProvider =
|
||||
FingerprintOverrideSettingsProvider._();
|
||||
|
||||
final class FingerprintOverrideSettingsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<Result<FingerprintOverrides>>,
|
||||
Result<FingerprintOverrides>,
|
||||
FutureOr<Result<FingerprintOverrides>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<Result<FingerprintOverrides>>,
|
||||
$FutureProvider<Result<FingerprintOverrides>> {
|
||||
FingerprintOverrideSettingsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'fingerprintOverrideSettingsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$fingerprintOverrideSettingsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<Result<FingerprintOverrides>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<Result<FingerprintOverrides>> create(Ref ref) {
|
||||
return fingerprintOverrideSettings(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$fingerprintOverrideSettingsHash() =>
|
||||
r'd4d40ec425098fb1f5a2f0c4944f058829a41a0a';
|
||||
|
||||
@ProviderFor(selectedProfile)
|
||||
final selectedProfileProvider = SelectedProfileProvider._();
|
||||
|
||||
final class SelectedProfileProvider
|
||||
extends $FunctionalProvider<AsyncValue<Profile>, Profile, FutureOr<Profile>>
|
||||
with $FutureModifier<Profile>, $FutureProvider<Profile> {
|
||||
SelectedProfileProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'selectedProfileProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$selectedProfileHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<Profile> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<Profile> create(Ref ref) {
|
||||
return selectedProfile(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$selectedProfileHash() => r'c703cad8f30abb4f5f42db0119756ee6791ac477';
|
||||
|
||||
@ProviderFor(backupList)
|
||||
final backupListProvider = BackupListProvider._();
|
||||
|
||||
final class BackupListProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SafDocumentFile>>,
|
||||
List<SafDocumentFile>,
|
||||
FutureOr<List<SafDocumentFile>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<SafDocumentFile>>,
|
||||
$FutureProvider<List<SafDocumentFile>> {
|
||||
BackupListProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'backupListProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$backupListHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SafDocumentFile>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SafDocumentFile>> create(Ref ref) {
|
||||
return backupList(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$backupListHash() => r'527bfdab7b537b08764ff77dd69de14d38846d41';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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:flutter/foundation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/user/domain/providers.dart';
|
||||
import 'package:weblibre/features/user/domain/services/local_authentication.dart';
|
||||
|
||||
part 'profile_auth.g.dart';
|
||||
|
||||
String profileAccessAuthKey(String profileId) => 'profile_access::$profileId';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ProfileAuthState extends _$ProfileAuthState {
|
||||
bool _bootstrapped = false;
|
||||
|
||||
Future<void> bootstrapFromProfile() async {
|
||||
if (_bootstrapped) return;
|
||||
|
||||
final profile = await ref.read(selectedProfileProvider.future);
|
||||
if (!ref.mounted) return;
|
||||
|
||||
_bootstrapped = true;
|
||||
|
||||
if (!profile.authSettings.authenticationRequired) {
|
||||
_unlock();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> authenticate() async {
|
||||
final profile = await ref.read(selectedProfileProvider.future);
|
||||
if (!ref.mounted) return false;
|
||||
|
||||
if (!profile.authSettings.authenticationRequired) {
|
||||
_unlock();
|
||||
return true;
|
||||
}
|
||||
|
||||
final result = await ref
|
||||
.read(localAuthenticationServiceProvider.notifier)
|
||||
.authenticate(
|
||||
authKey: profileAccessAuthKey(profile.id),
|
||||
localizedReason: 'Unlock profile',
|
||||
settings: profile.authSettings,
|
||||
useAuthCache: true,
|
||||
);
|
||||
|
||||
if (!ref.mounted) return false;
|
||||
|
||||
state = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> revalidateAfterResume() async {
|
||||
if (!state) return;
|
||||
|
||||
final profile = await ref.read(selectedProfileProvider.future);
|
||||
if (!ref.mounted || !profile.authSettings.authenticationRequired) return;
|
||||
|
||||
final cached = ref
|
||||
.read(localAuthenticationServiceProvider.notifier)
|
||||
.isCached(profileAccessAuthKey(profile.id));
|
||||
|
||||
if (!cached && ref.mounted) {
|
||||
_lock();
|
||||
}
|
||||
}
|
||||
|
||||
void _lock() {
|
||||
state = false;
|
||||
}
|
||||
|
||||
void _unlock() {
|
||||
state = true;
|
||||
}
|
||||
|
||||
@override
|
||||
bool build() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Raw<ProfileAuthNotifier> profileAuthNotifier(Ref ref) {
|
||||
final notifier = ProfileAuthNotifier();
|
||||
|
||||
ref.listen<bool>(profileAuthStateProvider, (_, _) {
|
||||
notifier.notify();
|
||||
});
|
||||
|
||||
ref.onDispose(notifier.dispose);
|
||||
|
||||
return notifier;
|
||||
}
|
||||
|
||||
class ProfileAuthNotifier extends ChangeNotifier {
|
||||
void notify() => notifyListeners();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'profile_auth.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(ProfileAuthState)
|
||||
final profileAuthStateProvider = ProfileAuthStateProvider._();
|
||||
|
||||
final class ProfileAuthStateProvider
|
||||
extends $NotifierProvider<ProfileAuthState, bool> {
|
||||
ProfileAuthStateProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'profileAuthStateProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$profileAuthStateHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ProfileAuthState create() => ProfileAuthState();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$profileAuthStateHash() => r'9eb65fdb76baa0b088fc12a8063ea4ee63d54ac4';
|
||||
|
||||
abstract class _$ProfileAuthState extends $Notifier<bool> {
|
||||
bool build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<bool, bool>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<bool, bool>,
|
||||
bool,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(profileAuthNotifier)
|
||||
final profileAuthProvider = ProfileAuthNotifierProvider._();
|
||||
|
||||
final class ProfileAuthNotifierProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
Raw<ProfileAuthNotifier>,
|
||||
Raw<ProfileAuthNotifier>,
|
||||
Raw<ProfileAuthNotifier>
|
||||
>
|
||||
with $Provider<Raw<ProfileAuthNotifier>> {
|
||||
ProfileAuthNotifierProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'profileAuthProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$profileAuthNotifierHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<Raw<ProfileAuthNotifier>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Raw<ProfileAuthNotifier> create(Ref ref) {
|
||||
return profileAuthNotifier(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(Raw<ProfileAuthNotifier> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<Raw<ProfileAuthNotifier>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$profileAuthNotifierHash() =>
|
||||
r'795f47b1494e4a9cdd74f5ff22d431b2bc59ffbd';
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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 'dart:typed_data';
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||
import 'package:weblibre/features/user/data/providers.dart';
|
||||
|
||||
part 'cache.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class CacheRepository extends _$CacheRepository {
|
||||
Future<void> clearCache() {
|
||||
return ref.read(userDatabaseProvider).cacheDao.clearIconCache();
|
||||
}
|
||||
|
||||
Future<void> cacheIcon(Uri url, Uint8List bytes) {
|
||||
return ref.read(userDatabaseProvider).cacheDao.cacheIcon(url.origin, bytes);
|
||||
}
|
||||
|
||||
Future<Uint8List?> getCachedIcon(String origin) {
|
||||
return ref
|
||||
.read(userDatabaseProvider)
|
||||
.cacheDao
|
||||
.getCachedIcon(origin)
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {
|
||||
final eventService = ref.watch(eventServiceProvider);
|
||||
|
||||
final db = ref.watch(userDatabaseProvider);
|
||||
|
||||
final sub = eventService.iconUpdateEvents.listen(
|
||||
(event) async {
|
||||
if (Uri.tryParse(event.url) case final Uri url) {
|
||||
await db.cacheDao.cacheIcon(url.origin, event.bytes);
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
logger.e(
|
||||
'Error in icon update events',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ref.onDispose(() async {
|
||||
await sub.cancel();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'cache.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(CacheRepository)
|
||||
final cacheRepositoryProvider = CacheRepositoryProvider._();
|
||||
|
||||
final class CacheRepositoryProvider
|
||||
extends $NotifierProvider<CacheRepository, void> {
|
||||
CacheRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'cacheRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$cacheRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
CacheRepository create() => CacheRepository();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$cacheRepositoryHash() => r'e3cd7461aefe9e034a663169cd81ab7f69c2640e';
|
||||
|
||||
abstract class _$CacheRepository extends $Notifier<void> {
|
||||
void build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* 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 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/user/data/models/engine_settings.dart';
|
||||
import 'package:weblibre/features/user/data/providers.dart';
|
||||
|
||||
part 'engine_settings.g.dart';
|
||||
|
||||
typedef UpdateEngineSettingsFunc =
|
||||
EngineSettings Function(EngineSettings currentSettings);
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class EngineSettingsRepository extends _$EngineSettingsRepository {
|
||||
final _partitionKey = 'engine';
|
||||
|
||||
EngineSettings _deserializeSettings(
|
||||
List<MapEntry<String, DriftAny?>> entries,
|
||||
) {
|
||||
final db = ref.read(userDatabaseProvider);
|
||||
final settings = Map.fromEntries(entries);
|
||||
|
||||
return EngineSettings.fromJson({
|
||||
'incognitoMode': settings['incognitoMode']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'javascriptEnabled': settings['javascriptEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'trackingProtectionPolicy': settings['trackingProtectionPolicy']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'httpsOnlyMode': settings['httpsOnlyMode']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'globalPrivacyControlEnabled': settings['globalPrivacyControlEnabled']
|
||||
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||
'cookieBannerHandlingMode': settings['cookieBannerHandlingMode']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'cookieBannerHandlingModePrivateBrowsing':
|
||||
settings['cookieBannerHandlingModePrivateBrowsing']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'cookieBannerHandlingGlobalRules':
|
||||
settings['cookieBannerHandlingGlobalRules']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'cookieBannerHandlingGlobalRulesSubFrames':
|
||||
settings['cookieBannerHandlingGlobalRulesSubFrames']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'webContentIsolationStrategy': settings['webContentIsolationStrategy']
|
||||
?.readAs(DriftSqlType.string, db.typeMapping),
|
||||
'userAgent': settings['userAgent']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'queryParameterStripping': settings['queryParameterStripping']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'bounceTrackingProtectionMode': settings['bounceTrackingProtectionMode']
|
||||
?.readAs(DriftSqlType.string, db.typeMapping),
|
||||
'enterpriseRootsEnabled': settings['enterpriseRootsEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'addonCollection': settings['addonCollection']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'dohSettingsMode': settings['dohSettingsMode']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'dohProviderUrl': settings['dohProviderUrl']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'dohDefaultProviderUrl': settings['dohDefaultProviderUrl']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'dohExceptionsList': settings['dohExceptionsList']
|
||||
?.readAs(DriftSqlType.string, db.typeMapping)
|
||||
.mapNotNull(jsonDecode),
|
||||
'fingerprintingProtectionOverrides':
|
||||
settings['fingerprintingProtectionOverrides']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'enablePdfJs': settings['enablePdfJs']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'locales': settings['locales']
|
||||
?.readAs(DriftSqlType.string, db.typeMapping)
|
||||
.mapNotNull(jsonDecode),
|
||||
// Custom Tracking Protection
|
||||
'blockCookies': settings['blockCookies']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'customCookiePolicy': settings['customCookiePolicy']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'blockTrackingContent': settings['blockTrackingContent']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'trackingContentScope': settings['trackingContentScope']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'blockCryptominers': settings['blockCryptominers']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'blockFingerprinters': settings['blockFingerprinters']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'blockRedirectTrackers': settings['blockRedirectTrackers']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'blockSuspectedFingerprinters': settings['blockSuspectedFingerprinters']
|
||||
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||
'suspectedFingerprintersScope': settings['suspectedFingerprintersScope']
|
||||
?.readAs(DriftSqlType.string, db.typeMapping),
|
||||
'allowListBaseline': settings['allowListBaseline']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'allowListConvenience': settings['allowListConvenience']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
// Web Content Settings
|
||||
'webFontsEnabled': settings['webFontsEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'automaticFontSizeAdjustment': settings['automaticFontSizeAdjustment']
|
||||
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||
'fontSizeFactor': settings['fontSizeFactor']?.readAs(
|
||||
DriftSqlType.double,
|
||||
db.typeMapping,
|
||||
),
|
||||
'fontInflationEnabled': settings['fontInflationEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'displayDensityOverride': settings['displayDensityOverride']?.readAs(
|
||||
DriftSqlType.double,
|
||||
db.typeMapping,
|
||||
),
|
||||
'screenWidthOverride': settings['screenWidthOverride']?.readAs(
|
||||
DriftSqlType.int,
|
||||
db.typeMapping,
|
||||
),
|
||||
'screenHeightOverride': settings['screenHeightOverride']?.readAs(
|
||||
DriftSqlType.int,
|
||||
db.typeMapping,
|
||||
),
|
||||
'inputAutoZoomEnabled': settings['inputAutoZoomEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
// Process Isolation Settings
|
||||
'fissionEnabled': settings['fissionEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'isolatedProcessEnabled': settings['isolatedProcessEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'appZygoteProcessEnabled': settings['appZygoteProcessEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'extensionsWebAPIEnabled': settings['extensionsWebAPIEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
// LNA Settings
|
||||
'lnaBlocking': settings['lnaBlocking']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'lnaBlockTrackers': settings['lnaBlockTrackers']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'lnaEnabled': settings['lnaEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateSettings(
|
||||
UpdateEngineSettingsFunc updateWithCurrent,
|
||||
) async {
|
||||
final db = ref.read(userDatabaseProvider);
|
||||
|
||||
final current = await fetchSettings();
|
||||
|
||||
final oldJson = current.toJson();
|
||||
final newJson = updateWithCurrent(current).toJson();
|
||||
|
||||
return db.transaction(() async {
|
||||
for (final MapEntry(:key, :value) in newJson.entries) {
|
||||
if (oldJson[key] != value) {
|
||||
await db.settingDao.updateSetting(key, _partitionKey, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<EngineSettings> fetchSettings() {
|
||||
return ref
|
||||
.read(userDatabaseProvider)
|
||||
.settingDao
|
||||
.getAllSettingsOfPartitionKey(_partitionKey)
|
||||
.get()
|
||||
.then(_deserializeSettings);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<EngineSettings> build() {
|
||||
final db = ref.watch(userDatabaseProvider);
|
||||
|
||||
return db.settingDao
|
||||
.getAllSettingsOfPartitionKey(_partitionKey)
|
||||
.watch()
|
||||
.map((entries) {
|
||||
return _deserializeSettings(entries);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
EngineSettings engineSettingsWithDefaults(Ref ref) {
|
||||
return ref.watch(
|
||||
engineSettingsRepositoryProvider.select(
|
||||
(value) => value.value ?? EngineSettings.withDefaults(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'engine_settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(EngineSettingsRepository)
|
||||
final engineSettingsRepositoryProvider = EngineSettingsRepositoryProvider._();
|
||||
|
||||
final class EngineSettingsRepositoryProvider
|
||||
extends $StreamNotifierProvider<EngineSettingsRepository, EngineSettings> {
|
||||
EngineSettingsRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'engineSettingsRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$engineSettingsRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
EngineSettingsRepository create() => EngineSettingsRepository();
|
||||
}
|
||||
|
||||
String _$engineSettingsRepositoryHash() =>
|
||||
r'4abe41cfeba8e39484683033f11c54be644fa2b2';
|
||||
|
||||
abstract class _$EngineSettingsRepository
|
||||
extends $StreamNotifier<EngineSettings> {
|
||||
Stream<EngineSettings> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<EngineSettings>, EngineSettings>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<EngineSettings>, EngineSettings>,
|
||||
AsyncValue<EngineSettings>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(engineSettingsWithDefaults)
|
||||
final engineSettingsWithDefaultsProvider =
|
||||
EngineSettingsWithDefaultsProvider._();
|
||||
|
||||
final class EngineSettingsWithDefaultsProvider
|
||||
extends $FunctionalProvider<EngineSettings, EngineSettings, EngineSettings>
|
||||
with $Provider<EngineSettings> {
|
||||
EngineSettingsWithDefaultsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'engineSettingsWithDefaultsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$engineSettingsWithDefaultsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<EngineSettings> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
EngineSettings create(Ref ref) {
|
||||
return engineSettingsWithDefaults(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(EngineSettings value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<EngineSettings>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$engineSettingsWithDefaultsHash() =>
|
||||
r'd47fa79c0ad87a2357de58133585b4f6b097b068';
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* 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 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/data/providers.dart';
|
||||
|
||||
part 'general_settings.g.dart';
|
||||
|
||||
typedef UpdateGeneralSettingsFunc =
|
||||
GeneralSettings Function(GeneralSettings currentSettings);
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class GeneralSettingsRepository extends _$GeneralSettingsRepository {
|
||||
final _partitionKey = 'general';
|
||||
|
||||
GeneralSettings _deserializeSettings(
|
||||
List<MapEntry<String, DriftAny?>> entries,
|
||||
) {
|
||||
final settings = Map.fromEntries(entries);
|
||||
|
||||
final db = ref.read(userDatabaseProvider);
|
||||
|
||||
return GeneralSettings.fromJson({
|
||||
'themeMode': settings['themeMode']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'uiScaleFactor': settings['uiScaleFactor']?.readAs(
|
||||
DriftSqlType.double,
|
||||
db.typeMapping,
|
||||
),
|
||||
'disableAnimations': settings['disableAnimations']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'showModalBarrier': settings['showModalBarrier']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'enableReadability': settings['enableReadability']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'enforceReadability': settings['enforceReadability']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'deleteBrowsingDataOnQuit': settings['deleteBrowsingDataOnQuit']
|
||||
?.readAs(DriftSqlType.string, db.typeMapping)
|
||||
.mapNotNull(jsonDecode),
|
||||
'defaultSearchProvider': settings['defaultSearchProvider']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'defaultSearchSuggestionsProvider':
|
||||
settings['defaultSearchSuggestionsProvider']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'createChildTabsOption': settings['createChildTabsOption']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'enableLocalAiFeatures': settings['enableLocalAiFeatures']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'showContainerUi': settings['showContainerUi']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'showIsolatedTabUi': settings['showIsolatedTabUi']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'defaultCreateTabType': settings['defaultCreateTabType']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'newTabPosition': settings['newTabPosition']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'tabIntentOpenSetting': settings['tabIntentOpenSetting']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'autoHideTabBar': settings['autoHideTabBar']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'tabBarSwipeAction': settings['tabBarSwipeAction']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'historyAutoCleanInterval': settings['historyAutoCleanInterval']?.readAs(
|
||||
DriftSqlType.int,
|
||||
db.typeMapping,
|
||||
),
|
||||
'tabViewBottomSheet': settings['tabViewBottomSheet']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'tabBarShowContextualBar': settings['tabBarShowContextualBar']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'tabBarShowQuickTabSwitcherBar': settings['tabBarShowQuickTabSwitcherBar']
|
||||
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||
'tabBarPosition': settings['tabBarPosition']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'tabBarLayout': settings['tabBarLayout']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'quickTabSwitcherMode': settings['quickTabSwitcherMode']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'pullToRefreshEnabled': settings['pullToRefreshEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'useExternalDownloadManager': settings['useExternalDownloadManager']
|
||||
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||
'doubleBackCloseTab': settings['doubleBackCloseTab']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'unassignedTabsAutoCleanInterval':
|
||||
settings['unassignedTabsAutoCleanInterval']?.readAs(
|
||||
DriftSqlType.int,
|
||||
db.typeMapping,
|
||||
),
|
||||
'maxSearchHistoryEntries': settings['maxSearchHistoryEntries']?.readAs(
|
||||
DriftSqlType.int,
|
||||
db.typeMapping,
|
||||
),
|
||||
'allowClipboardAccess': settings['allowClipboardAccess']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'tabListShowFavicons': settings['tabListShowFavicons']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'quickTabSwitcherShowTitles': settings['quickTabSwitcherShowTitles']
|
||||
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||
'quickTabSwitcherShowHistorySuggestions':
|
||||
settings['quickTabSwitcherShowHistorySuggestions']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'syncServerOverride': settings['syncServerOverride']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'syncTokenServerOverride': settings['syncTokenServerOverride']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerEnabled': settings['urlCleanerEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerAutoApply': settings['urlCleanerAutoApply']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerAllowReferralMarketing':
|
||||
settings['urlCleanerAllowReferralMarketing']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerCatalogUrl': settings['urlCleanerCatalogUrl']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerHashUrl': settings['urlCleanerHashUrl']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerAutoUpdate': settings['urlCleanerAutoUpdate']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerLastCheckEpochMs': settings['urlCleanerLastCheckEpochMs']
|
||||
?.readAs(DriftSqlType.int, db.typeMapping),
|
||||
'urlCleanerLastUpdateWasAuto': settings['urlCleanerLastUpdateWasAuto']
|
||||
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||
'smallWebTabType': settings['smallWebTabType']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'tabBarLongPressUrlCopy': settings['tabBarLongPressUrlCopy']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'unshortenerEnabled': settings['unshortenerEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'unshortenerToken': settings['unshortenerToken']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'allowNonManifestPwaInstall': settings['allowNonManifestPwaInstall']
|
||||
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||
});
|
||||
}
|
||||
|
||||
//Eager fetch, when up to date settings are required
|
||||
Future<GeneralSettings> fetchSettings() {
|
||||
return ref
|
||||
.read(userDatabaseProvider)
|
||||
.settingDao
|
||||
.getAllSettingsOfPartitionKey(_partitionKey)
|
||||
.get()
|
||||
.then(_deserializeSettings);
|
||||
}
|
||||
|
||||
Future<void> updateSettings(
|
||||
UpdateGeneralSettingsFunc updateWithCurrent,
|
||||
) async {
|
||||
final db = ref.read(userDatabaseProvider);
|
||||
|
||||
final current = await fetchSettings();
|
||||
|
||||
final oldJson = current.toJson();
|
||||
final newJson = updateWithCurrent(current).toJson();
|
||||
|
||||
return db.transaction(() async {
|
||||
for (final MapEntry(:key, :value) in newJson.entries) {
|
||||
if (oldJson[key] != value) {
|
||||
await db.settingDao.updateSetting(key, _partitionKey, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<GeneralSettings> build() {
|
||||
final db = ref.watch(userDatabaseProvider);
|
||||
|
||||
return db.settingDao
|
||||
.getAllSettingsOfPartitionKey(_partitionKey)
|
||||
.watch()
|
||||
.map((event) {
|
||||
return _deserializeSettings(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
GeneralSettings generalSettingsWithDefaults(Ref ref) {
|
||||
return ref.watch(
|
||||
generalSettingsRepositoryProvider.select(
|
||||
(value) => value.value ?? GeneralSettings.withDefaults(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'general_settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(GeneralSettingsRepository)
|
||||
final generalSettingsRepositoryProvider = GeneralSettingsRepositoryProvider._();
|
||||
|
||||
final class GeneralSettingsRepositoryProvider
|
||||
extends
|
||||
$StreamNotifierProvider<GeneralSettingsRepository, GeneralSettings> {
|
||||
GeneralSettingsRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'generalSettingsRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$generalSettingsRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
GeneralSettingsRepository create() => GeneralSettingsRepository();
|
||||
}
|
||||
|
||||
String _$generalSettingsRepositoryHash() =>
|
||||
r'afc63f4d929ea146f0b8a7c0f6936b06c5a41024';
|
||||
|
||||
abstract class _$GeneralSettingsRepository
|
||||
extends $StreamNotifier<GeneralSettings> {
|
||||
Stream<GeneralSettings> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<GeneralSettings>, GeneralSettings>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<GeneralSettings>, GeneralSettings>,
|
||||
AsyncValue<GeneralSettings>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(generalSettingsWithDefaults)
|
||||
final generalSettingsWithDefaultsProvider =
|
||||
GeneralSettingsWithDefaultsProvider._();
|
||||
|
||||
final class GeneralSettingsWithDefaultsProvider
|
||||
extends
|
||||
$FunctionalProvider<GeneralSettings, GeneralSettings, GeneralSettings>
|
||||
with $Provider<GeneralSettings> {
|
||||
GeneralSettingsWithDefaultsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'generalSettingsWithDefaultsProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$generalSettingsWithDefaultsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<GeneralSettings> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
GeneralSettings create(Ref ref) {
|
||||
return generalSettingsWithDefaults(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(GeneralSettings value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<GeneralSettings>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$generalSettingsWithDefaultsHash() =>
|
||||
r'9da4a00a3500286fbf515ee319fa911bfacab40e';
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/user/data/providers.dart';
|
||||
|
||||
part 'onboarding.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class OnboardingRepository extends _$OnboardingRepository {
|
||||
static const targetRevision = 3;
|
||||
|
||||
Future<int?> getCurrentRevision() {
|
||||
return ref
|
||||
.read(userDatabaseProvider)
|
||||
.onboardingDao
|
||||
.getLastRevision()
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> pushRevision(int revision) {
|
||||
return ref
|
||||
.read(userDatabaseProvider)
|
||||
.onboardingDao
|
||||
.pushRevision(revision, DateTime.now());
|
||||
}
|
||||
|
||||
Future<bool> isOutdated() async {
|
||||
final current = await getCurrentRevision();
|
||||
return current == null || current < targetRevision;
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'onboarding.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(OnboardingRepository)
|
||||
final onboardingRepositoryProvider = OnboardingRepositoryProvider._();
|
||||
|
||||
final class OnboardingRepositoryProvider
|
||||
extends $NotifierProvider<OnboardingRepository, void> {
|
||||
OnboardingRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'onboardingRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$onboardingRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
OnboardingRepository create() => OnboardingRepository();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$onboardingRepositoryHash() =>
|
||||
r'5d583af3ae38b351357b16809ef32bb6807f5c05';
|
||||
|
||||
abstract class _$OnboardingRepository extends $Notifier<void> {
|
||||
void build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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_annotation/riverpod_annotation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:weblibre/core/filesystem.dart';
|
||||
import 'package:weblibre/domain/entities/profile.dart';
|
||||
import 'package:weblibre/features/user/data/models/auth_settings.dart';
|
||||
|
||||
part 'profile.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ProfileRepository extends _$ProfileRepository {
|
||||
Future<List<Profile>> _readProfiles() {
|
||||
return filesystem.getAvailableProfileDirectories().then((dirs) async {
|
||||
final profiles = await Future.wait(
|
||||
dirs.map(filesystem.readProfileMetadata),
|
||||
);
|
||||
return profiles.nonNulls.toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> switchProfile(String id) async {
|
||||
await filesystem.setStartupProfile(UuidValue.withValidation(id));
|
||||
}
|
||||
|
||||
Future<Profile> createProfile({
|
||||
required String name,
|
||||
AuthSettings? authSettings,
|
||||
}) async {
|
||||
final profile = Profile.create(name: name, authSettings: authSettings);
|
||||
if (!await filesystem.createNewProfile(profile)) {
|
||||
throw Exception('Could not create profile');
|
||||
}
|
||||
|
||||
ref.invalidateSelf();
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
Future<void> updateProfileMetadata(Profile profile) async {
|
||||
await filesystem.updateProfileMetadata(profile);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Future<bool> deleteProfile(String id) async {
|
||||
final uuid = UuidValue.withValidation(id);
|
||||
if (filesystem.selectedProfile == uuid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await filesystem.getProfileDir(uuid).delete(recursive: true);
|
||||
|
||||
ref.invalidateSelf();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Profile>> build() {
|
||||
return _readProfiles();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'profile.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(ProfileRepository)
|
||||
final profileRepositoryProvider = ProfileRepositoryProvider._();
|
||||
|
||||
final class ProfileRepositoryProvider
|
||||
extends $AsyncNotifierProvider<ProfileRepository, List<Profile>> {
|
||||
ProfileRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'profileRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$profileRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ProfileRepository create() => ProfileRepository();
|
||||
}
|
||||
|
||||
String _$profileRepositoryHash() => r'b770e7406e1602f808cc8076c1eda67b4fce6b2d';
|
||||
|
||||
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
|
||||
FutureOr<List<Profile>> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<List<Profile>>, List<Profile>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<List<Profile>>, List<Profile>>,
|
||||
AsyncValue<List<Profile>>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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:drift/drift.dart';
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/user/data/models/tor_settings.dart';
|
||||
import 'package:weblibre/features/user/data/providers.dart';
|
||||
|
||||
part 'tor_settings.g.dart';
|
||||
|
||||
typedef UpdateTorSettingsFunc =
|
||||
TorSettings Function(TorSettings currentSettings);
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class TorSettingsRepository extends _$TorSettingsRepository {
|
||||
final _partitionKey = 'tor';
|
||||
|
||||
TorSettings _deserializeSettings(List<MapEntry<String, DriftAny?>> entries) {
|
||||
final settings = Map.fromEntries(entries);
|
||||
|
||||
final db = ref.read(userDatabaseProvider);
|
||||
|
||||
return TorSettings.fromJson({
|
||||
'proxyRegularTabsMode': settings['proxyRegularTabsMode']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'proxyPrivateTabsTor': settings['proxyPrivateTabsTor']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'config': settings['config']?.readAs(DriftSqlType.string, db.typeMapping),
|
||||
'requireBridge': settings['requireBridge']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'fetchRemoteBridges': settings['fetchRemoteBridges']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'entryNodeCountry': settings['entryNodeCountry']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'exitNodeCountry': settings['exitNodeCountry']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
//Eager fetch, when up to date settings are required
|
||||
Future<TorSettings> fetchSettings() {
|
||||
return ref
|
||||
.read(userDatabaseProvider)
|
||||
.settingDao
|
||||
.getAllSettingsOfPartitionKey(_partitionKey)
|
||||
.get()
|
||||
.then(_deserializeSettings);
|
||||
}
|
||||
|
||||
Future<void> updateSettings(UpdateTorSettingsFunc updateWithCurrent) async {
|
||||
final db = ref.read(userDatabaseProvider);
|
||||
|
||||
final current = await fetchSettings();
|
||||
|
||||
final oldJson = current.toJson();
|
||||
final newJson = updateWithCurrent(current).toJson();
|
||||
|
||||
return db.transaction(() async {
|
||||
for (final MapEntry(:key, :value) in newJson.entries) {
|
||||
if (oldJson[key] != value) {
|
||||
await db.settingDao.updateSetting(key, _partitionKey, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<TorSettings> build() {
|
||||
final db = ref.watch(userDatabaseProvider);
|
||||
|
||||
return db.settingDao
|
||||
.getAllSettingsOfPartitionKey(_partitionKey)
|
||||
.watch()
|
||||
.map((event) {
|
||||
return _deserializeSettings(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
TorSettings torSettingsWithDefaults(Ref ref) {
|
||||
return ref.watch(
|
||||
torSettingsRepositoryProvider.select(
|
||||
(value) => value.value ?? TorSettings.withDefaults(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'tor_settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(TorSettingsRepository)
|
||||
final torSettingsRepositoryProvider = TorSettingsRepositoryProvider._();
|
||||
|
||||
final class TorSettingsRepositoryProvider
|
||||
extends $StreamNotifierProvider<TorSettingsRepository, TorSettings> {
|
||||
TorSettingsRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'torSettingsRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$torSettingsRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
TorSettingsRepository create() => TorSettingsRepository();
|
||||
}
|
||||
|
||||
String _$torSettingsRepositoryHash() =>
|
||||
r'f771f23f17903bd192b24604bab522fb20571ffd';
|
||||
|
||||
abstract class _$TorSettingsRepository extends $StreamNotifier<TorSettings> {
|
||||
Stream<TorSettings> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<TorSettings>, TorSettings>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<TorSettings>, TorSettings>,
|
||||
AsyncValue<TorSettings>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(torSettingsWithDefaults)
|
||||
final torSettingsWithDefaultsProvider = TorSettingsWithDefaultsProvider._();
|
||||
|
||||
final class TorSettingsWithDefaultsProvider
|
||||
extends $FunctionalProvider<TorSettings, TorSettings, TorSettings>
|
||||
with $Provider<TorSettings> {
|
||||
TorSettingsWithDefaultsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'torSettingsWithDefaultsProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$torSettingsWithDefaultsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<TorSettings> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
TorSettings create(Ref ref) {
|
||||
return torSettingsWithDefaults(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(TorSettings value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<TorSettings>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$torSettingsWithDefaultsHash() =>
|
||||
r'501a7ed7f14870d40b8f60303d3c385a45d9f542';
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/user/data/models/rfp_target.dart';
|
||||
|
||||
part 'fingerprinting.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<List<RFPTarget>> fingerprintTargets(Ref ref) async {
|
||||
final json =
|
||||
await rootBundle
|
||||
.loadString('assets/preferences/rfp_targets.json')
|
||||
.then(jsonDecode)
|
||||
as List<dynamic>;
|
||||
|
||||
return json
|
||||
.map((e) => RFPTarget.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'fingerprinting.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(fingerprintTargets)
|
||||
final fingerprintTargetsProvider = FingerprintTargetsProvider._();
|
||||
|
||||
final class FingerprintTargetsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<RFPTarget>>,
|
||||
List<RFPTarget>,
|
||||
FutureOr<List<RFPTarget>>
|
||||
>
|
||||
with $FutureModifier<List<RFPTarget>>, $FutureProvider<List<RFPTarget>> {
|
||||
FingerprintTargetsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'fingerprintTargetsProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$fingerprintTargetsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<RFPTarget>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<RFPTarget>> create(Ref ref) {
|
||||
return fingerprintTargets(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$fingerprintTargetsHash() =>
|
||||
r'1ec5933a82941b84fdad2130ccf1ba2156ddc33a';
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 'dart:async';
|
||||
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/user/data/models/auth_settings.dart';
|
||||
|
||||
part 'local_authentication.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class LocalAuthenticationService extends _$LocalAuthenticationService {
|
||||
final _auth = LocalAuthentication();
|
||||
final _cache = <String, (DateTime, AuthSettings)>{};
|
||||
|
||||
void evictCacheOnBackground() {
|
||||
_cache.removeWhere(
|
||||
(key, value) => value.$2.autoLockMode == AutoLockMode.background,
|
||||
);
|
||||
}
|
||||
|
||||
bool isCached(String authKey) {
|
||||
final auth = _cache[authKey];
|
||||
|
||||
if (auth == null) return false;
|
||||
if (auth.$2.autoLockMode == AutoLockMode.timeout) {
|
||||
return DateTime.now().difference(auth.$1) < auth.$2.timeout;
|
||||
}
|
||||
|
||||
// Background mode cache stays valid until app background eviction.
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> authenticate({
|
||||
required String authKey,
|
||||
required String localizedReason,
|
||||
AuthSettings? settings,
|
||||
bool useAuthCache = false,
|
||||
}) async {
|
||||
try {
|
||||
final useCache = useAuthCache && isCached(authKey);
|
||||
final success =
|
||||
useCache ||
|
||||
await _auth.authenticate(localizedReason: localizedReason);
|
||||
|
||||
if (success && settings != null) {
|
||||
_cache[authKey] = (DateTime.now(), settings);
|
||||
}
|
||||
|
||||
return success;
|
||||
} on LocalAuthException catch (e, s) {
|
||||
logger.e('Could not authenticate', error: e, stackTrace: s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> build() {
|
||||
return _auth.canCheckBiometrics;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'local_authentication.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(LocalAuthenticationService)
|
||||
final localAuthenticationServiceProvider =
|
||||
LocalAuthenticationServiceProvider._();
|
||||
|
||||
final class LocalAuthenticationServiceProvider
|
||||
extends $AsyncNotifierProvider<LocalAuthenticationService, bool> {
|
||||
LocalAuthenticationServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'localAuthenticationServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$localAuthenticationServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
LocalAuthenticationService create() => LocalAuthenticationService();
|
||||
}
|
||||
|
||||
String _$localAuthenticationServiceHash() =>
|
||||
r'0f4b2b47e94b2426a2219eca4eb2258bf683ab7c';
|
||||
|
||||
abstract class _$LocalAuthenticationService extends $AsyncNotifier<bool> {
|
||||
FutureOr<bool> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<bool>, bool>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<bool>, bool>,
|
||||
AsyncValue<bool>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/*
|
||||
* 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 'dart:io';
|
||||
|
||||
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';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class UserBackupService extends _$UserBackupService {
|
||||
static final dateFormatter = FixedDateTimeFormatter('YYYY-MM-DD_hhmmss');
|
||||
static const _excludedBackupRelativePaths = {'cache'};
|
||||
|
||||
static final _safUtil = SafUtil();
|
||||
static final _safStream = SafStream();
|
||||
|
||||
bool _isExcludedBackupPath(String relativePath) {
|
||||
final normalizedPath = p.normalize(relativePath);
|
||||
|
||||
for (final excludedPath in _excludedBackupRelativePaths) {
|
||||
if (normalizedPath == excludedPath ||
|
||||
p.isWithin(excludedPath, normalizedPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _copyCuratedBackupSource(
|
||||
Directory rootDirectory,
|
||||
Directory sourceDirectory,
|
||||
Directory targetDirectory,
|
||||
) async {
|
||||
await targetDirectory.create(recursive: true);
|
||||
|
||||
await for (final entity in sourceDirectory.list(followLinks: false)) {
|
||||
final relativePath = p.relative(entity.path, from: rootDirectory.path);
|
||||
|
||||
if (_isExcludedBackupPath(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final targetPath = p.join(targetDirectory.path, p.basename(entity.path));
|
||||
|
||||
if (entity is Directory) {
|
||||
await _copyCuratedBackupSource(
|
||||
rootDirectory,
|
||||
entity,
|
||||
Directory(targetPath),
|
||||
);
|
||||
} else if (entity is File) {
|
||||
await entity.copy(targetPath);
|
||||
} else if (entity is Link) {
|
||||
await Link(targetPath).create(await entity.target());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Directory> _prepareBackupSourceDirectory(
|
||||
Directory sourceDirectory, {
|
||||
required bool skipCaches,
|
||||
}) async {
|
||||
if (!skipCaches) {
|
||||
return sourceDirectory;
|
||||
}
|
||||
|
||||
final tempDirectory = await getTemporaryDirectory();
|
||||
final curatedDirectory = Directory(
|
||||
p.join(
|
||||
tempDirectory.path,
|
||||
'backup_source_${DateTime.now().microsecondsSinceEpoch}',
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await _copyCuratedBackupSource(
|
||||
sourceDirectory,
|
||||
sourceDirectory,
|
||||
curatedDirectory,
|
||||
);
|
||||
return curatedDirectory;
|
||||
} catch (_) {
|
||||
try {
|
||||
if (await curatedDirectory.exists()) {
|
||||
await curatedDirectory.delete(recursive: true);
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore cleanup errors for partially copied backup sources.
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Uri _requireBackupDirectoryUri() {
|
||||
final uri = ref.read(backupDirectoryUriProvider);
|
||||
if (uri == null) {
|
||||
throw Exception('No backup directory configured');
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
Future<List<SafDocumentFile>> getBackupList(Uri dirUri) async {
|
||||
final files = await _safUtil.list(dirUri.toString());
|
||||
return files
|
||||
.where((f) => !f.isDir && f.name.endsWith('.weblibre'))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<bool> createUserBackup(
|
||||
Profile profile, {
|
||||
required String password,
|
||||
required bool integrityCheck,
|
||||
required bool skipCaches,
|
||||
}) async {
|
||||
final dirUri = _requireBackupDirectoryUri();
|
||||
final timestamp = dateFormatter.encode(DateTime.now());
|
||||
final fileName = 'backup_${profile.name}_$timestamp.weblibre';
|
||||
final sourceDirectory = filesystem.getProfileDir(profile.uuidValue);
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempFile = File(p.join(tempDir.path, fileName));
|
||||
Directory? curatedSourceDirectory;
|
||||
|
||||
try {
|
||||
curatedSourceDirectory = await _prepareBackupSourceDirectory(
|
||||
sourceDirectory,
|
||||
skipCaches: skipCaches,
|
||||
);
|
||||
|
||||
final backup = SecureArchivePack(
|
||||
outputFile: tempFile,
|
||||
sourceDirectory: curatedSourceDirectory,
|
||||
argon2Params: Argon2Params.memoryConstrained(),
|
||||
);
|
||||
|
||||
await backup.pack(password, integrityCheck: integrityCheck);
|
||||
|
||||
await _safStream.pasteLocalFile(
|
||||
tempFile.path,
|
||||
dirUri.toString(),
|
||||
fileName,
|
||||
'application/octet-stream',
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
if (curatedSourceDirectory != null &&
|
||||
curatedSourceDirectory.path != sourceDirectory.path) {
|
||||
try {
|
||||
if (await curatedSourceDirectory.exists()) {
|
||||
await curatedSourceDirectory.delete(recursive: true);
|
||||
}
|
||||
} catch (e, s) {
|
||||
logger.w(
|
||||
'Failed to cleanup curated backup directory: ${curatedSourceDirectory.path}',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> restoreAndCreateNew(
|
||||
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, 'restore_temp'),
|
||||
);
|
||||
|
||||
try {
|
||||
await _safStream.copyToLocalFile(backupFileUri.toString(), tempFile.path);
|
||||
|
||||
final backup = SecureArchiveUnpack(
|
||||
inputFile: tempFile,
|
||||
outputDirectory: outputDirectory,
|
||||
argon2Params: Argon2Params.memoryConstrained(),
|
||||
);
|
||||
await backup.unpack(password).then((_) async {
|
||||
final newProfile = Profile.create(name: profileName);
|
||||
final newPath = filesystem.getProfileDir(newProfile.uuidValue);
|
||||
|
||||
await outputDirectory.rename(newPath.path);
|
||||
await filesystem.updateProfileMetadata(newProfile);
|
||||
await filesystem.healProfile(newPath);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (e, s) {
|
||||
logger.w(
|
||||
'Failed to cleanup temporary backup directory: ${outputDirectory.path}',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> restoreAndCreateOrOverride(
|
||||
Uri backupFileUri, {
|
||||
required String password,
|
||||
required FutureOr<bool?> 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, 'restore_temp'),
|
||||
);
|
||||
|
||||
try {
|
||||
await _safStream.copyToLocalFile(backupFileUri.toString(), tempFile.path);
|
||||
|
||||
final backup = SecureArchiveUnpack(
|
||||
inputFile: tempFile,
|
||||
outputDirectory: outputDirectory,
|
||||
argon2Params: Argon2Params.memoryConstrained(),
|
||||
);
|
||||
await backup.unpack(password).then((_) async {
|
||||
final existingProfile = await filesystem.readProfileMetadata(
|
||||
outputDirectory,
|
||||
);
|
||||
if (existingProfile == null) {
|
||||
throw Exception('Backup does not contain valid profile metadata');
|
||||
}
|
||||
|
||||
if (existingProfile.uuidValue == filesystem.selectedProfile) {
|
||||
throw Exception(
|
||||
'Unable to override active User, please switch to another User and try again',
|
||||
);
|
||||
}
|
||||
|
||||
final profileDir = filesystem.getProfileDir(existingProfile.uuidValue);
|
||||
|
||||
if (await profileDir.exists()) {
|
||||
final result = await confirmOverrideCallback();
|
||||
|
||||
if (result == true) {
|
||||
await profileDir.delete(recursive: true);
|
||||
await outputDirectory.rename(profileDir.path);
|
||||
await filesystem.healProfile(profileDir);
|
||||
}
|
||||
} else {
|
||||
// Profile doesn't exist yet, just move the restored data into place
|
||||
await outputDirectory.rename(profileDir.path);
|
||||
await filesystem.healProfile(profileDir);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (e, s) {
|
||||
logger.w(
|
||||
'Failed to cleanup temporary backup directory: ${outputDirectory.path}',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
void build() {}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'user_backup.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(UserBackupService)
|
||||
final userBackupServiceProvider = UserBackupServiceProvider._();
|
||||
|
||||
final class UserBackupServiceProvider
|
||||
extends $NotifierProvider<UserBackupService, void> {
|
||||
UserBackupServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'userBackupServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$userBackupServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
UserBackupService create() => UserBackupService();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$userBackupServiceHash() => r'3746ae59d490e25a44815c125f30cd0981de555d';
|
||||
|
||||
abstract class _$UserBackupService extends $Notifier<void> {
|
||||
void build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user