Dang Le

Projects / Case study

JobTracker

A personal Android engineering project exploring how local-first job application tracking can stay useful through unreliable connectivity and synchronize safely afterward.

  • Kotlin
  • Jetpack Compose
  • Room
  • Apollo GraphQL
  • WorkManager
  • Hilt

Overview

JobTracker is a personal engineering project for tracking job applications. It explores an Android problem that appears in many mobile products: a screen should remain useful when connectivity is unreliable, while changes made locally still need a safe path to a remote system. The included GraphQL server is a demonstration backend with in-memory storage, not production infrastructure.

The problem

Creating an application or changing its status should not require an immediate API response. A user may leave the app, lose a connection, or retry after a request times out. In those cases, local and server versions can diverge. A retried create can also produce a duplicate unless the server recognizes it as the same operation. JobTracker models those cases through pending sync states, idempotent creation, version checks, and an explicit conflict choice.

Architecture

Write and synchronization path

  1. Compose UIUser action and Room-backed rendering
  2. ViewModel + StateFlowScreen state and UI events
  3. RepositoryLocal writes and reconciliation
  4. RoomUI source of truth · pending sync state
  5. WorkManagerConnected-network background work
  6. Apollo GraphQLQueries and create, update, delete mutations
  7. Demo GraphQL serverIn-memory remote state and version checks

The UI observes Room through the repository. It does not wait for the server to render a successful local edit.

The repository reads a Room Flow, while the list ViewModel combines that stream with search and loading state into StateFlow. The same repository owns local writes and remote reconciliation. See the repository implementation and list ViewModel.

Local-first writes

On create, the repository assigns a temporary local_ UUID, inserts a Room entity with PENDING_CREATE, and schedules sync. Status and notes edits similarly update the Room row immediately; a previously synced row becomes PENDING_UPDATE. Deleting a record that never reached the server removes it locally, while a synced record becomes PENDING_DELETE. The Room Flow lets the UI react to those local changes without waiting for GraphQL. The repository and DAO show this path.

Local write path

User action → Room write and pending state → Room Flow → updated UI → background synchronization when available.

Background synchronization

The repository enqueues unique one-time WorkManager work with a connected-network constraint. Its worker processes pending creates, updates, and deletes through Apollo GraphQL mutations. The application also observes connectivity; when a connection is detected, it refreshes remote state and schedules sync. The worker returns Result.retry() for Apollo or I/O exceptions and Result.failure() for other exceptions. This separates local interaction from work that may need to survive a lost connection or a closed screen. See the sync worker and application connectivity handling.

Idempotent creates

The temporary local UUID also becomes the create request’s idempotency key. If the same create is retried, the demonstration server can return the existing record for that key rather than adding a second one. After a successful create, a Room @Transaction replaces the temporary row with the server row. That atomic ID swap avoids an intermediate state in which the item disappears from the local list. The behavior is visible in the repository, DAO, and demo server.

Conflict handling

The server uses version numbers when updating or deleting a record. During refresh, the repository compares server and local state, preserves pending local edits, and can store a divergent server snapshot. A rejected mutation can also trigger a fetch of the current server record. When the relevant values differ, the Compose dialog shows a side-by-side comparison and offers Keep Mine or Keep Server. The dialog currently compares company, position, and status; this case study does not assume every field or conflict scenario is fully handled. See reconciliation logic and the conflict dialog.

Failure paths and targeted tests

The test suite includes repository tests for mapping the DAO’s Room Flow and queuing a local create, a Robolectric worker test that expects retry after a network exception, and instrumented DAO tests for replacement and deletion. These are targeted checks of important paths, not a claim of comprehensive coverage or passing CI. Review the repository tests, worker test, and DAO tests.

Engineering decisions

  • Room drives the screen so a successful local action remains visible during network loss. The repository reconciles remote data back into that same source of truth.
  • WorkManager owns deferred sync because pending changes may need processing after the screen’s ViewModel is gone; a connected-network constraint avoids starting remote work without connectivity.
  • Create requests carry an idempotency key because a timeout does not reveal whether the server accepted the first request.
  • Divergent versions are exposed instead of silently overwriting a pending local edit. The UI gives the user an explicit choice for the differences it currently presents.

Interface and technology

JobTracker dashboard with job application metrics, search, and application list
Application dashboard
JobTracker detail screen with application information and status timeline
Progress timeline

The project uses Kotlin, Jetpack Compose, StateFlow, Room, WorkManager, Apollo GraphQL, Hilt, Material 3, and DataStore for theme preference. Those tools support the local-first interaction and synchronization decisions above.

Explore the source

Read the repository and trace the local write, synchronization, and conflict paths in the implementation.

View JobTracker on GitHub