diff --git a/README.md b/README.md index 00e78d2..45e1aba 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e91ae34..4c927bd 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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.** { *; } diff --git a/app/src/main/java/com/labexposed/notas/data/local/Entities.kt b/app/src/main/java/com/labexposed/notas/data/local/Entities.kt index b74454f..7668df8 100644 --- a/app/src/main/java/com/labexposed/notas/data/local/Entities.kt +++ b/app/src/main/java/com/labexposed/notas/data/local/Entities.kt @@ -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, diff --git a/app/src/main/java/com/labexposed/notas/data/local/NotasDatabase.kt b/app/src/main/java/com/labexposed/notas/data/local/NotasDatabase.kt index f032eac..c293eb2 100644 --- a/app/src/main/java/com/labexposed/notas/data/local/NotasDatabase.kt +++ b/app/src/main/java/com/labexposed/notas/data/local/NotasDatabase.kt @@ -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 } } } } diff --git a/app/src/main/java/com/labexposed/notas/data/remote/ApiClient.kt b/app/src/main/java/com/labexposed/notas/data/remote/ApiClient.kt index 9d22ce5..4e529f8 100644 --- a/app/src/main/java/com/labexposed/notas/data/remote/ApiClient.kt +++ b/app/src/main/java/com/labexposed/notas/data/remote/ApiClient.kt @@ -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 call( method: String, path: String, body: String? = null, ): ApiResult { - 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") diff --git a/app/src/main/java/com/labexposed/notas/data/remote/Dtos.kt b/app/src/main/java/com/labexposed/notas/data/remote/Dtos.kt index 0d482f4..646477c 100644 --- a/app/src/main/java/com/labexposed/notas/data/remote/Dtos.kt +++ b/app/src/main/java/com/labexposed/notas/data/remote/Dtos.kt @@ -10,8 +10,8 @@ data class NoteSummaryDto( val id: String, val title: String = "", val format: String = "txt", - val categories: List = emptyList(), - val tags: List = emptyList(), + val categories: List? = null, + val tags: List? = 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 = emptyList(), - val tags: List = emptyList(), + val categories: List? = null, + val tags: List? = null, @SerialName("created_at") val createdAt: String = "", @SerialName("updated_at") val updatedAt: String = "", val hash: String = "", diff --git a/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt b/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt index e4262ff..1b77fbd 100644 --- a/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt +++ b/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt @@ -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), diff --git a/app/src/main/java/com/labexposed/notas/domain/SyncStatusStore.kt b/app/src/main/java/com/labexposed/notas/domain/SyncStatusStore.kt index f023e7d..f8e108f 100644 --- a/app/src/main/java/com/labexposed/notas/domain/SyncStatusStore.kt +++ b/app/src/main/java/com/labexposed/notas/domain/SyncStatusStore.kt @@ -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 = _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) } diff --git a/app/src/main/java/com/labexposed/notas/ui/NotasViewModel.kt b/app/src/main/java/com/labexposed/notas/ui/NotasViewModel.kt index 6c4faa3..ac05f3a 100644 --- a/app/src/main/java/com/labexposed/notas/ui/NotasViewModel.kt +++ b/app/src/main/java/com/labexposed/notas/ui/NotasViewModel.kt @@ -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, 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, + 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") } }