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 ✅
267 lines
11 KiB
Kotlin
267 lines
11 KiB
Kotlin
package com.labexposed.notas.ui
|
|
|
|
import android.app.Application
|
|
import androidx.lifecycle.AndroidViewModel
|
|
import androidx.lifecycle.viewModelScope
|
|
import com.labexposed.notas.data.local.NotasDatabase
|
|
import com.labexposed.notas.data.local.NoteEntity
|
|
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.domain.Settings
|
|
import com.labexposed.notas.domain.SettingsRepository
|
|
import com.labexposed.notas.domain.SyncStatusStore
|
|
import com.labexposed.notas.domain.SyncUiState
|
|
import com.labexposed.notas.sync.SyncScheduler
|
|
import android.content.Context
|
|
import java.util.UUID
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.SharingStarted
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
import kotlinx.coroutines.flow.combine
|
|
import kotlinx.coroutines.flow.stateIn
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
import com.labexposed.notas.data.remote.SseClient
|
|
import com.labexposed.notas.data.remote.SseEvent
|
|
import com.labexposed.notas.data.remote.toNoteEntity
|
|
|
|
sealed class ListEvent {
|
|
data class ConnectionOk(val notes: Int) : ListEvent()
|
|
data class ConnectionFail(val message: String) : ListEvent()
|
|
data class RemoteChange(val title: String) : ListEvent()
|
|
}
|
|
|
|
class NotasViewModel(app: Application) : AndroidViewModel(app) {
|
|
|
|
private val db = NotasDatabase.get(app)
|
|
private val settingsRepo = SettingsRepository(app)
|
|
|
|
val settings: StateFlow<Settings> = settingsRepo.flow
|
|
.stateIn(viewModelScope, SharingStarted.Eagerly, Settings())
|
|
|
|
val notes: StateFlow<List<NoteEntity>> = db.notes().observeAll()
|
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
|
|
|
val categories: StateFlow<List<com.labexposed.notas.data.local.CategoryEntity>> =
|
|
db.categories().observeAll()
|
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
|
|
|
val syncState: StateFlow<SyncUiState> = combine(
|
|
SyncStatusStore.state,
|
|
db.outbox().observeCount(),
|
|
db.notes().observePending(),
|
|
) { state, outbox, dirty ->
|
|
state.copy(pendingCount = outbox, dirtyCount = dirty)
|
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SyncUiState.Initial)
|
|
|
|
private val _events = MutableStateFlow<ListEvent?>(null)
|
|
val events: StateFlow<ListEvent?> = _events
|
|
|
|
/** id de sessão estável por instalação (mesmo valor usado no presence/SSE). */
|
|
val sid: String = com.labexposed.notas.domain.SessionId.get(getApplication<android.app.Application>())
|
|
|
|
private var sseJob: Job? = null
|
|
private var sseApi: ApiClient? = null
|
|
|
|
/** noteId → nº de outras sessões com a nota aberta (presença). */
|
|
val presence = MutableStateFlow<Map<String, Int>>(emptyMap())
|
|
|
|
/** notas sujas que mudaram remotamente (banner no editor). */
|
|
val remoteChanged = MutableStateFlow<Set<String>>(emptySet())
|
|
|
|
/** id da nota atualmente aberta no editor (para refresh ao vivo via SSE). */
|
|
@Volatile
|
|
var openNoteId: String? = null
|
|
|
|
/** conteúdo novo para o editor aberto (consumido e limpo pela UI). */
|
|
val editorRefresh = MutableStateFlow<Pair<String, String>?>(null)
|
|
|
|
fun onResumed() {
|
|
viewModelScope.launch {
|
|
val s = settingsRepo.current()
|
|
if (!s.configured) return@launch
|
|
if (sseJob?.isActive != true) {
|
|
val client = SseClient(s.serverUrl, s.token, sid)
|
|
sseApi = ApiClient(s.serverUrl, s.token)
|
|
sseJob = client.connect(viewModelScope) { ev -> handleSse(ev) }
|
|
}
|
|
SyncScheduler.runNow(getApplication(), "resume")
|
|
}
|
|
}
|
|
|
|
fun onPaused() {
|
|
sseJob?.cancel()
|
|
sseJob = null
|
|
}
|
|
|
|
private fun handleSse(ev: SseEvent) = viewModelScope.launch(Dispatchers.IO) {
|
|
when (ev) {
|
|
is SseEvent.Presence -> {
|
|
val map = mutableMapOf<String, Int>()
|
|
for (s in ev.sessions) {
|
|
if (s.session == sid) continue
|
|
for (n in s.notes ?: emptyList()) map[n] = (map[n] ?: 0) + 1
|
|
}
|
|
presence.value = map
|
|
}
|
|
is SseEvent.Saved -> {
|
|
if (ev.session == sid) return@launch
|
|
val note = db.notes().byId(ev.noteId) ?: return@launch
|
|
if (ev.deleted) {
|
|
if (!note.dirty) {
|
|
db.notes().delete(ev.noteId)
|
|
db.outbox().dropFor(ev.noteId)
|
|
_events.value = ListEvent.RemoteChange(ev.title)
|
|
}
|
|
return@launch
|
|
}
|
|
if (note.serverHash == ev.hash) return@launch
|
|
if (!note.dirty) {
|
|
val detail = sseApi?.getNote(ev.noteId)
|
|
if (detail is ApiResult.Ok) {
|
|
db.notes().upsert(detail.value.toNoteEntity())
|
|
_events.value = ListEvent.RemoteChange(ev.title)
|
|
if (openNoteId == ev.noteId || db.meta().get("rekey:$openNoteId") == ev.noteId) {
|
|
openNoteId?.let { editorRefresh.value = it to detail.value.content }
|
|
}
|
|
}
|
|
} else {
|
|
remoteChanged.value = remoteChanged.value + ev.noteId
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Nota abriu e mudou remotamente: recarrega do servidor sob demanda. */
|
|
fun reloadFromServer(id: String, then: ((NoteEntity?) -> Unit)? = null) = viewModelScope.launch(Dispatchers.IO) {
|
|
val detail = sseApi?.getNote(id)
|
|
if (detail is ApiResult.Ok) {
|
|
db.notes().upsert(detail.value.toNoteEntity())
|
|
remoteChanged.value = remoteChanged.value - id
|
|
}
|
|
then?.invoke(db.notes().byId(id))
|
|
}
|
|
|
|
fun dismissRemoteChange(id: String) {
|
|
remoteChanged.value = remoteChanged.value - id
|
|
}
|
|
|
|
fun renderMarkdown(content: String, onResult: (String) -> Unit) = viewModelScope.launch(Dispatchers.IO) {
|
|
val html = sseApi?.renderMd(content)
|
|
if (html is ApiResult.Ok) onResult(html.value) else onResult("<p>…sem conexão para renderizar…</p>")
|
|
}
|
|
|
|
fun saveSettings(s: Settings) = viewModelScope.launch {
|
|
settingsRepo.save(s)
|
|
sseApi = if (s.configured) ApiClient(s.serverUrl, s.token) else null
|
|
onPaused(); onResumed()
|
|
SyncScheduler.runNow(getApplication(), "config")
|
|
}
|
|
|
|
fun testConnection(s: Settings) = viewModelScope.launch(Dispatchers.IO) {
|
|
val result = ApiClient(s.serverUrl, s.token).testConnection()
|
|
_events.value = when (result) {
|
|
is ApiResult.Ok -> ListEvent.ConnectionOk(result.value.size)
|
|
is ApiResult.Unauthorized -> ListEvent.ConnectionFail("token inválido (401)")
|
|
is ApiResult.Network -> ListEvent.ConnectionFail("sem conexão: ${result.message}")
|
|
else -> ListEvent.ConnectionFail(result.toString())
|
|
}
|
|
}
|
|
|
|
fun consumeEvent() { _events.value = null }
|
|
|
|
fun syncNow() {
|
|
SyncScheduler.runNow(getApplication(), "manual")
|
|
}
|
|
|
|
fun createLocal(): String {
|
|
val id = "L" + UUID.randomUUID().toString().replace("-", "").substring(0, 12)
|
|
viewModelScope.launch(Dispatchers.IO) {
|
|
val now = System.currentTimeMillis()
|
|
db.notes().upsert(
|
|
NoteEntity(
|
|
id = id, title = "", format = "md", content = "",
|
|
createdAt = now, updatedAt = now, dirty = true,
|
|
),
|
|
)
|
|
// exatamente um create na fila; autosaves seguintes não re-enfileiram
|
|
if (!db.outbox().has(id, "create")) {
|
|
db.outbox().enqueue(OutboxEntity(noteId = id, op = "create", queuedAt = now))
|
|
}
|
|
}
|
|
return id
|
|
}
|
|
|
|
/** Nota por id, seguindo rekeys de create (id local → id do servidor). */
|
|
private suspend fun resolve(id: String): String? {
|
|
if (db.notes().byId(id) != null) return id
|
|
val mapped = db.meta().get("rekey:$id") ?: return null
|
|
return if (db.notes().byId(mapped) != null) mapped else null
|
|
}
|
|
|
|
/** Salva edição local (conteúdo OU metadados) + enfileira + agenda sync. */
|
|
suspend fun saveNoteSync(
|
|
id: String,
|
|
title: String,
|
|
format: String,
|
|
categories: List<String>,
|
|
content: String,
|
|
) {
|
|
val targetId = resolve(id) ?: return
|
|
val existing = db.notes().byId(targetId) ?: return
|
|
val now = System.currentTimeMillis()
|
|
val contentChanged = existing.content != content
|
|
val metaChanged = existing.title != title || existing.format != format ||
|
|
existing.categories != categories.joinToString(",")
|
|
db.notes().upsert(
|
|
existing.copy(
|
|
id = targetId,
|
|
title = title,
|
|
format = format,
|
|
categories = categories.joinToString(","),
|
|
content = content,
|
|
updatedAt = now,
|
|
dirty = true,
|
|
conflictNote = "",
|
|
),
|
|
)
|
|
if (contentChanged || metaChanged) {
|
|
// nota em create pendente já está na fila com o conteúdo atual (upsert acima)
|
|
if (!db.outbox().has(targetId, "create") && !db.outbox().has(targetId, "update")) {
|
|
db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "update", queuedAt = now))
|
|
}
|
|
SyncScheduler.runNow(getApplication(), "autosave")
|
|
}
|
|
}
|
|
|
|
fun saveNote(
|
|
id: String,
|
|
title: String,
|
|
format: String,
|
|
categories: List<String>,
|
|
content: String,
|
|
) = viewModelScope.launch(Dispatchers.IO) {
|
|
saveNoteSync(id, title, format, categories, content)
|
|
}
|
|
|
|
fun deleteNote(id: String) = viewModelScope.launch(Dispatchers.IO) {
|
|
val targetId = resolve(id) ?: return@launch
|
|
val note = db.notes().byId(targetId) ?: return@launch
|
|
if (note.isLocalOnly) {
|
|
db.notes().delete(targetId)
|
|
db.outbox().dropFor(targetId)
|
|
} else {
|
|
db.notes().upsert(note.copy(pendingDelete = true, dirty = true, updatedAt = System.currentTimeMillis()))
|
|
db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "delete", queuedAt = System.currentTimeMillis()))
|
|
SyncScheduler.runNow(getApplication(), "delete")
|
|
}
|
|
}
|
|
|
|
suspend fun noteById(id: String): NoteEntity? = withContext(Dispatchers.IO) {
|
|
db.notes().byId(id)
|
|
}
|
|
}
|