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