From 8561ae2f8b89768fc8a71d36b512cd85a45496dd Mon Sep 17 00:00:00 2001 From: codeall Date: Thu, 5 Nov 2026 21:02:10 -0300 Subject: [PATCH] fix: robustez de sync e cancelamento (v0.2.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SyncEngine: CancellationException volta a propagar (não vira erro falso) - SyncWorker: Failed (4xx/5xx) agora é failure, só NetworkDown retenta - ApiClient: métodos suspend com delay() em vez de Thread.sleep - SseClient: cancelamento fecha o socket; parse tolera 'data:' sem espaço - Room v3: migração real 2->3 (índice único outbox) sem apagar a fila - versionCode 4 / versionName 0.2.2 --- app/build.gradle.kts | 5 ++--- .../notas/data/local/NotasDatabase.kt | 17 ++++++++++++-- .../labexposed/notas/data/remote/ApiClient.kt | 22 +++++++++---------- .../labexposed/notas/data/remote/SseClient.kt | 14 ++++++++++-- .../com/labexposed/notas/domain/SyncEngine.kt | 2 ++ .../com/labexposed/notas/sync/SyncWorker.kt | 2 +- 6 files changed, 43 insertions(+), 19 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e62ea71..e71ab14 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,9 +13,8 @@ android { defaultConfig { applicationId = "com.labexposed.notas" minSdk = 26 - targetSdk = 35 - versionCode = 3 - versionName = "0.2.1" + versionCode = 4 + versionName = "0.2.2" } buildTypes { 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 c293eb2..c8d7913 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 @@ -4,10 +4,12 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase @Database( entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class], - version = 2, + version = 3, exportSchema = false, ) abstract class NotasDatabase : RoomDatabase() { @@ -20,6 +22,17 @@ abstract class NotasDatabase : RoomDatabase() { @Volatile private var instance: NotasDatabase? = null + /** + * v1→v2→v3: a mudança real foi o índice único (noteId, op) na outbox + * (v2). Migração recria o índice sem tocar nos dados — a fila não + * enviada sobrevive ao upgrade, ao contrário do destructive fallback. + */ + val MIGRATION_2_3: Migration = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_outbox_noteId_op` ON `outbox` (`noteId`, `op`)") + } + } + fun get(context: Context): NotasDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -27,7 +40,7 @@ abstract class NotasDatabase : RoomDatabase() { NotasDatabase::class.java, "notas.db", ) - .fallbackToDestructiveMigration() // v1→v2: dados de teste; em produção usar migração real + .addMigrations(MIGRATION_2_3) .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 4ee67a3..bf9ffac 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 @@ -1,5 +1,6 @@ package com.labexposed.notas.data.remote +import kotlinx.coroutines.delay import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -8,7 +9,6 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.io.IOException import java.util.concurrent.TimeUnit - /** * Cliente HTTP da API notas-online: Bearer token, timeout curto, retry com * backoff apenas para falhas de rede (IOException), mapeamento de status em @@ -29,7 +29,7 @@ class ApiClient(private val baseUrl: String, private val token: String) { private fun url(path: String) = baseUrl.trimEnd('/') + path /** Executa com até 2 tentativas; erros de rede/URL inválida viram Network. */ - private inline fun call( + private suspend inline fun call( method: String, path: String, body: String? = null, @@ -65,7 +65,7 @@ class ApiClient(private val baseUrl: String, private val token: String) { } } catch (e: IOException) { lastError = e - if (attempt == 0) Thread.sleep(500) + if (attempt == 0) delay(500) } catch (e: IllegalArgumentException) { // URL malformada (configuração errada): não adianta repetir return ApiResult.Network("URL inválida: ${e.message}") @@ -74,23 +74,23 @@ class ApiClient(private val baseUrl: String, private val token: String) { return ApiResult.Network(lastError?.message ?: "sem conexão") } - fun testConnection(): ApiResult> = call("GET", "/api/notes") + suspend fun testConnection(): ApiResult> = call("GET", "/api/notes") - fun listNotes(): ApiResult> = call("GET", "/api/notes") + suspend fun listNotes(): ApiResult> = call("GET", "/api/notes") - fun listCategories(): ApiResult> = call("GET", "/api/categories") + suspend fun listCategories(): ApiResult> = call("GET", "/api/categories") - fun getNote(id: String): ApiResult = call("GET", "/api/notes/$id") + suspend fun getNote(id: String): ApiResult = call("GET", "/api/notes/$id") - fun createNote(dto: NoteWriteDto): ApiResult = + suspend fun createNote(dto: NoteWriteDto): ApiResult = call("POST", "/api/notes", json.encodeToString(dto)) - fun updateNote(id: String, dto: NoteWriteDto): ApiResult = + suspend fun updateNote(id: String, dto: NoteWriteDto): ApiResult = call("PUT", "/api/notes/$id", json.encodeToString(dto)) - fun deleteNote(id: String): ApiResult = call("DELETE", "/api/notes/$id") + suspend fun deleteNote(id: String): ApiResult = call("DELETE", "/api/notes/$id") - fun renderMd(content: String): ApiResult { + suspend fun renderMd(content: String): ApiResult { val body = """{"content":${json.encodeToString(content)},"format":"md"}""" val r = call("POST", "/api/render", body) return if (r is ApiResult.Ok) ApiResult.Ok((r.value as RenderDto).html) else r as ApiResult diff --git a/app/src/main/java/com/labexposed/notas/data/remote/SseClient.kt b/app/src/main/java/com/labexposed/notas/data/remote/SseClient.kt index d5e8830..804faa1 100644 --- a/app/src/main/java/com/labexposed/notas/data/remote/SseClient.kt +++ b/app/src/main/java/com/labexposed/notas/data/remote/SseClient.kt @@ -1,10 +1,14 @@ package com.labexposed.notas.data.remote +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive +import kotlinx.coroutines.job import kotlinx.coroutines.launch import okhttp3.OkHttpClient import okhttp3.Request @@ -29,6 +33,8 @@ class SseClient(private val baseUrl: String, private val token: String, private try { streamOnce(onEvent) backoff = 1_000L // stream terminou limpo (scope cancelado não chega aqui) + } catch (e: CancellationException) { + throw e } catch (_: Exception) { // rede caiu / servidor fora: espera e tenta de novo } @@ -37,20 +43,24 @@ class SseClient(private val baseUrl: String, private val token: String, private } } - private fun streamOnce(onEvent: (SseEvent) -> Unit) { + private suspend fun streamOnce(onEvent: (SseEvent) -> Unit) { val request = Request.Builder() .url(baseUrl.trimEnd('/') + "/api/events?session=" + sessionId) .header("Authorization", "Bearer $token") .header("Accept", "text/event-stream") .build() - http.newCall(request).execute().use { resp -> + val call = http.newCall(request) + currentCoroutineContext().job.invokeOnCompletion { runCatching { call.cancel() } } + call.execute().use { resp -> if (!resp.isSuccessful) throw java.io.IOException("SSE HTTP ${resp.code}") val reader = resp.body?.source()?.inputStream()?.bufferedReader() ?: throw java.io.IOException("sem corpo") var data = StringBuilder() while (true) { + currentCoroutineContext().ensureActive() val line = reader.readLine() ?: break when { line.startsWith("data: ") -> data.append(line.removePrefix("data: ")) + line.startsWith("data:") -> data.append(line.removePrefix("data:").trimStart()) line.isEmpty() && data.isNotEmpty() -> { val raw = data.toString() data = StringBuilder() 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 b535140..05c12f2 100644 --- a/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt +++ b/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt @@ -51,6 +51,8 @@ class SyncEngine( SyncStatusStore.syncMutex.withLock { try { runSync(trigger) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) { android.util.Log.e("SyncEngine", "fullSync($trigger) falhou", e) status.set { it.copy(phase = SyncPhase.Error, message = e.message ?: "erro interno") } diff --git a/app/src/main/java/com/labexposed/notas/sync/SyncWorker.kt b/app/src/main/java/com/labexposed/notas/sync/SyncWorker.kt index 8713933..8b936c0 100644 --- a/app/src/main/java/com/labexposed/notas/sync/SyncWorker.kt +++ b/app/src/main/java/com/labexposed/notas/sync/SyncWorker.kt @@ -30,7 +30,7 @@ class SyncWorker(context: Context, params: WorkerParameters) : ) return when (val outcome = engine.fullSync(trigger = inputData.getString(TRIGGER) ?: "auto")) { is com.labexposed.notas.domain.SyncOutcome.NetworkDown -> Result.retry() - is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.retry() + is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.failure() is com.labexposed.notas.domain.SyncOutcome.Unauthorized -> Result.success() else -> Result.success() }