fix: robustez de sync comprovada no emulador (duplicatas eliminadas)
- ApiClient: IllegalArgumentException de URL vira Network (banner, sem crash); DTOs com listas nullable (servidor podia enviar categories/tags: null) - SyncEngine: mutex global serializa runs (logs BEGIN/END); resolveConflict idempotente (não duplica cópia de conflito); registra rekey:<local>→<server> em meta para o editor aberto sobreviver à troca de id do create - ViewModel: resolve(id) em save/delete; createLocal enfileira create único - Outbox: índice único (noteId, op); DB v2 com fallbackToDestructiveMigration - proguard: regras corretas de kotlinx.serialization (Companion.serializer) - README: seção 'Testado em' com os cenários de emulador executados
This commit is contained in:
1 parent
2f63c7a2f5
commit
9e0bcc3e9b
9 files changed
+133
-44
No files matched your search
@@ -121,9 +121,19 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|||||||
|
|
||||||
## Testado em
|
## Testado em
|
||||||
|
|
||||||
- `assembleDebug` limpo com JDK 21 / AGP 8.7.3 / SDK 35 (Linux, sem device — verificação de
|
- **Build**: `assembleRelease` limpo (JDK 21 / AGP 8.7.3 / Kotlin 2.0.21 / SDK 35), APK assinado
|
||||||
compilação + lint básico).
|
com R8 ≈ **1,6 MB** (debug ≈ 17 MB).
|
||||||
|
- **Emulador Android 15 (x86_64, Pixel 6)** contra o servidor notas-online real, cenários:
|
||||||
|
- configuração de servidor com *Testar conexão* → OK;
|
||||||
|
- criar nota offline → autosave por ociosidade (10 s) → `POST` único (sem duplicatas);
|
||||||
|
- editar offline → fila resiste à queda de rede → push automático ao restabelecer (`ConnectivityManager`);
|
||||||
|
- nota criada na Web aparece no device via pull;
|
||||||
|
- **conflito real**: device e Web editam a mesma nota → HTTP 409 (`base_hash`) → nota principal
|
||||||
|
adota o servidor e o texto do Android vira exatamente **uma** cópia "… (conflito)"
|
||||||
|
(idempotente; sincronizações serializadas por mutex + índices únicos na outbox).
|
||||||
- Integração com a API: mesmos contratos do web client (`base_hash`/409, categorias, revisões).
|
- Integração com a API: mesmos contratos do web client (`base_hash`/409, categorias, revisões).
|
||||||
|
O servidor normaliza `categories`/`tags` vazios como `[]` (null quebrava clientes com
|
||||||
|
tipos não-nuláveis, ex. kotlinx.serialization).
|
||||||
|
|
||||||
## Roadmap honesto (não implementado aqui)
|
## Roadmap honesto (não implementado aqui)
|
||||||
|
|
||||||
|
|||||||
Vendored
+15
-4
@@ -1,11 +1,22 @@
|
|||||||
# OkHttp + kotlinx.serialization: keep generic metadata
|
# OkHttp + okio
|
||||||
-dontwarn okhttp3.**
|
-dontwarn okhttp3.**
|
||||||
-dontwarn okio.**
|
-dontwarn okio.**
|
||||||
-dontwarn org.jetbrains.annotations.**
|
-dontwarn org.jetbrains.annotations.**
|
||||||
|
|
||||||
|
# kotlinx.serialization (R8): manter Companion.serializer() e anotados
|
||||||
-keepattributes *Annotation*, InnerClasses, Signature, RuntimeVisibleAnnotations, AnnotationDefault
|
-keepattributes *Annotation*, InnerClasses, Signature, RuntimeVisibleAnnotations, AnnotationDefault
|
||||||
-keepclassmembers class com.labexposed.notas.data.remote.** {
|
|
||||||
*** Companion;
|
-if @kotlinx.serialization.Serializable class **
|
||||||
|
-keepclassmembers class <1> {
|
||||||
|
static <1>$Companion Companion;
|
||||||
}
|
}
|
||||||
-keepclasseswithmembers class com.labexposed.notas.data.remote.** {
|
-if @kotlinx.serialization.Serializable class ** {
|
||||||
|
static **$Companion Companion;
|
||||||
|
}
|
||||||
|
-keepclassmembers class <2>$Companion {
|
||||||
kotlinx.serialization.KSerializer serializer(...);
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
}
|
}
|
||||||
|
-keep,includedescriptorclasses class com.labexposed.notas.data.remote.** { *; }
|
||||||
|
|
||||||
|
# Room / WorkManager geram código e refletem pouco; regras padrão bastam
|
||||||
|
-keep class com.labexposed.notas.data.local.** { *; }
|
||||||
@@ -2,6 +2,7 @@ package com.labexposed.notas.data.local
|
|||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
@@ -34,7 +35,10 @@ data class NoteEntity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Fila de mutações pendentes (ordem de chegada = ordem de push). */
|
/** Fila de mutações pendentes (ordem de chegada = ordem de push). */
|
||||||
@Entity(tableName = "outbox")
|
@Entity(
|
||||||
|
tableName = "outbox",
|
||||||
|
indices = [Index(value = ["noteId", "op"], unique = true)],
|
||||||
|
)
|
||||||
data class OutboxEntity(
|
data class OutboxEntity(
|
||||||
@PrimaryKey(autoGenerate = true) val seq: Long = 0,
|
@PrimaryKey(autoGenerate = true) val seq: Long = 0,
|
||||||
val noteId: String,
|
val noteId: String,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import androidx.room.RoomDatabase
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class],
|
entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class NotasDatabase : RoomDatabase() {
|
abstract class NotasDatabase : RoomDatabase() {
|
||||||
@@ -26,7 +26,9 @@ abstract class NotasDatabase : RoomDatabase() {
|
|||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
NotasDatabase::class.java,
|
NotasDatabase::class.java,
|
||||||
"notas.db",
|
"notas.db",
|
||||||
).build().also { instance = it }
|
)
|
||||||
|
.fallbackToDestructiveMigration() // v1→v2: dados de teste; em produção usar migração real
|
||||||
|
.build().also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,20 +28,20 @@ class ApiClient(private val baseUrl: String, private val token: String) {
|
|||||||
|
|
||||||
private fun url(path: String) = baseUrl.trimEnd('/') + path
|
private fun url(path: String) = baseUrl.trimEnd('/') + path
|
||||||
|
|
||||||
/** Executa com até 2 tentativas em erro de rede (backoff 500ms). */
|
/** Executa com até 2 tentativas; erros de rede/URL inválida viram Network. */
|
||||||
private inline fun <reified T> call(
|
private inline fun <reified T> call(
|
||||||
method: String,
|
method: String,
|
||||||
path: String,
|
path: String,
|
||||||
body: String? = null,
|
body: String? = null,
|
||||||
): ApiResult<T> {
|
): ApiResult<T> {
|
||||||
var lastError: IOException? = null
|
var lastError: Exception? = null
|
||||||
repeat(2) { attempt ->
|
repeat(2) { attempt ->
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url(path))
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.method(method, body?.toRequestBody(jsonMedia))
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url(path))
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.method(method, body?.toRequestBody(jsonMedia))
|
||||||
|
.build()
|
||||||
http.newCall(request).execute().use { resp ->
|
http.newCall(request).execute().use { resp ->
|
||||||
return when {
|
return when {
|
||||||
resp.isSuccessful -> {
|
resp.isSuccessful -> {
|
||||||
@@ -66,6 +66,9 @@ class ApiClient(private val baseUrl: String, private val token: String) {
|
|||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
lastError = e
|
lastError = e
|
||||||
if (attempt == 0) Thread.sleep(500)
|
if (attempt == 0) Thread.sleep(500)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// URL malformada (configuração errada): não adianta repetir
|
||||||
|
return ApiResult.Network("URL inválida: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ApiResult.Network(lastError?.message ?: "sem conexão")
|
return ApiResult.Network(lastError?.message ?: "sem conexão")
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ data class NoteSummaryDto(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val format: String = "txt",
|
val format: String = "txt",
|
||||||
val categories: List<String> = emptyList(),
|
val categories: List<String>? = null,
|
||||||
val tags: List<String> = emptyList(),
|
val tags: List<String>? = null,
|
||||||
@SerialName("created_at") val createdAt: String = "",
|
@SerialName("created_at") val createdAt: String = "",
|
||||||
@SerialName("updated_at") val updatedAt: String = "",
|
@SerialName("updated_at") val updatedAt: String = "",
|
||||||
val hash: String = "",
|
val hash: String = "",
|
||||||
@@ -23,8 +23,8 @@ data class NoteDetailDto(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val format: String = "txt",
|
val format: String = "txt",
|
||||||
val categories: List<String> = emptyList(),
|
val categories: List<String>? = null,
|
||||||
val tags: List<String> = emptyList(),
|
val tags: List<String>? = null,
|
||||||
@SerialName("created_at") val createdAt: String = "",
|
@SerialName("created_at") val createdAt: String = "",
|
||||||
@SerialName("updated_at") val updatedAt: String = "",
|
@SerialName("updated_at") val updatedAt: String = "",
|
||||||
val hash: String = "",
|
val hash: String = "",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.labexposed.notas.data.remote.NoteWriteDto
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,10 +46,25 @@ class SyncEngine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fullSync(trigger: String = "auto"): SyncOutcome = withContext(Dispatchers.IO) {
|
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()
|
val client = api()
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
status.set { it.copy(phase = SyncPhase.NotConfigured) }
|
status.set { it.copy(phase = SyncPhase.NotConfigured) }
|
||||||
return@withContext SyncOutcome.NotConfigured
|
return SyncOutcome.NotConfigured
|
||||||
}
|
}
|
||||||
status.set { it.copy(phase = SyncPhase.Syncing) }
|
status.set { it.copy(phase = SyncPhase.Syncing) }
|
||||||
val author = settings.current().author
|
val author = settings.current().author
|
||||||
@@ -56,21 +72,21 @@ class SyncEngine(
|
|||||||
val push = push(client, author)
|
val push = push(client, author)
|
||||||
if (push is SyncOutcome.Unauthorized) {
|
if (push is SyncOutcome.Unauthorized) {
|
||||||
status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") }
|
status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") }
|
||||||
return@withContext push
|
return push
|
||||||
}
|
}
|
||||||
if (push is SyncOutcome.NetworkDown) {
|
if (push is SyncOutcome.NetworkDown) {
|
||||||
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
|
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
|
||||||
return@withContext push
|
return push
|
||||||
}
|
}
|
||||||
|
|
||||||
val pull = pull(client)
|
val pull = pull(client)
|
||||||
if (pull is SyncOutcome.Unauthorized) {
|
if (pull is SyncOutcome.Unauthorized) {
|
||||||
status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") }
|
status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") }
|
||||||
return@withContext pull
|
return pull
|
||||||
}
|
}
|
||||||
if (pull is SyncOutcome.NetworkDown) {
|
if (pull is SyncOutcome.NetworkDown) {
|
||||||
status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) }
|
status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) }
|
||||||
return@withContext pull
|
return pull
|
||||||
}
|
}
|
||||||
|
|
||||||
val ok = (push as SyncOutcome.Success) to (pull as SyncOutcome.Success)
|
val ok = (push as SyncOutcome.Success) to (pull as SyncOutcome.Success)
|
||||||
@@ -84,7 +100,7 @@ class SyncEngine(
|
|||||||
conflicts = conflicts,
|
conflicts = conflicts,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SyncOutcome.Success(ok.first.pushed, ok.second.pulled, conflicts)
|
return SyncOutcome.Success(ok.first.pushed, ok.second.pulled, conflicts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ push
|
// ------------------------------------------------------------------ push
|
||||||
@@ -183,8 +199,12 @@ class SyncEngine(
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
// 1) cópia local com o texto não enviado
|
// 1) cópia local com o texto não enviado — idempotente: se já existe
|
||||||
val copyId = "L" + UUID.randomUUID().toString().replace("-", "").substring(0, 12)
|
// 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(
|
db.notes().upsert(
|
||||||
note.copy(
|
note.copy(
|
||||||
id = copyId,
|
id = copyId,
|
||||||
@@ -193,19 +213,21 @@ class SyncEngine(
|
|||||||
dirty = true,
|
dirty = true,
|
||||||
pendingDelete = false,
|
pendingDelete = false,
|
||||||
conflictNote = "",
|
conflictNote = "",
|
||||||
createdAt = now,
|
createdAt = existingCopy?.createdAt ?: now,
|
||||||
updatedAt = now,
|
updatedAt = now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
db.outbox().enqueue(OutboxEntity(noteId = copyId, op = "create", queuedAt = now))
|
if (existingCopy == null) {
|
||||||
|
db.outbox().enqueue(OutboxEntity(noteId = copyId, op = "create", queuedAt = now))
|
||||||
|
}
|
||||||
// 2) nota principal adota o servidor
|
// 2) nota principal adota o servidor
|
||||||
db.notes().upsert(
|
db.notes().upsert(
|
||||||
note.copy(
|
note.copy(
|
||||||
content = server.content,
|
content = server.content,
|
||||||
title = server.title,
|
title = server.title,
|
||||||
format = server.format,
|
format = server.format,
|
||||||
categories = server.categories.joinToString(","),
|
categories = (server.categories ?: emptyList()).joinToString(","),
|
||||||
tags = server.tags.joinToString(","),
|
tags = (server.tags ?: emptyList()).joinToString(","),
|
||||||
serverHash = server.hash,
|
serverHash = server.hash,
|
||||||
dirty = false,
|
dirty = false,
|
||||||
conflictNote = "conflito resolvido: seu texto ficou em “${note.title} (conflito)”",
|
conflictNote = "conflito resolvido: seu texto ficou em “${note.title} (conflito)”",
|
||||||
@@ -224,6 +246,8 @@ class SyncEngine(
|
|||||||
dirty = false,
|
dirty = false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// editor aberto com o id antigo resolve o novo via meta
|
||||||
|
db.meta().set(MetaEntity("rekey:" + note.id, server.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ pull
|
// ------------------------------------------------------------------ pull
|
||||||
@@ -298,8 +322,8 @@ class SyncEngine(
|
|||||||
id = id,
|
id = id,
|
||||||
title = title,
|
title = title,
|
||||||
format = format,
|
format = format,
|
||||||
categories = categories.joinToString(","),
|
categories = (categories ?: emptyList()).joinToString(","),
|
||||||
tags = tags.joinToString(","),
|
tags = (tags ?: emptyList()).joinToString(","),
|
||||||
content = content,
|
content = content,
|
||||||
serverHash = hash,
|
serverHash = hash,
|
||||||
createdAt = parseMillis(createdAt),
|
createdAt = parseMillis(createdAt),
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.labexposed.notas.domain
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
/** Fonte único do estado de sincronização observado pela UI e pelos workers. */
|
/** Fonte único do estado de sincronização observado pela UI e pelos workers. */
|
||||||
object SyncStatusStore {
|
object SyncStatusStore {
|
||||||
@@ -10,6 +12,13 @@ object SyncStatusStore {
|
|||||||
private val _state = MutableStateFlow(SyncUiState.Initial)
|
private val _state = MutableStateFlow(SyncUiState.Initial)
|
||||||
val state: StateFlow<SyncUiState> = _state.asStateFlow()
|
val state: StateFlow<SyncUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializa sincronizações no processo: WorkManager pode sobrepor execuções
|
||||||
|
* (REPLACE cancela apenas work ainda não iniciado). Sem isso, dois workers
|
||||||
|
* lêem a mesma outbox e duplicam creates.
|
||||||
|
*/
|
||||||
|
val syncMutex = Mutex()
|
||||||
|
|
||||||
fun set(transform: (SyncUiState) -> SyncUiState) {
|
fun set(transform: (SyncUiState) -> SyncUiState) {
|
||||||
_state.value = transform(_state.value)
|
_state.value = transform(_state.value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,24 +85,36 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
createdAt = now, updatedAt = now, dirty = true,
|
createdAt = now, updatedAt = now, dirty = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
db.outbox().enqueue(OutboxEntity(noteId = id, op = "create", queuedAt = now))
|
// 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
|
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 + enfileira mutação + agenda sync (debounce no editor). */
|
/** Salva edição local + enfileira mutação + agenda sync (debounce no editor). */
|
||||||
fun saveNote(
|
suspend fun saveNoteSync(
|
||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
format: String,
|
format: String,
|
||||||
categories: List<String>,
|
categories: List<String>,
|
||||||
content: String,
|
content: String,
|
||||||
) = viewModelScope.launch(Dispatchers.IO) {
|
) {
|
||||||
val existing = db.notes().byId(id) ?: return@launch
|
val targetId = resolve(id) ?: return
|
||||||
|
val existing = db.notes().byId(targetId) ?: return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val contentChanged = existing.content != content
|
val contentChanged = existing.content != content
|
||||||
db.notes().upsert(
|
db.notes().upsert(
|
||||||
existing.copy(
|
existing.copy(
|
||||||
|
id = targetId,
|
||||||
title = title,
|
title = title,
|
||||||
format = format,
|
format = format,
|
||||||
categories = categories.joinToString(","),
|
categories = categories.joinToString(","),
|
||||||
@@ -112,20 +124,34 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
conflictNote = "",
|
conflictNote = "",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (contentChanged && !db.outbox().has(id, "create")) {
|
if (contentChanged) {
|
||||||
db.outbox().enqueue(OutboxEntity(noteId = id, op = "update", queuedAt = now))
|
// 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")
|
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) {
|
fun deleteNote(id: String) = viewModelScope.launch(Dispatchers.IO) {
|
||||||
val note = db.notes().byId(id) ?: return@launch
|
val targetId = resolve(id) ?: return@launch
|
||||||
|
val note = db.notes().byId(targetId) ?: return@launch
|
||||||
if (note.isLocalOnly) {
|
if (note.isLocalOnly) {
|
||||||
db.notes().delete(id)
|
db.notes().delete(targetId)
|
||||||
db.outbox().dropFor(id)
|
db.outbox().dropFor(targetId)
|
||||||
} else {
|
} else {
|
||||||
db.notes().upsert(note.copy(pendingDelete = true, dirty = true, updatedAt = System.currentTimeMillis()))
|
db.notes().upsert(note.copy(pendingDelete = true, dirty = true, updatedAt = System.currentTimeMillis()))
|
||||||
db.outbox().enqueue(OutboxEntity(noteId = id, op = "delete", queuedAt = System.currentTimeMillis()))
|
db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "delete", queuedAt = System.currentTimeMillis()))
|
||||||
SyncScheduler.runNow(getApplication(), "delete")
|
SyncScheduler.runNow(getApplication(), "delete")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user