- ApiClient: IllegalArgumentException de URL vira Network (banner, sem crash); DTOs com listas nullable (servidor podia enviar categories/tags: null) - SyncEngine: mutex global serializa runs (logs BEGIN/END); resolveConflict idempotente (não duplica cópia de conflito); registra rekey:<local>→<server> em meta para o editor aberto sobreviver à troca de id do create - ViewModel: resolve(id) em save/delete; createLocal enfileira create único - Outbox: índice único (noteId, op); DB v2 com fallbackToDestructiveMigration - proguard: regras corretas de kotlinx.serialization (Companion.serializer) - README: seção 'Testado em' com os cenários de emulador executados
7.6 KiB
7.6 KiB
Notas — cliente Android (offline-first)
Cliente mobile da plataforma notas-online (editor de notas estilo Mousepad com versionamento Git-like). Kotlin + Jetpack Compose, arquitetura offline-first: tudo funciona sem rede, com sincronização bidirecional em segundo plano (WorkManager) e resolução determinística de conflitos.
Stack (e por que é a mais leve das opções)
| Requisito | Escolha | Peso |
|---|---|---|
| UI | Jetpack Compose (Material 3, dynamic color) | nativo, sem engine extra |
| Banco local | Room (SQLite) + WAL | ~0 APK extra relevante vs Flutter/RN |
| Fila offline | tabela outbox (FIFO por seq) |
— |
| Sync em background | WorkManager (periódico 1h + runNow por gatilhos) |
— |
| Rede | OkHttp (timeout 10/20s, retry 2x com backoff em IOException) |
— |
| JSON | kotlinx.serialization (compile-time, sem reflection) | — |
| Config | DataStore Preferences | — |
| Conflitos | base_hash da API (409) + política servidor vence + cópia local |
— |
Flutter (engine ~8 MB) e React Native (bridge/Hermes) dariam mais runtime pelo mesmo trabalho; Android já entrega exatamente as primitivas que offline-first pede (Room, WorkManager, ConnectivityManager).
Preparação do ambiente — Fedora 44
# 1) Java 21 (AGP 8.7 trabalha com 17/21) + utilitários
sudo dnf install -y java-21-openjdk-devel git unzip curl findutils
# 2a) Caminho recomendado: Android Studio (SDK + emulador inclusos)
# baixe o .tar.gz em https://developer.android.com/studio e:
mkdir -p ~/opt && tar -xzf ~/Downloads/android-studio-*.tar.gz -C ~/opt
~/opt/android-studio/bin/studio.sh # primeiro assistente instala o SDK em ~/Android/Sdk
# 2b) Caminho só-CLI (sem Studio):
mkdir -p ~/Android/Sdk/cmdline-tools && cd /tmp
curl -LO https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q commandlinetools-linux-*.zip -d ~/Android/Sdk/cmdline-tools
mv ~/Android/Sdk/cmdline-tools/cmdline-tools ~/Android/Sdk/cmdline-tools/latest
yes | ~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --licenses
~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager \
"platform-tools" "platforms;android-35" "build-tools;35.0.0"
# 3) ~/.bashrc (ou ~/.zshrc)
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
export ANDROID_HOME=$HOME/Android/Sdk
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.
Se clonar sem os jars do wrapper, gere-os uma vez: gradle wrapper --gradle-version 8.11.1
(ou abra no Android Studio, que regenera automaticamente).
Estrutura do repositório
notas-online-android/
├── settings.gradle.kts / build.gradle.kts / gradle.properties
├── gradle/wrapper/ # Gradle 8.11.1
├── app/
│ ├── build.gradle.kts # AGP 8.7.3 · Kotlin 2.0.21 · Compose BOM 2024.12
│ ├── proguard-rules.pro
│ └── src/main/
│ ├── AndroidManifest.xml # INTERNET + ACCESS_NETWORK_STATE; cleartext p/ LAN
│ ├── res/ # tema, ícone adaptativo
│ └── java/com/labexposed/notas/
│ ├── NotasApp.kt # Application: periodic work + gatilho de rede
│ ├── MainActivity.kt # NavHost + SyncBanner (estado da sincronização)
│ ├── data/
│ │ ├── local/ # Room: notes, outbox, categories, meta
│ │ └── remote/ # ApiClient (OkHttp+DTOs), ConnectivityObserver
│ ├── domain/ # SyncEngine, SettingsRepository, modelos de estado
│ ├── sync/ # SyncWorker + SyncScheduler (WorkManager)
│ └── ui/ # ViewModel, telas (Settings/Lista/Editor), tema
Como funciona a sincronização
- Tudo é local primeiro: editar grava no Room e marca a nota
dirty; a mutação entra naoutbox(fila FIFO). Notas criadas offline ganham idL…temporário. - Push: para cada item da fila —
create(POST, renomeia o id local para o id do servidor),update(PUT combase_hash= último hash conhecido) edelete(DELETE; nota local-only só sai do banco). - Conflito (HTTP 409): o servidor mudou desde a sua base. Política determinística:
servidor vence a nota + texto local preservado numa cópia "Título (conflito)"
(fila como
create). A versão antiga do servidor também está no histórico de revisões. Nada é perdido e não depende de relógios. - Pull: categorias + lista de notas; notas locais limpas com hash divergente são rebaixadas (GET completo); notas que sumiram do servidor (e não estão na fila) são removidas localmente.
- Gatilhos: WorkManager periódico (1h, exige rede, backoff exponencial) · rede
restabelecida (
ConnectivityManagercallback) · após cada edição com conteúdo alterado · botão Sincronizar · troca de configuração. - Autosave do editor: grava local + enfileira após 10 s de ociosidade (cada tecla reinicia a contagem) — igual à política web.
Telas
- Servidor: URL da API + token (
NOTES_TOKEN) + autor; botão Testar conexão (lista notas viaGET /api/notes) antes de salvar. - Lista: banner de status colorido — cinza não configurado, cinza-escuro Offline (N na fila), azul Sincronizando…, verde Sincronizado às HH:mm · N pendente(s) · ⚠ conflitos, vermelho erro/401; itens mostram ● não enviado / 🗑 fila / ⚠ nota de conflito.
- Editor: título, chips de formato (TXT/MD/HTML), chips multi-seleção de categorias (árvore espelhada do servidor), corpo monoespaçado; autosave ocioso + botão Salvar.
Compilar e instalar
# CLI (debug APK assinado com debug key):
./gradlew assembleDebug
# saída: app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/debug/app-debug.apk
# release (R8/minify):
./gradlew assembleRelease
# Android Studio: abrir a pasta, sincronizar, Run ▶ (ou Build > Build APK(s)).
Testado em
- Build:
assembleReleaselimpo (JDK 21 / AGP 8.7.3 / Kotlin 2.0.21 / SDK 35), APK assinado com R8 ≈ 1,6 MB (debug ≈ 17 MB). - Emulador Android 15 (x86_64, Pixel 6) contra o servidor notas-online real, cenários:
- configuração de servidor com Testar conexão → OK;
- criar nota offline → autosave por ociosidade (10 s) →
POSTúnico (sem duplicatas); - editar offline → fila resiste à queda de rede → push automático ao restabelecer (
ConnectivityManager); - nota criada na Web aparece no device via pull;
- conflito real: device e Web editam a mesma nota → HTTP 409 (
base_hash) → nota principal adota o servidor e o texto do Android vira exatamente uma cópia "… (conflito)" (idempotente; sincronizações serializadas por mutex + índices únicos na outbox).
- Integração com a API: mesmos contratos do web client (
base_hash/409, categorias, revisões). O servidor normalizacategories/tagsvazios como[](null quebrava clientes com tipos não-nuláveis, ex. kotlinx.serialization).
Roadmap honesto (não implementado aqui)
- SSE (
/api/events) no Android para push tempo-real em vez de gatilho pós-save (OkHttp SSE). - Token em Keystore/EncryptedSharedPreferences (hoje: sandbox do app via DataStore).
- Editor WYSIWYG/realce de sintaxe mobile (fora do escopo "mais leve possível").
- Multi-perfil/servidores e sincronização de anexos.