Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cccba68036 | ||
|
|
d10853bcaa | ||
|
|
e608493c81 | ||
|
|
81e945b490 | ||
|
|
641c374a03 |
No files matched your search
@@ -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,14 @@ 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
|
||||||
|
"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,
|
||||||
campo de busca, estado vazio com CTA e editor com chips de modo/formato verificados por
|
campo de busca, estado vazio com CTA e editor com chips de modo/formato verificados por
|
||||||
screenshot + uiautomator (config → lista → criar nota → editor).
|
screenshot + uiautomator (config → lista → criar nota → editor).
|
||||||
@@ -159,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
-17
@@ -8,13 +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
|
||||||
versionCode = 5
|
targetSdk = 36
|
||||||
versionName = "0.3.0"
|
versionCode = 8
|
||||||
|
versionName = "0.4.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -41,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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user