Compare commits
17 Commits
fixReqVal
...
autobackup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88d96b89fa | ||
|
|
0a1bcc863b | ||
|
|
8e9a43564a | ||
|
|
fdbc248228 | ||
|
|
fe732f2778 | ||
|
|
ca8a067966 | ||
|
|
5e3a779925 | ||
|
|
d1b06abada | ||
|
|
9e70dc4312 | ||
|
|
541d71f65c | ||
|
|
d8fc369a21 | ||
|
|
8efbebe9c4 | ||
|
|
a7300b7ac7 | ||
|
|
9224cea96f | ||
|
|
9fbc618d69 | ||
|
|
4614428f76 | ||
|
|
6fde4ee45f |
1
mobile/apps/auth/.gitignore
vendored
1
mobile/apps/auth/.gitignore
vendored
@@ -8,6 +8,7 @@
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
android/app/build/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
|
||||
Submodule mobile/apps/auth/flutter updated: edada7c56e...2663184aa7
@@ -527,5 +527,24 @@
|
||||
"errorInvalidQRCodeBody": "The scanned QR code is not a valid 2FA account.",
|
||||
"errorNoQRCode": "No QR code found",
|
||||
"errorGenericTitle": "An Error Occurred",
|
||||
"errorGenericBody": "An unexpected error occurred while importing."
|
||||
"errorGenericBody": "An unexpected error occurred while importing.",
|
||||
"localBackupSettingsTitle": "Local backup",
|
||||
"localBackupSidebarTitle": "Local backup",
|
||||
"enableAutomaticBackups": "Enable automatic backups",
|
||||
"backupDescription": "This will automatically backup your data to an on-device location. Backups are updated whenever entries are added, edited or deleted",
|
||||
"currentLocation": "Current backup location:",
|
||||
"securityNotice": "Security notice",
|
||||
"backupSecurityNotice": "This encrypted backup holds your 2FA keys. If lost, you may not be able to recover your accounts. Keep it safe!",
|
||||
"locationUpdatedAndBackupCreated": "Location updated and initial backup created!",
|
||||
"initialBackupCreated": "Initial backup created!",
|
||||
"passwordTooShort": "Password must be at least 8 characters long.",
|
||||
"noDefaultBackupFolder": "Could not create default backup folder.",
|
||||
"backupLocationChoiceDescription": "Where do you want to save your backups?",
|
||||
"chooseBackupLocation": "Choose a backup location",
|
||||
"loadDefaultLocation": "Loading default location...",
|
||||
"couldNotDetermineLocation":"Could not determine location...",
|
||||
"saveAction":"Save",
|
||||
"saveBackup":"Save backup",
|
||||
"changeLocation": "Change location",
|
||||
"changeCurrentLocation": "Change current location"
|
||||
}
|
||||
177
mobile/apps/auth/lib/services/local_backup_service.dart
Normal file
177
mobile/apps/auth/lib/services/local_backup_service.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:ente_auth/models/export/ente.dart';
|
||||
import 'package:ente_auth/store/code_store.dart';
|
||||
import 'package:ente_crypto_dart/ente_crypto_dart.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:intl/intl.dart'; //for time based file naming
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
//we gonn change
|
||||
|
||||
class LocalBackupService {
|
||||
final _logger = Logger('LocalBackupService');
|
||||
static final LocalBackupService instance =
|
||||
LocalBackupService._privateConstructor();
|
||||
LocalBackupService._privateConstructor();
|
||||
|
||||
static const int _maxBackups = 2;
|
||||
|
||||
// to create an encrypted backup file if the toggle is on
|
||||
Future<void> triggerAutomaticBackup() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final isEnabled = prefs.getBool('isAutoBackupEnabled') ?? false;
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final backupPath = prefs.getString('autoBackupPath');
|
||||
if (backupPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = FlutterSecureStorage();
|
||||
final password = await storage.read(key: 'autoBackupPassword');
|
||||
if (password == null || password.isEmpty) {
|
||||
_logger.warning("Automatic backup skipped: password not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.info("Change detected, triggering automatic encrypted backup...");
|
||||
|
||||
|
||||
String rawContent = await CodeStore.instance.getCodesForExport();
|
||||
|
||||
List<String> lines = rawContent.split('\n');
|
||||
List<String> cleanedLines = [];
|
||||
|
||||
for (String line in lines) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
|
||||
String cleanUrl;
|
||||
if (line.startsWith('"') && line.endsWith('"')) {
|
||||
cleanUrl = jsonDecode(line);
|
||||
}
|
||||
|
||||
else {
|
||||
cleanUrl = line;
|
||||
}
|
||||
|
||||
cleanedLines.add(cleanUrl);
|
||||
}
|
||||
|
||||
final plainTextContent = cleanedLines.join('\n');
|
||||
|
||||
if (plainTextContent.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final kekSalt = CryptoUtil.getSaltToDeriveKey();
|
||||
final derivedKeyResult = await CryptoUtil.deriveSensitiveKey(
|
||||
utf8.encode(password),
|
||||
kekSalt,
|
||||
);
|
||||
|
||||
final encResult = await CryptoUtil.encryptData(
|
||||
utf8.encode(plainTextContent),
|
||||
derivedKeyResult.key,
|
||||
);
|
||||
|
||||
final encContent = CryptoUtil.bin2base64(encResult.encryptedData!);
|
||||
final encNonce = CryptoUtil.bin2base64(encResult.header!);
|
||||
|
||||
final EnteAuthExport data = EnteAuthExport(
|
||||
version: 1,
|
||||
encryptedData: encContent,
|
||||
encryptionNonce: encNonce,
|
||||
kdfParams: KDFParams(
|
||||
memLimit: derivedKeyResult.memLimit,
|
||||
opsLimit: derivedKeyResult.opsLimit,
|
||||
salt: CryptoUtil.bin2base64(kekSalt),
|
||||
),
|
||||
);
|
||||
|
||||
final encryptedJson = jsonEncode(data.toJson());
|
||||
|
||||
final now = DateTime.now();
|
||||
final formatter = DateFormat('yyyy-MM-dd_HH-mm-ss');
|
||||
final formattedDate = formatter.format(now);
|
||||
final fileName = 'ente-auth-auto-backup-$formattedDate.json';
|
||||
|
||||
final filePath = '$backupPath/$fileName';
|
||||
final backupFile = File(filePath);
|
||||
|
||||
await backupFile.writeAsString(encryptedJson);
|
||||
await _manageOldBackups(backupPath);
|
||||
|
||||
_logger.info('Automatic encrypted backup successful! Saved to: $filePath');
|
||||
} catch (e, s) {
|
||||
_logger.severe('Silent error during automatic backup', e, s);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _manageOldBackups(String backupPath) async {
|
||||
try {
|
||||
_logger.info("Checking for old backups to clean up...");
|
||||
final directory = Directory(backupPath);
|
||||
|
||||
// fetch all filenames in the folder, filter out ente backup files
|
||||
final files = directory.listSync()
|
||||
.where((entity) =>
|
||||
entity is File &&
|
||||
entity.path.split('/').last.startsWith('ente-auth-auto-backup-'),)
|
||||
.map((entity) => entity as File)
|
||||
.toList();
|
||||
|
||||
// sort the fetched files in asc order (oldest first because the name is a timestamp)
|
||||
files.sort((a, b) => a.path.compareTo(b.path));
|
||||
|
||||
// if we have more files than our limit, delete the oldest ones (current limit=_maxBackups)
|
||||
while (files.length > _maxBackups) {
|
||||
// remove the oldest file (at index 0) from the list
|
||||
final fileToDelete = files.removeAt(0);
|
||||
// and delete it from the device's storage..
|
||||
await fileToDelete.delete();
|
||||
_logger.info('Deleted old backup: ${fileToDelete.path}');
|
||||
}
|
||||
_logger.info('Backup count is now ${files.length}. Cleanup complete.');
|
||||
} catch (e, s) {
|
||||
_logger.severe('Error during old backup cleanup', e, s);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAllBackupsIn(String path) async {
|
||||
try {
|
||||
_logger.info("Deleting all backups in old location: $path");
|
||||
final directory = Directory(path);
|
||||
|
||||
if (!await directory.exists()) {
|
||||
_logger.warning("Old backup directory not found. Nothing to delete.");
|
||||
return;
|
||||
}
|
||||
|
||||
final files = directory.listSync()
|
||||
.where((entity) =>
|
||||
entity is File &&
|
||||
entity.path.split('/').last.startsWith('ente-auth-auto-backup-'),)
|
||||
.map((entity) => entity as File)
|
||||
.toList();
|
||||
|
||||
if (files.isEmpty) {
|
||||
_logger.info("No old backup files found to delete.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (final file in files) {
|
||||
await file.delete();
|
||||
_logger.info('Deleted: ${file.path}');
|
||||
}
|
||||
_logger.info("Successfully cleaned up old backup location.");
|
||||
|
||||
} catch (e, s) {
|
||||
_logger.severe('Error during full backup cleanup of old directory', e, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:ente_auth/events/codes_updated_event.dart';
|
||||
import 'package:ente_auth/models/authenticator/entity_result.dart';
|
||||
import 'package:ente_auth/models/code.dart';
|
||||
import 'package:ente_auth/services/authenticator_service.dart';
|
||||
import 'package:ente_auth/services/local_backup_service.dart';
|
||||
import 'package:ente_auth/store/offline_authenticator_db.dart';
|
||||
import 'package:ente_events/event_bus.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -64,6 +65,27 @@ class CodeStore {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> updateCode(Code originalCode, Code updatedCode, {bool shouldSync = true}) async {
|
||||
if (updatedCode.generatedID == null) return;
|
||||
|
||||
await _authenticatorService.updateEntry(
|
||||
updatedCode.generatedID!,
|
||||
updatedCode.toOTPAuthUrlFormat(),
|
||||
shouldSync,
|
||||
_authenticatorService.getAccountMode(),
|
||||
);
|
||||
Bus.instance.fire(CodesUpdatedEvent());
|
||||
|
||||
final bool isMajorChange = originalCode.issuer != updatedCode.issuer ||
|
||||
originalCode.account != updatedCode.account ||
|
||||
originalCode.secret != updatedCode.secret ||
|
||||
originalCode.display.note != updatedCode.display.note;
|
||||
|
||||
if (isMajorChange) {
|
||||
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Code>> getAllCodes({
|
||||
AccountMode? accountMode,
|
||||
bool sortCodes = true,
|
||||
@@ -95,7 +117,6 @@ class CodeStore {
|
||||
}
|
||||
|
||||
if (sortCodes) {
|
||||
// sort codes by issuer,account
|
||||
codes.sort((firstCode, secondCode) {
|
||||
if (secondCode.isPinned && !firstCode.isPinned) return 1;
|
||||
if (!secondCode.isPinned && firstCode.isPinned) return -1;
|
||||
@@ -120,13 +141,17 @@ class CodeStore {
|
||||
bool shouldSync = true,
|
||||
AccountMode? accountMode,
|
||||
List<Code>? existingAllCodes,
|
||||
bool isFrequencyOrRecencyUpdate = false,
|
||||
}) async {
|
||||
|
||||
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
||||
final allCodes = existingAllCodes ?? (await getAllCodes(accountMode: mode));
|
||||
bool isExistingCode = false;
|
||||
bool hasSameCode = false;
|
||||
|
||||
for (final existingCode in allCodes) {
|
||||
if (existingCode.hasError) continue;
|
||||
|
||||
if (code.generatedID != null &&
|
||||
existingCode.generatedID == code.generatedID) {
|
||||
isExistingCode = true;
|
||||
@@ -148,6 +173,9 @@ class CodeStore {
|
||||
shouldSync,
|
||||
mode,
|
||||
);
|
||||
if (!isFrequencyOrRecencyUpdate) {
|
||||
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||
}
|
||||
} else {
|
||||
result = AddResult.newCode;
|
||||
code.generatedID = await _authenticatorService.addEntry(
|
||||
@@ -155,6 +183,7 @@ class CodeStore {
|
||||
shouldSync,
|
||||
mode,
|
||||
);
|
||||
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||
}
|
||||
Bus.instance.fire(CodesUpdatedEvent());
|
||||
return result;
|
||||
@@ -164,6 +193,7 @@ class CodeStore {
|
||||
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
||||
await _authenticatorService.deleteEntry(code.generatedID!, mode);
|
||||
Bus.instance.fire(CodesUpdatedEvent());
|
||||
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||
}
|
||||
|
||||
bool _isOfflineImportRunning = false;
|
||||
@@ -214,7 +244,6 @@ class CodeStore {
|
||||
'importingCode: genID ${eachCode.generatedID} & isAlreadyPresent $alreadyPresent',
|
||||
);
|
||||
if (!alreadyPresent) {
|
||||
// Avoid conflict with generatedID of online codes
|
||||
eachCode.generatedID = null;
|
||||
final AddResult result = await CodeStore.instance.addCode(
|
||||
eachCode,
|
||||
@@ -236,10 +265,21 @@ class CodeStore {
|
||||
_isOfflineImportRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> getCodesForExport() async {
|
||||
final allCodes = await getAllCodes(sortCodes: false);
|
||||
String data = "";
|
||||
for (final code in allCodes) {
|
||||
if (code.hasError) continue;
|
||||
data += "${code.toOTPAuthUrlFormat()}\n";
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum AddResult {
|
||||
newCode,
|
||||
duplicate,
|
||||
updateCode,
|
||||
}
|
||||
}
|
||||
@@ -478,7 +478,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
_getCurrentOTP(),
|
||||
confirmationMessage: context.l10n.copiedToClipboard,
|
||||
);
|
||||
_udateCodeMetadata().ignore();
|
||||
_updateCodeMetadata().ignore();
|
||||
}
|
||||
|
||||
void _copyNextToClipboard() {
|
||||
@@ -486,10 +486,10 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
_getNextTotp(),
|
||||
confirmationMessage: context.l10n.copiedNextToClipboard,
|
||||
);
|
||||
_udateCodeMetadata().ignore();
|
||||
_updateCodeMetadata().ignore();
|
||||
}
|
||||
|
||||
Future<void> _udateCodeMetadata() async {
|
||||
Future<void> _updateCodeMetadata() async {
|
||||
if (widget.sortKey == null) return;
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
@@ -502,7 +502,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
lastUsedAt: DateTime.now().microsecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
unawaited(CodeStore.instance.addCode(code));
|
||||
unawaited(CodeStore.instance.addCode(code, isFrequencyOrRecencyUpdate: true));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -94,6 +94,7 @@ class _HomePageState extends State<HomePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_codeSortKey = PreferenceService.instance.codeSortKey();
|
||||
_textController.addListener(_applyFilteringAndRefresh);
|
||||
_loadCodes();
|
||||
@@ -153,6 +154,7 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
|
||||
void _loadCodes() {
|
||||
debugPrint("[HOME_DEBUG] _loadCodes triggered!");
|
||||
CodeStore.instance.getAllCodes().then((codes) {
|
||||
_allCodes = codes;
|
||||
hasTrashedCodes = false;
|
||||
@@ -817,4 +819,4 @@ class _HomePageState extends State<HomePage> {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:ente_auth/ui/settings/common_settings.dart';
|
||||
import 'package:ente_auth/ui/settings/data/duplicate_code_page.dart';
|
||||
import 'package:ente_auth/ui/settings/data/export_widget.dart';
|
||||
import 'package:ente_auth/ui/settings/data/import_page.dart';
|
||||
import 'package:ente_auth/ui/settings/data/local_backup_settings_page.dart'; //for local backup
|
||||
import 'package:ente_auth/utils/dialog_util.dart';
|
||||
import 'package:ente_auth/utils/navigation_util.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -29,6 +30,10 @@ class DataSectionWidget extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleLocalBackupClick(BuildContext context) async {
|
||||
await routeToPage(context, const LocalBackupSettingsPage());
|
||||
}
|
||||
|
||||
Column _getSectionOptions(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
List<Widget> children = [];
|
||||
@@ -86,10 +91,21 @@ class DataSectionWidget extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: l10n.localBackupSidebarTitle,
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon: Icons.chevron_right_outlined,
|
||||
trailingIconIsMuted: true,
|
||||
onTap: () async {
|
||||
await _handleLocalBackupClick(context);
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
]);
|
||||
return Column(
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,4 +153,4 @@ Future<void> _pickEnteJsonFile(BuildContext context) async {
|
||||
context.l10n.importFailureDescNew,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ente_auth/ente_theme_data.dart';
|
||||
import 'package:ente_auth/l10n/l10n.dart';
|
||||
import 'package:ente_auth/services/local_backup_service.dart';
|
||||
import 'package:ente_auth/theme/ente_theme.dart';
|
||||
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
|
||||
import 'package:ente_auth/ui/components/dialog_widget.dart';
|
||||
import 'package:ente_auth/ui/components/models/button_result.dart';
|
||||
import 'package:ente_auth/ui/components/models/button_type.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class LocalBackupSettingsPage extends StatefulWidget {
|
||||
const LocalBackupSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<LocalBackupSettingsPage> createState() =>
|
||||
_LocalBackupSettingsPageState();
|
||||
}
|
||||
|
||||
class _LocalBackupSettingsPageState extends State<LocalBackupSettingsPage> {
|
||||
bool _isBackupEnabled = false;
|
||||
String? _backupPath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
// to load the saved settings from SharedPreferences when the page opens.
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_isBackupEnabled = prefs.getBool('isAutoBackupEnabled') ?? false;
|
||||
_backupPath = prefs.getString('autoBackupPath');
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> _showCustomPasswordDialog() async {
|
||||
final l10n = context.l10n;
|
||||
final textController = TextEditingController();
|
||||
// state variable to track password visibility
|
||||
bool isPasswordHidden = true;
|
||||
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.setPasswordTitle, style: getEnteTextTheme(context).largeBold),
|
||||
content: TextField(
|
||||
controller: textController,
|
||||
autofocus: true,
|
||||
obscureText: isPasswordHidden,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.enterPassword,
|
||||
hintStyle: getEnteTextTheme(context).mini,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
isPasswordHidden ? Icons.visibility_off : Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
isPasswordHidden = !isPasswordHidden;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
onChanged: (text) => setState(() {}),
|
||||
),
|
||||
actions: [
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: ButtonWidget(
|
||||
buttonType: ButtonType.secondary,
|
||||
labelText: l10n.cancel,
|
||||
onTap: () async => Navigator.of(context).pop(null),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
Expanded(
|
||||
child: ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
labelText: l10n.saveAction,
|
||||
isDisabled: textController.text.isEmpty,
|
||||
onTap: () async => Navigator.of(context).pop(textController.text),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<ButtonResult?> _showLocationChoiceDialog({required String displayPath}) async {
|
||||
final l10n = context.l10n;
|
||||
|
||||
final dialogBody =
|
||||
'${l10n.backupLocationChoiceDescription}\n\nSelected: ${_simplifyPath(displayPath)}';
|
||||
|
||||
final result = await showDialogWidget(
|
||||
title: l10n.chooseBackupLocation,
|
||||
context: context,
|
||||
body: dialogBody,
|
||||
buttons: [
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
labelText: l10n.saveBackup,
|
||||
isInAlert: true,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.first,
|
||||
),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.secondary,
|
||||
labelText: l10n.changeLocation,
|
||||
isInAlert: true,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.second,
|
||||
),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.secondary,
|
||||
labelText: l10n.cancel,
|
||||
isInAlert: true,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.cancel,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<bool> _handleLocationSetup() async {
|
||||
|
||||
String currentPath = _backupPath ?? await _getDefaultBackupPath();
|
||||
|
||||
while (true) {
|
||||
final result = await _showLocationChoiceDialog(displayPath: currentPath);
|
||||
|
||||
if (result?.action == ButtonAction.first) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
try {
|
||||
await Directory(currentPath).create(recursive: true);
|
||||
await prefs.setString('autoBackupPath', currentPath);
|
||||
setState(() {
|
||||
_backupPath = currentPath;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.initialBackupCreated)),
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.noDefaultBackupFolder)),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
else if (result?.action == ButtonAction.second) {
|
||||
final newPath = await FilePicker.platform.getDirectoryPath();
|
||||
if (newPath != null) {
|
||||
currentPath = newPath;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _getDefaultBackupPath() async {
|
||||
if (Platform.isAndroid) {
|
||||
Directory? externalDir = await getExternalStorageDirectory();
|
||||
if (externalDir != null) {
|
||||
String storagePath = externalDir.path.split('/Android')[0];
|
||||
return '$storagePath/Download/EnteAuthBackups';
|
||||
}
|
||||
}
|
||||
|
||||
Directory? dir = await getDownloadsDirectory();
|
||||
dir ??= await getApplicationDocumentsDirectory();
|
||||
return '${dir.path}/EnteAuthBackups';
|
||||
}
|
||||
|
||||
String _simplifyPath(String fullPath) { //takes a file path string and shortens it if it matches the common Android root path.
|
||||
const rootToRemove = '/storage/emulated/0/';
|
||||
if (fullPath.startsWith(rootToRemove)) {
|
||||
return fullPath.substring(rootToRemove.length);
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
// opens directory picker
|
||||
Future<bool> _pickAndSaveBackupLocation({String? successMessage}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final l10n = context.l10n;
|
||||
|
||||
String? directoryPath = await FilePicker.platform.getDirectoryPath();
|
||||
|
||||
if (directoryPath != null) {
|
||||
|
||||
await prefs.setString('autoBackupPath', directoryPath);
|
||||
|
||||
// we only set the state and create the backup if a path was chosen
|
||||
setState(() {
|
||||
_backupPath = directoryPath;
|
||||
});
|
||||
await LocalBackupService.instance.triggerAutomaticBackup();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(successMessage ?? l10n.locationUpdatedAndBackupCreated),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false; //user cancelled the file picker
|
||||
}
|
||||
|
||||
Future<void> _showSetPasswordDialog() async {
|
||||
final String? password = await _showCustomPasswordDialog();
|
||||
if (password == null) {
|
||||
setState(() {
|
||||
_isBackupEnabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.passwordTooShort),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_isBackupEnabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = FlutterSecureStorage();
|
||||
await storage.write(key: 'autoBackupPassword', value: password);
|
||||
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) async {
|
||||
final bool setupCompleted = await _handleLocationSetup();
|
||||
if (!mounted) return;
|
||||
|
||||
if (setupCompleted) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('isAutoBackupEnabled', true);
|
||||
setState(() {
|
||||
_isBackupEnabled = true;
|
||||
});
|
||||
await LocalBackupService.instance.triggerAutomaticBackup();
|
||||
} else {
|
||||
setState(() {
|
||||
_isBackupEnabled = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.localBackupSettingsTitle), //text shown on appbar
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.enableAutomaticBackups, //toggle text
|
||||
style: getEnteTextTheme(context).largeBold,
|
||||
),
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: _isBackupEnabled,
|
||||
activeColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.enteTheme
|
||||
.colorScheme
|
||||
.primary400,
|
||||
activeTrackColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.enteTheme
|
||||
.colorScheme
|
||||
.primary300,
|
||||
inactiveTrackColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.enteTheme
|
||||
.colorScheme
|
||||
.fillMuted,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onChanged: (value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
if (value == true) {
|
||||
//when toggle is ON, show password dialog
|
||||
await _showSetPasswordDialog();
|
||||
} else {
|
||||
await prefs.setBool('isAutoBackupEnabled', false);
|
||||
setState(() {
|
||||
_isBackupEnabled = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10.0),
|
||||
child: Text(
|
||||
l10n.backupDescription, //text below toggle
|
||||
style: getEnteTextTheme(context).mini,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Opacity(
|
||||
opacity: _isBackupEnabled ? 1.0 : 0.4,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_isBackupEnabled,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.currentLocation, //shows current backup location
|
||||
style: getEnteTextTheme(context).body,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (_backupPath != null)
|
||||
Text(
|
||||
_simplifyPath(_backupPath!),
|
||||
style: getEnteTextTheme(context).small,
|
||||
)
|
||||
else
|
||||
FutureBuilder<String>(
|
||||
future: _getDefaultBackupPath(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState ==
|
||||
ConnectionState.waiting) {
|
||||
return Text(
|
||||
l10n.loadDefaultLocation,
|
||||
style: getEnteTextTheme(context)
|
||||
.small
|
||||
.copyWith(color: Colors.grey),
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
return Text(
|
||||
l10n.couldNotDetermineLocation,
|
||||
style: getEnteTextTheme(context)
|
||||
.small
|
||||
.copyWith(color: Colors.red),
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
_simplifyPath(snapshot.data ?? ''),
|
||||
style: getEnteTextTheme(context)
|
||||
.small
|
||||
.copyWith(color: Colors.grey),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _pickAndSaveBackupLocation(),
|
||||
child: Text(l10n.changeCurrentLocation),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withAlpha(26),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.orange.withAlpha(77),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.security_outlined,
|
||||
color: Colors.orange,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.securityNotice, //security notice title
|
||||
style: getEnteTextTheme(context)
|
||||
.smallBold
|
||||
.copyWith(
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.backupSecurityNotice, //security notice description
|
||||
style: getEnteTextTheme(context).mini.copyWith(
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -414,6 +414,7 @@ Future<dynamic> showTextInputDialog(
|
||||
bool alwaysShowSuccessState = false,
|
||||
bool isPasswordInput = false,
|
||||
bool useRootNavigator = false,
|
||||
VoidCallback? onCancel,
|
||||
}) {
|
||||
return showDialog(
|
||||
barrierColor: backdropFaintDark,
|
||||
|
||||
@@ -1153,9 +1153,9 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
|
||||
resolved-ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
|
||||
url: "https://github.com/Sayegh7/move_to_background"
|
||||
ref: v2-only
|
||||
resolved-ref: "0cdfeed654d79636eff0c57110f3f6ad5801ba2f"
|
||||
url: "https://github.com/ente-io/move_to_background.git"
|
||||
source: git
|
||||
version: "1.0.2"
|
||||
native_dio_adapter:
|
||||
|
||||
@@ -95,10 +95,10 @@ dependencies:
|
||||
local_auth_darwin: ^1.2.2
|
||||
logging: ^1.0.1
|
||||
modal_bottom_sheet: ^3.0.0
|
||||
move_to_background: # no package updates on pub.dev
|
||||
move_to_background: # no updates in git, replace package
|
||||
git:
|
||||
url: https://github.com/Sayegh7/move_to_background
|
||||
ref: 91e4d1a9c55b28bf93425d1f12faf410efc1e48d
|
||||
url: https://github.com/ente-io/move_to_background.git
|
||||
ref: v2-only
|
||||
native_dio_adapter: ^1.4.0
|
||||
otp: ^3.1.1
|
||||
package_info_plus: ^8.0.2
|
||||
|
||||
Reference in New Issue
Block a user