prepare for multiple apps

This commit is contained in:
Fabian Freund
2026-04-06 12:23:11 +02:00
parent bd1600e8dc
commit 5afc323f04
904 changed files with 29 additions and 29 deletions
@@ -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];
}
@@ -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'),
),
],
);
},
);
}
@@ -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'),
),
],
);
},
);
}
@@ -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,
),
),
],
),
);
}
}
@@ -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);
}
}