Files
notas/lib/data/note_repository.dart
T

703 lines
22 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';
import 'package:flutter/foundation.dart' show debugPrint;
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<List<Category>> loadCategories() async {
final List<DbCategory> dbCategories = await _database.getAllCategories();
final List<Category> categories = [];
for (final DbCategory row in dbCategories) {
categories.add(
Category(
id: row.id,
name: row.name,
serverVersion: row.serverVersion,
isDeleted: row.isDeleted,
updatedAt: row.updatedAt,
isDirty: row.isDirty,
colorValue: row.colorValue,
iconCodePoint: row.iconCodePoint,
),
);
}
return categories;
}
Future<void> createCategory(Category category) async {
debugPrint('createCategory called with: ${category.name}');
final DbCategory? existingCategory = await (
_database.select(_database.categories)..where((c) => c.id.equals(category.id))
).getSingleOrNull();
final int effectiveServerVersion = math.max(
category.serverVersion,
existingCategory?.serverVersion ?? category.serverVersion,
);
await _database.upsertCategory(
CategoriesCompanion.insert(
id: category.id,
name: category.name,
updatedAt: category.updatedAt,
serverVersion: Value(effectiveServerVersion),
isDeleted: const Value(false),
isDirty: const Value(true),
colorValue: Value<int?>(category.colorValue),
iconCodePoint: Value<int?>(category.iconCodePoint),
),
);
debugPrint('Category inserted to database');
}
Future<void> deleteCategory(String id) async {
await _database.deleteCategory(id);
await _database.customStatement(
'UPDATE notes SET category_id = NULL, is_dirty = 1 WHERE category_id = ?',
[id],
);
}
Future<Note> createNote(Note note) async {
await _database.insertNoteAtTop(
NotesCompanion.insert(
id: note.id,
title: note.title,
body: note.body,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
sortIndex: 0,
serverVersion: const Value(0),
isDeleted: const Value(false),
categoryId: Value(note.categoryId),
isDirty: const Value(true),
),
);
return note.copyWith(position: 0, isDirty: true);
}
Future<Note> updateNote(Note note) async {
final DbNote? existingNote = await (_database.select(
_database.notes,
)..where((n) => n.id.equals(note.id))).getSingleOrNull();
final DbNote row =
existingNote ??
(throw ArgumentError('Note not found for id ${note.id}.'));
await _database.updateNoteRow(
DbNote(
id: row.id,
title: note.title,
body: note.body,
createdAt: row.createdAt,
updatedAt: note.updatedAt,
sortIndex: row.sortIndex,
serverVersion: note.serverVersion,
isDeleted: false,
categoryId: note.categoryId,
isDirty: true,
),
);
return note.copyWith(
isDeleted: false,
isPermanentlyDeleted: false,
isDirty: true,
position: row.sortIndex.toDouble(),
);
}
Future<void> deleteNote(Note note) async {
final DbNote? existingNote = await (_database.select(
_database.notes,
)..where((n) => n.id.equals(note.id))).getSingleOrNull();
final DbNote row =
existingNote ??
(throw ArgumentError('Note not found for id ${note.id}.'));
if (row.isDeleted || note.isDeleted || note.isPermanentlyDeleted) {
await _database.permanentlyDeleteNote(row.id);
} else {
await _database.deleteNoteAndShift(
id: row.id,
removedIndex: row.sortIndex,
);
}
}
Future<void> moveNote(Note note, int newIndex) async {
final DbNote? existingNote = await (_database.select(
_database.notes,
)..where((n) => n.id.equals(note.id))).getSingleOrNull();
final DbNote row =
existingNote ??
(throw ArgumentError('Note not found for id ${note.id}.'));
await _database.moveNote(
id: row.id,
oldIndex: row.sortIndex,
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. Dirty flags are the source of truth for
// outbound sync; `lastSyncAt` is only used for asking the server what it
// changed since our previous successful sync.
final List<DbNote> unsyncedNotes = await _database.getUnsyncedNotes();
final List<DbCategory> unsyncedCategories = await _database
.getUnsyncedCategories();
final int totalNotesToEncrypt = unsyncedNotes.length;
// Build sync request: local data is plaintext, encryption happens only
// for the outbound payload.
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 =
await _encryptCategories(unsyncedCategories, masterKey: _masterKey);
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) {
final List<String> details = [];
final Object? message = syncResult['message'];
final Object? exception = syncResult['exception'];
final Object? stackTrace = syncResult['stackTrace'];
final Object? body = syncResult['body'];
if (message != null) details.add(message.toString());
if (exception != null && exception.toString() != details.firstOrNull) {
details.add('Exception: ${exception.toString()}');
}
if (body != null) {
details.add('Body: ${body.toString()}');
}
if (stackTrace != null) {
details.add('StackTrace: ${stackTrace.toString()}');
}
return {'error': true, 'message': details.join('\n\n')};
}
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, st) {
return {'error': true, 'message': '$e\n\nStackTrace: $st'};
}
}
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) {
final Category category = await catResponse.toCategory(
masterKey: _masterKey,
);
await _database.upsertCategory(
CategoriesCompanion(
id: Value(category.id),
name: Value(category.name),
serverVersion: Value(category.serverVersion),
isDeleted: Value(category.isDeleted),
colorValue: Value<int?>(category.colorValue),
iconCodePoint: Value<int?>(category.iconCodePoint),
updatedAt: Value(category.updatedAt),
isDirty: const Value(false),
),
);
}
// 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 SyncNoteResponse noteResponse = response.changes.notes[index];
final String noteId = (decryptedNote['id'] as String?) ?? noteResponse.id;
final existingNote = await (_database.select(
_database.notes,
)..where((n) => n.id.equals(noteId))).getSingleOrNull();
final String decryptedTitle = (decryptedNote['title'] as String?) ?? '';
final String decryptedBody = (decryptedNote['body'] as String?) ?? '';
final bool isPermanentlyDeleted = noteResponse.isPermanentlyDeleted;
if (existingNote != null) {
// Update existing note
await _database.updateNoteRow(
DbNote(
id: existingNote.id,
title: isPermanentlyDeleted ? '' : decryptedTitle,
body: isPermanentlyDeleted ? '' : decryptedBody,
createdAt: existingNote.createdAt,
updatedAt: noteResponse.updatedAt,
sortIndex: noteResponse.position.round(),
serverVersion: noteResponse.serverVersion,
isDeleted: noteResponse.isDeleted,
categoryId: isPermanentlyDeleted ? null : noteResponse.categoryId,
isDirty: false,
),
);
} else {
// Insert new note
await _database
.into(_database.notes)
.insert(
NotesCompanion(
id: Value(noteResponse.id),
title: Value(isPermanentlyDeleted ? '' : decryptedTitle),
body: Value(isPermanentlyDeleted ? '' : decryptedBody),
createdAt: Value(noteResponse.updatedAt),
updatedAt: Value(noteResponse.updatedAt),
sortIndex: Value(noteResponse.position.round()),
serverVersion: Value(noteResponse.serverVersion),
isDeleted: Value(noteResponse.isDeleted),
categoryId: Value(
isPermanentlyDeleted ? null : noteResponse.categoryId,
),
isDirty: const Value(false),
),
);
}
}
}
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,
title: row.title,
body: row.body,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
position: row.sortIndex.toDouble(),
serverVersion: row.serverVersion,
isDeleted: row.isDeleted,
isPermanentlyDeleted: _isPermanentlyDeleted(row),
categoryId: row.categoryId,
isDirty: row.isDirty,
);
}
}
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>();
}
Future<List<SyncCategoryPayload>> _encryptCategories(
List<DbCategory> categories, {
required String masterKey,
}) async {
final List<SyncCategoryPayload> payloads = [];
for (final DbCategory row in categories) {
payloads.add(
await SyncCategoryPayload.fromCategory(
Category(
id: row.id,
name: row.name,
serverVersion: row.serverVersion,
isDeleted: row.isDeleted,
updatedAt: row.updatedAt,
isDirty: row.isDirty,
colorValue: row.colorValue,
iconCodePoint: row.iconCodePoint,
),
masterKey: masterKey,
),
);
}
return payloads;
}
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,
'id': row.id,
'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 num).toDouble(),
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['id'] 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.decrypt(
note['encryptedTitle']! as String,
masterKey,
);
decryptedBody = await NoteEncryption.decrypt(
note['encryptedBody']! as String,
masterKey,
);
} catch (e) {
print('Failed to decrypt note ${note['id']}: $e');
}
} else {
decryptedTitle = '';
decryptedBody = '';
}
final String noteId = note['id']! as String;
decryptedNotes.add(<String, Object?>{
'index': note['index'] as int,
'id': noteId,
'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));
}