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.

Três telas de celular do app de controle de despesas: a lista Expenses de outubro de 2026 com total de US$ 157,84 e um gráfico de barras por categoria, a mesma lista offline com 9 operações não sincronizadas e um orçamento de US$ 500 em 14%, e a tela Settings com o orçamento mensal e um card Sync mostrando atualizado, 0 operações pendentes, 0 conflitos

Tradução do original em inglês, testado em 23 de outubro de 2026. Código, números e datas são os do original. Read in English

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.

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

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 arm645.660 MB em disco, 7 min 29 s de downloads
APK de debug14.325.116 bytes
APK de release, minificado com R8 e com recursos reduzidos2.083.111 bytes
Suíte de testes JVM29 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

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.

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árioO que aconteceu no build final
Criar, editar e excluir offline, um lançamento cadacada 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 offlineambos 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 offlinea edição enfileirada é descartada, só o delete é enviado; tombstone versão 2
Matar o app com 9 operações enfileiradas, relançar offline9 unsynced ainda aparece; o outbox está no Room, não na memória
Reconectar com 9 operações enfileiradas9 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 nowa linha mostra a nota do outro dispositivo depois do pull
Os dois dispositivos editam o mesmo lançamento, o celular offlinea 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 ligadao worker registrou a falha, esperou 30 s e reenviou o mesmo id; uma linha em AppliedOp, sem duplicata
Emulador reiniciado com tudo sincronizadodados 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

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:

TentativaResposta do backend ao vivo
qualquer Cloud function sem sessão209 “Sign in to sync.”
POST /classes/Entry com sessão119 “Permission denied for action create on class Entry.”
PUT ou DELETE em uma linha de Entry com sessão119
GET /classes/AppliedOp com sessão119 “Permission denied for action find on class AppliedOp.”
GET /classes/Entry com sessão200, e só as linhas do próprio chamador
o mesmo id de operação enviado duas vezes em sequênciaa segunda responde applied: false, reason: duplicate opId, uma linha
o mesmo id de operação enviado duas vezes em paraleloum 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çãoValorRótulo
Boot a frio do emulador até sys.boot_completed18 smedido, uma rodada
Cold start do app, perfil Pixel 6, Android 15 arm64, build de debug, 10 rodadasp50 de 932 ms, mín. 872, máx. 1.021medido, exploratório
Esvaziamento de 9 operações enfileiradas depois de reconectar130 ms no servidor, contador em 0 em 2 smedido, uma rodada
Verificação de contrato contra o backend ao vivo19 de 19, depois da correção do incrementmedido
APK de release no emulador contra o backend ao vivologin como convidado, uma despesa, sincronização em dia, linha na dashboardmedido, uma rodada
Download do SDK para emulador e imagem de sistema7 min 29 s, 2,2 GBmedido, 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.

Tagsandroidkotlinjetpack-composeroomworkmanageroffline-firstsyncparsecloud-codeexpense-trackersource-code

Perguntas frequentes

Posso só baixar o APK e testar?

Sim. O APK de release (2,08 MB) está na página de Releases do repositório, apontando para um backend de demonstração. Instale em um Android 8.0 ou mais novo, toque em Continue as guest e adicione uma despesa. Suas linhas são legíveis só pela sua conta; os dados do backend de demonstração podem ser apagados a qualquer momento. Para rodar o código-fonte você precisa do Android Studio e de um app Back4app seu, e o README leva você por isso em cerca de dez minutos.

Por que as chaves dentro do APK não importam?

O app só carrega o Application ID e a Client key, que identificam o app, não uma pessoa. Toda escrita passa por uma Cloud function que verifica a sessão e define uma ACL só do dono na linha, as permissões por classe recusam qualquer escrita direta, e um hook no servidor recusa de novo mesmo que uma permissão fosse afrouxada por engano. Enviamos essas escritas diretas com as chaves do APK: todas responderam com o código 119. A master key nunca sai da dashboard.

O que acontece quando dois celulares editam a mesma despesa offline?

As duas edições são aplicadas, na ordem em que chegam ao servidor, campo a campo, e a segunda é registrada como conflito porque a versão em que se baseava estava desatualizada. Nada é mesclado em silêncio e nada é descartado: a linha de Conflict guarda os campos que o dispositivo perdedor enviou, e a tela Settings lista os mais recentes. Uma exclusão vence qualquer edição posterior.

Como o app evita salvar uma despesa duas vezes quando há uma nova tentativa?

Cada operação enfileirada carrega o próprio UUID. O servidor insere esse id em uma classe AppliedOp com índice único antes de tocar no lançamento; a segunda chegada falha ao inserir e o servidor responde 'duplicate opId' com o estado atual. Enviamos o mesmo id duas vezes em sequência e duas vezes em paralelo contra o backend ao vivo: uma linha a cada vez.

Por que Gradle 9.5, e não o 8 ou o mais novo 9.8?

O Android Studio 2026.2 vem com o JDK 25. O Gradle 8.14 se recusa a iniciar nele, e o Android Gradle Plugin 8.13 recusa o Gradle 9.6 ou mais novo porque uma API interna do Gradle que ele usava foi removida. A janela que funciona nessa máquina é JDK 25, Gradle 9.1 a 9.5, AGP 8.x. O diário de build tem os textos exatos dos erros.

O Parse Local Datastore é usado para o suporte offline?

Não. O estado offline vive no Room: uma tabela de lançamentos e uma tabela de operações que funciona como outbox, escritas em uma única transação. O Parse SDK é usado para entrar e para chamar as duas Cloud functions. Isso mantém as regras de sincronização em um único objeto Kotlin puro que um teste unitário consegue exercitar sem dispositivo.

Leitura complementar

Revisado e verificado por Back4app Engineering, Editorial review na Back4app

Testado em 23 de outubro de 2026 — 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.

Mais da Back4app Engineering

Containers · tutorial

Como criar um controle de fluxo de caixa com JavaScript

Backend · tutorial

Como criar o backend de um app Flutter: login, dados e notificações push

Backend · error-guide

Why Does the API Return 101, 119, 142 or 209? Every Backend Error Code, Reproduced [Full Repo]