feat: Optimize note encryption and decryption processes with parallel execution

This commit is contained in:
2026-05-19 10:09:20 +02:00
parent 9769087fd8
commit 2a898111fa
3 changed files with 316 additions and 74 deletions
+270 -50
View File
@@ -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:drift/drift.dart';
import 'package:notas/data/app_database.dart'; import 'package:notas/data/app_database.dart';
import 'package:notas/data/api_client.dart'; import 'package:notas/data/api_client.dart';
@@ -126,8 +131,7 @@ class NoteRepository {
final int totalNotesToEncrypt = unsyncedNotes.length; final int totalNotesToEncrypt = unsyncedNotes.length;
// Build sync request (note: we send encrypted data, but locally we have plaintext) // Build sync request (note: we send encrypted data, but locally we have plaintext)
// Encrypt all notes before sending // Encrypt all notes before sending.
final List<SyncNotePayload> encryptedNotesPayload = [];
if (totalNotesToEncrypt == 0) { if (totalNotesToEncrypt == 0) {
onProgress?.call( onProgress?.call(
SyncStatus.encrypting, SyncStatus.encrypting,
@@ -136,32 +140,21 @@ class NoteRepository {
); );
} }
for (var index = 0; index < unsyncedNotes.length; index += 1) { final List<SyncNotePayload>
final DbNote dbNote = unsyncedNotes[index]; encryptedNotesPayload = await _encryptNotesInParallel(
final note = _fromDbNote(dbNote); unsyncedNotes,
final encryptedTitle = await NoteEncryption.encryptNote( masterKey: _masterKey,
note.title, onProgress: (int encryptedCount) {
_masterKey, onProgress?.call(
); SyncStatus.encrypting,
final encryptedBody = await NoteEncryption.encryptNote( progress: totalNotesToEncrypt == 0
note.body, ? 1.0
_masterKey, : encryptedCount / totalNotesToEncrypt,
); message:
encryptedNotesPayload.add( 'Encriptando notas para subir: $encryptedCount de $totalNotesToEncrypt',
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<SyncCategoryPayload> categoriesPayload = unsyncedCategories final List<SyncCategoryPayload> categoriesPayload = unsyncedCategories
.map((cat) => SyncCategoryPayload.fromCategory(_fromDbCategory(cat))) .map((cat) => SyncCategoryPayload.fromCategory(_fromDbCategory(cat)))
@@ -241,35 +234,33 @@ class NoteRepository {
// Apply notes from server // Apply notes from server
final int totalNotesToDecrypt = response.changes.notes.length; final int totalNotesToDecrypt = response.changes.notes.length;
for (var index = 0; index < response.changes.notes.length; index += 1) { final List<Map<String, Object?>> decryptedNotes =
final SyncNoteResponse noteResponse = response.changes.notes[index]; 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<String, Object?> decryptedNote = decryptedNotes[index];
final String noteId = decryptedNote['id']! as String;
final existingNote = await (_database.select( final existingNote = await (_database.select(
_database.notes, _database.notes,
)..where((n) => n.uuid.equals(noteResponse.id))).getSingleOrNull(); )..where((n) => n.uuid.equals(noteId))).getSingleOrNull();
// Decrypt note content final String decryptedTitle = decryptedNote['title']! as String;
String decryptedTitle = 'Encrypted'; final String decryptedBody = decryptedNote['body']! as String;
String decryptedBody = 'Encrypted'; final SyncNoteResponse noteResponse = response.changes.notes[index];
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) { if (existingNote != null) {
// Update existing note // Update existing note
await _database.updateNoteRow( await _database.updateNoteRow(
DbNote( DbNote(
id: existingNote.id, id: existingNote.id,
uuid: noteResponse.id, uuid: noteId,
title: decryptedTitle, title: decryptedTitle,
body: decryptedBody, body: decryptedBody,
createdAt: existingNote.createdAt, createdAt: existingNote.createdAt,
@@ -298,8 +289,6 @@ class NoteRepository {
), ),
); );
} }
onDecryptProgress?.call(index + 1, totalNotesToDecrypt);
} }
} }
@@ -333,3 +322,234 @@ class NoteRepository {
); );
} }
} }
List<Future<List<Map<String, Object?>>>> _encryptNoteBatches(
List<DbNote> notes, {
required String masterKey,
}) {
if (notes.isEmpty) {
return <Future<List<Map<String, Object?>>>>[];
}
final int workerCount = _parallelWorkerCount(notes.length);
final int batchSize = (notes.length / workerCount).ceil();
final List<Future<List<Map<String, Object?>>>> batchFutures = [];
for (var start = 0; start < notes.length; start += batchSize) {
final int end = math.min(start + batchSize, notes.length);
final List<Map<String, Object?>> 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<List<SyncNotePayload>> _encryptNotesInParallel(
List<DbNote> notes, {
required String masterKey,
void Function(int encryptedCount)? onProgress,
}) async {
final List<Future<List<Map<String, Object?>>>> batchFutures =
_encryptNoteBatches(notes, masterKey: masterKey);
final List<SyncNotePayload?> orderedPayloads = List<SyncNotePayload?>.filled(
notes.length,
null,
);
var encryptedCount = 0;
await for (final List<Map<String, Object?>> batchResult in Stream.fromFutures(
batchFutures,
)) {
for (final Map<String, Object?> row in batchResult) {
final int index = row['index']! as int;
orderedPayloads[index] = _syncNotePayloadFromEncryptionResult(row);
}
encryptedCount += batchResult.length;
onProgress?.call(encryptedCount);
}
return orderedPayloads.cast<SyncNotePayload>();
}
List<Future<List<Map<String, Object?>>>> _decryptNoteBatches(
List<SyncNoteResponse> notes, {
required String masterKey,
}) {
if (notes.isEmpty) {
return <Future<List<Map<String, Object?>>>>[];
}
final int workerCount = _parallelWorkerCount(notes.length);
final int batchSize = (notes.length / workerCount).ceil();
final List<Future<List<Map<String, Object?>>>> batchFutures = [];
for (var start = 0; start < notes.length; start += batchSize) {
final int end = math.min(start + batchSize, notes.length);
final List<Map<String, Object?>> 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<List<Map<String, Object?>>> _decryptResponseNotesInParallel(
List<SyncNoteResponse> notes, {
required String masterKey,
void Function(int processed)? onProgress,
}) async {
final List<Future<List<Map<String, Object?>>>> batchFutures =
_decryptNoteBatches(notes, masterKey: masterKey);
final List<Map<String, Object?>?> decryptedNotes =
List<Map<String, Object?>?>.filled(notes.length, null);
var processed = 0;
await for (final List<Map<String, Object?>> batchResult in Stream.fromFutures(
batchFutures,
)) {
for (final Map<String, Object?> row in batchResult) {
final int index = row['index']! as int;
decryptedNotes[index] = row;
}
processed += batchResult.length;
onProgress?.call(processed);
}
return decryptedNotes.cast<Map<String, Object?>>();
}
Map<String, Object?> _syncNoteToDecryptionInput(
SyncNoteResponse row,
int index,
) {
return <String, Object?>{
'index': index,
'id': row.id,
'encryptedTitle': row.encryptedTitle,
'encryptedBody': row.encryptedBody,
};
}
Map<String, Object?> _dbNoteToEncryptionInput(DbNote row, int index) {
return <String, Object?>{
'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<String, Object?> 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<List<Map<String, Object?>>> _encryptNoteBatch(
Map<String, Object?> request,
) async {
final String masterKey = request['masterKey']! as String;
final List<Map<String, Object?>> notes = (request['notes']! as List)
.cast<Map<String, Object?>>();
final List<Map<String, Object?>> encryptedNotes = [];
for (final Map<String, Object?> 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(<String, Object?>{
'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<List<Map<String, Object?>>> _decryptNoteBatch(
Map<String, Object?> request,
) async {
final String masterKey = request['masterKey']! as String;
final List<Map<String, Object?>> notes = (request['notes']! as List)
.cast<Map<String, Object?>>();
final List<Map<String, Object?>> decryptedNotes = [];
for (final Map<String, Object?> 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(<String, Object?>{
'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));
}
+45 -23
View File
@@ -1,13 +1,12 @@
import 'package:notas/models/note.dart'; import 'package:notas/models/note.dart';
import 'package:notas/models/category.dart'; import 'package:notas/models/category.dart';
import 'dart:convert';
// DTOs para sincronización con el servidor // DTOs para sincronización con el servidor
class SyncRequest { class SyncRequest {
SyncRequest({ SyncRequest({DateTime? lastSyncAt, required this.changes})
DateTime? lastSyncAt, : lastSyncAt = lastSyncAt ?? DateTime.utc(1970, 1, 1);
required this.changes,
}) : lastSyncAt = lastSyncAt ?? DateTime.utc(1970, 1, 1);
final DateTime lastSyncAt; final DateTime lastSyncAt;
final SyncChanges changes; final SyncChanges changes;
@@ -21,10 +20,7 @@ class SyncRequest {
} }
class SyncChanges { class SyncChanges {
const SyncChanges({ const SyncChanges({this.categories = const [], this.notes = const []});
this.categories = const [],
this.notes = const [],
});
final List<SyncCategoryPayload> categories; final List<SyncCategoryPayload> categories;
final List<SyncNotePayload> notes; final List<SyncNotePayload> notes;
@@ -33,8 +29,7 @@ class SyncChanges {
return { return {
if (categories.isNotEmpty) if (categories.isNotEmpty)
'categories': categories.map((c) => c.toJson()).toList(), 'categories': categories.map((c) => c.toJson()).toList(),
if (notes.isNotEmpty) if (notes.isNotEmpty) 'notes': notes.map((n) => n.toJson()).toList(),
'notes': notes.map((n) => n.toJson()).toList(),
}; };
} }
} }
@@ -49,7 +44,8 @@ class SyncChangesResponse {
final List<SyncNoteResponse> notes; final List<SyncNoteResponse> notes;
factory SyncChangesResponse.fromJson(Map<String, dynamic> json) { factory SyncChangesResponse.fromJson(Map<String, dynamic> json) {
final List<dynamic> categoriesJson = json['categories'] as List<dynamic>? ?? []; final List<dynamic> categoriesJson =
json['categories'] as List<dynamic>? ?? [];
final List<dynamic> notesJson = json['notes'] as List<dynamic>? ?? []; final List<dynamic> notesJson = json['notes'] as List<dynamic>? ?? [];
return SyncChangesResponse( 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 { class SyncCategoryPayload {
const SyncCategoryPayload({ const SyncCategoryPayload({
required this.id, required this.id,
@@ -163,11 +183,11 @@ class SyncResponse {
factory SyncResponse.fromJson(Map<String, dynamic> json) { factory SyncResponse.fromJson(Map<String, dynamic> json) {
return SyncResponse( return SyncResponse(
serverTimestamp: serverTimestamp: DateTime.parse(json['serverTimestamp'] as String),
DateTime.parse(json['serverTimestamp'] as String),
synced: json['synced'] as bool? ?? false, synced: json['synced'] as bool? ?? false,
changes: SyncChangesResponse.fromJson( changes: SyncChangesResponse.fromJson(
json['changes'] as Map<String, dynamic>? ?? {}), json['changes'] as Map<String, dynamic>? ?? {},
),
); );
} }
} }
@@ -189,9 +209,9 @@ class SyncCategoryResponse {
factory SyncCategoryResponse.fromJson(Map<String, dynamic> json) { factory SyncCategoryResponse.fromJson(Map<String, dynamic> json) {
return SyncCategoryResponse( return SyncCategoryResponse(
id: json['id'] as String, id: _readStringValue(json['id']),
encryptedName: json['encrypted_name'] as String, encryptedName: _readStringValue(json['encrypted_name']),
serverVersion: json['serverVersion'] as int, serverVersion: _readIntValue(json['serverVersion']),
isDeleted: json['isDeleted'] as bool? ?? false, isDeleted: json['isDeleted'] as bool? ?? false,
updatedAt: DateTime.parse(json['updatedAt'] as String), updatedAt: DateTime.parse(json['updatedAt'] as String),
); );
@@ -231,11 +251,13 @@ class SyncNoteResponse {
factory SyncNoteResponse.fromJson(Map<String, dynamic> json) { factory SyncNoteResponse.fromJson(Map<String, dynamic> json) {
return SyncNoteResponse( return SyncNoteResponse(
id: json['id'] as String, id: _readStringValue(json['id']),
categoryId: json['categoryId'] as String?, categoryId: json['categoryId'] == null
encryptedTitle: json['encrypted_title'] as String, ? null
encryptedBody: json['encrypted_body'] as String, : _readStringValue(json['categoryId']),
serverVersion: json['serverVersion'] as int, encryptedTitle: _readStringValue(json['encrypted_title']),
encryptedBody: _readStringValue(json['encrypted_body']),
serverVersion: _readIntValue(json['serverVersion']),
position: json['position'] as int? ?? 0, position: json['position'] as int? ?? 0,
isDeleted: json['isDeleted'] as bool? ?? false, isDeleted: json['isDeleted'] as bool? ?? false,
updatedAt: DateTime.parse(json['updatedAt'] as String), updatedAt: DateTime.parse(json['updatedAt'] as String),
+1 -1
View File
@@ -150,7 +150,7 @@ class SyncStatusIndicator extends StatelessWidget {
_buildStatusBadge( _buildStatusBadge(
icon: Icons.cloud_download_outlined, icon: Icons.cloud_download_outlined,
color: const Color.fromARGB(255, 154, 194, 112), color: const Color.fromARGB(255, 154, 194, 112),
determinate: false, determinate: true,
), ),
), ),
); );