Skip to content

franzueto/android-interview-example

Repository files navigation

TodoApp — Offline-First Android Interview Exercise

A small, deliberately opinionated Android project built to rehearse and discuss the patterns a Senior / Lead Android interview typically probes: Clean Architecture, MVVM, Room + Flow, Hilt, WorkManager, Compose, testing, and mobile security.

The app is two screens — a Todo list and an Add-Todo form — backed by an offline-first repository. Every write goes to Room first; a SyncWorker ships the unsynced rows to a (fake) remote API when connectivity is available.

The point of this repo is the discussion, not the product. Each layer is short enough to read end-to-end in a few minutes and is wired so you can talk through why each decision was made.


Quick start

Requirements: Android Studio (any recent stable), JDK 17+, an emulator or device on API 26+.

./gradlew assembleDebug          # build
./gradlew installDebug           # install on a connected device/emulator

Open in Android Studio and run the app configuration to launch on an emulator.

Trying the offline-first flow on an emulator

  1. Launch the app. Add a todo while online — it briefly shows 🔄 Pending sync and flips to ✅ Synced within ~half a second (the fake API simulates 300ms latency).
  2. Toggle the emulator's network off (keyboard F8, or Extended Controls → Cellular → Data status: Denied, plus Wi-Fi off).
  3. Add another todo. It appears immediately and stays on 🔄 Pending synctrySync saw isConnected = false and bailed.
  4. Re-enable the network. The SyncWorker (constrained to NetworkType.CONNECTED) fires automatically and the badge flips to .

The fake API has a 10% random failure rate so the WorkManager retry path (exponential backoff) is actually exercised — if you see a row stuck on 🔄 for a few seconds, that's the worker retrying.


Project structure

app/src/main/java/<package>/
├── TodoApp.kt                    @HiltAndroidApp + WorkManager Configuration.Provider
├── MainActivity.kt               @AndroidEntryPoint, hosts the NavGraph
├── di/
│   ├── DatabaseModule.kt         Room + DAO
│   ├── NetworkModule.kt          API + NetworkMonitor bindings
│   ├── RepositoryModule.kt       TodoRepository binding (@Binds)
│   └── WorkModule.kt             WorkManager
├── domain/                       Pure Kotlin — no Android imports
│   ├── model/Todo.kt
│   ├── repository/TodoRepository.kt
│   └── usecase/{Get,Add,Toggle,Delete}TodoUseCase.kt
├── data/
│   ├── local/{db,dao,entity}/    Room
│   ├── mapper/TodoMappers.kt     Entity ↔ Domain
│   ├── network/                  NetworkMonitor + ConnectivityNetworkMonitor
│   ├── remote/                   TodoApiService + FakeTodoApiService
│   ├── repository/               TodoRepositoryImpl (offline-first writes + sync hooks)
│   ├── security/SecurePreferences.kt
│   └── sync/SyncWorker.kt        @HiltWorker, exponential backoff
└── presentation/
    ├── TodoNavGraph.kt
    ├── list/{TodoListScreen,TodoListViewModel}.kt
    └── add/{AddTodoScreen,AddTodoViewModel}.kt

Running tests

# Unit tests (JVM) — fast. Covers ViewModel via MockK + Turbine.
./gradlew :app:testDebugUnitTest

# Instrumented tests — requires an emulator/device. Covers the DAO with
# an in-memory Room database.
./gradlew :app:connectedDebugAndroidTest

# Everything
./gradlew check connectedCheck

What each test demonstrates:

Test Layer Purpose
TodoListViewModelTest ViewModel State transitions; use-case interaction verified with MockK; main-thread dispatcher swapped via MainDispatcherRule; flow output asserted with Turbine.
TodoDaoTest DAO Real Room, in-memory database, soft-delete filtering, getUnsynced query semantics.

Architecture in one paragraph

UI (Compose) collects a StateFlow from the ViewModel. The ViewModel delegates to use cases. Use cases call a TodoRepository interface from the domain layer. The data-layer implementation (TodoRepositoryImpl) treats the local Room database as the single source of truth: every write goes to Room first, then a best-effort api.create/update/delete runs if the device is online. Anything left with isSynced = false is drained later by SyncWorker, which runs under WorkManager with a network constraint and exponential backoff.


How to use this repo in an interview

Whether you are the candidate rehearsing or the interviewer designing a session, here is a rough map of what each file lets you discuss. Spend 5–10 minutes per topic.

1. Clean Architecture / layering

  • Show: the three packages domain/, data/, presentation/ and the lack of Android imports inside domain/.
  • Talk about: why use cases exist when the repository already exposes the same methods; when they are overkill; the dependency-inversion direction (data → domain, not the other way around).
  • Ask / be asked: "What changes if we wanted to swap Room for SQLDelight?" (Answer: only data/ — the domain and presentation never see Room types.)

2. MVVM + unidirectional state

  • Show: TodoListViewModel exposes a single immutable TodoListUiState via StateFlow. TodoListScreen consumes it via collectAsStateWithLifecycle().
  • Talk about: StateFlow vs LiveData (Kotlin-native, composable, no Android dep in ViewModel); why one immutable state class beats several separate LiveData<Loading>, LiveData<Error>, LiveData<Data> fields.
  • Ask: "How would you handle one-shot events (snackbar, navigation) without re-emitting them on config change?" (Channel / SharedFlow with replay=0, or consume-once flags like the error/saved flags in AddTodoUiState.)

3. Offline-first

  • Show: TodoRepositoryImpl — write to Room, then trySync. observeTodos reads from dao.observeAll().
  • Talk about: single source of truth; instant UI feedback; sync conflict strategies (last-write-wins via updatedAt, server-wins, manual resolution); why soft delete is required (you cannot ship a hard delete to a server you haven't reached yet).
  • Ask: "What happens if two clients edit the same todo offline?" (Discuss vector clocks, CRDTs, or the simpler LWW reality.)

4. Coroutines + Flow

  • Show: viewModelScope.launch { … } in the ViewModels, Flow<List<Todo>> threaded through dao → repository → use case → ViewModel.
  • Talk about: viewModelScope cancellation; cold vs hot flows; why we use StateFlow for UI state but a cold Flow for the DB stream; collectAsStateWithLifecycle and what happens without it (collection continues while the UI is in the background, wasting CPU and battery).

5. Dependency Injection (Hilt)

  • Show: the four modules under di/. Note the @Binds for interface → impl and @Provides for things you don't own (Room, WorkManager).
  • Talk about: scopes (@Singleton for stateless services), @HiltViewModel vs @HiltWorker, why TodoApp implements Configuration.Provider (because workers are constructed by WorkManager, not by Hilt directly — the HiltWorkerFactory bridges them).
  • Ask: "Why not just construct things by hand?" (Fine for small apps; grows combinatorially with the number of ViewModels; testing seam gets tedious.)

6. Room

  • Show: the entity with isSynced + isDeleted columns; @Upsert; observeAll() returning Flow<List<TodoEntity>> and filtering isDeleted = 0.
  • Talk about: why a Flow query auto-emits when the underlying table changes (Room maintains an invalidation tracker per query); when you would reach for @Transaction (multi-statement consistency); migration strategy.

7. WorkManager

  • Show: SyncWorker@HiltWorker, Constraints.NetworkType.CONNECTED, BackoffPolicy.EXPONENTIAL, ExistingWorkPolicy.KEEP.
  • Talk about: why this isn't just a coroutine on app start (survives process death, restarts, doze mode); unique work to prevent duplicate enqueues; periodic vs one-time; why we disabled the default initializer in AndroidManifest.xml (so the Hilt-aware factory is used).

8. Testing

  • Show: TodoListViewModelTest (unit) and TodoDaoTest (instrumented).
  • Talk about: the test pyramid — most logic should be unit-testable because the ViewModel/use-cases have no Android dependencies; in-memory Room is the pragmatic compromise for DAO tests; UI tests are slow and flaky, keep them for happy paths only.
  • Ask: "How would you test the SyncWorker?" (Answer: WorkManagerTestInitHelper
    • fake API + assert that isSynced flips. Mentioned in the build script.)

9. Security

  • Show: network_security_config.xml (TLS-only + pinning template) and SecurePreferences (EncryptedSharedPreferences, Keystore-backed master key).
  • Talk about: AES256-SIV for keys (deterministic — lookup still works) vs AES256-GCM for values (authenticated, random IV — same value, different ciphertext); why the master key never leaves secure hardware; what cert pinning protects against (rogue CA / corporate MITM) and the operational tradeoff (forgetting a backup pin can brick old app versions).

What is intentionally not implemented (good interview material)

Each of these is something the project has the baseline for but stops short of fully implementing. They are good open-ended discussion prompts — the candidate can talk through how they would extend the project, and the interviewer gets to see how someone reasons about scope, tradeoffs, and production readiness.

Database encryption (SQLCipher)

SecurePreferences exists and can store a random passphrase, but DatabaseModule builds the Room database unencrypted. The wiring is:

val passphrase = securePreferences.get("db_passphrase")
    ?: generateRandomPassphrase().also { securePreferences.put("db_passphrase", it) }

Room.databaseBuilder(ctx, TodoDatabase::class.java, "todo_db")
    .openHelperFactory(SupportFactory(passphrase.toByteArray()))
    .build()

Discussion prompts

  • When is full-DB encryption worth the size/perf cost? (Regulated data, PII, health, financial.)
  • Where does the passphrase ultimately come from? (Derived from a Keystore key; never user-entered for a "silent" model.)
  • What breaks? (Schema export, certain migrations, performance on large tables; the SQLCipher SO adds ~5 MB to the APK.)

Real backend (Retrofit + Moshi)

FakeTodoApiService simulates the network in-process. A real Retrofit setup would be one Hilt module: OkHttpClient (with logging interceptor + cert pinner) → RetrofitTodoApiService interface with @GET/@POST/@PUT/@DELETE.

Discussion prompts

  • DTOs vs domain models — why we'd add a separate TodoDto and convert at the boundary (decouples wire format from internal model; lets the backend evolve independently).
  • Error handling: HTTP error codes vs network failures vs parse errors — three different Throwable shapes the repository must collapse to a meaningful UI state.
  • Cancellation: Retrofit + coroutines cancels the underlying request automatically when the parent scope cancels.

Sync conflict resolution

syncPendingTodos currently pushes local writes to the server but assumes the server accepts them. There is no pull, no merge, no conflict handling.

Discussion prompts

  • Last-write-wins via updatedAt is the simplest viable strategy; when is it not enough?
  • How do you handle the case where a local update arrives at the server after a newer update from another device? (HTTP 409, server-wins, client-wins, or surface a UI prompt — each has tradeoffs.)
  • Where should this logic live? (In the repository, not the use case — it is data-layer concern.)

One-shot UI events

The current ViewModels expose mutable error/saved flags that the UI consumes and clears (consumeError()). This works but couples the consumer.

Discussion prompts

  • Channel / SharedFlow(replay = 0) vs the consume-once pattern — which survives configuration change correctly?
  • Why navigation events are tricky as state (you don't want to navigate twice when the user rotates the device mid-transition).

Compose previews + UI tests

The project has no @Preview composables and no Compose UI tests.

Discussion prompts

  • Preview vs screenshot tests (Paparazzi, Roborazzi) — when each is worth the maintenance cost.
  • createAndroidComposeRule<HiltTestActivity>() + a Hilt test runner to swap the repository for a fake — what would change in the modules to make this ergonomic.

R8 / ProGuard

isMinifyEnabled = false in the release build. We've never had to write keep rules.

Discussion prompts

  • What needs explicit -keep rules? (Room entities reflected at runtime, Moshi-generated adapters, Retrofit interfaces, anything used via reflection.)
  • Why R8 shrinking is also a security measure — obfuscated bytecode is meaningfully harder to reverse-engineer than the readable Kotlin metadata Compose produces by default.

Root / tamper detection, biometric auth, backup exclusion of specific files

Each is a one-paragraph conversation, not code, but worth being able to have on demand.


Suggested questions to ask (or expect to be asked)

Sample prompts that the code in this repo gives you concrete ground to answer on:

  1. "Walk me through what happens when the user taps Save on the Add screen while offline." (Use the data flow in §3.)
  2. "Why is the repository an interface that lives in domain/, but implemented in data/?" (Dependency inversion. Lets you swap impls without touching the domain.)
  3. "How would you test that the SyncWorker actually drains the unsynced queue?"
  4. "What does collectAsStateWithLifecycle give you over collectAsState?"
  5. "If I told you the backend rejects writes that arrive out of order, how would you adjust the offline-first model?"
  6. "Where in this code would you put a feature flag, and why?"
  7. "We need to add an end-to-end encryption layer for todo bodies. How would you wire it in?"

Good questions to ask the interviewer in return — these show you think about systems, not just code:

  • What's the team's stance on KMP / shared business logic with iOS?
  • How do you handle schema migrations for users who skipped versions?
  • Where does observability live — Crashlytics, Sentry, custom? What do you log from a sync worker?
  • How is feature-flag rollout structured (Firebase Remote Config, internal service, AB framework)?

License

MIT — see LICENSE. Use it freely for interview prep, internal training, blog posts, or as a starting point for your own project.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages