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:
1 parent
34a6c38540
commit
8561ae2f8b
6 files changed
+43
-19
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <reified T> call(
|
||||
private suspend inline fun <reified T> 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<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))
|
||||
|
||||
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))
|
||||
|
||||
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 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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user