Android Expense Tracker App With Source Code: Kotlin, Jetpack Compose and Offline Sync That Is Actually Specified [Full Repo]

A Kotlin and Jetpack Compose app you can clone, open and run: expenses, categories, a budget, one chart, guest or e-mail sign-in, and an offline queue whose rules are written down, unit-tested and exercised one scenario at a time.

Three phone screens of the expense tracker app: the Expenses list for October 2026 with a $157.84 total and a bar chart by category, the same list offline with 9 unsynced operations and a $500 budget at 14%, and the Settings screen with the monthly budget and a Sync card reading up to date, 0 pending operations, 0 conflicts

The search that leads here is usually “android expense tracker app source code”, and what it finds is either a ten-year-old project that no longer builds or a tutorial whose “offline support” is a sentence. This post is a complete, current app with its backend, and the offline part is the point: the rules are written down, unit-tested, and then run on an emulator one scenario at a time.

Everything below was done on October 23, 2026: the build on a Mac with a fresh Android Studio, the emulator run against a local Parse Server, and the backend side against the live Back4app platform. The numbers come from those runs and the logs are in the repository.

Download it, run it, read it: the source is at github.com/templates-back4app/android-expense-tracker, MIT, and the release APK installs on Android 8.0 or newer and points at a demo backend. Tap Continue as guest.

What you get, screen by screen

Five screens, and nothing on them that the sync story does not need. Login offers e-mail and password or Continue as guest, which creates an anonymous Parse user. Entries shows one month at a time, the total, the budget bar once a budget exists, a bar chart of category totals drawn with Compose Canvas, and the rows.

The entry form takes an amount typed as 12.34, a category, a date and a note. Categories and Settings do what their names say.

The Expenses screen for October 2026: a $157.84 total, a bar chart with Transport $100.50, Coffee $45.00 and Food $12.34, and the three rows below it

Money is integer cents in USD. 12.34 becomes 1234, percentages round half up, and a unit test holds the worked example. A decimal comma is not a thousands separator: the first test run caught 12,34 being read as 1234 dollars, and the parser now accepts commas only in groups of three.

The budget and the categories are stored on the device and not synced. That is a scope decision, stated in the README, not an oversight: the sync machinery is for the entries, and keeping it to one class keeps the rules readable.

The stack, and the version window that builds today

Kotlin 2.4, Jetpack Compose with the 2026.01 BOM and Material 3, Room 2.8, WorkManager 2.11, Navigation Compose, the Parse Android SDK 4.4 from JitPack, and on the server Cloud Code for Parse Server 7 on Node 19. Build tooling: Android Gradle Plugin 8.13.2 on Gradle 9.5.1, compileSdk 35, minSdk 26.

Those two build numbers were not a free choice. The machine had a fresh Android Studio 2026.2, which bundles JDK 25 and nothing else. Gradle 8.14 refuses to start on JDK 25, and its only message is the bare string 25.0.3. Gradle 9.8, the newest, fails at configuration because AGP 8.x used an internal Gradle API that 9.6 removed. The window that works is JDK 25, Gradle 9.1 to 9.5, AGP 8.x.

The library versions were checked the same way, by reading each AAR’s metadata: core-ktx 1.17 and activity-compose 1.11 require compileSdk 36, so the template pins the last versions that accept 35. The build diary lists every one, with the download sizes.

WhatMeasured on the build machine
Minimal SDK for a command-line build (platform 35, build-tools, platform-tools, cmdline-tools)526 MB on disk
With the emulator and one Android 15 arm64 system image5,660 MB on disk, 7 min 29 s of downloads
Debug APK14,325,116 bytes
Release APK, R8 minified and resource-shrunk2,083,111 bytes
JVM test suite29 tests, about 4 s on a warm daemon

The New expense form filled in: amount 12.34, category Food, today's date and the note lunch

The offline sync, as a specification

The app never waits for the network. Every write is one Room transaction that updates the entries table and appends an operation to the ops table, the outbox. A network-constrained WorkManager job drains the outbox oldest first and then pulls. Six rules make that safe, and each one is a line in a pure Kotlin object with a unit test.

  1. Entries have a client-made identity. The key is a UUID generated on the phone, so an expense exists and can be edited before the server has heard of it.

  2. Every operation has an idempotency key. Its own UUID, kept across retries. The server inserts it into an AppliedOp class that has a unique index before it touches the entry; a second arrival fails to insert and is answered with the current state and duplicate opId.

  3. Order matters per entry, not globally. If one operation fails, only later operations of the same entry wait.

  4. Last arrival wins, per field. An update carries the changed fields and the version the phone last saw. The server applies it regardless and bumps the version. If the base version was stale, it still applies it, because “last” means server arrival order, and records a Conflict row with the fields that arrived.

  5. Deletions are tombstones. A delete sets deletedAt, bumps the version, and the row stays so other devices learn of it. A tombstone beats any later edit. Tombstones are purged after 90 days by a job; a device offline longer than that does a full pull.

  6. Chained offline operations are rebased. Create, edit, edit of one entry while offline queues three operations built on top of each other; when one completes with server version V, the still-queued ones of that entry are rebased to V. Without this rule every chain produced a false conflict, which is how the rule was found.

Rule 4 is the one to read twice. A phone that reconnects tomorrow with an older offline edit will overwrite today’s edit for the fields it changed. That is deliberate, it is what “last arrival wins” means, and the alternative, rejecting stale edits, is a one-line change in the Cloud function that the repository points at.

Conflicts are recorded, not resolved. For a template, that is the honest default: the evidence is kept, and the policy is yours to write.

The server half is one Cloud function, syncOp, for create, update and delete, and one, listEntries, for the pull. Clients cannot write the three classes directly: the class-level permissions refuse it, and a beforeSave hook refuses it again for any request that does not carry the master key.

Fourteen offline scenarios, one outcome each

The emulator run drove the app with adb only, no instrumented tests, against a local Parse Server with PostgreSQL so that every request could be read in the server log and every row in psql. “Offline” was airplane mode; zero requests reached the server while it was on. The full matrix is in the repository; these are the cells that matter.

ScenarioWhat happened on the final build
Create, edit and delete while offline, one entry eacheach saved instantly, counter 1 unsynced to 9 unsynced, rows marked; after reconnecting, create version 1, update version 2, delete a tombstone
Create then edit the same entry offlineboth queued; server: create version 1, update version 2, no conflict (the unfixed build recorded a false one)
Edit then delete the same entry offlinethe queued edit is dropped, only the delete is sent; tombstone version 2
Kill the app with 9 queued operations, relaunch offline9 unsynced still shown; the outbox is in Room, not in memory
Reconnect with 9 queued operations9 syncOp calls in 130 ms, oldest first, 12 distinct ids in AppliedOp, every operation applied once, counter at 0 in 2 s
A second device edits an entry online, the phone taps Sync nowthe row shows the other device’s note after the pull
Both devices edit the same entry, the phone offlinethe phone’s edit arrives last and wins; a Conflict row records base 4 against server 5 with the sent fields; Settings lists it
Server unreachable for 60 s with the network upthe worker logged the failure, backed off 30 s, resent the same id; one AppliedOp row, no duplicate
Emulator rebooted with everything syncedRoom data, categories, budget and the Parse session all present

The Expenses screen in airplane mode: 9 unsynced on the budget card, rows marked with a dot, totals already updated

What only running it found

Twenty-eight unit tests passed and the contract check passed before the app was ever launched. The emulator found five bugs in twenty-five minutes, all fixed on the same day:

  • Every sync pulled three times. The server log showed one syncOp followed by three listEntries. A return@repeat inside repeat(3) ends the iteration, not the loop.
  • False conflicts for chained offline edits. Rule 6 above did not exist. The first fix rebased the rows in Room but not the list the engine held in memory, and the conflicts kept coming; the second fix rebased both.
  • Settings said “syncing…” while offline. WorkManager reports ENQUEUED for “constraints not met” too. Only RUNNING means syncing.
  • The category picker was invisible to accessibility. A transparent clickable box with no semantics: TalkBack and uiautomator saw neither label nor value.
  • The Cloud Code loaded as an ES module locally. The root package.json says "type": "module" for the scripts, so require was undefined inside cloud/main.js and the setup guard fell back to its default without a word. A package.json in the cloud folder makes it CommonJS, which is what Back4app runs anyway.

Security, with the keys from the APK

An APK can be unzipped by anyone, so the question is what its two keys allow. The Application ID and the Client key identify the app, and the tests in scripts/check-sync.mjs send what a hostile client would send with exactly those keys, against the live backend:

AttemptAnswer from the live backend
any Cloud function without a session209 “Sign in to sync.”
POST /classes/Entry with a session119 “Permission denied for action create on class Entry.”
PUT or DELETE on an Entry row with a session119
GET /classes/AppliedOp with a session119 “Permission denied for action find on class AppliedOp.”
GET /classes/Entry with a session200, and only the caller’s own rows
the same operation id sent twice in sequencesecond answers applied: false, reason: duplicate opId, one row
the same operation id sent twice in parallelone applied: true, one duplicate opId, one row

The master key is not in the app, not in the scripts, and not needed on your machine: schema and permissions are created by a Cloud function that runs with the master key inside the backend, guarded by a SETUP_ENABLED switch that the README tells you to turn off before real users arrive.

Deploying your own backend

  1. Create a Back4app app and copy the Application ID, Client key and REST key. Put the first two in local.properties for the app and the first and third in .env for the scripts. Leave the master key where it is.

  2. Cloud Code. In the dashboard’s Cloud Code page select the cloud folder, upload cloud/config.js, paste cloud/main.js into the editor, confirm the badge Files pending deploy (2) and Deploy. A fresh backend’s first deploy can report success and ship nothing; the proof is node scripts/setup.mjs printing Status now: LIVE.

  3. Schema. node scripts/setup.mjs calls setupSchema with the REST key: three classes, their fields and class-level permissions, in one call, idempotent.

  4. The unique index on AppliedOp.opId, after the first synced row, as above.

  5. The contract check. node scripts/check-sync.mjs creates a throw-away user, runs the 19 checks and deletes what it made, with the REST key alone.

  6. Close the setup door. Set SETUP_ENABLED: false in cloud/config.js, upload and Deploy again.

  7. Run the app. Press Run in Android Studio, or ./gradlew assembleDebug from the command line with JAVA_HOME pointing at Android Studio’s bundled JDK.

No Back4app account yet? scripts/dev-server.mjs starts the same Cloud Code on a local parse-server with PostgreSQL, and the debug build allows plain HTTP to the emulator’s host address only. That is how the offline matrix was produced.

Measurements, and what they are worth

Five numbers, each with the label it deserves. One of them is worth less than it looks, and the table says which.

MeasurementValueLabel
Emulator cold boot to sys.boot_completed18 smeasured, one run
App cold start, Pixel 6 profile, Android 15 arm64, debug build, 10 runsp50 932 ms, min 872, max 1,021measured, exploratory
Drain of 9 queued operations after reconnecting130 ms server-side, counter at 0 in 2 smeasured, one run
Contract check against the live backend19 of 19, after the increment fixmeasured
Release APK on the emulator against the live backendguest sign-in, one expense, sync up to date, row in the dashboardmeasured, one run
SDK download for emulator and system image7 min 29 s, 2.2 GBmeasured, one run

The cold start is a number to compare against itself after a change, not a device figure: it is a debug build on an emulator running on an M-series Mac. The brief for this post asked for a named mid-range phone, and that run has not happened.

When not to use this

If you need receipts, multiple currencies, shared budgets or bank import, this is a starting point, not an app. If you need the sync to resolve conflicts rather than record them, write the policy first; the repository shows where it goes.

If your app must work without any backend, the Parse Local Datastore is a different design with different trade-offs, and this template deliberately does not use it.

And note what was not verified: the app on a physical device, the offline scenarios against the live backend rather than the local server, session expiry on the worker path, and the 90-day purge, which is unit-tested as a rule and not yet observed. When those runs happen, this paragraph gets replaced by their results.

The repository

github.com/templates-back4app/android-expense-tracker, MIT. app/ is the Android project, cloud/ the two functions and the hooks, scripts/ the setup, the contract check and the local server.

docs/notes/build-diary.md records every download, version decision and bug with timestamps, and docs/evidence/ holds the build logs, the emulator run, the offline matrix, the screenshots, the live-backend logs and the APK checksum.

Tagsandroidkotlinjetpack-composeroomworkmanageroffline-firstsyncparsecloud-codeexpense-trackersource-code

Frequently asked questions

Can I just download the APK and try it?

Yes. The release APK (2.08 MB) is on the repository's Releases page, pointed at a demo backend. Install it on Android 8.0 or newer, tap Continue as guest and add an expense. Your rows are readable by your account only; the demo backend's data may be wiped at any time. To run the source code you need Android Studio and your own Back4app app, which the README walks through in about ten minutes.

Why do the keys in the APK not matter?

The app ships only the Application ID and the Client key, which identify the app, not a person. Every write goes through one Cloud function that checks the session and sets an owner-only ACL on the row, the class-level permissions refuse any direct write, and a server hook refuses them again even if a permission were widened by mistake. We sent those direct writes with the keys from the APK: all answered code 119. The master key never leaves the dashboard.

What happens when two phones edit the same expense offline?

Both edits are applied, in the order they reach the server, field by field, and the second one is recorded as a conflict because the version it was based on was stale. Nothing is merged silently and nothing is thrown away: the Conflict row holds the fields the losing device sent, and the Settings screen lists the most recent ones. A delete beats any later edit.

How does the app avoid saving an expense twice when a retry happens?

Every queued operation carries its own UUID. The server inserts that id into an AppliedOp class that has a unique index before it touches the entry; the second arrival fails to insert and the server answers 'duplicate opId' with the current state. We sent the same id twice in sequence and twice in parallel against the live backend: one row each time.

Why Gradle 9.5 and not 8 or the newest 9.8?

Android Studio 2026.2 bundles JDK 25. Gradle 8.14 refuses to start on it, and the Android Gradle Plugin 8.13 refuses Gradle 9.6 or newer because an internal Gradle API it used was removed. The working window on that machine is JDK 25, Gradle 9.1 to 9.5, AGP 8.x. The build diary has the exact error texts.

Is the Parse Local Datastore used for offline support?

No. Offline state lives in Room: an entries table and an ops table that acts as the outbox, written in one transaction. The Parse SDK is used for sign-in and for calling the two Cloud functions. That keeps the sync rules in one pure Kotlin object that a unit test can exercise without a device.

Further reading

Reviewed & verified by Back4app Engineering, Editorial review at Back4app

Tested Oct 23, 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 · Emulator: Pixel 6 profile, Android 15, arm64, on an Apple Silicon Mac · Backend: Back4app, Parse Server 7.5.2 (MongoDB), Cloud Code on Node 19.9, Free plan · Local check: parse-server 7.5.4 on PostgreSQL 17.

More from Back4app Engineering

Containers · tutorial

How to Build a Cash Flow Tracker With JavaScript

Backend · tutorial

How to Build the Backend for a Flutter App: Login, Data and Push Notifications [Full Repo]

Backend · error-guide

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