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:
@@ -1,10 +1,23 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:notas/data/app_database.dart';
|
||||
import 'package:notas/data/api_client.dart';
|
||||
import 'package:notas/data/sync_models.dart';
|
||||
import 'package:notas/models/note.dart';
|
||||
import 'package:notas/models/category.dart';
|
||||
|
||||
import 'package:notas/data/note_encryption.dart';
|
||||
class NoteRepository {
|
||||
NoteRepository({required AppDatabase database}) : _database = database;
|
||||
NoteRepository({
|
||||
required AppDatabase database,
|
||||
required AuthApi authApi,
|
||||
required String masterKey,
|
||||
}) : _database = database,
|
||||
_authApi = authApi,
|
||||
_masterKey = masterKey;
|
||||
|
||||
final AppDatabase _database;
|
||||
final AuthApi _authApi;
|
||||
final String _masterKey;
|
||||
|
||||
Future<List<Note>> loadNotes() async {
|
||||
return _loadNotesFromDatabase();
|
||||
@@ -13,11 +26,15 @@ class NoteRepository {
|
||||
Future<Note> createNote(Note note) async {
|
||||
final int id = await _database.insertNoteAtTop(
|
||||
NotesCompanion.insert(
|
||||
uuid: note.uuid,
|
||||
title: note.title,
|
||||
body: note.body,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
sortIndex: 0,
|
||||
serverVersion: const Value(0),
|
||||
isDeleted: const Value(false),
|
||||
categoryId: const Value(null),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -30,11 +47,15 @@ class NoteRepository {
|
||||
await _database.updateNoteRow(
|
||||
DbNote(
|
||||
id: noteId,
|
||||
uuid: note.uuid,
|
||||
title: note.title,
|
||||
body: note.body,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
sortIndex: note.index,
|
||||
serverVersion: note.serverVersion,
|
||||
isDeleted: note.isDeleted,
|
||||
categoryId: note.categoryId,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -60,19 +81,176 @@ class NoteRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Note>> _loadNotesFromDatabase() async {
|
||||
final List<DbNote> rows = await _database.getAllNotes();
|
||||
return rows.map(_fromRow).toList();
|
||||
// ========== Sync logic ==========
|
||||
|
||||
/// Sincroniza notas con el servidor.
|
||||
/// Requiere que el usuario esté autenticado (token válido).
|
||||
Future<Map<String, dynamic>> performSync({bool forceFull = false}) async {
|
||||
try {
|
||||
// Get last sync timestamp
|
||||
final DateTime? lastSync = await _authApi.getLastSyncAt();
|
||||
final DateTime? lastSyncForRequest = forceFull ? DateTime.utc(1970, 1, 1) : lastSync;
|
||||
|
||||
// Collect pending changes
|
||||
final List<DbNote> unsyncedNotes = await _database.getUnsyncedNotes();
|
||||
final List<DbCategory> unsyncedCategories = await _database.getUnsyncedCategories();
|
||||
|
||||
// Build sync request (note: we send encrypted data, but locally we have plaintext)
|
||||
// Encrypt all notes before sending
|
||||
final List<SyncNotePayload> encryptedNotesPayload = [];
|
||||
for (final dbNote in unsyncedNotes) {
|
||||
final note = _fromDbNote(dbNote);
|
||||
final encryptedTitle = await NoteEncryption.encryptNote(note.title, _masterKey);
|
||||
final encryptedBody = await NoteEncryption.encryptNote(note.body, _masterKey);
|
||||
encryptedNotesPayload.add(
|
||||
SyncNotePayload.fromNote(
|
||||
note,
|
||||
encryptedTitle: encryptedTitle,
|
||||
encryptedBody: encryptedBody,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final List<SyncCategoryPayload> categoriesPayload = unsyncedCategories
|
||||
.map((cat) => SyncCategoryPayload.fromCategory(
|
||||
_fromDbCategory(cat),
|
||||
))
|
||||
.toList();
|
||||
|
||||
final SyncRequest syncRequest = SyncRequest(
|
||||
lastSyncAt: lastSyncForRequest,
|
||||
changes: SyncChanges(
|
||||
categories: categoriesPayload,
|
||||
notes: encryptedNotesPayload,
|
||||
),
|
||||
);
|
||||
|
||||
// Call sync API
|
||||
final Map<String, dynamic> syncResult =
|
||||
await _authApi.sync(syncRequest);
|
||||
|
||||
if (syncResult['error'] == true) {
|
||||
return {'error': true, 'message': syncResult['body']};
|
||||
}
|
||||
|
||||
final SyncResponse response = syncResult['data'] as SyncResponse;
|
||||
|
||||
// Apply server changes to local database
|
||||
await _applySyncResponse(response);
|
||||
|
||||
// Update lastSyncAt
|
||||
await _authApi.setLastSyncAt(response.serverTimestamp);
|
||||
|
||||
return {
|
||||
'error': false,
|
||||
'synced': response.synced,
|
||||
'notesCount': response.changes.notes.length,
|
||||
'categoriesCount': response.changes.categories.length,
|
||||
};
|
||||
} catch (e) {
|
||||
return {'error': true, 'message': e.toString()};
|
||||
}
|
||||
}
|
||||
|
||||
Note _fromRow(DbNote row) {
|
||||
Future<void> _applySyncResponse(SyncResponse response) async {
|
||||
// Apply categories from server
|
||||
for (final SyncCategoryResponse catResponse in response.changes.categories) {
|
||||
await _database.upsertCategory(
|
||||
CategoriesCompanion(
|
||||
uuid: Value(catResponse.id),
|
||||
encryptedName: Value(catResponse.encryptedName),
|
||||
serverVersion: Value(catResponse.serverVersion),
|
||||
isDeleted: Value(catResponse.isDeleted),
|
||||
updatedAt: Value(catResponse.updatedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply notes from server
|
||||
for (final SyncNoteResponse noteResponse in response.changes.notes) {
|
||||
final existingNote = await (_database.select(_database.notes)
|
||||
..where((n) => n.uuid.equals(noteResponse.id)))
|
||||
.getSingleOrNull();
|
||||
|
||||
// Decrypt note content
|
||||
String decryptedTitle = 'Encrypted';
|
||||
String decryptedBody = 'Encrypted';
|
||||
try {
|
||||
decryptedTitle = await NoteEncryption.decryptNote(
|
||||
noteResponse.encryptedTitle,
|
||||
_masterKey,
|
||||
);
|
||||
decryptedBody = await NoteEncryption.decryptNote(
|
||||
noteResponse.encryptedBody,
|
||||
_masterKey,
|
||||
);
|
||||
} catch (e) {
|
||||
// If decryption fails, keep default encrypted placeholders
|
||||
print('Failed to decrypt note ${noteResponse.id}: $e');
|
||||
}
|
||||
|
||||
if (existingNote != null) {
|
||||
// Update existing note
|
||||
await _database.updateNoteRow(
|
||||
DbNote(
|
||||
id: existingNote.id,
|
||||
uuid: noteResponse.id,
|
||||
title: decryptedTitle,
|
||||
body: decryptedBody,
|
||||
createdAt: existingNote.createdAt,
|
||||
updatedAt: noteResponse.updatedAt,
|
||||
sortIndex: noteResponse.position,
|
||||
serverVersion: noteResponse.serverVersion,
|
||||
isDeleted: noteResponse.isDeleted,
|
||||
categoryId: noteResponse.categoryId,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Insert new note
|
||||
await _database.into(_database.notes).insert(
|
||||
NotesCompanion(
|
||||
uuid: Value(noteResponse.id),
|
||||
title: Value(decryptedTitle),
|
||||
body: Value(decryptedBody),
|
||||
createdAt: Value(noteResponse.updatedAt),
|
||||
updatedAt: Value(noteResponse.updatedAt),
|
||||
sortIndex: Value(noteResponse.position),
|
||||
serverVersion: Value(noteResponse.serverVersion),
|
||||
isDeleted: Value(noteResponse.isDeleted),
|
||||
categoryId: Value(noteResponse.categoryId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Note>> _loadNotesFromDatabase() async {
|
||||
final List<DbNote> rows = await _database.getAllNotes();
|
||||
return rows.map(_fromDbNote).toList();
|
||||
}
|
||||
|
||||
Note _fromDbNote(DbNote row) {
|
||||
return Note(
|
||||
id: row.id,
|
||||
uuid: row.uuid,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
index: row.sortIndex,
|
||||
serverVersion: row.serverVersion,
|
||||
isDeleted: row.isDeleted,
|
||||
categoryId: row.categoryId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Category _fromDbCategory(DbCategory row) {
|
||||
return Category(
|
||||
uuid: row.uuid,
|
||||
encryptedName: row.encryptedName,
|
||||
serverVersion: row.serverVersion,
|
||||
isDeleted: row.isDeleted,
|
||||
updatedAt: row.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user