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
|
||||
|
||||
- `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)
|
||||
|
||||
|
||||
Vendored
+15
-4
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user