# App Android de controle de despesas com código-fonte: Kotlin, Jetpack Compose e uma sincronização offline de fato especificada

Um app em Kotlin e Jetpack Compose para clonar, abrir e rodar: despesas, categorias, orçamento, um gráfico, login como convidado ou por e-mail, e uma fila offline com regras escritas, testadas por unidade e exercitadas um cenário por vez.

URL: https://www.back4app.com/blog/pt/app-android-de-controle-de-despesas-com-codigo-fonte
Publicado: 2026-11-06 · Testado: 2026-10-23
Testado em: App: Kotlin 2.4, Jetpack Compose BOM 2026.01 (Material 3), Room 2.8, WorkManager 2.11, Parse Android SDK 4.4, AGP 8.13.2, Gradle 9.5.1, compileSdk 35, minSdk 26 · Emulador: perfil Pixel 6, Android 15, arm64, num Mac Apple Silicon · Backend: Back4app, Parse Server 7.5.2 (MongoDB), Cloud Code no Node 19.9, plano Free · Verificação local: parse-server 7.5.4 no PostgreSQL 17
Publisher: Back4app Engineering

A busca que leva até aqui costuma ser "app android de controle de despesas com código fonte". O que ela encontra é um projeto de dez anos atrás que não compila mais, ou um tutorial cujo "suporte offline" cabe em uma frase.

Este post é um app completo e atual, com o backend, e a parte offline é o ponto: as regras estão escritas, têm testes unitários e depois rodam num emulador, um cenário por vez.

Tudo abaixo foi feito em 23 de outubro de 2026: o build num Mac com um Android Studio recém-instalado, a rodada no emulador contra um Parse Server local e o lado do backend contra a plataforma Back4app ao vivo. Os números vêm dessas rodadas e os logs estão no repositório.

**Baixe, rode, leia:** o código-fonte está em [github.com/templates-back4app/android-expense-tracker](https://github.com/templates-back4app/android-expense-tracker), MIT, e o [APK de release](https://github.com/templates-back4app/android-expense-tracker/releases/tag/v0.1.0) instala em Android 8.0 ou mais novo e aponta para um backend de demonstração. Toque em *Continue as guest*.

## O que você recebe, tela por tela

Cinco telas, e nada nelas que a história da sincronização não precise. A tela de login oferece e-mail e senha ou *Continue as guest*, que cria um usuário anônimo do Parse. A de despesas mostra um mês por vez, o total, a barra de orçamento quando um orçamento existe, um gráfico de barras com os totais por categoria desenhado com o `Canvas` do Compose, e as linhas.

O formulário de lançamento recebe um valor digitado como `12.34`, uma categoria, uma data e uma nota. Categorias e configurações fazem o que o nome diz.

![A tela Expenses de outubro de 2026: total de US$ 157,84, um gráfico de barras com Transport US$ 100,50, Coffee US$ 45,00 e Food US$ 12,34, e as três linhas abaixo dele](/blog/blog-assets/android-expense-tracker-app-with-source-code/02-entries-chart-acme.jpg)

Dinheiro é centavo inteiro em USD. `12.34` vira 1234, porcentagens arredondam a metade para cima, e um teste unitário guarda o exemplo trabalhado. Vírgula decimal não é separador de milhar: a primeira rodada de testes pegou `12,34` sendo lido como 1234 dólares, e o parser agora só aceita vírgulas em grupos de três.

O orçamento e as categorias ficam no dispositivo e não são sincronizados. É uma decisão de escopo, declarada no README, não um descuido: a máquina de sincronização existe para os lançamentos, e mantê-la em uma só classe mantém as regras legíveis.

## A stack, e a janela de versões que compila hoje

Kotlin 2.4, Jetpack Compose com o BOM 2026.01 e Material 3, Room 2.8, WorkManager 2.11, Navigation Compose, o Parse Android SDK 4.4 via JitPack e, no servidor, Cloud Code para Parse Server 7 no Node 19. Ferramentas de build: Android Gradle Plugin 8.13.2 no Gradle 9.5.1, compileSdk 35, minSdk 26.

Esses dois números de build não foram escolha livre. A máquina tinha um Android Studio 2026.2 recém-instalado, que vem com o JDK 25 e nada mais. O Gradle 8.14 se recusa a iniciar no JDK 25, e sua única mensagem é a string nua `25.0.3`.

O Gradle 9.8, o mais novo, falha na configuração porque o AGP 8.x usava uma API interna do Gradle que o 9.6 removeu. A janela que funciona é JDK 25, Gradle 9.1 a 9.5, AGP 8.x.

As versões das bibliotecas foram verificadas do mesmo jeito, lendo os metadados de cada AAR: core-ktx 1.17 e activity-compose 1.11 exigem compileSdk 36, então o template fixa as últimas versões que aceitam 35. O diário de build lista cada uma, com os tamanhos de download.

| O quê | Medido na máquina de build |
|---|---|
| SDK mínimo para um build por linha de comando (platform 35, build-tools, platform-tools, cmdline-tools) | 526 MB em disco |
| Com o emulador e uma imagem de sistema Android 15 arm64 | 5.660 MB em disco, 7 min 29 s de downloads |
| APK de debug | 14.325.116 bytes |
| APK de release, minificado com R8 e com recursos reduzidos | 2.083.111 bytes |
| Suíte de testes JVM | 29 testes, cerca de 4 s num daemon aquecido |

![O formulário New expense preenchido: valor 12.34, categoria Food, a data de hoje e a nota lunch](/blog/blog-assets/android-expense-tracker-app-with-source-code/03-entry-form-acme.jpg)

## A sincronização offline, como especificação

O app nunca espera pela rede. Toda escrita é uma transação do Room que atualiza a tabela `entries` e acrescenta uma operação à tabela `ops`, o outbox. Um job do WorkManager com restrição de rede esvazia o outbox do mais antigo para o mais novo e depois faz o pull. Seis regras tornam isso seguro, e cada uma é uma linha em um objeto Kotlin puro com um teste unitário.

1. **Lançamentos têm identidade criada no cliente.** A chave é um UUID gerado no celular, então uma despesa existe e pode ser editada antes de o servidor ouvir falar dela.

2. **Toda operação tem uma chave de idempotência.** O próprio UUID, mantido entre tentativas. O servidor o insere em uma classe `AppliedOp` com índice único antes de tocar no lançamento; uma segunda chegada falha ao inserir e é respondida com o estado atual e `duplicate opId`.

3. **A ordem importa por lançamento, não globalmente.** Se uma operação falha, só as operações posteriores do mesmo lançamento esperam.

4. **O último a chegar vence, por campo.** Um update carrega os campos alterados e a versão que o celular viu por último. O servidor aplica de qualquer jeito e incrementa a versão. Se a versão base estava desatualizada, aplica mesmo assim, porque "último" significa ordem de chegada no servidor, e registra uma linha de `Conflict` com os campos que chegaram.

5. **Exclusões são tombstones.** Um delete define `deletedAt`, incrementa a versão, e a linha fica para que os outros dispositivos saibam dela. Um tombstone vence qualquer edição posterior. Tombstones são expurgados depois de 90 dias por um job; um dispositivo offline por mais tempo que isso faz um pull completo.

6. **Operações offline encadeadas passam por rebase.** Criar, editar e editar de novo um mesmo lançamento offline enfileira três operações construídas uma sobre a outra; quando uma termina com a versão V do servidor, as ainda enfileiradas desse lançamento passam por rebase para V. Sem essa regra, toda cadeia produzia um falso conflito, e foi assim que a regra foi encontrada.

A regra 4 é a que merece duas leituras. Um celular que reconecta amanhã com uma edição offline mais antiga vai sobrescrever a edição de hoje nos campos que alterou. Isso é deliberado, é o que "o último a chegar vence" significa, e a alternativa, rejeitar edições desatualizadas, é uma mudança de uma linha na Cloud function, que o repositório indica.

A metade do servidor é uma Cloud function, `syncOp`, para create, update e delete, e outra, `listEntries`, para o pull. Clientes não conseguem escrever nas três classes diretamente: as permissões por classe recusam, e um hook `beforeSave` recusa de novo qualquer requisição que não carregue a master key.

## Catorze cenários offline, um resultado para cada

A rodada no emulador conduziu o app só com `adb`, sem testes instrumentados, contra um Parse Server local com PostgreSQL, para que toda requisição pudesse ser lida no log do servidor e toda linha no `psql`.

"Offline" era o modo avião; zero requisições chegaram ao servidor enquanto ele estava ligado. A matriz completa está no repositório; estas são as células que importam.

| Cenário | O que aconteceu no build final |
|---|---|
| Criar, editar e excluir offline, um lançamento cada | cada um salvo na hora, contador de `1 unsynced` a `9 unsynced`, linhas marcadas; depois de reconectar, create versão 1, update versão 2, delete um tombstone |
| Criar e depois editar o mesmo lançamento offline | ambos enfileirados; servidor: create versão 1, update versão 2, sem conflito (o build sem a correção registrava um falso) |
| Editar e depois excluir o mesmo lançamento offline | a edição enfileirada é descartada, só o delete é enviado; tombstone versão 2 |
| Matar o app com 9 operações enfileiradas, relançar offline | `9 unsynced` ainda aparece; o outbox está no Room, não na memória |
| Reconectar com 9 operações enfileiradas | 9 chamadas `syncOp` em 130 ms, da mais antiga para a mais nova, 12 ids distintos em `AppliedOp`, cada operação aplicada uma vez, contador em 0 em 2 s |
| Um segundo dispositivo edita um lançamento online, o celular toca em *Sync now* | a linha mostra a nota do outro dispositivo depois do pull |
| Os dois dispositivos editam o mesmo lançamento, o celular offline | a edição do celular chega por último e vence; uma linha de `Conflict` registra base 4 contra servidor 5 com os campos enviados; *Settings* lista o conflito |
| Servidor inacessível por 60 s com a rede ligada | o worker registrou a falha, esperou 30 s e reenviou o mesmo id; uma linha em `AppliedOp`, sem duplicata |
| Emulador reiniciado com tudo sincronizado | dados do Room, categorias, orçamento e a sessão do Parse, todos presentes |

![A tela Expenses em modo avião: 9 unsynced no card de orçamento, linhas marcadas com um ponto, totais já atualizados](/blog/blog-assets/android-expense-tracker-app-with-source-code/04-offline-queued-acme.jpg)

## O que só rodar o app encontrou

Vinte e oito testes unitários passaram e a verificação de contrato passou antes de o app ser aberto pela primeira vez. O emulador encontrou cinco bugs em vinte e cinco minutos, todos corrigidos no mesmo dia:

- **Toda sincronização fazia o pull três vezes.** O log do servidor mostrava um `syncOp` seguido de três `listEntries`. Um `return@repeat` dentro de `repeat(3)` encerra a iteração, não o loop.
- **Falsos conflitos em edições offline encadeadas.** A regra 6 acima não existia. A primeira correção fez o rebase das linhas no Room, mas não da lista que o motor mantinha em memória, e os conflitos continuaram; a segunda correção fez o rebase das duas.
- **Settings dizia "syncing…" offline.** O WorkManager reporta `ENQUEUED` também para "restrições não atendidas". Só `RUNNING` significa sincronizando.
- **O seletor de categoria era invisível para a acessibilidade.** Uma caixa clicável transparente sem semântica: o TalkBack e o `uiautomator` não viam rótulo nem valor.
- **O Cloud Code carregava como módulo ES localmente.** O `package.json` da raiz diz `"type": "module"` para os scripts, então `require` era undefined dentro de `cloud/main.js` e a guarda do setup caía no padrão sem avisar. Um `package.json` na pasta `cloud` o torna CommonJS, que é o que a Back4app roda de qualquer forma.

## Segurança, com as chaves de dentro do APK

Qualquer um consegue descompactar um APK, então a pergunta é o que as duas chaves dele permitem. O Application ID e a Client key identificam o app, e os testes em `scripts/check-sync.mjs` enviam o que um cliente hostil enviaria com exatamente essas chaves, contra o backend ao vivo:

| Tentativa | Resposta do backend ao vivo |
|---|---|
| qualquer Cloud function sem sessão | 209 "Sign in to sync." |
| `POST /classes/Entry` com sessão | 119 "Permission denied for action create on class Entry." |
| `PUT` ou `DELETE` em uma linha de Entry com sessão | 119 |
| `GET /classes/AppliedOp` com sessão | 119 "Permission denied for action find on class AppliedOp." |
| `GET /classes/Entry` com sessão | 200, e só as linhas do próprio chamador |
| o mesmo id de operação enviado duas vezes em sequência | a segunda responde `applied: false, reason: duplicate opId`, uma linha |
| o mesmo id de operação enviado duas vezes em paralelo | um `applied: true`, um `duplicate opId`, uma linha |

A master key não está no app, não está nos scripts e não é necessária na sua máquina: schema e permissões são criados por uma Cloud function que roda com a master key dentro do backend, protegida por uma chave `SETUP_ENABLED` que o README manda desligar antes de os usuários reais chegarem.

## Como fazer o deploy do seu próprio backend

1. **Crie um app na Back4app** e copie o Application ID, a Client key e a REST key. Coloque as duas primeiras no `local.properties` para o app, e a primeira e a terceira no `.env` para os scripts. Deixe a master key onde está.

2. **Cloud Code.** Na página Cloud Code da dashboard, selecione a pasta `cloud`, envie `cloud/config.js`, cole `cloud/main.js` no editor, confirme o selo *Files pending deploy (2)* e clique em *Deploy*. O primeiro deploy de um backend recém-criado pode reportar sucesso e não enviar nada; a prova é `node scripts/setup.mjs` imprimir *Status now: LIVE*.

3. **Schema.** `node scripts/setup.mjs` chama `setupSchema` com a REST key: três classes, seus campos e permissões por classe, em uma chamada, idempotente.

4. **O índice único** em `AppliedOp.opId`, depois da primeira linha sincronizada, como acima.

5. **A verificação de contrato.** `node scripts/check-sync.mjs` cria um usuário descartável, roda as 19 verificações e apaga o que criou, só com a REST key.

6. **Feche a porta do setup.** Defina `SETUP_ENABLED: false` em `cloud/config.js`, envie e faça o deploy de novo.

7. **Rode o app.** Aperte *Run* no Android Studio, ou `./gradlew assembleDebug` na linha de comando com `JAVA_HOME` apontando para o JDK que vem com o Android Studio.

Ainda sem conta na Back4app? `scripts/dev-server.mjs` sobe o mesmo Cloud Code em um parse-server local com PostgreSQL, e o build de debug permite HTTP sem TLS só para o endereço do host do emulador. Foi assim que a matriz offline foi produzida.

## Medições, e quanto cada uma vale

Cinco números, cada um com o rótulo que merece. Um deles vale menos do que parece, e a tabela diz qual.

| Medição | Valor | Rótulo |
|---|---|---|
| Boot a frio do emulador até `sys.boot_completed` | 18 s | medido, uma rodada |
| Cold start do app, perfil Pixel 6, Android 15 arm64, build de debug, 10 rodadas | p50 de 932 ms, mín. 872, máx. 1.021 | medido, exploratório |
| Esvaziamento de 9 operações enfileiradas depois de reconectar | 130 ms no servidor, contador em 0 em 2 s | medido, uma rodada |
| Verificação de contrato contra o backend ao vivo | 19 de 19, depois da correção do increment | medido |
| APK de release no emulador contra o backend ao vivo | login como convidado, uma despesa, sincronização em dia, linha na dashboard | medido, uma rodada |
| Download do SDK para emulador e imagem de sistema | 7 min 29 s, 2,2 GB | medido, uma rodada |

O cold start é um número para comparar consigo mesmo depois de uma mudança, não um dado de dispositivo: é um build de debug num emulador rodando num Mac M-series. O brief deste post pedia um celular intermediário com nome, e essa rodada ainda não aconteceu.

## Quando não usar este template

Se você precisa de recibos, várias moedas, orçamentos compartilhados ou importação bancária, isto é um ponto de partida, não um app. Se precisa que a sincronização resolva conflitos em vez de registrá-los, escreva a política primeiro; o repositório mostra onde ela entra.

Se o seu app precisa funcionar sem backend nenhum, o Parse Local Datastore é um design diferente com trade-offs diferentes, e este template deliberadamente não o usa.

E note o que não foi verificado: o app num dispositivo físico, os cenários offline contra o backend ao vivo em vez do servidor local, a expiração de sessão no caminho do worker e o expurgo de 90 dias, que tem teste unitário como regra e ainda não foi observado. Quando essas rodadas acontecerem, este parágrafo será substituído pelos resultados.

## O repositório

[github.com/templates-back4app/android-expense-tracker](https://github.com/templates-back4app/android-expense-tracker), MIT. `app/` é o projeto Android, `cloud/` tem as duas funções e os hooks, `scripts/` tem o setup, a verificação de contrato e o servidor local.

`docs/notes/build-diary.md` registra cada download, decisão de versão e bug com carimbo de hora, e `docs/evidence/` guarda os logs de build, a rodada no emulador, a matriz offline, as capturas de tela, os logs do backend ao vivo e o checksum do APK.
