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:
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),
|
||||
|
||||
Reference in new issue
Block a user