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 ✅
353 lines
15 KiB
Kotlin
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()
|
|
}
|
|
}
|