Add local backup/restore: encrypted .enc export to Download/loginvault, restore from list (no file_picker to avoid SDK conflict)

This commit is contained in:
cania
2026-08-13 14:47:14 +02:00
parent 92a3ca4d82
commit 7cb2ca42c5
8 changed files with 256 additions and 2 deletions

94
lib/backup_service.dart Normal file
View File

@@ -0,0 +1,94 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:login_vault_app/database_helper.dart';
import 'package:login_vault_app/encryptor.dart';
import 'package:login_vault_app/auth_service.dart';
import 'package:login_vault_app/constants.dart';
/// Backup & restore the local vault to a file on device storage.
/// The file is encrypted with the app password (not the constant key), so it
/// stays safe if copied to Drive / shared. User can later move the .enc file
/// to Google Drive manually.
class BackupService {
/// Export all vault entries into an encrypted JSON file.
/// Returns the saved file path, or throws on error.
static Future<String> exportEncrypted(AuthService auth) async {
final pw = await auth.getAppPassword();
if (pw == null) throw Exception('App password belum diset');
final all = await DatabaseHelper().getAll();
final plain = jsonEncode({
'app': 'login_vault',
'version': 1,
'entries': all.map((e) => {'id': e.id, 'content': e.content}).toList(),
});
// encrypt with app password-derived key
final enc = Encryptor.encryptWithPassword(plain, pw);
final dir = await _targetDir();
final ts = DateTime.now().toIso8601String().replaceAll(':', '-').split('.')[0];
final file = File('${dir.path}/loginvault_backup_$ts.enc');
await file.writeAsString(enc);
return file.path;
}
/// Import an encrypted backup file into the DB (merge by id, overwrite).
/// [path] is the file the user picked (or default backup file).
static Future<int> importEncrypted(AuthService auth, String path) async {
final pw = await auth.getAppPassword();
if (pw == null) throw Exception('App password belum diset');
final file = File(path);
if (!await file.exists()) throw Exception('File backup tidak ditemukan');
final enc = await file.readAsString();
final plain = Encryptor.decryptWithPassword(enc, pw);
final data = jsonDecode(plain) as Map<String, dynamic>;
final entries = (data['entries'] as List).cast<Map<String, dynamic>>();
final db = DatabaseHelper();
int count = 0;
for (final e in entries) {
final id = e['id'] as String;
final content = e['content'] as String;
// insert or update
try {
await db.insert(VaultEntry(id: id, content: content));
} catch (_) {
await db.update(id, content);
}
count++;
}
return count;
}
/// Default backup directory:
/// - Android: Download/loginvault (so user can move to Drive)
/// - others: app documents dir
static Future<Directory> _targetDir() async {
if (Platform.isAndroid) {
// request storage permission for Android < 33 (scoped)
if (await Permission.storage.request().isGranted ||
await Permission.manageExternalStorage.request().isGranted) {
final download = Directory('/storage/emulated/0/Download/loginvault');
if (!await download.exists()) await download.create(recursive: true);
return download;
}
}
final docs = await getExternalStorageDirectory();
final dir = Directory('${docs?.path ?? (await getApplicationDocumentsDirectory()).path}/loginvault');
if (!await dir.exists()) await dir.create(recursive: true);
return dir;
}
/// List existing backup files in the default folder.
static Future<List<FileSystemEntity>> listBackups() async {
try {
final dir = await _targetDir();
return dir
.listSync()
.where((f) => f.path.endsWith('.enc'))
.toList();
} catch (_) {
return [];
}
}
}

View File

@@ -26,4 +26,20 @@ class Encryptor {
final xored = _xorBytes(base64.decode(cipher), keyBytes);
return utf8.decode(xored);
}
/// Encrypt [plain] with a user password (for backup files).
/// Uses the password bytes as the XOR key so the backup is protected by the
/// app password, not the in-app constant key.
static String encryptWithPassword(String plain, String password) {
final keyBytes = utf8.encode(password);
final xored = _xorBytes(utf8.encode(plain), keyBytes);
return base64.encode(xored);
}
/// Decrypt a backup [cipher] produced by [encryptWithPassword].
static String decryptWithPassword(String cipher, String password) {
final keyBytes = utf8.encode(password);
final xored = _xorBytes(base64.decode(cipher), keyBytes);
return utf8.decode(xored);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:login_vault_app/auth_service.dart';
import 'package:login_vault_app/auth_screen.dart';
import 'package:login_vault_app/theme_provider.dart';
import 'package:login_vault_app/backup_service.dart';
/// Settings: change app password, switch theme (dark/system), session timeout,
/// fingerprint toggle, and Exit.
@@ -72,6 +73,73 @@ class _SettingsScreenState extends State<SettingsScreen> {
_newCtrl.clear();
}
Future<void> _backup() async {
try {
final path = await BackupService.exportEncrypted(widget.auth);
if (mounted) {
setState(() {
_msg = 'Backup tersimpan: $path';
_msgColor = Colors.green;
});
}
} catch (e) {
if (mounted) {
setState(() {
_msg = 'Backup gagal: $e';
_msgColor = Colors.red;
});
}
}
}
Future<void> _restore() async {
try {
final backups = await BackupService.listBackups();
if (backups.isEmpty) {
if (mounted) {
setState(() {
_msg = 'Tidak ada file backup di folder Download/loginvault';
_msgColor = Colors.red;
});
}
return;
}
if (!mounted) return;
final chosen = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Pilih file backup'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: backups.length,
itemBuilder: (_, i) => ListTile(
title: Text(backups[i].path.split('/').last),
onTap: () => Navigator.of(ctx).pop(backups[i].path),
),
),
),
),
);
if (chosen == null) return;
final count = await BackupService.importEncrypted(widget.auth, chosen);
if (mounted) {
setState(() {
_msg = 'Restore berhasil: $count entri';
_msgColor = Colors.green;
});
}
} catch (e) {
if (mounted) {
setState(() {
_msg = 'Restore gagal: $e';
_msgColor = Colors.red;
});
}
}
}
Future<void> _toggleBiometric(bool value) async {
if (value && !_biometricAvailable) {
setState(() {
@@ -209,6 +277,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Text(_msg!, style: TextStyle(color: _msgColor)),
),
const Divider(),
const Text('Backup & Restore',
style: TextStyle(fontWeight: FontWeight.bold)),
ListTile(
leading: const Icon(Icons.backup),
title: const Text('Backup ke file (lokal)'),
subtitle: const Text(
'Simpan vault terenkripsi ke folder Download. Bisa dipindah ke Drive manual.'),
onTap: _backup,
),
ListTile(
leading: const Icon(Icons.restore),
title: const Text('Restore dari file'),
subtitle: const Text('Pilih file backup .enc untuk dikembalikan.'),
onTap: _restore,
),
const Divider(),
ListTile(
leading: const Icon(Icons.exit_to_app),
title: const Text('Keluar (Exit)'),