- 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.
167 lines
10 KiB
Markdown
167 lines
10 KiB
Markdown
# Notas — cliente Android (offline-first)
|
|
|
|
Cliente mobile da plataforma [notas-online](https://git.labexposed.com/lgcosta/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
|
|
|
|
```bash
|
|
# 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-36" "build-tools;36.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.13) — não é preciso instalar Gradle.
|
|
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).
|
|
|
|
## Estrutura do repositório
|
|
|
|
```
|
|
notas-online-android/
|
|
├── settings.gradle.kts / build.gradle.kts / gradle.properties
|
|
├── gradle/wrapper/ # Gradle 8.13
|
|
├── app/
|
|
│ ├── build.gradle.kts # AGP 8.13.2 · Kotlin 2.2.20 · Compose BOM 2026.06
|
|
│ ├── 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
|
|
|
|
1. **Tudo é local primeiro**: editar grava no Room e marca a nota `dirty`; a mutação entra na
|
|
`outbox` (fila FIFO). Notas criadas offline ganham id `L…` temporário.
|
|
2. **Push**: para cada item da fila — `create` (POST, renomeia o id local para o id do
|
|
servidor), `update` (PUT com `base_hash` = último hash conhecido) e `delete` (DELETE;
|
|
nota local-only só sai do banco).
|
|
3. **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.
|
|
4. **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.
|
|
5. **Gatilhos**: WorkManager periódico (1h, exige rede, backoff exponencial) · rede
|
|
restabelecida (`ConnectivityManager` callback) · **a cada edição com autosave (10 s de
|
|
ociosidade), incluindo mudanças só de título/formato/categorias** · **ao voltar para o app**
|
|
(`ON_RESUME`) · botão **Sincronizar** · troca de configuração. Uma auto-reconciliação a cada
|
|
rodada enfileira qualquer nota suja sem entrada na fila (recupera estados presos).
|
|
6. **Tempo real via SSE** (`GET /api/events`, OkHttp, reconexão com backoff, só em primeiro
|
|
plano): eventos `saved` atualizam a UI na hora — nota limpa aberta no editor é recarregada
|
|
ao vivo; nota suja mostra banner "Mudou no servidor — Recarregar/Manter"; snackbar na lista.
|
|
Eventos `presence` desenham **👥N** na nota quando outra sessão (app ou navegador web) está
|
|
com ela aberta.
|
|
7. **WYSIWYG e Preview** (WebView nativo, sem dependência extra): modo WYSIWYG renderiza o
|
|
Markdown (via `/api/render` do servidor) num `contenteditable`; cada edição é convertida de
|
|
volta para Markdown por um serializador JS embutido (títulos, negrito/itálico/tachado, listas,
|
|
citação, código, links, tabelas) e sincronizada — notas `.html` editam o HTML preservando
|
|
tags/atributos (scripts/`on*` sanitizados). Preview é o mesmo WebView não-editável. Barra de
|
|
ferramentas aplica `execCommand` (B I S H1 H2 listas ❝ 🔗 código).
|
|
|
|
## UI (Material 3, dynamic color, edge-to-edge) — v0.3.0
|
|
|
|
- **Lista**: `MediumTopAppBar`, estado de sync compacto com cores M3 e chips acionáveis, busca por título/conteúdo/tags, atualização por gesto, filtros por categoria/tag, cards animados com status e presença acessível, estado vazio com CTA e FAB "Nova".
|
|
- **Editor**: campo de título na top bar, seletor de modo (Editar / WYSIWYG / Preview), chips de formato e categorias, corpo monoespaçado com toolbar Markdown, presença com ícone acessível, autosave e exclusão confirmada.
|
|
- **Visual**: cores dinâmicas quando disponíveis, tipografia ajustada e edge-to-edge para integrar conteúdo às barras do sistema.
|
|
- **Exclusões sincronizam**: apagar marca `pendingDelete`, entra `delete` na fila e sobe na próxima sync; nota local-only some direto. Eventos SSE removem notas limpas das outras sessões.
|
|
- **Servidor**: URL + token + autor com *Testar conexão* e validação de esquema http(s).
|
|
|
|
## Compilar e instalar
|
|
|
|
```bash
|
|
# 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**: `testDebugUnitTest` + `assembleDebug` + `assembleRelease` limpos (JDK 21 / AGP 8.13.2 /
|
|
Kotlin 2.2.20 / SDK 36 / Compose BOM 2026.06); release R8 ≈ **1,9 MB**, debug ≈ 20 MB.
|
|
- **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,
|
|
campo de busca, estado vazio com CTA e editor com chips de modo/formato verificados por
|
|
screenshot + uiautomator (config → lista → criar nota → editor).
|
|
- **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;
|
|
- **mudança só de título sincroniza** (bug reportado em celular real, corrigido: metadados
|
|
entram na fila + auto-reconciliação + sync no `ON_RESUME`);
|
|
- **SSE ao vivo**: PUT vindo da Web atualiza o editor aberto em ~2 s, sem toque; snackbar na
|
|
lista; **👥 presença** aparece quando outra sessão abre a mesma nota;
|
|
- **WYSIWYG**: digitar no `contenteditable` → serialização html→md na ponte JS → autosave →
|
|
servidor recebe o texto; Preview renderiza o Markdown via `/api/render`;
|
|
- **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,
|
|
`/api/events`, `/api/presence`). O servidor normaliza `categories`/`tags` vazios como `[]`
|
|
(null quebrava clientes com tipos não-nuláveis, ex. kotlinx.serialization).
|
|
|
|
## Roadmap honesto (não implementado aqui)
|
|
|
|
- Token em Keystore/EncryptedSharedPreferences (hoje: sandbox do app via DataStore).
|
|
- Sync reativo incremental (o pull compara hashes por nota; milhares de notas pediriam um
|
|
endpoint de delta no servidor).
|
|
- Multi-perfil/servidores e sincronização de anexos.
|