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:
@@ -1,6 +1,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- Internet permission required for Google Sign-In -->
|
<!-- Internet permission -->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<!-- Storage for backup export to Download folder -->
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32"/>
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||||
<!-- Biometric / fingerprint authentication -->
|
<!-- Biometric / fingerprint authentication -->
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||||
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
|
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
|
||||||
|
|||||||
94
lib/backup_service.dart
Normal file
94
lib/backup_service.dart
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,4 +26,20 @@ class Encryptor {
|
|||||||
final xored = _xorBytes(base64.decode(cipher), keyBytes);
|
final xored = _xorBytes(base64.decode(cipher), keyBytes);
|
||||||
return utf8.decode(xored);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:login_vault_app/auth_service.dart';
|
import 'package:login_vault_app/auth_service.dart';
|
||||||
import 'package:login_vault_app/auth_screen.dart';
|
import 'package:login_vault_app/auth_screen.dart';
|
||||||
import 'package:login_vault_app/theme_provider.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,
|
/// Settings: change app password, switch theme (dark/system), session timeout,
|
||||||
/// fingerprint toggle, and Exit.
|
/// fingerprint toggle, and Exit.
|
||||||
@@ -72,6 +73,73 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_newCtrl.clear();
|
_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 {
|
Future<void> _toggleBiometric(bool value) async {
|
||||||
if (value && !_biometricAvailable) {
|
if (value && !_biometricAvailable) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -209,6 +277,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
child: Text(_msg!, style: TextStyle(color: _msgColor)),
|
child: Text(_msg!, style: TextStyle(color: _msgColor)),
|
||||||
),
|
),
|
||||||
const Divider(),
|
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(
|
ListTile(
|
||||||
leading: const Icon(Icons.exit_to_app),
|
leading: const Icon(Icons.exit_to_app),
|
||||||
title: const Text('Keluar (Exit)'),
|
title: const Text('Keluar (Exit)'),
|
||||||
|
|||||||
50
pubspec.lock
50
pubspec.lock
@@ -353,7 +353,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||||
@@ -400,6 +400,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
permission_handler:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: permission_handler
|
||||||
|
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.4.0"
|
||||||
|
permission_handler_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_android
|
||||||
|
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.1.0"
|
||||||
|
permission_handler_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_apple
|
||||||
|
sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.6.1"
|
||||||
|
permission_handler_html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_html
|
||||||
|
sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.4+1"
|
||||||
|
permission_handler_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_platform_interface
|
||||||
|
sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.4.0"
|
||||||
|
permission_handler_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_windows
|
||||||
|
sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
sqflite: ^2.3.3
|
sqflite: ^2.3.3
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
|
path_provider: ^2.1.1
|
||||||
|
permission_handler: ^11.3.0
|
||||||
crypto: ^3.0.3
|
crypto: ^3.0.3
|
||||||
flutter_secure_storage: ^9.0.0
|
flutter_secure_storage: ^9.0.0
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
|
|||||||
@@ -8,10 +8,13 @@
|
|||||||
|
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
#include <local_auth_windows/local_auth_plugin.h>
|
#include <local_auth_windows/local_auth_plugin.h>
|
||||||
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
LocalAuthPluginRegisterWithRegistrar(
|
LocalAuthPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||||
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
local_auth_windows
|
local_auth_windows
|
||||||
|
permission_handler_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
Reference in New Issue
Block a user