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.
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/emulatorOpen in Android Studio and run the app configuration to launch on an
emulator.
- Launch the app. Add a todo while online — it briefly shows
🔄 Pending syncand flips to✅ Syncedwithin ~half a second (the fake API simulates 300ms latency). - Toggle the emulator's network off (keyboard
F8, or Extended Controls → Cellular → Data status: Denied, plus Wi-Fi off). - Add another todo. It appears immediately and stays on
🔄 Pending sync—trySyncsawisConnected = falseand bailed. - Re-enable the network. The
SyncWorker(constrained toNetworkType.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.
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
# 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 connectedCheckWhat 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. |
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.
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.
- Show: the three packages
domain/,data/,presentation/and the lack of Android imports insidedomain/. - 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.)
- Show:
TodoListViewModelexposes a single immutableTodoListUiStateviaStateFlow.TodoListScreenconsumes it viacollectAsStateWithLifecycle(). - 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/savedflags inAddTodoUiState.)
- Show:
TodoRepositoryImpl— write to Room, thentrySync.observeTodosreads fromdao.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.)
- Show:
viewModelScope.launch { … }in the ViewModels,Flow<List<Todo>>threaded throughdao → repository → use case → ViewModel. - Talk about:
viewModelScopecancellation; cold vs hot flows; why we useStateFlowfor UI state but a coldFlowfor the DB stream;collectAsStateWithLifecycleand what happens without it (collection continues while the UI is in the background, wasting CPU and battery).
- Show: the four modules under
di/. Note the@Bindsfor interface → impl and@Providesfor things you don't own (Room,WorkManager). - Talk about: scopes (
@Singletonfor stateless services),@HiltViewModelvs@HiltWorker, whyTodoAppimplementsConfiguration.Provider(because workers are constructed by WorkManager, not by Hilt directly — theHiltWorkerFactorybridges them). - Ask: "Why not just construct things by hand?" (Fine for small apps; grows combinatorially with the number of ViewModels; testing seam gets tedious.)
- Show: the entity with
isSynced+isDeletedcolumns;@Upsert;observeAll()returningFlow<List<TodoEntity>>and filteringisDeleted = 0. - Talk about: why a
Flowquery 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.
- 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).
- Show:
TodoListViewModelTest(unit) andTodoDaoTest(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
isSyncedflips. Mentioned in the build script.)
- fake API + assert that
- Show:
network_security_config.xml(TLS-only + pinning template) andSecurePreferences(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).
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.
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.)
FakeTodoApiService simulates the network in-process. A real Retrofit setup
would be one Hilt module: OkHttpClient (with logging interceptor + cert
pinner) → Retrofit → TodoApiService interface with @GET/@POST/@PUT/@DELETE.
Discussion prompts
- DTOs vs domain models — why we'd add a separate
TodoDtoand 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
Throwableshapes the repository must collapse to a meaningful UI state. - Cancellation: Retrofit + coroutines cancels the underlying request automatically when the parent scope cancels.
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
updatedAtis 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.)
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).
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.
isMinifyEnabled = false in the release build. We've never had to write
keep rules.
Discussion prompts
- What needs explicit
-keeprules? (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.
Each is a one-paragraph conversation, not code, but worth being able to have on demand.
Sample prompts that the code in this repo gives you concrete ground to answer on:
- "Walk me through what happens when the user taps Save on the Add screen while offline." (Use the data flow in §3.)
- "Why is the repository an interface that lives in
domain/, but implemented indata/?" (Dependency inversion. Lets you swap impls without touching the domain.) - "How would you test that the SyncWorker actually drains the unsynced queue?"
- "What does
collectAsStateWithLifecyclegive you overcollectAsState?" - "If I told you the backend rejects writes that arrive out of order, how would you adjust the offline-first model?"
- "Where in this code would you put a feature flag, and why?"
- "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)?
MIT — see LICENSE. Use it freely for interview prep, internal
training, blog posts, or as a starting point for your own project.