Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: Android

# Release do Android por tag semver. Assina o AAB com a upload key (secret) e
# envia direto para o track `internal` da Play — o AAB assinado nunca vira
# artifact público. A promoção para `closed`/produção é manual no Console.
#
# Secrets: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS,
# ANDROID_KEY_PASSWORD, PLAY_SERVICE_ACCOUNT_JSON. Ver docs/PLAY-STORE.md.
on:
push:
tags: ['v*']
workflow_dispatch:

permissions:
contents: read

concurrency:
group: android-${{ github.ref }}
cancel-in-progress: false

jobs:
bundle:
name: AAB assinado → Play (internal)
runs-on: ubuntu-latest
env:
# `secrets` não pode ser usado em `if`; o env no job pode.
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '17'

# O runner do ubuntu já traz o SDK; `android-actions/setup-android`
# quebra ao tentar instalar o pacote `tools` com o cmdline-tools novo.
- name: Android SDK + NDK
run: |
export ANDROID_HOME="${ANDROID_HOME:-/usr/local/lib/android/sdk}"
sdkmgr="$(command -v sdkmanager || true)"
if [ -z "$sdkmgr" ]; then
sdkmgr="$(find "$ANDROID_HOME/cmdline-tools" /usr/local/lib/android/sdk/cmdline-tools -name sdkmanager -type f 2>/dev/null | head -n 1)"
fi
if [ -z "$sdkmgr" ]; then
echo "sdkmanager não encontrado no runner" >&2
exit 1
fi
echo "sdkmanager: $sdkmgr"
yes | "$sdkmgr" --licenses > /dev/null
"$sdkmgr" --install "platforms;android-36" "build-tools;36.0.0" "ndk;27.0.12077973"
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
echo "NDK_HOME=$ANDROID_HOME/ndk/27.0.12077973" >> "$GITHUB_ENV"

- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri

- name: Versão da tag
if: startsWith(github.ref, 'refs/tags/v')
run: |
tag="${GITHUB_REF_NAME#v}"
if ! printf '%s' "$tag" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Tag '$GITHUB_REF_NAME' fora do padrão vX.Y.Z" >&2
exit 1
fi
node tools/version.mjs check "$tag"
echo "ANDROID_TAG_VERSION=$tag" >> "$GITHUB_ENV"

- name: Restaurar keystore de upload
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
if [ -z "$KEYSTORE_BASE64" ]; then
echo "Sem ANDROID_KEYSTORE_BASE64: build sem assinatura." >&2
exit 0
fi
mkdir -p "$RUNNER_TEMP/keystore"
printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/keystore/upload.jks"
echo "ANDROID_KEYSTORE_PATH=$RUNNER_TEMP/keystore/upload.jks" >> "$GITHUB_ENV"

- name: Build do AAB
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: npx tauri android build --aab --ci

- name: Conferir o AAB
run: |
aab=$(find src-tauri/gen/android/app/build/outputs/bundle -name '*.aab' | head -n 1)
if [ -z "$aab" ]; then
echo "Nenhum AAB gerado" >&2
exit 1
fi
echo "AAB: $aab ($(du -h "$aab" | cut -f1))"

- name: Enviar para o track internal
if: ${{ startsWith(github.ref, 'refs/tags/v') && env.PLAY_SERVICE_ACCOUNT_JSON != '' }}
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJson: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.reinaldoassis.atlas
releaseFiles: src-tauri/gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab
track: internal
status: completed
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.3.3
0.4.0
154 changes: 154 additions & 0 deletions docs/ANDROID-BETA.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Atlas — beta Android na Google Play

Decisões e plano da primeira distribuição do Atlas no Android. Vale para a
série **0.4.x**; o PC (macOS/Windows) continua **100% grátis**.

> Estado: decisões fechadas em 21/10/2026; código entregue no PR #46. Fatias
> marcadas ✅ já entregues.

## Estado em 22/10/2026

**Código** — PR [#46](https://github.com/ReinaldoAssis/Atlas/pull/46), 9 commits,
CI principal 4/4 verde (frontend ubuntu/windows, rust, instalador Windows).
O workflow `Android` (`workflow_dispatch`/tag) está em validação piloto no CI
(último run: SDK/NDK resolvidos; build do AAB em andamento).

**Verificado localmente (Mac)**

| Item | Resultado |
|---|---|
| `npm run check` | 0 erros / 0 warnings |
| `npm test` | 264 testes |
| `npm run build` | ok |
| `npm run csp:check` | 23/23 (inclui boot em `en`) |
| `cargo test` | 50 testes |
| APK debug aarch64 | `com.reinaldoassis.atlas.debug`, versionCode 400, minSdk 29, target 36, `MulticastLockPlugin` e permissão `BILLING` presentes |
| AAB release | gerado e assinado com keystore descartável (valida o signing config do Gradle) |

**Não verificado** — nada disto tem device/Play por trás ainda: boot e sync no
aparelho, limites do free na prática, compra/restore no sandbox, upload real no
track `internal` (sem secrets configurados).

**Ambiente local usado** (reproduzir em outra máquina): JDK 17
(`brew install openjdk@17`), `cmdline-tools` em `~/Library/Android/sdk`,
NDK `27.0.12077973`, targets Rust `aarch64-linux-android`,
`armv7-linux-androideabi`, `x86_64-linux-android`.

## Decisões travadas

| Tema | Decisão |
|---|---|
| Application ID | `com.reinaldoassis.atlas` (todas as plataformas) |
| `minSdk` / `targetSdk` | **29** / **36** (Play exige 36 desde 31/08/2026) |
| `versionCode` | `major*10000 + minor*100 + patch` (0.4.0 → 400) |
| Canais | `internal` (até 100) + `closed` (12 testers) em paralelo |
| Produção | conta **pessoal nova** → closed test com 12 testers opt-in por 14 dias |
| Assinatura | upload key local + secret do CI; **Play App Signing** ligado; AAB nunca vira artifact público |
| Pipeline | tag `v*` → job ubuntu assina e sobe o AAB no track `internal`; promoção pra `closed` manual no Console |
| Billing | `tauri-plugin-iap` (Play Billing 9, MIT), Android agora e iOS depois |
| Produtos | mensal **R$ 12,50**, anual **R$ 97**, trial **7 dias** |
| Entitlement | cache local no SQLite + reconsulta à Play em launch/resume + **grace de 7 dias offline**; checagem em Rust |
| Gating | limites compilados só para mobile (`cfg(android/ios)`); PC sem gating; backstop em Rust no `db_batch` |
| Sync | entra na beta; descoberta via `mdns-sd` + `MulticastLock` (plugin Kotlin) |
| Lembretes | paridade com iOS (`POST_NOTIFICATIONS`, alarme exato no Android 12+) |
| Idiomas | pt-BR (base) + en; seletor Sistema / Português / English; fallback **en** fora de pt/en |
| Política | `atlas.reinaldoassis.com/privacy` — texto pronto em `docs/PRIVACY.md`; publicação no site pendente |
| Assets da ficha | depois do código e do billing |

## Free vs Pro (mobile)

PC não tem limite nem paywall.

| Item | Free | Pro |
|---|---|---|
| Notas ativas | 7 | ilimitado |
| Anexos | 5 | ilimitado |
| Hábitos ativos | 3 | ilimitado |
| Trackers ativos | 2 | ilimitado |
| Histórico (hábitos/trackers) | últimos 7 dias | completo |
| Nós do grafo | 5 (origem não conta) | ilimitado |
| Categorias de finanças | 4 custom | ilimitado |
| Categorias de tempo | 5 custom | ilimitado |
| Sync entre aparelhos | — | ✅ |
| Backup manual | ✅ | ✅ |

O seed de categorias cabe nos limites: finanças = Salário, Moradia,
Alimentação e Lazer; tempo = Foco profundo, Leitura, Saúde, Pessoal e
Descanso. `Transporte` e `Administração` saíram do seed (recriáveis como
categoria custom) para valer também no PC.

## Fatias

1. **Fundação** ✅ — versão 0.4.0, `tauri android init`, JDK 17 + NDK 27,
build debug no device, identifier/minSdk/targetSdk ajustados.
2. **Sync Android** ✅ — plugin Kotlin com `MulticastLock`, `sync` compilando
no Android, pareamento a validar em device real.
3. **i18n completo** ✅ — pt-BR + en, seletor nas Configurações, migração de
toda a copy (markup, atributos, toasts, erros), `Intl` por locale, guard
sem allowlist. Ver `docs/I18N.md`.
4. **Gating + entitlement** ✅ — `plan.rs` valida os limites dentro da
transação do `db_batch` (rollback + `plan-limit:<chave>:<limite>`), cache de
Pro com grace de 7 dias, guard otimista na UI e paywall (sheet no limite +
linha em Configurações). Histórico do free corta em 7 dias na lista.
5. **Billing** ✅ — `tauri-plugin-iap` (Billing 9) só no mobile, preços da
loja com fallback, compra/restore/acknowledge e renovação via listener.
Falta criar os produtos na Play e testar com license tester.
6. **Pipeline de release** ✅ — workflow `Android` em tag, upload key pelos
secrets, AAB direto pro track `internal`, `versionCode` conferido.
7. **Loja e site** — textos da ficha e política prontos (`docs/STORE-LISTING.md`,
`docs/PRIVACY.md`); falta publicar no site, produzir assets e preencher os
formulários da Play.

## Checklist manual (fora do código)

Ordem prática; detalhes em `docs/PLAY-STORE.md` e `docs/RELEASES.md`.

**1. Conta e canais**
- [ ] Conta pessoal no Play Console (US$ 25) e app `com.reinaldoassis.atlas`.
- [ ] Track `internal` liberado.
- [ ] Recrutar **12+ testers** com conta Google e abrir o **closed test**
(14 dias corridos com todos opt-in; internal não conta).

**2. Assinatura e automação**
- [ ] Gerar a upload key (`keytool -genkeypair …`) e guardar `.jks` + senhas.
- [ ] Ligar **Play App Signing** no Console.
- [ ] Cadastrar os secrets: `ANDROID_KEYSTORE_BASE64`,
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
- [ ] Conta de serviço da Play Developer API → `PLAY_SERVICE_ACCOUNT_JSON`.
- [ ] Rodar o workflow `Android` com os secrets e conferir o AAB no track
`internal` (build local já validado).

**3. Produtos**
- [ ] `atlas_pro_monthly` — R$ 12,50/mês, trial 7 dias.
- [ ] `atlas_pro_annual` — R$ 97/ano, trial 7 dias.
- [ ] License testers + testar compra, acknowledge, restore e cancelamento.

**4. Device**
- [ ] Instalar o APK debug e testar boot, i18n (troca pt/en), limites do free
e o paywall.
- [ ] Parear Android ↔ desktop e Android ↔ Android na mesma rede (sync).
- [ ] Conferir lembretes locais e o pedido de `POST_NOTIFICATIONS`.

**5. Loja**
- [ ] Publicar a política em `https://atlas.reinaldoassis.com/privacy`
(texto em `docs/PRIVACY.md`).
- [ ] Ficha pt-BR/en (`docs/STORE-LISTING.md`), segurança de dados,
classificação etária e declaração de anúncios.
- [ ] Produzir assets: ícone 512, feature graphic 1024×500, screenshots.

## Riscos

- **Assinatura e GPL**: o app é open source; quem compilar por conta própria
contorna o gating. O objetivo é deter o usuário comum, não impedir fork.
- **Sync no Android**: multicast com `MulticastLock` cai em economia de energia;
sync continua só com o app aberto, como no desktop.
- **Play Billing**: reembolso/cancelamento só chegam via reconsulta; a grace de
7 dias é o que evita bloquear usuário legítimo sem rede. O plugin
`tauri-plugin-iap` está em RC e pinado por versão exata; subir de versão exige
testar compra/restore de novo.
- **Troca de identificador**: `com.reinaldoassis.atlas` substitui o antigo
`com.randroi75.atlas`, então instalações de teste de desktop/iOS começam com
appdata/bundle novos. Dados antigos só voltam por import de backup `.zip`.
- **Keystore**: perder a upload key não derruba a distribuição (o Play App
Signing permite rotacionar), mas o AAB precisa ser reenviado com a chave nova;
guarde o `.jks` e as senhas fora do repositório.
59 changes: 59 additions & 0 deletions docs/I18N.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# i18n — dois idiomas, sem reload

Atlas fala **pt-BR** (base) e **en**. A escolha do usuário vive no aparelho;
não existe rota `/pt-BR/...`, cookie nem servidor.

## Como funciona

| Peça | Onde |
|---|---|
| Mensagens | `messages/pt-BR.json`, `messages/en.json` |
| Locales e estratégia | `project.inlang/settings.json`, `project.inlang/paraglide.config.ts` |
| Resolução (pura, testada) | `src/lib/i18n/locale.ts` |
| Estado reativo e troca | `src/lib/i18n/locale.svelte.ts` (`i18n.locale`, `setLocaleChoice`) |
| `Intl` (data/moeda) | `src/lib/i18n/intl.ts` (`intlLocale()`) |
| Guard contra copy solta | `src/lib/i18n/hardcoded.test.ts` |

Regras de resolução, em ordem:
1. escolha salva (`atlas.locale.mode`) — `pt-BR`, `en` ou `system`;
2. idioma do sistema: `pt*` → pt-BR, **qualquer outra língua → en**;
3. `baseLocale` (pt-BR) é só fallback de chave ausente.

Trocar o idioma **não recarrega** a página: `+page.svelte` envolve o app num
`{#key i18n.locale}`. As funções `m.*()` do Paraglide não são reativas, então a
remontagem é o que faz a árvore inteira reler as mensagens.

## Escrevendo copy

- Toda string visível vem de `m.chave()`. Isso inclui `placeholder`,
`aria-label`, `title`, `alt`, toasts, erros e estados vazios.
- Chaves em `snake_case`, prefixadas pela área: `settings_`, `fin_`, `notes_`,
`graph_`, `trash_`, `common_`…
- Interpolação sempre na mensagem (`"synced {minutes} min ago"`), nunca por
concatenação.
- Plurais: duas chaves (`*_one` / `*_many`) escolhidas por mensagem inteira.
- **Não** entram no catálogo: números, símbolos, unidades (`kg`, `min`), ids,
classes CSS, nomes próprios ("Atlas"), datas e dados do usuário.
- Dados criados pelo app (categorias, domínios do grafo, rótulos sim/não) são
semeados no idioma do primeiro boot e ficam gravados; trocar o idioma depois
**não** renomeia dados existentes.

## Guard

`npm test` roda `hardcoded.test.ts`: qualquer nó de texto/atributo com copy em
arquivo fora de `hardcoded-allowlist.json` quebra o CI. Ao migrar uma tela, a
lista encolhe; para regravá-la use:

```sh
UPDATE_I18N_ALLOWLIST=1 npx vitest run src/lib/i18n/hardcoded.test.ts
```

Nunca use o comando acima para "consertar" uma tela nova — ele esconde o
problema em vez de traduzir.

## Limitações conhecidas

- Strings dentro de `<script>` e em módulos `.ts` ainda não são cobertas pelo
guard (o detector cobre markup/atributos). Migração manual nesses casos.
- Também não entram no guard: rótulos que vêm de arrays de dados em `lib/`
(tratados por funções como `horizonLabel`, `sourceLabel`).
Loading
Loading