596 lines
18 KiB
Dart
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));
|
|
}
|