fix: robustez de sync e cancelamento (v0.2.2)

- 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
This commit is contained in:
codeall committed 2026-11-05 21:02:10 -03:00
1 parent 34a6c38540
commit 8561ae2f8b
6 files changed
+43 -19

No files matched your search

+2 -3
View File
@@ -13,9 +13,8 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.labexposed.notas" applicationId = "com.labexposed.notas"
minSdk = 26 minSdk = 26
targetSdk = 35 versionCode = 4
versionCode = 3 versionName = "0.2.2"
versionName = "0.2.1"
} }
buildTypes { buildTypes {
@@ -4,10 +4,12 @@ import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database( @Database(
entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class], entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class],
version = 2, version = 3,
exportSchema = false, exportSchema = false,
) )
abstract class NotasDatabase : RoomDatabase() { abstract class NotasDatabase : RoomDatabase() {
@@ -20,6 +22,17 @@ abstract class NotasDatabase : RoomDatabase() {
@Volatile @Volatile
private var instance: NotasDatabase? = null 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 = fun get(context: Context): NotasDatabase =
instance ?: synchronized(this) { instance ?: synchronized(this) {
instance ?: Room.databaseBuilder( instance ?: Room.databaseBuilder(
@@ -27,7 +40,7 @@ abstract class NotasDatabase : RoomDatabase() {
NotasDatabase::class.java, NotasDatabase::class.java,
"notas.db", "notas.db",
) )
.fallbackToDestructiveMigration() // v1→v2: dados de teste; em produção usar migração real .addMigrations(MIGRATION_2_3)
.build().also { instance = it } .build().also { instance = it }
} }
} }
@@ -1,5 +1,6 @@
package com.labexposed.notas.data.remote package com.labexposed.notas.data.remote
import kotlinx.coroutines.delay
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -8,7 +9,6 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
* Cliente HTTP da API notas-online: Bearer token, timeout curto, retry com * Cliente HTTP da API notas-online: Bearer token, timeout curto, retry com
* backoff apenas para falhas de rede (IOException), mapeamento de status em * 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 private fun url(path: String) = baseUrl.trimEnd('/') + path
/** Executa com até 2 tentativas; erros de rede/URL inválida viram Network. */ /** Executa com até 2 tentativas; erros de rede/URL inválida viram Network. */
private inline fun <reified T> call( private suspend inline fun <reified T> call(
method: String, method: String,
path: String, path: String,
body: String? = null, body: String? = null,
@@ -65,7 +65,7 @@ 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) delay(500)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
// URL malformada (configuração errada): não adianta repetir // URL malformada (configuração errada): não adianta repetir
return ApiResult.Network("URL inválida: ${e.message}") 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") return ApiResult.Network(lastError?.message ?: "sem conexão")
} }
fun testConnection(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes") suspend fun testConnection(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
fun listNotes(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes") suspend fun listNotes(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
fun listCategories(): ApiResult<List<CategoryDto>> = call("GET", "/api/categories") suspend fun listCategories(): ApiResult<List<CategoryDto>> = call("GET", "/api/categories")
fun getNote(id: String): ApiResult<NoteDetailDto> = call("GET", "/api/notes/$id") suspend fun getNote(id: String): ApiResult<NoteDetailDto> = call("GET", "/api/notes/$id")
fun createNote(dto: NoteWriteDto): ApiResult<NoteDetailDto> = suspend fun createNote(dto: NoteWriteDto): ApiResult<NoteDetailDto> =
call("POST", "/api/notes", json.encodeToString(dto)) call("POST", "/api/notes", json.encodeToString(dto))
fun updateNote(id: String, dto: NoteWriteDto): ApiResult<NoteDetailDto> = suspend fun updateNote(id: String, dto: NoteWriteDto): ApiResult<NoteDetailDto> =
call("PUT", "/api/notes/$id", json.encodeToString(dto)) call("PUT", "/api/notes/$id", json.encodeToString(dto))
fun deleteNote(id: String): ApiResult<Unit> = call("DELETE", "/api/notes/$id") suspend fun deleteNote(id: String): ApiResult<Unit> = call("DELETE", "/api/notes/$id")
fun renderMd(content: String): ApiResult<String> { suspend fun renderMd(content: String): ApiResult<String> {
val body = """{"content":${json.encodeToString(content)},"format":"md"}""" val body = """{"content":${json.encodeToString(content)},"format":"md"}"""
val r = call<RenderDto>("POST", "/api/render", body) val r = call<RenderDto>("POST", "/api/render", body)
return if (r is ApiResult.Ok) ApiResult.Ok((r.value as RenderDto).html) else r as ApiResult<Nothing> return if (r is ApiResult.Ok) ApiResult.Ok((r.value as RenderDto).html) else r as ApiResult<Nothing>
@@ -1,10 +1,14 @@
package com.labexposed.notas.data.remote package com.labexposed.notas.data.remote
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -29,6 +33,8 @@ class SseClient(private val baseUrl: String, private val token: String, private
try { try {
streamOnce(onEvent) streamOnce(onEvent)
backoff = 1_000L // stream terminou limpo (scope cancelado não chega aqui) backoff = 1_000L // stream terminou limpo (scope cancelado não chega aqui)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) { } catch (_: Exception) {
// rede caiu / servidor fora: espera e tenta de novo // 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() val request = Request.Builder()
.url(baseUrl.trimEnd('/') + "/api/events?session=" + sessionId) .url(baseUrl.trimEnd('/') + "/api/events?session=" + sessionId)
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.header("Accept", "text/event-stream") .header("Accept", "text/event-stream")
.build() .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}") if (!resp.isSuccessful) throw java.io.IOException("SSE HTTP ${resp.code}")
val reader = resp.body?.source()?.inputStream()?.bufferedReader() ?: throw java.io.IOException("sem corpo") val reader = resp.body?.source()?.inputStream()?.bufferedReader() ?: throw java.io.IOException("sem corpo")
var data = StringBuilder() var data = StringBuilder()
while (true) { while (true) {
currentCoroutineContext().ensureActive()
val line = reader.readLine() ?: break val line = reader.readLine() ?: break
when { when {
line.startsWith("data: ") -> data.append(line.removePrefix("data: ")) line.startsWith("data: ") -> data.append(line.removePrefix("data: "))
line.startsWith("data:") -> data.append(line.removePrefix("data:").trimStart())
line.isEmpty() && data.isNotEmpty() -> { line.isEmpty() && data.isNotEmpty() -> {
val raw = data.toString() val raw = data.toString()
data = StringBuilder() data = StringBuilder()
@@ -51,6 +51,8 @@ class SyncEngine(
SyncStatusStore.syncMutex.withLock { SyncStatusStore.syncMutex.withLock {
try { try {
runSync(trigger) runSync(trigger)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("SyncEngine", "fullSync($trigger) falhou", e) android.util.Log.e("SyncEngine", "fullSync($trigger) falhou", e)
status.set { it.copy(phase = SyncPhase.Error, message = e.message ?: "erro interno") } status.set { it.copy(phase = SyncPhase.Error, message = e.message ?: "erro interno") }
@@ -30,7 +30,7 @@ class SyncWorker(context: Context, params: WorkerParameters) :
) )
return when (val outcome = engine.fullSync(trigger = inputData.getString(TRIGGER) ?: "auto")) { 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.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() is com.labexposed.notas.domain.SyncOutcome.Unauthorized -> Result.success()
else -> Result.success() else -> Result.success()
} }