Skip to content

rahulstech/budget-app-android

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

57 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

App Icon

Budget App β€” Offline-First & Collaborative

An Android budgeting app built on event-driven sync architecture β€” handling real-world network failures, multi-user conflicts, and distributed data consistency using patterns found in production backend systems.

Android Kotlin Min SDK

LinkedIn Β· GitHub Β· Email


Why This Project?

Most Android portfolio apps demonstrate UI and API calls. This one solves a harder problem: what happens when users on different devices edit shared data, sometimes without internet, and changes need to converge correctly?

This app implements an offline-first collaborative budgeting system using an Outbox/Inbox sync model, server-side versioning, and idempotent event processing β€” the same class of patterns used in distributed production systems. The goal was to go beyond standard CRUD and engage seriously with data consistency and sync reliability at the application layer.


πŸ“Έ Demo

Checkout a demo vide of Budgetify Android App

Budgetify App Demo


Table of Contents


✨ Core Features

Area What It Does
Budgeting Create budgets, categories, and expenses with full CRUD support
Offline-First All actions work without internet; changes sync automatically when network returns
Collaboration Share budgets with up to 10 participants via invite link
Conflict Resolution Server-side versioning prevents stale overwrites across devices
Sync Reliability Idempotent events with retry guarantee no data loss
Modes Offline-only (no login) or collaborative (Firebase Auth)

πŸ— Architecture

The app follows Clean Architecture with MVVM, organized into independent Gradle modules with a strict unidirectional dependency flow: app β†’ domain β†’ data.

app (UI Layer)
β”‚
domain (Business Logic)
β”œβ”€β”€ repository
β”œβ”€β”€ auth-repo
└── sync              ← all sync complexity lives here
β”‚
data (Implementation)
β”œβ”€β”€ budget-database   ← Room
β”œβ”€β”€ budget-api        ← Retrofit
β”œβ”€β”€ auth              ← Firebase
└── media-store       ← local file storage

Data Flow β€” Sending a Local Change

When a user adds an expense in a shared budget:

graph LR
    A[UI] -->|Input| B[ViewModel]
    B -->|Domain Model| C[Repository]

    subgraph Atomic Transaction
        C --> D[(Room DB)]
        C --> E[(Outbox Table)]
    end

    E -->|Triggers| F[Sync Worker]
    F -->|API Request| G[Server]
    G -.->|Acknowledgment| F
    F -.->|Delete Record| E
    D -.->|Reactive Flow| B
    B -.->|ViewState| A
Loading

The local DB and Outbox are written atomically in the same transaction β€” ensuring a change is never persisted without also being queued for sync.

Data Flow β€” Receiving Remote Changes

graph LR
    Server((Server)) -- Pull/Push --> Sync[Sync Worker]
    Sync -- Raw Storage --> Inbox[(Inbox Buffer)]
    Inbox -- Process Sequentially --> Room[(Room DB)]
    Room -- Kotlin Flow --> Repo[Repository]
    Repo --> VM[ViewModel]
    VM --> UI[UI]
Loading

Incoming events are buffered in an Inbox table before being applied, preserving ordering and enabling safe retry if processing fails mid-way.

Source of Truth

The server is the authoritative source of truth β€” only server-accepted events become globally consistent state. The local database acts as an optimistic operational store: changes appear instantly in the UI, then are validated and confirmed asynchronously. This is a server-authoritative, offline-first model.


πŸ“Š Design Decisions & Trade-offs

This is the most important section of the project. Every major architectural choice involved a deliberate trade-off.

Event-Driven Sync vs. Direct CRUD APIs

User actions are stored as immutable events (e.g., expense.add, category.delete) rather than sent directly as API mutations. Events can be replayed safely, retried idempotently, and processed in order. The cost is significantly higher system complexity β€” but the payoff is reliable sync under any network condition.

Version-Based Conflict Resolution

Each entity row carries a server-assigned version number. Update and delete events must include the current version. If there's a mismatch, the server either returns the latest record or rejects the event, and the client reconciles. This is simpler than merge-based strategies (no CRDT complexity), but requires explicit client-side reconciliation on conflict.

WorkManager + Foreground Service Hybrid

WorkManager alone isn't suited for long continuous tasks like a full snapshot download. Coroutines alone offer no reliability guarantees across process death. The solution uses WorkManager for scheduling, constraints, and retries β€” and a foreground service for heavy, long-running sync operations. Each handles what it's best at.

Long Polling vs. Real-Time Sync

Budget data does not change with high frequency. WebSockets or Firebase Realtime Database would add infrastructure complexity for marginal benefit. Long polling is simpler, maintainable, and sufficient for the eventual-consistency model this app targets. The trade-off is a small delay in propagating remote changes.

Full Snapshot Download on Login/Join

When a user logs in or joins a shared budget, a full dataset snapshot is downloaded rather than a delta. This guarantees correct initial state without needing to track complex ordering across entity types. The trade-off is slower first-load for large datasets β€” addressed by making snapshot sync resumable via persisted progress tracking.


πŸ”„ Sync System Design

Event Model

All changes are represented as immutable, idempotent events with a globally unique eventId. The server uses this ID to detect and deduplicate retried requests, returning a cached response instead of reprocessing.

// Create event (no version)
{
  "eventId": "d0e9f472-0992-4841-a43a-537b9a89d9b6",
  "eventType": "expense.add",
  "budgetId": "64143031-e16c-4c97-859d-8997e94ed98e",
  "recordId": "813667ca-8a40-4678-9602-f198d86eff1e",
  "when": 1774718381798,
  "note": "coffee",
  "amount": "150.00",
  "date": "2025-05-06"
}

// Update/Delete event (version required)
{
  "eventId": "2bcf8502-49e4-4601-aa28-2c9da0d88bb3",
  "eventType": "expense.delete",
  "budgetId": "a8d7a529-99fe-4a93-9ed5-cd9db2b1349a",
  "recordId": "d99a0b0d-b8ae-4d2d-94aa-a12399049697",
  "version": 3,
  "when": 1774718619903
}

Outbox Pattern (Local β†’ Server)

Outbox events are written atomically with the local data change in the same Room transaction. The sync worker reads pending events, batches them (size 25), sends to server, and deletes confirmed events. Failed events remain in the Outbox and are retried automatically.

Inbox Pattern (Server β†’ Local)

InboxEnqueueService β†’ Inbox Table β†’ InboxDequeueWorker β†’ InboxCoordinator β†’ Room DB

Long polling fetches events ordered by a monotonically increasing server sequence number. The client stores a per-budget cursor (last processed sequence) to fetch only new events. Events are staged in the Inbox buffer before being applied β€” preserving ordering guarantees and enabling recovery if the device dies mid-processing.

Conflict Resolution

Scenario Resolution
Version match Update applied normally
Version mismatch β€” server returns latest record Client applies server state directly
Version mismatch β€” server rejects without data Client fetches latest snapshot manually
Inbox event arrives Applied without version check (server already resolved it)

Sync Triggers

Sync is triggered by multiple conditions rather than a single polling loop:

  • Event-driven: Outbox observer fires sync when new events are added
  • Periodic: Inbox polling worker, snapshot recovery worker
  • Lifecycle: App startup, user login, budget join, screen open
  • Conditional: Network availability and battery constraints via WorkManager

Ordering Guarantees

  • Outbox events are ordered by eventTime (when) β€” client-side causality
  • Inbox events are ordered by server sequence number β€” global causality

This ensures deterministic state reconstruction across all participating devices.


βš™οΈ Tech Stack

Layer Technology Reason
UI Jetpack Compose Declarative, reactive, pairs naturally with StateFlow
State Management ViewModel + StateFlow Lifecycle-aware; UDF enforced by design
Local DB Room Compile-time safety, Flow integration, PagingSource support
Networking Retrofit + Kotlinx Serialization Type-safe APIs; serialization integrated with Kotlin
Background Sync WorkManager + Foreground Service Guaranteed execution, constraint handling, retry logic
Async Kotlin Coroutines + Flow Cold streams from DB, hot StateFlow for UI
DI Hilt Module-scoped bindings, testability
Auth Firebase Authentication Handles collaborative mode login
Pagination Paging 3 Efficient large dataset rendering with Room
Image Loading Coil Kotlin-first, Compose compatible
CI GitHub Actions Automated build and test pipeline

Key choices explained:

  • WorkManager over coroutines-only β€” coroutines don't survive process death; WorkManager guarantees execution
  • Flow over LiveData β€” better coroutine integration, no lifecycle import in domain layer, native Compose support
  • Room over raw SQLite β€” type converters, relationship support, and compile-time query validation

πŸ§ͺ Testing

Current Coverage

Room migration tests β€” validates schema evolution correctness, critical given the architectural shift from a simple CRUD schema to the offline-first Outbox/Inbox model. Breaking a migration breaks user data.

API layer unit tests β€” validates request/response handling and Kotlinx Serialization correctness for all event types.

Tools: JUnit, MockK

Manual Sync Scenario Testing

Given the complexity of the sync system, manual testing covers:

  • Create/update/delete in airplane mode β†’ restore network β†’ verify sync
  • Duplicate event submission β†’ verify idempotency (no duplicates in DB)
  • Version conflict scenarios β†’ verify client reconciliation
  • Partial snapshot download interrupted β†’ verify resumable recovery
  • Concurrent Outbox events β†’ verify ordering and no data loss

Testability by Design

The modular architecture allows each layer to be tested independently. Domain layer has no Android dependencies. Data sources are abstracted behind interfaces, enabling full mocking without instrumentation.

Test Coverage Gaps (Honest Assessment)

ViewModel state logic, repository coordination, and sync orchestration currently lack automated coverage. These are the highest-priority areas for the testing roadmap (see Limitations & Roadmap below).


πŸ“ Project Structure

app/                        ← UI layer (Compose screens, ViewModels, Navigation)

domain/
β”œβ”€β”€ repository/             ← data coordination, entity ↔ domain transformation
β”œβ”€β”€ auth-repo/              ← user session, login state
└── sync/                   ← Outbox/Inbox workers, sync orchestration

data/
β”œβ”€β”€ budget-database/        ← Room DB, DAOs, migrations, Outbox/Inbox tables
β”œβ”€β”€ budget-api/             ← Retrofit services, event serialization
β”œβ”€β”€ auth/                   ← Firebase Auth integration
└── media-store/            ← local file handling (profile images)

Dependency rule: app depends on domain; domain depends on data; data has no knowledge of domain or app. This is strictly enforced β€” data modules contain zero business logic.


🧰 Setup & Installation

Prerequisites

  • Android Studio Narwhal Feature Drop 2025.1.4+ (Canary 4)
  • Java 17+
  • Gradle 8.11.1 / AGP 8.10.1

Getting Started

git clone https://github.com/rahulstech/budget-app-android.git
cd budget-app-android

Open in Android Studio and allow Gradle sync to complete.

Configuration

Firebase (required for collaborative mode)

Add google-services.json to app/ and enable Email/Password authentication in Firebase Console. Without this, the app runs in offline-only mode.

Backend API

Update release.properties:

BUDGET_API_BASE_URL=your_base_url_here
BUDGET_API_KEY=your_api_key_here

debug.properties is pre-configured for localhost β€” leave it unchanged if running the backend locally. Without a backend, the app works fully in offline mode.

Recommended Test Scenarios

Offline flow

  1. Enable airplane mode
  2. Create budgets, categories, and expenses
  3. Disable airplane mode
  4. Observe automatic background sync

Collaborative flow

  1. Log in with Firebase Auth on two sessions
  2. Perform conflicting edits on shared budget
  3. Observe conflict resolution and eventual consistency

🚧 Limitations & Roadmap

Known Limitations

  • No real-time sync β€” long polling introduces a small propagation delay
  • Full snapshot on first load β€” initial sync is slow for large datasets
  • No conflict resolution UI β€” conflicts are resolved silently server-side; users don't see them
  • Limited automated test coverage β€” ViewModel, repository, and sync logic not yet covered by unit tests
  • No delta sync β€” every initial load downloads the full dataset regardless of what's changed

Roadmap

Priority Improvement
High Unit tests for ViewModel, repository, and sync orchestration
High Flow testing with Turbine; fake repository implementations
Medium Incremental (delta) sync β€” fetch only changes since last cursor
Medium Conflict resolution UI β€” surface conflicts to users with resolution options
Medium Improved sync progress visibility in UI
Low Dark mode and visual polish
Long-term Explore CRDTs for automatic conflict resolution without strict version matching

πŸ‘€ Author β€” Rahul Bagchi

Open to Android Developer roles and backend/system design discussions.

If this project is interesting to you β€” whether as a recruiter, collaborator, or fellow engineer β€” feel free to reach out.

About

Create your budget and track spedings in categories.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages