fix: robustez de sync comprovada no emulador (duplicatas eliminadas)

- ApiClient: IllegalArgumentException de URL vira Network (banner, sem crash);
  DTOs com listas nullable (servidor podia enviar categories/tags: null)
- SyncEngine: mutex global serializa runs (logs BEGIN/END); resolveConflict
  idempotente (não duplica cópia de conflito); registra rekey:<local>→<server>
  em meta para o editor aberto sobreviver à troca de id do create
- ViewModel: resolve(id) em save/delete; createLocal enfileira create único
- Outbox: índice único (noteId, op); DB v2 com fallbackToDestructiveMigration
- proguard: regras corretas de kotlinx.serialization (Companion.serializer)
- README: seção 'Testado em' com os cenários de emulador executados
This commit is contained in:
lgcosta committed 2026-11-04 19:28:02 -03:00
1 parent 2f63c7a2f5
commit 9e0bcc3e9b
9 files changed
+133 -44

No files matched your search

@@ -85,24 +85,36 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
createdAt = now, updatedAt = now, dirty = true,
),
)
db.outbox().enqueue(OutboxEntity(noteId = id, op = "create", queuedAt = now))
// exatamente um create na fila; autosaves seguintes não re-enfileiram
if (!db.outbox().has(id, "create")) {
db.outbox().enqueue(OutboxEntity(noteId = id, op = "create", queuedAt = now))
}
}
return id
}
/** Nota por id, seguindo rekeys de create (id local → id do servidor). */
private suspend fun resolve(id: String): String? {
if (db.notes().byId(id) != null) return id
val mapped = db.meta().get("rekey:$id") ?: return null
return if (db.notes().byId(mapped) != null) mapped else null
}
/** Salva edição local + enfileira mutação + agenda sync (debounce no editor). */
fun saveNote(
suspend fun saveNoteSync(
id: String,
title: String,
format: String,
categories: List<String>,
content: String,
) = viewModelScope.launch(Dispatchers.IO) {
val existing = db.notes().byId(id) ?: return@launch
) {
val targetId = resolve(id) ?: return
val existing = db.notes().byId(targetId) ?: return
val now = System.currentTimeMillis()
val contentChanged = existing.content != content
db.notes().upsert(
existing.copy(
id = targetId,
title = title,
format = format,
categories = categories.joinToString(","),
@@ -112,20 +124,34 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
conflictNote = "",
),
)
if (contentChanged && !db.outbox().has(id, "create")) {
db.outbox().enqueue(OutboxEntity(noteId = id, op = "update", queuedAt = now))
if (contentChanged) {
// nota em create pendente já está na fila com o conteúdo atual (upsert acima)
if (!db.outbox().has(targetId, "create") && !db.outbox().has(targetId, "update")) {
db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "update", queuedAt = now))
}
SyncScheduler.runNow(getApplication(), "autosave")
}
}
fun saveNote(
id: String,
title: String,
format: String,
categories: List<String>,
content: String,
) = viewModelScope.launch(Dispatchers.IO) {
saveNoteSync(id, title, format, categories, content)
}
fun deleteNote(id: String) = viewModelScope.launch(Dispatchers.IO) {
val note = db.notes().byId(id) ?: return@launch
val targetId = resolve(id) ?: return@launch
val note = db.notes().byId(targetId) ?: return@launch
if (note.isLocalOnly) {
db.notes().delete(id)
db.outbox().dropFor(id)
db.notes().delete(targetId)
db.outbox().dropFor(targetId)
} else {
db.notes().upsert(note.copy(pendingDelete = true, dirty = true, updatedAt = System.currentTimeMillis()))
db.outbox().enqueue(OutboxEntity(noteId = id, op = "delete", queuedAt = System.currentTimeMillis()))
db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "delete", queuedAt = System.currentTimeMillis()))
SyncScheduler.runNow(getApplication(), "delete")
}
}