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

@@ -12,6 +12,7 @@ import com.labexposed.notas.data.remote.NoteWriteDto
import java.time.Instant
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
@@ -45,10 +46,25 @@ class SyncEngine(
}
suspend fun fullSync(trigger: String = "auto"): SyncOutcome = withContext(Dispatchers.IO) {
android.util.Log.i("SyncEngine", "BEGIN run trigger=$trigger tid=${Thread.currentThread().id}")
SyncStatusStore.syncMutex.withLock {
try {
runSync(trigger)
} catch (e: Exception) {
android.util.Log.e("SyncEngine", "fullSync($trigger) falhou", e)
status.set { it.copy(phase = SyncPhase.Error, message = e.message ?: "erro interno") }
SyncOutcome.Failed(e.message ?: "erro interno")
} finally {
android.util.Log.i("SyncEngine", "END run trigger=$trigger")
}
}
}
private suspend fun runSync(trigger: String): SyncOutcome {
val client = api()
if (client == null) {
status.set { it.copy(phase = SyncPhase.NotConfigured) }
return@withContext SyncOutcome.NotConfigured
return SyncOutcome.NotConfigured
}
status.set { it.copy(phase = SyncPhase.Syncing) }
val author = settings.current().author
@@ -56,21 +72,21 @@ class SyncEngine(
val push = push(client, author)
if (push is SyncOutcome.Unauthorized) {
status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") }
return@withContext push
return push
}
if (push is SyncOutcome.NetworkDown) {
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
return@withContext push
return push
}
val pull = pull(client)
if (pull is SyncOutcome.Unauthorized) {
status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") }
return@withContext pull
return pull
}
if (pull is SyncOutcome.NetworkDown) {
status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) }
return@withContext pull
return pull
}
val ok = (push as SyncOutcome.Success) to (pull as SyncOutcome.Success)
@@ -84,7 +100,7 @@ class SyncEngine(
conflicts = conflicts,
)
}
SyncOutcome.Success(ok.first.pushed, ok.second.pulled, conflicts)
return SyncOutcome.Success(ok.first.pushed, ok.second.pulled, conflicts)
}
// ------------------------------------------------------------------ push
@@ -183,8 +199,12 @@ class SyncEngine(
return 0
}
val now = System.currentTimeMillis()
// 1) cópia local com o texto não enviado
val copyId = "L" + UUID.randomUUID().toString().replace("-", "").substring(0, 12)
// 1) cópia local com o texto não enviado — idempotente: se já existe
// uma cópia pendente com o mesmo conteúdo, não duplica
val existingCopy = db.notes().all().firstOrNull {
it.dirty && it.title == note.title + " (conflito)" && it.content == note.content
}
val copyId = existingCopy?.id ?: ("L" + UUID.randomUUID().toString().replace("-", "").substring(0, 12))
db.notes().upsert(
note.copy(
id = copyId,
@@ -193,19 +213,21 @@ class SyncEngine(
dirty = true,
pendingDelete = false,
conflictNote = "",
createdAt = now,
createdAt = existingCopy?.createdAt ?: now,
updatedAt = now,
),
)
db.outbox().enqueue(OutboxEntity(noteId = copyId, op = "create", queuedAt = now))
if (existingCopy == null) {
db.outbox().enqueue(OutboxEntity(noteId = copyId, op = "create", queuedAt = now))
}
// 2) nota principal adota o servidor
db.notes().upsert(
note.copy(
content = server.content,
title = server.title,
format = server.format,
categories = server.categories.joinToString(","),
tags = server.tags.joinToString(","),
categories = (server.categories ?: emptyList()).joinToString(","),
tags = (server.tags ?: emptyList()).joinToString(","),
serverHash = server.hash,
dirty = false,
conflictNote = "conflito resolvido: seu texto ficou em “${note.title} (conflito)”",
@@ -224,6 +246,8 @@ class SyncEngine(
dirty = false,
),
)
// editor aberto com o id antigo resolve o novo via meta
db.meta().set(MetaEntity("rekey:" + note.id, server.id))
}
// ------------------------------------------------------------------ pull
@@ -298,8 +322,8 @@ class SyncEngine(
id = id,
title = title,
format = format,
categories = categories.joinToString(","),
tags = tags.joinToString(","),
categories = (categories ?: emptyList()).joinToString(","),
tags = (tags ?: emptyList()).joinToString(","),
content = content,
serverHash = hash,
createdAt = parseMillis(createdAt),