Files
notas-online-android/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt
T
lgcosta 61d4abebbd feat: v0.2.0 — sincronização do botão garantida, SSE em tempo real, WYSIWYG/Preview e lista rica
Sync (bug do celular real):
- mudanças só de metadados (título/formato/categorias) entram na fila
- auto-reconciliação: toda rodada enfileira notas sujas sem entrada na fila
- re-enfileira update se o usuário digitou durante o push
- sync no ON_RESUME; botão Sincronizar com spinner no lugar do ícone

Tempo real (SSE):
- SseClient (OkHttp, Dispatchers.IO, backoff) consome /api/events com Bearer
- saved: nota limpa recarrega sozinha (editor aberto incluído, com rekey por meta),
  nota suja mostra banner Recarregar/Manter; snackbar na lista
- presence: 👥N na lista/editor quando outra sessão (web ou app) abre a mesma nota
- SessionId único por instalação, enviado em presence e ecoado nos saves

Editor moderno (WebView nativo, sem deps novas):
- modos Editar / WYSIWYG / Preview; WYSIWYG = contenteditable com serializador
  html→md em JS na página-asset + ponte @JavascriptInterface (tudo na main thread)
- toolbar Markdown no modo texto (wrap/prefix seleção) e execCommand no WYSIWYG
- Preview renderiza md via /api/render; links não navegam fora do app
- lista: MediumTopAppBar, chips de filtro (categoria com caminho da árvore e tags),
  cards com status ☁/●/🗑/ e 👥, FAB estendido; telas em Material 3 dynamic color

Testado no emulador Android 15: título-only sync ✅, SSE ao vivo ✅, WYSIWYG→servidor ✅,
👥 presença ✅, conflito 409 com cópia única ✅
2026-11-04 20:52:59 -03:00

353 lines
15 KiB
Kotlin

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()
}
}