From 2a898111fa777217312d6a3d7bfd954fdf46b032 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 19 May 2026 10:09:20 +0200 Subject: [PATCH] feat: Optimize note encryption and decryption processes with parallel execution --- lib/data/note_repository.dart | 320 +++++++++++++++++++++---- lib/data/sync_models.dart | 68 ++++-- lib/widgets/sync_status_indicator.dart | 2 +- 3 files changed, 316 insertions(+), 74 deletions(-) diff --git a/lib/data/note_repository.dart b/lib/data/note_repository.dart index cdeb722..64fd582 100644 --- a/lib/data/note_repository.dart +++ b/lib/data/note_repository.dart @@ -1,3 +1,8 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:math' as math; +import 'dart:io' show Platform; + import 'package:drift/drift.dart'; import 'package:notas/data/app_database.dart'; import 'package:notas/data/api_client.dart'; @@ -126,8 +131,7 @@ class NoteRepository { final int totalNotesToEncrypt = unsyncedNotes.length; // Build sync request (note: we send encrypted data, but locally we have plaintext) - // Encrypt all notes before sending - final List encryptedNotesPayload = []; + // Encrypt all notes before sending. if (totalNotesToEncrypt == 0) { onProgress?.call( SyncStatus.encrypting, @@ -136,32 +140,21 @@ class NoteRepository { ); } - for (var index = 0; index < unsyncedNotes.length; index += 1) { - final DbNote dbNote = unsyncedNotes[index]; - 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, - ), - ); - - onProgress?.call( - SyncStatus.encrypting, - progress: (index + 1) / totalNotesToEncrypt, - message: - 'Encriptando notas para subir: ${index + 1} de $totalNotesToEncrypt', - ); - } + final List + encryptedNotesPayload = await _encryptNotesInParallel( + unsyncedNotes, + masterKey: _masterKey, + onProgress: (int encryptedCount) { + onProgress?.call( + SyncStatus.encrypting, + progress: totalNotesToEncrypt == 0 + ? 1.0 + : encryptedCount / totalNotesToEncrypt, + message: + 'Encriptando notas para subir: $encryptedCount de $totalNotesToEncrypt', + ); + }, + ); final List categoriesPayload = unsyncedCategories .map((cat) => SyncCategoryPayload.fromCategory(_fromDbCategory(cat))) @@ -241,35 +234,33 @@ class NoteRepository { // Apply notes from server final int totalNotesToDecrypt = response.changes.notes.length; - for (var index = 0; index < response.changes.notes.length; index += 1) { - final SyncNoteResponse noteResponse = response.changes.notes[index]; + final List> decryptedNotes = + await _decryptResponseNotesInParallel( + response.changes.notes, + masterKey: _masterKey, + onProgress: onDecryptProgress == null + ? null + : (processed) => + onDecryptProgress(processed, totalNotesToDecrypt), + ); + + for (var index = 0; index < decryptedNotes.length; index += 1) { + final Map decryptedNote = decryptedNotes[index]; + final String noteId = decryptedNote['id']! as String; final existingNote = await (_database.select( _database.notes, - )..where((n) => n.uuid.equals(noteResponse.id))).getSingleOrNull(); + )..where((n) => n.uuid.equals(noteId))).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'); - } + final String decryptedTitle = decryptedNote['title']! as String; + final String decryptedBody = decryptedNote['body']! as String; + final SyncNoteResponse noteResponse = response.changes.notes[index]; if (existingNote != null) { // Update existing note await _database.updateNoteRow( DbNote( id: existingNote.id, - uuid: noteResponse.id, + uuid: noteId, title: decryptedTitle, body: decryptedBody, createdAt: existingNote.createdAt, @@ -298,8 +289,6 @@ class NoteRepository { ), ); } - - onDecryptProgress?.call(index + 1, totalNotesToDecrypt); } } @@ -333,3 +322,234 @@ class NoteRepository { ); } } + +List>>> _encryptNoteBatches( + List notes, { + required String masterKey, +}) { + if (notes.isEmpty) { + return >>>[]; + } + + final int workerCount = _parallelWorkerCount(notes.length); + final int batchSize = (notes.length / workerCount).ceil(); + + final List>>> batchFutures = []; + for (var start = 0; start < notes.length; start += batchSize) { + final int end = math.min(start + batchSize, notes.length); + final List> batchNotes = []; + for (var index = start; index < end; index += 1) { + batchNotes.add(_dbNoteToEncryptionInput(notes[index], index)); + } + + batchFutures.add( + Isolate.run( + () => _encryptNoteBatch({'masterKey': masterKey, 'notes': batchNotes}), + ), + ); + } + + return batchFutures; +} + +Future> _encryptNotesInParallel( + List notes, { + required String masterKey, + void Function(int encryptedCount)? onProgress, +}) async { + final List>>> batchFutures = + _encryptNoteBatches(notes, masterKey: masterKey); + final List orderedPayloads = List.filled( + notes.length, + null, + ); + var encryptedCount = 0; + + await for (final List> batchResult in Stream.fromFutures( + batchFutures, + )) { + for (final Map row in batchResult) { + final int index = row['index']! as int; + orderedPayloads[index] = _syncNotePayloadFromEncryptionResult(row); + } + + encryptedCount += batchResult.length; + onProgress?.call(encryptedCount); + } + + return orderedPayloads.cast(); +} + +List>>> _decryptNoteBatches( + List notes, { + required String masterKey, +}) { + if (notes.isEmpty) { + return >>>[]; + } + + final int workerCount = _parallelWorkerCount(notes.length); + final int batchSize = (notes.length / workerCount).ceil(); + + final List>>> batchFutures = []; + for (var start = 0; start < notes.length; start += batchSize) { + final int end = math.min(start + batchSize, notes.length); + final List> batchNotes = []; + for (var index = start; index < end; index += 1) { + batchNotes.add(_syncNoteToDecryptionInput(notes[index], index)); + } + + batchFutures.add( + Isolate.run( + () => _decryptNoteBatch({'masterKey': masterKey, 'notes': batchNotes}), + ), + ); + } + + return batchFutures; +} + +Future>> _decryptResponseNotesInParallel( + List notes, { + required String masterKey, + void Function(int processed)? onProgress, +}) async { + final List>>> batchFutures = + _decryptNoteBatches(notes, masterKey: masterKey); + final List?> decryptedNotes = + List?>.filled(notes.length, null); + var processed = 0; + + await for (final List> batchResult in Stream.fromFutures( + batchFutures, + )) { + for (final Map row in batchResult) { + final int index = row['index']! as int; + decryptedNotes[index] = row; + } + processed += batchResult.length; + onProgress?.call(processed); + } + + return decryptedNotes.cast>(); +} + +Map _syncNoteToDecryptionInput( + SyncNoteResponse row, + int index, +) { + return { + 'index': index, + 'id': row.id, + 'encryptedTitle': row.encryptedTitle, + 'encryptedBody': row.encryptedBody, + }; +} + +Map _dbNoteToEncryptionInput(DbNote row, int index) { + return { + 'index': index, + 'uuid': row.uuid, + 'title': row.title, + 'body': row.body, + 'createdAt': row.createdAt.toIso8601String(), + 'updatedAt': row.updatedAt.toIso8601String(), + 'categoryId': row.categoryId, + 'serverVersion': row.serverVersion, + 'position': row.sortIndex, + 'isDeleted': row.isDeleted, + }; +} + +SyncNotePayload _syncNotePayloadFromEncryptionResult(Map row) { + return SyncNotePayload( + id: row['id']! as String, + categoryId: row['categoryId'] as String?, + encryptedTitle: row['encryptedTitle']! as String, + encryptedBody: row['encryptedBody']! as String, + serverVersion: row['serverVersion']! as int, + position: row['position']! as int, + isDeleted: row['isDeleted']! as bool, + updatedAt: DateTime.parse(row['updatedAt']! as String), + ); +} + +Future>> _encryptNoteBatch( + Map request, +) async { + final String masterKey = request['masterKey']! as String; + final List> notes = (request['notes']! as List) + .cast>(); + + final List> encryptedNotes = []; + for (final Map note in notes) { + final String title = note['title']! as String; + final String body = note['body']! as String; + + final String encryptedTitle = await NoteEncryption.encryptNote( + title, + masterKey, + ); + final String encryptedBody = await NoteEncryption.encryptNote( + body, + masterKey, + ); + + encryptedNotes.add({ + 'index': note['index'] as int, + 'id': note['uuid'] as String, + 'categoryId': note['categoryId'] as String?, + 'encryptedTitle': encryptedTitle, + 'encryptedBody': encryptedBody, + 'serverVersion': note['serverVersion']! as int, + 'position': note['position']! as int, + 'isDeleted': note['isDeleted']! as bool, + 'updatedAt': note['updatedAt']! as String, + }); + } + + return encryptedNotes; +} + +Future>> _decryptNoteBatch( + Map request, +) async { + final String masterKey = request['masterKey']! as String; + final List> notes = (request['notes']! as List) + .cast>(); + + final List> decryptedNotes = []; + for (final Map note in notes) { + String decryptedTitle = 'Encrypted'; + String decryptedBody = 'Encrypted'; + try { + decryptedTitle = await NoteEncryption.decryptNote( + note['encryptedTitle']! as String, + masterKey, + ); + decryptedBody = await NoteEncryption.decryptNote( + note['encryptedBody']! as String, + masterKey, + ); + } catch (e) { + print('Failed to decrypt note ${note['id']}: $e'); + } + + decryptedNotes.add({ + 'index': note['index'] as int, + 'id': note['id'] as String, + 'title': decryptedTitle, + 'body': decryptedBody, + }); + } + + return decryptedNotes; +} + +int _parallelWorkerCount(int itemCount) { + final int cappedByCpu = math.max( + 1, + (Platform.numberOfProcessors * 0.6).floor(), + ); + return math.max(1, math.min(itemCount, cappedByCpu)); +} diff --git a/lib/data/sync_models.dart b/lib/data/sync_models.dart index 48ba97a..c69f5c5 100644 --- a/lib/data/sync_models.dart +++ b/lib/data/sync_models.dart @@ -1,13 +1,12 @@ import 'package:notas/models/note.dart'; import 'package:notas/models/category.dart'; +import 'dart:convert'; // DTOs para sincronización con el servidor class SyncRequest { - SyncRequest({ - DateTime? lastSyncAt, - required this.changes, - }) : lastSyncAt = lastSyncAt ?? DateTime.utc(1970, 1, 1); + SyncRequest({DateTime? lastSyncAt, required this.changes}) + : lastSyncAt = lastSyncAt ?? DateTime.utc(1970, 1, 1); final DateTime lastSyncAt; final SyncChanges changes; @@ -21,10 +20,7 @@ class SyncRequest { } class SyncChanges { - const SyncChanges({ - this.categories = const [], - this.notes = const [], - }); + const SyncChanges({this.categories = const [], this.notes = const []}); final List categories; final List notes; @@ -33,8 +29,7 @@ class SyncChanges { return { if (categories.isNotEmpty) 'categories': categories.map((c) => c.toJson()).toList(), - if (notes.isNotEmpty) - 'notes': notes.map((n) => n.toJson()).toList(), + if (notes.isNotEmpty) 'notes': notes.map((n) => n.toJson()).toList(), }; } } @@ -49,7 +44,8 @@ class SyncChangesResponse { final List notes; factory SyncChangesResponse.fromJson(Map json) { - final List categoriesJson = json['categories'] as List? ?? []; + final List categoriesJson = + json['categories'] as List? ?? []; final List notesJson = json['notes'] as List? ?? []; return SyncChangesResponse( @@ -62,6 +58,30 @@ class SyncChangesResponse { ); } } + +String _readStringValue(dynamic value) { + if (value is String) { + return value; + } + if (value == null) { + throw FormatException('Expected String value but found null'); + } + return jsonEncode(value); +} + +int _readIntValue(dynamic value) { + if (value is int) { + return value; + } + if (value is String) { + final int? parsed = int.tryParse(value); + if (parsed != null) { + return parsed; + } + } + throw FormatException('Expected int value but found $value'); +} + class SyncCategoryPayload { const SyncCategoryPayload({ required this.id, @@ -163,11 +183,11 @@ class SyncResponse { factory SyncResponse.fromJson(Map json) { return SyncResponse( - serverTimestamp: - DateTime.parse(json['serverTimestamp'] as String), + serverTimestamp: DateTime.parse(json['serverTimestamp'] as String), synced: json['synced'] as bool? ?? false, - changes: SyncChangesResponse.fromJson( - json['changes'] as Map? ?? {}), + changes: SyncChangesResponse.fromJson( + json['changes'] as Map? ?? {}, + ), ); } } @@ -189,9 +209,9 @@ class SyncCategoryResponse { factory SyncCategoryResponse.fromJson(Map json) { return SyncCategoryResponse( - id: json['id'] as String, - encryptedName: json['encrypted_name'] as String, - serverVersion: json['serverVersion'] as int, + id: _readStringValue(json['id']), + encryptedName: _readStringValue(json['encrypted_name']), + serverVersion: _readIntValue(json['serverVersion']), isDeleted: json['isDeleted'] as bool? ?? false, updatedAt: DateTime.parse(json['updatedAt'] as String), ); @@ -231,11 +251,13 @@ class SyncNoteResponse { factory SyncNoteResponse.fromJson(Map json) { return SyncNoteResponse( - id: json['id'] as String, - categoryId: json['categoryId'] as String?, - encryptedTitle: json['encrypted_title'] as String, - encryptedBody: json['encrypted_body'] as String, - serverVersion: json['serverVersion'] as int, + id: _readStringValue(json['id']), + categoryId: json['categoryId'] == null + ? null + : _readStringValue(json['categoryId']), + encryptedTitle: _readStringValue(json['encrypted_title']), + encryptedBody: _readStringValue(json['encrypted_body']), + serverVersion: _readIntValue(json['serverVersion']), position: json['position'] as int? ?? 0, isDeleted: json['isDeleted'] as bool? ?? false, updatedAt: DateTime.parse(json['updatedAt'] as String), diff --git a/lib/widgets/sync_status_indicator.dart b/lib/widgets/sync_status_indicator.dart index d2205b4..1b3eaa9 100644 --- a/lib/widgets/sync_status_indicator.dart +++ b/lib/widgets/sync_status_indicator.dart @@ -150,7 +150,7 @@ class SyncStatusIndicator extends StatelessWidget { _buildStatusBadge( icon: Icons.cloud_download_outlined, color: const Color.fromARGB(255, 154, 194, 112), - determinate: false, + determinate: true, ), ), );