Compare commits
17 Commits
multiselec
...
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/
|
.buildlog/
|
||||||
.history
|
.history
|
||||||
.svn/
|
.svn/
|
||||||
|
android/app/build/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
Submodule mobile/apps/auth/flutter updated: edada7c56e...2663184aa7
@@ -527,5 +527,24 @@
|
|||||||
"errorInvalidQRCodeBody": "The scanned QR code is not a valid 2FA account.",
|
"errorInvalidQRCodeBody": "The scanned QR code is not a valid 2FA account.",
|
||||||
"errorNoQRCode": "No QR code found",
|
"errorNoQRCode": "No QR code found",
|
||||||
"errorGenericTitle": "An Error Occurred",
|
"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/authenticator/entity_result.dart';
|
||||||
import 'package:ente_auth/models/code.dart';
|
import 'package:ente_auth/models/code.dart';
|
||||||
import 'package:ente_auth/services/authenticator_service.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_auth/store/offline_authenticator_db.dart';
|
||||||
import 'package:ente_events/event_bus.dart';
|
import 'package:ente_events/event_bus.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -64,6 +65,27 @@ class CodeStore {
|
|||||||
return true;
|
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({
|
Future<List<Code>> getAllCodes({
|
||||||
AccountMode? accountMode,
|
AccountMode? accountMode,
|
||||||
bool sortCodes = true,
|
bool sortCodes = true,
|
||||||
@@ -95,7 +117,6 @@ class CodeStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sortCodes) {
|
if (sortCodes) {
|
||||||
// sort codes by issuer,account
|
|
||||||
codes.sort((firstCode, secondCode) {
|
codes.sort((firstCode, secondCode) {
|
||||||
if (secondCode.isPinned && !firstCode.isPinned) return 1;
|
if (secondCode.isPinned && !firstCode.isPinned) return 1;
|
||||||
if (!secondCode.isPinned && firstCode.isPinned) return -1;
|
if (!secondCode.isPinned && firstCode.isPinned) return -1;
|
||||||
@@ -120,13 +141,17 @@ class CodeStore {
|
|||||||
bool shouldSync = true,
|
bool shouldSync = true,
|
||||||
AccountMode? accountMode,
|
AccountMode? accountMode,
|
||||||
List<Code>? existingAllCodes,
|
List<Code>? existingAllCodes,
|
||||||
|
bool isFrequencyOrRecencyUpdate = false,
|
||||||
}) async {
|
}) async {
|
||||||
|
|
||||||
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
||||||
final allCodes = existingAllCodes ?? (await getAllCodes(accountMode: mode));
|
final allCodes = existingAllCodes ?? (await getAllCodes(accountMode: mode));
|
||||||
bool isExistingCode = false;
|
bool isExistingCode = false;
|
||||||
bool hasSameCode = false;
|
bool hasSameCode = false;
|
||||||
|
|
||||||
for (final existingCode in allCodes) {
|
for (final existingCode in allCodes) {
|
||||||
if (existingCode.hasError) continue;
|
if (existingCode.hasError) continue;
|
||||||
|
|
||||||
if (code.generatedID != null &&
|
if (code.generatedID != null &&
|
||||||
existingCode.generatedID == code.generatedID) {
|
existingCode.generatedID == code.generatedID) {
|
||||||
isExistingCode = true;
|
isExistingCode = true;
|
||||||
@@ -148,6 +173,9 @@ class CodeStore {
|
|||||||
shouldSync,
|
shouldSync,
|
||||||
mode,
|
mode,
|
||||||
);
|
);
|
||||||
|
if (!isFrequencyOrRecencyUpdate) {
|
||||||
|
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
result = AddResult.newCode;
|
result = AddResult.newCode;
|
||||||
code.generatedID = await _authenticatorService.addEntry(
|
code.generatedID = await _authenticatorService.addEntry(
|
||||||
@@ -155,6 +183,7 @@ class CodeStore {
|
|||||||
shouldSync,
|
shouldSync,
|
||||||
mode,
|
mode,
|
||||||
);
|
);
|
||||||
|
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||||
}
|
}
|
||||||
Bus.instance.fire(CodesUpdatedEvent());
|
Bus.instance.fire(CodesUpdatedEvent());
|
||||||
return result;
|
return result;
|
||||||
@@ -164,6 +193,7 @@ class CodeStore {
|
|||||||
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
||||||
await _authenticatorService.deleteEntry(code.generatedID!, mode);
|
await _authenticatorService.deleteEntry(code.generatedID!, mode);
|
||||||
Bus.instance.fire(CodesUpdatedEvent());
|
Bus.instance.fire(CodesUpdatedEvent());
|
||||||
|
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isOfflineImportRunning = false;
|
bool _isOfflineImportRunning = false;
|
||||||
@@ -214,7 +244,6 @@ class CodeStore {
|
|||||||
'importingCode: genID ${eachCode.generatedID} & isAlreadyPresent $alreadyPresent',
|
'importingCode: genID ${eachCode.generatedID} & isAlreadyPresent $alreadyPresent',
|
||||||
);
|
);
|
||||||
if (!alreadyPresent) {
|
if (!alreadyPresent) {
|
||||||
// Avoid conflict with generatedID of online codes
|
|
||||||
eachCode.generatedID = null;
|
eachCode.generatedID = null;
|
||||||
final AddResult result = await CodeStore.instance.addCode(
|
final AddResult result = await CodeStore.instance.addCode(
|
||||||
eachCode,
|
eachCode,
|
||||||
@@ -236,10 +265,21 @@ class CodeStore {
|
|||||||
_isOfflineImportRunning = false;
|
_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 {
|
enum AddResult {
|
||||||
newCode,
|
newCode,
|
||||||
duplicate,
|
duplicate,
|
||||||
updateCode,
|
updateCode,
|
||||||
}
|
}
|
||||||
@@ -478,7 +478,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
_getCurrentOTP(),
|
_getCurrentOTP(),
|
||||||
confirmationMessage: context.l10n.copiedToClipboard,
|
confirmationMessage: context.l10n.copiedToClipboard,
|
||||||
);
|
);
|
||||||
_udateCodeMetadata().ignore();
|
_updateCodeMetadata().ignore();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _copyNextToClipboard() {
|
void _copyNextToClipboard() {
|
||||||
@@ -486,10 +486,10 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
_getNextTotp(),
|
_getNextTotp(),
|
||||||
confirmationMessage: context.l10n.copiedNextToClipboard,
|
confirmationMessage: context.l10n.copiedNextToClipboard,
|
||||||
);
|
);
|
||||||
_udateCodeMetadata().ignore();
|
_updateCodeMetadata().ignore();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _udateCodeMetadata() async {
|
Future<void> _updateCodeMetadata() async {
|
||||||
if (widget.sortKey == null) return;
|
if (widget.sortKey == null) return;
|
||||||
Future.delayed(const Duration(milliseconds: 100), () {
|
Future.delayed(const Duration(milliseconds: 100), () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -502,7 +502,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
lastUsedAt: DateTime.now().microsecondsSinceEpoch,
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
_codeSortKey = PreferenceService.instance.codeSortKey();
|
_codeSortKey = PreferenceService.instance.codeSortKey();
|
||||||
_textController.addListener(_applyFilteringAndRefresh);
|
_textController.addListener(_applyFilteringAndRefresh);
|
||||||
_loadCodes();
|
_loadCodes();
|
||||||
@@ -153,6 +154,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _loadCodes() {
|
void _loadCodes() {
|
||||||
|
debugPrint("[HOME_DEBUG] _loadCodes triggered!");
|
||||||
CodeStore.instance.getAllCodes().then((codes) {
|
CodeStore.instance.getAllCodes().then((codes) {
|
||||||
_allCodes = codes;
|
_allCodes = codes;
|
||||||
hasTrashedCodes = false;
|
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/duplicate_code_page.dart';
|
||||||
import 'package:ente_auth/ui/settings/data/export_widget.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/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/dialog_util.dart';
|
||||||
import 'package:ente_auth/utils/navigation_util.dart';
|
import 'package:ente_auth/utils/navigation_util.dart';
|
||||||
import 'package:flutter/material.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) {
|
Column _getSectionOptions(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
List<Widget> children = [];
|
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,
|
sectionOptionSpacing,
|
||||||
]);
|
]);
|
||||||
return Column(
|
return Column(
|
||||||
children: children,
|
children: children,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,4 +153,4 @@ Future<void> _pickEnteJsonFile(BuildContext context) async {
|
|||||||
context.l10n.importFailureDescNew,
|
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 alwaysShowSuccessState = false,
|
||||||
bool isPasswordInput = false,
|
bool isPasswordInput = false,
|
||||||
bool useRootNavigator = false,
|
bool useRootNavigator = false,
|
||||||
|
VoidCallback? onCancel,
|
||||||
}) {
|
}) {
|
||||||
return showDialog(
|
return showDialog(
|
||||||
barrierColor: backdropFaintDark,
|
barrierColor: backdropFaintDark,
|
||||||
|
|||||||
@@ -1153,9 +1153,9 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
|
ref: v2-only
|
||||||
resolved-ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
|
resolved-ref: "0cdfeed654d79636eff0c57110f3f6ad5801ba2f"
|
||||||
url: "https://github.com/Sayegh7/move_to_background"
|
url: "https://github.com/ente-io/move_to_background.git"
|
||||||
source: git
|
source: git
|
||||||
version: "1.0.2"
|
version: "1.0.2"
|
||||||
native_dio_adapter:
|
native_dio_adapter:
|
||||||
|
|||||||
@@ -95,10 +95,10 @@ dependencies:
|
|||||||
local_auth_darwin: ^1.2.2
|
local_auth_darwin: ^1.2.2
|
||||||
logging: ^1.0.1
|
logging: ^1.0.1
|
||||||
modal_bottom_sheet: ^3.0.0
|
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:
|
git:
|
||||||
url: https://github.com/Sayegh7/move_to_background
|
url: https://github.com/ente-io/move_to_background.git
|
||||||
ref: 91e4d1a9c55b28bf93425d1f12faf410efc1e48d
|
ref: v2-only
|
||||||
native_dio_adapter: ^1.4.0
|
native_dio_adapter: ^1.4.0
|
||||||
otp: ^3.1.1
|
otp: ^3.1.1
|
||||||
package_info_plus: ^8.0.2
|
package_info_plus: ^8.0.2
|
||||||
|
|||||||
Reference in New Issue
Block a user