feat: Implement note encryption and synchronization features
- Added NoteEncryption class for encrypting and decrypting note content using AES-GCM. - Updated NoteRepository to handle synchronization of notes and categories with the server, including encryption of note data before sending. - Introduced SyncRequest and SyncResponse models for managing synchronization data. - Enhanced LocalVaultService to store and retrieve the encryption key. - Modified HomeScreen and SettingsScreen to trigger synchronization after note operations and manage API endpoint settings. - Added SyncStatusIndicator to provide visual feedback on synchronization status in the app title bar. - Created Category model to manage note categories with encryption support. - Updated note model to include UUID, server version, deletion status, and category ID. - Added necessary UI elements for displaying and managing the encryption key in SettingsScreen. - Updated dependencies in pubspec.yaml for cryptography and HTTP handling.
This commit is contained in:
+196
-6
@@ -2,7 +2,9 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:notas/data/app_database.dart';
|
||||
import 'package:notas/data/api_client.dart';
|
||||
import 'package:notas/data/local_vault_service.dart';
|
||||
import 'package:notas/data/note_repository.dart';
|
||||
import 'package:notas/platform/app_platform.dart';
|
||||
@@ -14,6 +16,7 @@ import 'package:notas/screens/settings_screen.dart';
|
||||
import 'package:notas/screens/vault_access_screen.dart';
|
||||
import 'package:notas/theme/app_theme.dart';
|
||||
import 'package:notas/widgets/app_title_bar.dart';
|
||||
import 'package:notas/widgets/sync_status_indicator.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
@@ -31,6 +34,10 @@ enum _AppPhase {
|
||||
notes,
|
||||
}
|
||||
|
||||
class PerformSyncIntent extends Intent {
|
||||
const PerformSyncIntent();
|
||||
}
|
||||
|
||||
class NotesApp extends StatefulWidget {
|
||||
const NotesApp({super.key});
|
||||
|
||||
@@ -42,6 +49,7 @@ class _NotesAppState extends State<NotesApp>
|
||||
with WindowListener, WidgetsBindingObserver {
|
||||
static const Duration _screenTransitionDuration = Duration(milliseconds: 280);
|
||||
static const Duration _biometricInactivityTimeout = Duration(minutes: 5);
|
||||
static const Duration _syncInterval = Duration(minutes: 5);
|
||||
|
||||
final LocalVaultService _vaultService = LocalVaultService.instance;
|
||||
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey =
|
||||
@@ -56,9 +64,12 @@ class _NotesAppState extends State<NotesApp>
|
||||
bool _biometricGateEnabled = false;
|
||||
int _biometricGateSession = 0;
|
||||
Timer? _biometricLockTimer;
|
||||
Timer? _syncTimer;
|
||||
bool _isHandlingWindowClose = false;
|
||||
_AppPhase _phase = _AppPhase.loading;
|
||||
_AppSection _currentSection = _AppSection.home;
|
||||
SyncStatus _syncStatus = SyncStatus.idle;
|
||||
String? _syncErrorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -79,6 +90,7 @@ class _NotesAppState extends State<NotesApp>
|
||||
windowManager.setPreventClose(false);
|
||||
}
|
||||
_biometricLockTimer?.cancel();
|
||||
_syncTimer?.cancel();
|
||||
_database?.close();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -179,9 +191,18 @@ class _NotesAppState extends State<NotesApp>
|
||||
|
||||
setState(() {
|
||||
_database = database;
|
||||
_repository = NoteRepository(database: database);
|
||||
_repository = NoteRepository(
|
||||
database: database,
|
||||
authApi: AuthApi.instance,
|
||||
masterKey: encryptionKey,
|
||||
);
|
||||
_phase = _AppPhase.notes;
|
||||
});
|
||||
|
||||
// Start periodic sync
|
||||
_startPeriodicSync();
|
||||
// Run an initial full sync immediately to pull server changes
|
||||
unawaited(_performSync(forceFull: true));
|
||||
} catch (e) {
|
||||
// If the database file is not a valid SQLite DB (e.g., wrong key or corruption),
|
||||
// reset the local vault so the app doesn't crash. The reset will delete DB files
|
||||
@@ -238,6 +259,86 @@ class _NotesAppState extends State<NotesApp>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _beginRemoteVaultFlow({
|
||||
required String username,
|
||||
required String password,
|
||||
required bool isRegister,
|
||||
}) async {
|
||||
if (_isUnlocking) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isUnlocking = true;
|
||||
});
|
||||
|
||||
try {
|
||||
if (isRegister) {
|
||||
final String encryptionKey = _vaultService.generateEncryptionKey();
|
||||
final String encryptedMasterKey =
|
||||
await AuthApi.instance.encryptWithPassword(encryptionKey, password);
|
||||
|
||||
final Map<String, dynamic> response = await AuthApi.instance.register(
|
||||
username,
|
||||
password,
|
||||
encryptedMasterKey: encryptedMasterKey,
|
||||
);
|
||||
|
||||
if (response['error'] == true) {
|
||||
throw StateError('No se pudo registrar el usuario.');
|
||||
}
|
||||
|
||||
await _vaultService.storeEncryptionKey(encryptionKey);
|
||||
_pendingEncryptionKey = encryptionKey;
|
||||
} else {
|
||||
final Map<String, dynamic> response = await AuthApi.instance.login(
|
||||
username,
|
||||
password,
|
||||
);
|
||||
|
||||
if (response['error'] == true) {
|
||||
throw StateError('No se pudo iniciar sesión.');
|
||||
}
|
||||
|
||||
final String? encryptedMasterKey =
|
||||
(response['encrypted_master_key'] as String?) ??
|
||||
(response['encryptedMasterKey'] as String?);
|
||||
|
||||
if (encryptedMasterKey == null || encryptedMasterKey.isEmpty) {
|
||||
throw StateError('La API no devolvió la clave de encriptación.');
|
||||
}
|
||||
|
||||
final String encryptionKey =
|
||||
await AuthApi.instance.decryptWithPassword(encryptedMasterKey, password);
|
||||
|
||||
await _vaultService.storeEncryptionKey(encryptionKey);
|
||||
_pendingEncryptionKey = encryptionKey;
|
||||
}
|
||||
|
||||
await _vaultService.setVaultAccessCompleted(true);
|
||||
await _vaultService.setBiometricChoicePending(true);
|
||||
await _vaultService.setBiometricGateEnabled(false);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_phase = _AppPhase.biometricChoice;
|
||||
_biometricGateEnabled = false;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(content: Text('No se pudo completar la autenticación: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isUnlocking = false;
|
||||
_isBootstrapping = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _enterWithoutAccount() {
|
||||
return _beginInitialVaultFlow();
|
||||
}
|
||||
@@ -496,6 +597,66 @@ class _NotesAppState extends State<NotesApp>
|
||||
await WindowStateStore.instance.saveWindowSize(currentSize);
|
||||
}
|
||||
|
||||
void _startPeriodicSync() {
|
||||
_syncTimer?.cancel();
|
||||
_syncTimer = Timer.periodic(_syncInterval, (_) {
|
||||
_performSync();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _performSync({bool forceFull = false}) async {
|
||||
if (_repository == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_syncStatus = SyncStatus.syncing;
|
||||
_syncErrorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final Map<String, dynamic> result = await _repository!.performSync(forceFull: forceFull);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result['error'] == true) {
|
||||
setState(() {
|
||||
_syncStatus = SyncStatus.error;
|
||||
_syncErrorMessage = result['message'] as String?;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_syncStatus = SyncStatus.synced;
|
||||
_syncErrorMessage = null;
|
||||
});
|
||||
|
||||
// Reset to idle after 3 seconds
|
||||
Future<void>.delayed(const Duration(seconds: 3), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_syncStatus = SyncStatus.idle;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_syncStatus = SyncStatus.error;
|
||||
_syncErrorMessage = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLoadingScreen() {
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
@@ -557,8 +718,23 @@ class _NotesAppState extends State<NotesApp>
|
||||
debugShowCheckedModeBanner: false,
|
||||
scaffoldMessengerKey: _scaffoldMessengerKey,
|
||||
theme: AppTheme.theme,
|
||||
home: Scaffold(
|
||||
body: Container(
|
||||
home: Shortcuts(
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.f5): const PerformSyncIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
PerformSyncIntent: CallbackAction<PerformSyncIntent>(
|
||||
onInvoke: (PerformSyncIntent intent) {
|
||||
_performSync();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
child: Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
@@ -573,7 +749,10 @@ class _NotesAppState extends State<NotesApp>
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const AppTitleBar(),
|
||||
AppTitleBar(
|
||||
syncStatus: _syncStatus,
|
||||
syncErrorMessage: _syncErrorMessage,
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: _screenTransitionDuration,
|
||||
@@ -598,6 +777,9 @@ class _NotesAppState extends State<NotesApp>
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -669,10 +851,18 @@ class _NotesAppState extends State<NotesApp>
|
||||
home: VaultAccessScreen(
|
||||
isBusy: _isUnlocking,
|
||||
onCreateAccountPressed: (String email, String password) async {
|
||||
await _beginInitialVaultFlow(actionLabel: 'Crear cuenta');
|
||||
await _beginRemoteVaultFlow(
|
||||
username: email,
|
||||
password: password,
|
||||
isRegister: true,
|
||||
);
|
||||
},
|
||||
onSignInPressed: (String email, String password) async {
|
||||
await _beginInitialVaultFlow(actionLabel: 'Iniciar sesión');
|
||||
await _beginRemoteVaultFlow(
|
||||
username: email,
|
||||
password: password,
|
||||
isRegister: false,
|
||||
);
|
||||
},
|
||||
onContinueWithoutAccount: _enterWithoutAccount,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user