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, MIT, e o APK de release 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.
![]()
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 |
![]()
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.
-
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.
-
Toda operação tem uma chave de idempotência. O próprio UUID, mantido entre tentativas. O servidor o insere em uma classe
AppliedOpcom índice único antes de tocar no lançamento; uma segunda chegada falha ao inserir e é respondida com o estado atual eduplicate opId. -
A ordem importa por lançamento, não globalmente. Se uma operação falha, só as operações posteriores do mesmo lançamento esperam.
-
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
Conflictcom os campos que chegaram. -
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. -
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.
Conflitos são registrados, não resolvidos. Para um template, esse é o padrão honesto: a evidência fica guardada, e a política é você quem escreve.
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 |
![]()
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
syncOpseguido de trêslistEntries. Umreturn@repeatdentro derepeat(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
ENQUEUEDtambém para “restrições não atendidas”. SóRUNNINGsignifica sincronizando. - O seletor de categoria era invisível para a acessibilidade. Uma caixa clicável transparente sem semântica: o TalkBack e o
uiautomatornão viam rótulo nem valor. - O Cloud Code carregava como módulo ES localmente. O
package.jsonda raiz diz"type": "module"para os scripts, entãorequireera undefined dentro decloud/main.jse a guarda do setup caía no padrão sem avisar. Umpackage.jsonna pastacloudo 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
-
Crie um app na Back4app e copie o Application ID, a Client key e a REST key. Coloque as duas primeiras no
local.propertiespara o app, e a primeira e a terceira no.envpara os scripts. Deixe a master key onde está. -
Cloud Code. Na página Cloud Code da dashboard, selecione a pasta
cloud, enviecloud/config.js, colecloud/main.jsno 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.mjsimprimir Status now: LIVE. -
Schema.
node scripts/setup.mjschamasetupSchemacom a REST key: três classes, seus campos e permissões por classe, em uma chamada, idempotente. -
O índice único em
AppliedOp.opId, depois da primeira linha sincronizada, como acima. -
A verificação de contrato.
node scripts/check-sync.mjscria um usuário descartável, roda as 19 verificações e apaga o que criou, só com a REST key. -
Feche a porta do setup. Defina
SETUP_ENABLED: falseemcloud/config.js, envie e faça o deploy de novo. -
Rode o app. Aperte Run no Android Studio, ou
./gradlew assembleDebugna linha de comando comJAVA_HOMEapontando 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, 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.
