diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 47b6053..fbf0076 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,12 @@ - + + + + + diff --git a/lib/backup_service.dart b/lib/backup_service.dart new file mode 100644 index 0000000..ddcede9 --- /dev/null +++ b/lib/backup_service.dart @@ -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 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 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; + final entries = (data['entries'] as List).cast>(); + 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 _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> listBackups() async { + try { + final dir = await _targetDir(); + return dir + .listSync() + .where((f) => f.path.endsWith('.enc')) + .toList(); + } catch (_) { + return []; + } + } +} diff --git a/lib/encryptor.dart b/lib/encryptor.dart index c3b6d5f..69926f9 100644 --- a/lib/encryptor.dart +++ b/lib/encryptor.dart @@ -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); + } } diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index 95e5ee1..3e8f8bf 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -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 { _newCtrl.clear(); } + Future _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 _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( + 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 _toggleBiometric(bool value) async { if (value && !_biometricAvailable) { setState(() { @@ -209,6 +277,22 @@ class _SettingsScreenState extends State { 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)'), diff --git a/pubspec.lock b/pubspec.lock index f0c8775..253f79f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -353,7 +353,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 @@ -400,6 +400,54 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index df02559..1361bad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,8 @@ dependencies: sdk: flutter sqflite: ^2.3.3 path: ^1.9.0 + path_provider: ^2.1.1 + permission_handler: ^11.3.0 crypto: ^3.0.3 flutter_secure_storage: ^9.0.0 provider: ^6.1.2 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 011734d..16383da 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,13 @@ #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalAuthPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LocalAuthPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index de15aee..35b4492 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows local_auth_windows + permission_handler_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST