4 Commits
Author SHA1 Message Date
lgcosta cccba68036 chore(release): bump v0.4.1 (versionCode 8)
fix(sync) 5xx -> Transient com retry; runSync trata Failed/NotConfigured;
SSE multilinha; ApiClient sem imports mortos.

Verificado: testDebugUnitTest + assembleDebug + assembleRelease limpos
(JDK 21 / AGP 8.13.2 / SDK 36).
2026-11-06 10:38:08 -03:00
lgcosta d10853bcaa fix(sync): erros 5xx viram Transient com retry; SSE multilinha; trata Failed/NotConfigured
- SyncOutcome.Transient para 5xx (push/pull/serverOutcome); worker da retry
- runSync: Failed -> Error, NotConfigured respeitado (evita cast crash)
- SseClient: junta linhas data: com \n (spec SSE)
- ApiClient: remove imports sem uso
2026-11-06 10:33:43 -03:00
lgcosta e608493c81 docs: roadmap — Expressive completo aguarda material3 1.5 estável 2026-11-06 08:51:32 -03:00
lgcosta 81e945b490 build: toolchain AGP 8.13.2 + Kotlin 2.2.20 + SDK 36 (v0.4.0)
- Gradle wrapper 8.11.1 -> 8.13 (exigido pelo AGP 8.13)
- AGP 8.7.3 -> 8.13.2; Kotlin 2.0.21 -> 2.2.20; KSP -> 2.2.20-2.0.4
- compileSdk/targetSdk 35 -> 36 (Android 16 como alvo real)
- Compose BOM 2025.06 -> 2026.06 (material3 1.4.0 estável; Expressive
  completo segue beta em 1.5.x e fica de fora a pedido: só estável)
- activity-compose 1.10.1, navigation-compose 2.9.0, lifecycle 2.9.0
- Room 2.6.1 -> 2.8.3; WorkManager 2.9.1 -> 2.10.1; DataStore 1.1.7
- kotlinx-serialization 1.8.1; coroutines 1.10.2
- versionCode 7 / versionName 0.4.0

Verificado: testes unitários verdes; APKs debug+release OK; em Android 16
(API 36, Pixel 6) upgrade in-place, criar nota -> autosave -> Room -> lista,
editor e chips de modo/formato sem crashes. README atualizado.
2026-11-06 08:48:16 -03:00
8 changed files with 74 additions and 33 deletions

No files matched your search

+13 -7
View File
@@ -40,7 +40,7 @@ unzip -q commandlinetools-linux-*.zip -d ~/Android/Sdk/cmdline-tools
mv ~/Android/Sdk/cmdline-tools/cmdline-tools ~/Android/Sdk/cmdline-tools/latest mv ~/Android/Sdk/cmdline-tools/cmdline-tools ~/Android/Sdk/cmdline-tools/latest
yes | ~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --licenses yes | ~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --licenses
~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager \ ~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager \
"platform-tools" "platforms;android-35" "build-tools;35.0.0" "platform-tools" "platforms;android-36" "build-tools;36.0.0"
# 3) ~/.bashrc (ou ~/.zshrc) # 3) ~/.bashrc (ou ~/.zshrc)
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
@@ -48,8 +48,8 @@ export ANDROID_HOME=$HOME/Android/Sdk
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools
``` ```
O projeto usa o **Gradle Wrapper** (8.11.1) — não é preciso instalar Gradle. O projeto usa o **Gradle Wrapper** (8.13) — não é preciso instalar Gradle.
Se clonar sem os jars do wrapper, gere-os uma vez: `gradle wrapper --gradle-version 8.11.1` Se clonar sem os jars do wrapper, gere-os uma vez: `gradle wrapper --gradle-version 8.13`
(ou abra no Android Studio, que regenera automaticamente). (ou abra no Android Studio, que regenera automaticamente).
## Estrutura do repositório ## Estrutura do repositório
@@ -57,9 +57,9 @@ Se clonar sem os jars do wrapper, gere-os uma vez: `gradle wrapper --gradle-vers
``` ```
notas-online-android/ notas-online-android/
├── settings.gradle.kts / build.gradle.kts / gradle.properties ├── settings.gradle.kts / build.gradle.kts / gradle.properties
├── gradle/wrapper/ # Gradle 8.11.1 ├── gradle/wrapper/ # Gradle 8.13
├── app/ ├── app/
│ ├── build.gradle.kts # AGP 8.7.3 · Kotlin 2.0.21 · Compose BOM 2024.12 │ ├── build.gradle.kts # AGP 8.13.2 · Kotlin 2.2.20 · Compose BOM 2026.06
│ ├── proguard-rules.pro │ ├── proguard-rules.pro
│ └── src/main/ │ └── src/main/
│ ├── AndroidManifest.xml # INTERNET + ACCESS_NETWORK_STATE; cleartext p/ LAN │ ├── AndroidManifest.xml # INTERNET + ACCESS_NETWORK_STATE; cleartext p/ LAN
@@ -130,8 +130,12 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
## Testado em ## Testado em
- **Build**: `testDebugUnitTest` + `assembleDebug` + `assembleRelease` limpos (JDK 21 / AGP 8.7.3 / - **Emulador Android 16 (API 36, x86_64, Pixel 6)** — v0.4.1: build release R8 limpo,
Kotlin 2.0.21 / SDK 35 / Compose BOM 2025.06); release R8 ≈ **1,6 MB**, debug ≈ 17 MB. `testDebugUnitTest` verde; push/pull 5xx → `Transient` com retry, `Failed` → Error,
SSE multilinha, sem crashes.
- **Emulador Android 16 (API 36, x86_64, Pixel 6)** — v0.4.0: upgrade in-place sem bloqueio,
criar nota → autosave → persiste no Room → lista atualiza; editor WYSIWYG/Preview ok;
sem crashes (logcat AndroidRuntime limpo).
- **Emulador Android 16 (API 36, x86_64, Pixel 6)** — v0.3.1: instalação sem bloqueio de - **Emulador Android 16 (API 36, x86_64, Pixel 6)** — v0.3.1: instalação sem bloqueio de
"versão antiga", boot limpo e fluxo config → lista (status, busca, empty state) verificados. "versão antiga", boot limpo e fluxo config → lista (status, busca, empty state) verificados.
- **Emulador Android 15 (x86_64, Pixel 6)** — v0.3.0: edge-to-edge, status de sync compacto, - **Emulador Android 15 (x86_64, Pixel 6)** — v0.3.0: edge-to-edge, status de sync compacto,
@@ -161,3 +165,5 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
- Sync reativo incremental (o pull compara hashes por nota; milhares de notas pediriam um - Sync reativo incremental (o pull compara hashes por nota; milhares de notas pediriam um
endpoint de delta no servidor). endpoint de delta no servidor).
- Multi-perfil/servidores e sincronização de anexos. - Multi-perfil/servidores e sincronização de anexos.
- Material 3 Expressive completo (`MaterialExpressiveTheme` + componentes 1.5.x) quando a
linha 1.5 sair de beta — hoje o estável (material3 1.4.0) não expõe a API.
+17 -18
View File
@@ -8,14 +8,14 @@ plugins {
android { android {
namespace = "com.labexposed.notas" namespace = "com.labexposed.notas"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.labexposed.notas" applicationId = "com.labexposed.notas"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 36
versionCode = 6 versionCode = 8
versionName = "0.3.1" versionName = "0.4.1"
} }
buildTypes { buildTypes {
@@ -42,32 +42,31 @@ android {
} }
dependencies { dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.06.00") val composeBom = platform("androidx.compose:compose-bom:2026.06.00")
implementation(composeBom) implementation(composeBom)
// UI // UI
implementation("androidx.activity:activity-compose:1.9.3") implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.8.5") implementation("androidx.navigation:navigation-compose:2.9.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0")
// Dados locais // Dados locais
implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-runtime:2.8.3")
implementation("androidx.room:room-ktx:2.6.1") implementation("androidx.room:room-ktx:2.8.3")
ksp("androidx.room:room-compiler:2.6.1") ksp("androidx.room:room-compiler:2.8.3")
implementation("androidx.datastore:datastore-preferences:1.1.1") implementation("androidx.datastore:datastore-preferences:1.1.7")
// Rede + serialização
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
// Sync em segundo plano // Sync em segundo plano
implementation("androidx.work:work-runtime-ktx:2.9.1") implementation("androidx.work:work-runtime-ktx:2.10.1")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
} }
@@ -59,8 +59,11 @@ class SseClient(private val baseUrl: String, private val token: String, private
currentCoroutineContext().ensureActive() 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:") -> {
line.startsWith("data:") -> data.append(line.removePrefix("data:").trimStart()) // evento multilinha: junta os blocos com \n (spec SSE)
if (data.isNotEmpty()) data.append('\n')
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()
@@ -22,5 +22,7 @@ sealed class SyncOutcome {
data class Success(val pushed: Int, val pulled: Int, val conflicts: Int) : SyncOutcome() data class Success(val pushed: Int, val pulled: Int, val conflicts: Int) : SyncOutcome()
data class NetworkDown(val message: String) : SyncOutcome() data class NetworkDown(val message: String) : SyncOutcome()
data object Unauthorized : SyncOutcome() data object Unauthorized : SyncOutcome()
/** Erro transitório do servidor (5xx): pode repetir depois. */
data class Transient(val message: String) : SyncOutcome()
data class Failed(val message: String) : SyncOutcome() data class Failed(val message: String) : SyncOutcome()
} }
@@ -81,6 +81,19 @@ class SyncEngine(
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) } status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
return push return push
} }
if (push is SyncOutcome.Transient) {
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
return push
}
if (push is SyncOutcome.Failed) {
status.set { it.copy(phase = SyncPhase.Error, message = push.message) }
return push
}
if (push is SyncOutcome.NotConfigured) {
status.set { it.copy(phase = SyncPhase.NotConfigured) }
return push
}
val pull = pull(client) val pull = pull(client)
if (pull is SyncOutcome.Unauthorized) { if (pull is SyncOutcome.Unauthorized) {
@@ -91,6 +104,14 @@ class SyncEngine(
status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) } status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) }
return pull return pull
} }
if (pull is SyncOutcome.Transient) {
status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) }
return pull
}
if (pull is SyncOutcome.Failed) {
status.set { it.copy(phase = SyncPhase.Error, message = pull.message) }
return pull
}
val ok = (push as SyncOutcome.Success) to (pull as SyncOutcome.Success) val ok = (push as SyncOutcome.Success) to (pull as SyncOutcome.Success)
val conflicts = ok.first.conflicts + ok.second.conflicts val conflicts = ok.first.conflicts + ok.second.conflicts
@@ -151,6 +172,7 @@ class SyncEngine(
} }
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message) is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
is ApiResult.Server -> return serverOutcome("create ${note.id}", resp)
else -> return SyncOutcome.Failed("create ${note.id}: ${describe(resp)}") else -> return SyncOutcome.Failed("create ${note.id}: ${describe(resp)}")
} }
} }
@@ -189,6 +211,7 @@ class SyncEngine(
} }
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message) is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
is ApiResult.Server -> return serverOutcome("update ${note.id}", resp)
else -> return SyncOutcome.Failed("update ${note.id}: ${describe(resp)}") else -> return SyncOutcome.Failed("update ${note.id}: ${describe(resp)}")
} }
} }
@@ -200,6 +223,7 @@ class SyncEngine(
is ApiResult.Ok, is ApiResult.NotFound -> Unit is ApiResult.Ok, is ApiResult.NotFound -> Unit
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message) is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
is ApiResult.Server -> return serverOutcome("delete ${note.id}", resp)
else -> return SyncOutcome.Failed("delete ${note.id}: ${describe(resp)}") else -> return SyncOutcome.Failed("delete ${note.id}: ${describe(resp)}")
} }
} }
@@ -290,12 +314,14 @@ class SyncEngine(
} }
is ApiResult.Network -> return SyncOutcome.NetworkDown(cats.message) is ApiResult.Network -> return SyncOutcome.NetworkDown(cats.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
is ApiResult.Server -> return serverOutcome("categories", cats)
else -> {} else -> {}
} }
val list = client.listNotes() val list = client.listNotes()
if (list is ApiResult.Network) return SyncOutcome.NetworkDown(list.message) if (list is ApiResult.Network) return SyncOutcome.NetworkDown(list.message)
if (list is ApiResult.Unauthorized) return SyncOutcome.Unauthorized if (list is ApiResult.Unauthorized) return SyncOutcome.Unauthorized
if (list is ApiResult.Server) return serverOutcome("list", list)
if (list !is ApiResult.Ok) return SyncOutcome.Failed("list: ${describe(list)}") if (list !is ApiResult.Ok) return SyncOutcome.Failed("list: ${describe(list)}")
val serverIds = list.value.map { it.id }.toSet() val serverIds = list.value.map { it.id }.toSet()
@@ -345,6 +371,10 @@ class SyncEngine(
private fun NoteDetailDto.toEntity() = toNoteEntity() private fun NoteDetailDto.toEntity() = toNoteEntity()
private fun serverOutcome(prefix: String, r: ApiResult.Server): SyncOutcome =
if (r.code >= 500) SyncOutcome.Transient("$prefix: HTTP ${r.code} ${r.message}")
else SyncOutcome.Failed("$prefix: HTTP ${r.code} ${r.message}")
private fun describe(r: ApiResult<*>): String = when (r) { private fun describe(r: ApiResult<*>): String = when (r) {
is ApiResult.Server -> "HTTP ${r.code} ${r.message}" is ApiResult.Server -> "HTTP ${r.code} ${r.message}"
is ApiResult.Network -> r.message is ApiResult.Network -> r.message
@@ -30,6 +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.Transient -> Result.retry()
is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.failure() 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()
+5 -5
View File
@@ -1,8 +1,8 @@
// Arquivo raiz — só declara plugins; versão centralizada aqui. // Arquivo raiz — só declara plugins; versão centralizada aqui.
plugins { plugins {
id("com.android.application") version "8.7.3" apply false id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false
id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false id("com.google.devtools.ksp") version "2.2.20-2.0.4" apply false
} }
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME