feat: cliente Android offline-first (Kotlin + Jetpack Compose)

- Room (notes/outbox/categories/meta): edição 100% local, fila FIFO de mutações
- SyncEngine bidirecional: push (create/update com base_hash/delete, rekey de id
  local→servidor) + pull (categorias, notas divergentes, exclusões remotas)
- Conflito 409: servidor vence a nota + texto local preservado em cópia
  "Título (conflito)" enfileirada como create — nada perdido, sem relógios
- WorkManager: periódico 1h (rede + backoff exponencial), runNow único REPLACE
  em gatilhos (pós-save, rede restabelecida, boot, config, botão manual)
- OkHttp com timeout/retry em IOException; kotlinx.serialization; DataStore
- UI Compose: tela Servidor (URL+token+teste), Lista com banner de status
  (Offline/Sincronizando/Sincronizado/Erro/401 + pendências + conflitos),
  Editor com autosave por ociosidade (10 s) e categorias multi-seleção
- Build validado: assembleDebug (AGP 8.7.3/Kotlin 2.0.21/SDK 35, JDK 21) —
  app-debug.apk 17,6 MB; gradle-wrapper.jar fora do commit (regenerável com
  `gradle wrapper`, documentado no README)
This commit is contained in:
lgcosta committed 2026-11-04 17:55:50 -03:00
commit 2f63c7a2f5
30 files changed
+2096

No files matched your search

@@ -0,0 +1,319 @@
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 java.time.Instant
import java.util.UUID
import kotlinx.coroutines.Dispatchers
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 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) {
val client = api()
if (client == null) {
status.set { it.copy(phase = SyncPhase.NotConfigured) }
return@withContext 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@withContext push
}
if (push is SyncOutcome.NetworkDown) {
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
return@withContext push
}
val pull = pull(client)
if (pull is SyncOutcome.Unauthorized) {
status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") }
return@withContext pull
}
if (pull is SyncOutcome.NetworkDown) {
status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) }
return@withContext 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,
)
}
SyncOutcome.Success(ok.first.pushed, ok.second.pulled, conflicts)
}
// ------------------------------------------------------------------ push
private suspend fun push(client: ApiClient, author: String): SyncOutcome {
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)
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
val copyId = "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 = now,
updatedAt = now,
),
)
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(","),
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,
),
)
}
// ------------------------------------------------------------------ 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,
)
private fun NoteDetailDto.toEntity() = NoteEntity(
id = id,
title = title,
format = format,
categories = categories.joinToString(","),
tags = tags.joinToString(","),
content = content,
serverHash = hash,
createdAt = parseMillis(createdAt),
updatedAt = parseMillis(updatedAt),
dirty = false,
)
private fun parseMillis(iso: String): Long =
runCatching { Instant.parse(iso).toEpochMilli() }.getOrElse { System.currentTimeMillis() }
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()
}
}