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:
lgcosta committed 2026-11-04 19:28:02 -03:00
1 parent 2f63c7a2f5
commit 9e0bcc3e9b
9 files changed
+133 -44

No files matched your search

+12 -2
View File
@@ -121,9 +121,19 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
## Testado em
- `assembleDebug` limpo com JDK 21 / AGP 8.7.3 / SDK 35 (Linux, sem device — verificação de
compilação + lint básico).
- **Build**: `assembleRelease` limpo (JDK 21 / AGP 8.7.3 / Kotlin 2.0.21 / SDK 35), APK assinado
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).
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)
+15 -4
View File
@@ -1,11 +1,22 @@
# OkHttp + kotlinx.serialization: keep generic metadata
# OkHttp + okio
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn org.jetbrains.annotations.**
# kotlinx.serialization (R8): manter Companion.serializer() e anotados
-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(...);
}
-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.Entity
import androidx.room.Index
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
@@ -34,7 +35,10 @@ data class NoteEntity(
}
/** 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(
@PrimaryKey(autoGenerate = true) val seq: Long = 0,
val noteId: String,
@@ -7,7 +7,7 @@ import androidx.room.RoomDatabase
@Database(
entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class],
version = 1,
version = 2,
exportSchema = false,
)
abstract class NotasDatabase : RoomDatabase() {
@@ -26,7 +26,9 @@ abstract class NotasDatabase : RoomDatabase() {
context.applicationContext,
NotasDatabase::class.java,
"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
/** 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(
method: String,
path: String,
body: String? = null,
): ApiResult<T> {
var lastError: IOException? = null
var lastError: Exception? = null
repeat(2) { attempt ->
val request = Request.Builder()
.url(url(path))
.header("Authorization", "Bearer $token")
.method(method, body?.toRequestBody(jsonMedia))
.build()
try {
val request = Request.Builder()
.url(url(path))
.header("Authorization", "Bearer $token")
.method(method, body?.toRequestBody(jsonMedia))
.build()
http.newCall(request).execute().use { resp ->
return when {
resp.isSuccessful -> {
@@ -66,6 +66,9 @@ class ApiClient(private val baseUrl: String, private val token: String) {
} catch (e: IOException) {
lastError = e
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")
@@ -10,8 +10,8 @@ data class NoteSummaryDto(
val id: String,
val title: String = "",
val format: String = "txt",
val categories: List<String> = emptyList(),
val tags: List<String> = emptyList(),
val categories: List<String>? = null,
val tags: List<String>? = null,
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
val hash: String = "",
@@ -23,8 +23,8 @@ data class NoteDetailDto(
val id: String,
val title: String = "",
val format: String = "txt",
val categories: List<String> = emptyList(),
val tags: List<String> = emptyList(),
val categories: List<String>? = null,
val tags: List<String>? = null,
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
val hash: String = "",
@@ -12,6 +12,7 @@ import com.labexposed.notas.data.remote.NoteWriteDto
import java.time.Instant
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
@@ -45,10 +46,25 @@ class SyncEngine(
}
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()
if (client == null) {
status.set { it.copy(phase = SyncPhase.NotConfigured) }
return@withContext SyncOutcome.NotConfigured
return SyncOutcome.NotConfigured
}
status.set { it.copy(phase = SyncPhase.Syncing) }
val author = settings.current().author
@@ -56,21 +72,21 @@ class SyncEngine(
val push = push(client, author)
if (push is SyncOutcome.Unauthorized) {
status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") }
return@withContext push
return push
}
if (push is SyncOutcome.NetworkDown) {
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
return@withContext push
return push
}
val pull = pull(client)
if (pull is SyncOutcome.Unauthorized) {
status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") }
return@withContext pull
return pull
}
if (pull is SyncOutcome.NetworkDown) {
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)
@@ -84,7 +100,7 @@ class SyncEngine(
conflicts = conflicts,
)
}
SyncOutcome.Success(ok.first.pushed, ok.second.pulled, conflicts)
return SyncOutcome.Success(ok.first.pushed, ok.second.pulled, conflicts)
}
// ------------------------------------------------------------------ push
@@ -183,8 +199,12 @@ class SyncEngine(
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)
// 1) cópia local com o texto não enviado — idempotente: se já existe
// 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(
note.copy(
id = copyId,
@@ -193,19 +213,21 @@ class SyncEngine(
dirty = true,
pendingDelete = false,
conflictNote = "",
createdAt = now,
createdAt = existingCopy?.createdAt ?: 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
db.notes().upsert(
note.copy(
content = server.content,
title = server.title,
format = server.format,
categories = server.categories.joinToString(","),
tags = server.tags.joinToString(","),
categories = (server.categories ?: emptyList()).joinToString(","),
tags = (server.tags ?: emptyList()).joinToString(","),
serverHash = server.hash,
dirty = false,
conflictNote = "conflito resolvido: seu texto ficou em “${note.title} (conflito)”",
@@ -224,6 +246,8 @@ class SyncEngine(
dirty = false,
),
)
// editor aberto com o id antigo resolve o novo via meta
db.meta().set(MetaEntity("rekey:" + note.id, server.id))
}
// ------------------------------------------------------------------ pull
@@ -298,8 +322,8 @@ class SyncEngine(
id = id,
title = title,
format = format,
categories = categories.joinToString(","),
tags = tags.joinToString(","),
categories = (categories ?: emptyList()).joinToString(","),
tags = (tags ?: emptyList()).joinToString(","),
content = content,
serverHash = hash,
createdAt = parseMillis(createdAt),
@@ -3,6 +3,8 @@ package com.labexposed.notas.domain
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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. */
object SyncStatusStore {
@@ -10,6 +12,13 @@ object SyncStatusStore {
private val _state = MutableStateFlow(SyncUiState.Initial)
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) {
_state.value = transform(_state.value)
}
@@ -85,24 +85,36 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
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
}
/** 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). */
fun saveNote(
suspend fun saveNoteSync(
id: String,
title: String,
format: String,
categories: List<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 contentChanged = existing.content != content
db.notes().upsert(
existing.copy(
id = targetId,
title = title,
format = format,
categories = categories.joinToString(","),
@@ -112,20 +124,34 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
conflictNote = "",
),
)
if (contentChanged && !db.outbox().has(id, "create")) {
db.outbox().enqueue(OutboxEntity(noteId = id, op = "update", queuedAt = now))
if (contentChanged) {
// 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 note = db.notes().byId(id) ?: return@launch
val targetId = resolve(id) ?: return@launch
val note = db.notes().byId(targetId) ?: return@launch
if (note.isLocalOnly) {
db.notes().delete(id)
db.outbox().dropFor(id)
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 = id, op = "delete", queuedAt = System.currentTimeMillis()))
db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "delete", queuedAt = System.currentTimeMillis()))
SyncScheduler.runNow(getApplication(), "delete")
}
}