Files
notas/lib/data/note_repository.dart
T

596 lines
18 KiB
Dart

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';
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';
import 'package:notas/widgets/sync_status.dart';
class NoteRepository {
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();
}
Future<List<Note>> loadDeletedNotes() async {
return _loadDeletedNotesFromDatabase();
}
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),
),
);
return note.copyWith(id: id, index: 0);
}
Future<Note> updateNote(Note note) async {
final int noteId =
note.id ??
(throw ArgumentError('Note id is required to update a note.'));
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: false,
categoryId: note.categoryId,
),
);
return note.copyWith(isDeleted: false, isPermanentlyDeleted: false);
}
Future<void> deleteNote(Note note) async {
final int noteId =
note.id ??
(throw ArgumentError('Note id is required to delete a note.'));
if (note.isDeleted || note.isPermanentlyDeleted) {
await _database.permanentlyDeleteNote(noteId);
} else {
await _database.deleteNoteAndShift(id: noteId, removedIndex: note.index);
}
}
Future<void> moveNote(Note note, int newIndex) async {
final int noteId =
note.id ??
(throw ArgumentError('Note id is required to reorder a note.'));
await _database.moveNote(
id: noteId,
oldIndex: note.index,
newIndex: newIndex,
);
}
// ========== Sync logic ==========
/// Sincroniza notas con el servidor.
/// Requiere que el usuario esté autenticado (token válido).
Future<Map<String, dynamic>> performSync({
bool forceFull = false,
void Function(SyncStatus status, {double? progress, String? message})?
onProgress,
}) async {
try {
onProgress?.call(
SyncStatus.preparing,
message: 'Preparando sincronización...',
);
// Get last sync timestamp
final DateTime? lastSync = await _authApi.getLastSyncAt();
final DateTime? lastSyncForRequest = forceFull
? DateTime.utc(1970, 1, 1)
: lastSync;
// Collect pending local changes.
// If we already synced at least once, use updatedAt to avoid re-sending
// old notes that were already uploaded.
final List<DbNote> unsyncedNotes;
final List<DbCategory> unsyncedCategories;
if (forceFull || lastSync == null) {
unsyncedNotes = await _database.getUnsyncedNotes();
unsyncedCategories = await _database.getUnsyncedCategories();
} else {
unsyncedNotes = await _database.getNotesChangedSince(lastSync);
unsyncedCategories = await _database.getCategoriesChangedSince(
lastSync,
);
}
final int totalNotesToEncrypt = unsyncedNotes.length;
// Build sync request (note: we send encrypted data, but locally we have plaintext)
// Encrypt notes that still contain content before sending.
if (totalNotesToEncrypt == 0) {
onProgress?.call(
SyncStatus.encrypting,
progress: 1.0,
message: 'No hay notas pendientes de encriptar.',
);
}
final List<SyncNotePayload>
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<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
onProgress?.call(
SyncStatus.uploading,
message: 'Subiendo datos al servidor...',
);
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
onProgress?.call(
SyncStatus.waitingResponse,
message: 'Esperando respuesta del servidor...',
);
await _applySyncResponse(
response,
onDecryptProgress: (int processed, int total) {
onProgress?.call(
SyncStatus.decrypting,
progress: total == 0 ? 1.0 : processed / total,
message: total == 0
? 'Desencriptando datos recibidos...'
: 'Desencriptando respuesta: $processed de $total',
);
},
);
// 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()};
}
}
Future<void> _applySyncResponse(
SyncResponse response, {
void Function(int processed, int total)? onDecryptProgress,
}) 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
final int totalNotesToDecrypt = response.changes.notes.length;
final List<Map<String, Object?>> 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<String, Object?> decryptedNote = decryptedNotes[index];
final String noteId = decryptedNote['id']! as String;
final existingNote = await (_database.select(
_database.notes,
)..where((n) => n.uuid.equals(noteId))).getSingleOrNull();
final String decryptedTitle = decryptedNote['title']! as String;
final String decryptedBody = decryptedNote['body']! as String;
final SyncNoteResponse noteResponse = response.changes.notes[index];
final bool isPermanentlyDeleted = noteResponse.isPermanentlyDeleted;
if (existingNote != null) {
// Update existing note
await _database.updateNoteRow(
DbNote(
id: existingNote.id,
uuid: noteId,
title: isPermanentlyDeleted ? '' : decryptedTitle,
body: isPermanentlyDeleted ? '' : decryptedBody,
createdAt: existingNote.createdAt,
updatedAt: noteResponse.updatedAt,
sortIndex: noteResponse.position,
serverVersion: noteResponse.serverVersion,
isDeleted: noteResponse.isDeleted,
categoryId: isPermanentlyDeleted ? null : noteResponse.categoryId,
),
);
} else {
// Insert new note
await _database
.into(_database.notes)
.insert(
NotesCompanion(
uuid: Value(noteResponse.id),
title: Value(isPermanentlyDeleted ? '' : decryptedTitle),
body: Value(isPermanentlyDeleted ? '' : decryptedBody),
createdAt: Value(noteResponse.updatedAt),
updatedAt: Value(noteResponse.updatedAt),
sortIndex: Value(noteResponse.position),
serverVersion: Value(noteResponse.serverVersion),
isDeleted: Value(noteResponse.isDeleted),
categoryId: Value(isPermanentlyDeleted ? null : noteResponse.categoryId),
),
);
}
}
}
Future<List<Note>> _loadNotesFromDatabase() async {
final List<DbNote> rows = await _database.getAllNotes();
return rows.map(_fromDbNote).toList();
}
Future<List<Note>> _loadDeletedNotesFromDatabase() async {
final List<DbNote> rows = await _database.getDeletedNotes();
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,
isPermanentlyDeleted: _isPermanentlyDeleted(row),
categoryId: row.categoryId,
);
}
Category _fromDbCategory(DbCategory row) {
return Category(
uuid: row.uuid,
encryptedName: row.encryptedName,
serverVersion: row.serverVersion,
isDeleted: row.isDeleted,
updatedAt: row.updatedAt,
);
}
}
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,
'isPermanentlyDeleted': row.isPermanentlyDeleted,
};
}
Map<String, Object?> _dbNoteToEncryptionInput(DbNote row, int index) {
final bool isPermanentlyDeleted = _isPermanentlyDeleted(row);
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,
'isPermanentlyDeleted': isPermanentlyDeleted,
};
}
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,
isPermanentlyDeleted: row['isPermanentlyDeleted']! 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 bool isPermanentlyDeleted = note['isPermanentlyDeleted']! as bool;
final String title = note['title']! as String;
final String body = note['body']! as String;
final String encryptedTitle;
final String encryptedBody;
if (isPermanentlyDeleted) {
encryptedTitle = '';
encryptedBody = '';
} else {
encryptedTitle = await NoteEncryption.encryptNote(
title,
masterKey,
);
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,
'isPermanentlyDeleted': isPermanentlyDeleted,
'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) {
final bool isPermanentlyDeleted = note['isPermanentlyDeleted']! as bool;
String decryptedTitle = 'Encrypted';
String decryptedBody = 'Encrypted';
if (!isPermanentlyDeleted) {
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');
}
} else {
decryptedTitle = '';
decryptedBody = '';
}
decryptedNotes.add(<String, Object?>{
'index': note['index'] as int,
'id': note['id'] as String,
'title': decryptedTitle,
'body': decryptedBody,
'isPermanentlyDeleted': isPermanentlyDeleted,
});
}
return decryptedNotes;
}
bool _isPermanentlyDeleted(DbNote row) {
return row.isDeleted && row.title.isEmpty && row.body.isEmpty;
}
int _parallelWorkerCount(int itemCount) {
final int cappedByCpu = math.max(
1,
(Platform.numberOfProcessors * 0.6).floor(),
);
return math.max(1, math.min(itemCount, cappedByCpu));
}