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 ✅
This commit is contained in:
1 parent
9e0bcc3e9b
commit
61d4abebbd
12 files changed
+1096
-154
No files matched your search
@@ -9,7 +9,7 @@ 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 com.labexposed.notas.data.remote.toNoteEntity
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -37,6 +37,7 @@ class SyncEngine(
|
||||
private val db: NotasDatabase,
|
||||
private val settings: SettingsRepository,
|
||||
private val status: SyncStatusStore,
|
||||
private val session: String = "android",
|
||||
) {
|
||||
|
||||
private suspend fun api(): ApiClient? {
|
||||
@@ -106,6 +107,21 @@ class SyncEngine(
|
||||
// ------------------------------------------------------------------ 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
|
||||
@@ -152,6 +168,12 @@ class SyncEngine(
|
||||
),
|
||||
)
|
||||
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)
|
||||
@@ -316,23 +338,10 @@ class SyncEngine(
|
||||
author = author,
|
||||
message = "sync android",
|
||||
baseHash = serverHash,
|
||||
session = this@SyncEngine.session,
|
||||
)
|
||||
|
||||
private fun NoteDetailDto.toEntity() = NoteEntity(
|
||||
id = id,
|
||||
title = title,
|
||||
format = format,
|
||||
categories = (categories ?: emptyList()).joinToString(","),
|
||||
tags = (tags ?: emptyList()).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 NoteDetailDto.toEntity() = toNoteEntity()
|
||||
|
||||
private fun describe(r: ApiResult<*>): String = when (r) {
|
||||
is ApiResult.Server -> "HTTP ${r.code} ${r.message}"
|
||||
|
||||
Reference in new issue
Block a user