package com.labexposed.notas.domain import com.labexposed.notas.data.local.CategoryEntity import com.labexposed.notas.data.local.MetaEntity import com.labexposed.notas.data.local.NoteEntity import com.labexposed.notas.data.local.NotasDatabase import com.labexposed.notas.data.local.OutboxEntity import com.labexposed.notas.data.remote.ApiClient import com.labexposed.notas.data.remote.ApiResult import com.labexposed.notas.data.remote.NoteDetailDto import com.labexposed.notas.data.remote.NoteWriteDto import com.labexposed.notas.data.remote.toNoteEntity import java.util.UUID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext /** * Sincronização bidirecional offline-first. * * Ordem por rodada: * 1. PUSH da outbox (ordem FIFO por `seq`): create/update/delete. * - update usa `base_hash` = hash local conhecido → o servidor devolve * 409 se outro dispositivo mudou a nota desde então. * - Conflito (409): política determinística "servidor vence + cópia": * o texto local nunca enviado é preservado numa NOVA nota * "Título (conflito)" (entra na fila como create) e a nota principal * adota o conteúdo do servidor. Nada é perdido — a versão anterior do * servidor também está no histórico/revisões da plataforma. * 2. PULL: árvore de categorias + lista de notas; notas locais limpas cujo * hash divergiu do servidor são rebaixadas (GET completo); notas locais * ausentes no servidor (e não sujas) são removidas (delete remoto). * * Falha de rede em qualquer ponto → NetworkDown (WorkManager aplica backoff). */ class SyncEngine( private val db: NotasDatabase, private val settings: SettingsRepository, private val status: SyncStatusStore, private val session: String = "android", ) { private suspend fun api(): ApiClient? { val s = settings.current() if (!s.configured) return null return ApiClient(s.serverUrl, s.token) } 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 SyncOutcome.NotConfigured } status.set { it.copy(phase = SyncPhase.Syncing) } val author = settings.current().author val push = push(client, author) if (push is SyncOutcome.Unauthorized) { status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") } return push } if (push is SyncOutcome.NetworkDown) { status.set { it.copy(phase = SyncPhase.Offline, message = push.message) } return push } val pull = pull(client) if (pull is SyncOutcome.Unauthorized) { status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") } return pull } if (pull is SyncOutcome.NetworkDown) { status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) } return pull } val ok = (push as SyncOutcome.Success) to (pull as SyncOutcome.Success) val conflicts = ok.first.conflicts + ok.second.conflicts db.meta().set(MetaEntity("last_pull", System.currentTimeMillis().toString())) status.set { it.copy( phase = SyncPhase.Synced, message = "sincronizado ($trigger)", lastSyncAt = System.currentTimeMillis(), conflicts = conflicts, ) } return SyncOutcome.Success(ok.first.pushed, ok.second.pulled, conflicts) } // ------------------------------------------------------------------ push private suspend fun push(client: ApiClient, author: String): SyncOutcome { // auto-reconciliação: notas sujas sem entrada na fila (ex.: só mudaram // metadados, ou ficaram presas de versões anteriores do app) for (n in db.notes().all()) { if (n.dirty && !n.pendingDelete && !db.outbox().has(n.id, "create") && !db.outbox().has(n.id, "update") ) { db.outbox().enqueue( OutboxEntity( noteId = n.id, op = if (n.isLocalOnly) "create" else "update", queuedAt = System.currentTimeMillis(), ), ) } } val queue = db.outbox().queue() if (queue.isEmpty()) return SyncOutcome.Success(0, 0, 0) var pushed = 0 var conflicts = 0 for (entry in queue) { val note = db.notes().byId(entry.noteId) if (note == null) { db.outbox().drop(entry.seq) continue } when (entry.op) { "create" -> { if (note.pendingDelete) { // criada e apagada offline: nada a enviar db.outbox().dropFor(note.id) db.notes().delete(note.id) continue } val resp = client.createNote(note.toWrite(author)) when (resp) { is ApiResult.Ok -> { rekeyLocalNote(note, resp.value) db.outbox().drop(entry.seq) pushed++ } is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message) is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized else -> return SyncOutcome.Failed("create ${note.id}: ${describe(resp)}") } } "update" -> { val sent = note.content val resp = client.updateNote(note.id, note.toWrite(author)) when (resp) { is ApiResult.Ok -> { // limpa a sujeira só se o usuário não digitou durante o push val fresh = db.notes().byId(note.id) ?: note val clean = fresh.content == sent db.notes().upsert( fresh.copy( serverHash = resp.value.hash, dirty = if (clean) false else fresh.dirty, ), ) db.outbox().drop(entry.seq) if (!clean && !fresh.pendingDelete) { // conteúdo mudou durante o push: mantém na fila db.outbox().enqueue( OutboxEntity(noteId = fresh.id, op = "update", queuedAt = System.currentTimeMillis()), ) } pushed++ } is ApiResult.Conflict -> conflicts += resolveConflict(client, note, resp.serverHash) is ApiResult.NotFound -> { // sumiu no servidor: volta a ser create db.outbox().drop(entry.seq) db.notes().upsert(note.copy(serverHash = "", dirty = true)) db.outbox().enqueue( OutboxEntity(noteId = note.id, op = "create", queuedAt = System.currentTimeMillis()), ) } is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message) is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized else -> return SyncOutcome.Failed("update ${note.id}: ${describe(resp)}") } } "delete" -> { db.outbox().dropFor(note.id) // remove create/update pendentes if (!note.isLocalOnly) { when (val resp = client.deleteNote(note.id)) { is ApiResult.Ok, is ApiResult.NotFound -> Unit is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message) is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized else -> return SyncOutcome.Failed("delete ${note.id}: ${describe(resp)}") } } db.notes().delete(note.id) pushed++ } else -> db.outbox().drop(entry.seq) } } return SyncOutcome.Success(pushed, 0, conflicts) } /** 409: servidor vence a nota; texto local vira cópia em conflito. */ private suspend fun resolveConflict(client: ApiClient, note: NoteEntity, serverHash: String): Int { val detail = client.getNote(note.id) val server = (detail as? ApiResult.Ok)?.value ?: return 0 if (server.content == note.content) { // convergência: mesmo texto, hash diferente db.notes().upsert(note.copy(serverHash = server.hash, dirty = false)) db.outbox().dropFor(note.id) return 0 } val now = System.currentTimeMillis() // 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, title = note.title + " (conflito)", serverHash = "", dirty = true, pendingDelete = false, conflictNote = "", createdAt = existingCopy?.createdAt ?: now, updatedAt = 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 ?: emptyList()).joinToString(","), tags = (server.tags ?: emptyList()).joinToString(","), serverHash = server.hash, dirty = false, conflictNote = "conflito resolvido: seu texto ficou em “${note.title} (conflito)”", ), ) db.outbox().dropFor(note.id) return 1 } private suspend fun rekeyLocalNote(note: NoteEntity, server: NoteDetailDto) { val now = System.currentTimeMillis() db.notes().delete(note.id) db.notes().upsert( server.toEntity().copy( createdAt = if (note.createdAt > 0) note.createdAt else now, dirty = false, ), ) // editor aberto com o id antigo resolve o novo via meta db.meta().set(MetaEntity("rekey:" + note.id, server.id)) } // ------------------------------------------------------------------ pull private suspend fun pull(client: ApiClient): SyncOutcome { var pulled = 0 when (val cats = client.listCategories()) { is ApiResult.Ok -> { val known = db.categories().ids().toSet() val fresh = cats.value.map { CategoryEntity(it.id, it.name, it.parent) } if (fresh.map { it.id }.toSet() != known) { db.categories().clear() db.categories().insertAll(fresh) } } is ApiResult.Network -> return SyncOutcome.NetworkDown(cats.message) is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized else -> {} } val list = client.listNotes() if (list is ApiResult.Network) return SyncOutcome.NetworkDown(list.message) if (list is ApiResult.Unauthorized) return SyncOutcome.Unauthorized if (list !is ApiResult.Ok) return SyncOutcome.Failed("list: ${describe(list)}") val serverIds = list.value.map { it.id }.toSet() for (summary in list.value) { val local = db.notes().byId(summary.id) when { local == null -> { val detail = client.getNote(summary.id) if (detail is ApiResult.Ok) { db.notes().upsert(detail.value.toEntity()) pulled++ } } local.dirty || local.pendingDelete -> { /* push é dono */ } local.serverHash != summary.hash -> { val detail = client.getNote(summary.id) if (detail is ApiResult.Ok) { db.notes().upsert(detail.value.toEntity().copy(createdAt = local.createdAt)) pulled++ } } } } // exclusões remotas: notas limpas que sumiram do servidor for (local in db.notes().all()) { if (!serverIds.contains(local.id) && !local.dirty && !local.pendingDelete) { db.notes().delete(local.id) db.outbox().dropFor(local.id) } } return SyncOutcome.Success(0, pulled, 0) } // ----------------------------------------------------------------- utils private fun NoteEntity.toWrite(author: String) = NoteWriteDto( title = title.ifBlank { "Sem título" }, format = format, categories = categories.split(",").filter { it.isNotBlank() }, content = content, author = author, message = "sync android", baseHash = serverHash, session = this@SyncEngine.session, ) private fun NoteDetailDto.toEntity() = toNoteEntity() private fun describe(r: ApiResult<*>): String = when (r) { is ApiResult.Server -> "HTTP ${r.code} ${r.message}" is ApiResult.Network -> r.message is ApiResult.Conflict -> "conflito" else -> r.toString() } }