Problem RestatementProblem
Design the client-side architecture of an Android app screen that calls backend APIs, using MVVM (Model–View–ViewModel), as asked at OpenAI for a mobile role. For example: a list of conversations that loads from an API, supports pull-to-refresh, pagination and offline viewing, and survives screen rotation. Cover the HTTP request lifecycle, error handling, caching and testability.
The Layers
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
V["View - Activity / Compose UI"] -->|"user events"| VM["ViewModel - holds UiState"]
VM -->|"StateFlow of UiState"| V
VM --> UC["Use cases (optional)"]
UC --> R["Repository - single source of truth"]
R --> LOCAL[("Local DB - Room")]
R --> REMOTE["Remote API - Retrofit / OkHttp"]
REMOTE --> NET["Backend"]- View (Jetpack Compose or Activity/Fragment): draws the UI from state and sends user events (tap, refresh) to the ViewModel. No business logic.
- ViewModel: holds the screen's UiState, survives configuration changes (rotation), runs work in
viewModelScope, and exposesStateFlow<UiState>. - Repository: decides where data comes from (cache or network), and hides Retrofit and Room from the ViewModel.
- Data sources: Retrofit API (remote) and Room database (local).
- Dependency injection (Hilt) wires these together, which makes testing easy.
UI State
data class ConversationsUiState(
val items: List<ConversationUi> = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val error: String? = null, // user-friendly message
val endReached: Boolean = false
)One immutable state object per screen. The View just renders it. This avoids bugs where the loading spinner and error message disagree.
Deep Dive — Where the request livesDeep dive
A screen loads conversations from an API. Where that call is owned decides what happens on rotation, on a slow network, and when the user leaves mid-request.
Call the API from the Activity
The Activity starts the request in onCreate and updates the views in the callback.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
A["Activity - starts the request"] --> ROT["User rotates the phone"]
ROT --> DES["Activity destroyed and recreated"]
DES --> RE["Request starts again from scratch"]
DES --> LEAK["The old callback still holds the dead Activity"]
LEAK --> CRASH["Updates a destroyed view - crash or leak"]The request's lifetime is tied to a component Android destroys routinely. Every rotation refetches, and the in-flight callback outlives the thing it was going to update.
Move it to a ViewModel with callbacks
A ViewModel survives configuration changes, so the request is not restarted and the callback has something valid to update.
The lifecycle problem is solved. The state problem is not: the screen ends up with isLoading, items, error and isRefreshing as separate fields, updated from different callbacks. They drift into combinations that should be impossible — a spinner over an error, stale items under a fresh error — and every new caller has to remember to reset all of them.
One immutable state object, one source of truth
Expose the whole screen as a single immutable UiState in a StateFlow, and read the data from the database rather than from the network response:
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
NET["Network refresh"] --> ROOM[("Room - single source of truth")]
ROOM -->|"Flow emits on change"| VM["ViewModel - updates one UiState"]
VM --> UI["UI renders the state it is given"]
UI -->|"rotation"| VM
VM -->|"onCleared"| CANCEL["viewModelScope cancels in-flight work"]
ROOM --> OFF["Offline: the same Flow serves cached data"]- One state object means the screen can only render combinations the ViewModel actually constructed. There is no set of fields to keep consistent, because there is only one value.
- The database is the source of truth, not the response. The network writes to Room; the UI observes Room. Offline works for free, a partial refresh never blanks the list, and two screens showing the same data cannot disagree.
viewModelScopeties work to the screen's real lifetime. Leaving cancels in-flight requests automatically — no manual bookkeeping, no callback firing into a destroyed view.
The rule underneath all three: the UI renders state and emits events; it never decides anything. That is what makes the ViewModel testable without an emulator, and it is what the interviewer is checking for.
Networking Details
- Retrofit + OkHttp: an interceptor adds the auth token, and an authenticator refreshes expired tokens once and retries.
- Timeouts: connect/read timeouts (e.g., 10s/30s). For streaming responses (LLM tokens), use a streaming call and update the state incrementally.
- Retries: automatic retry with backoff only for idempotent GETs and network errors. Never auto-retry non-idempotent POSTs without an idempotency key.
- Error mapping: convert HTTP and IO errors into a
Result.Errorwith a user-friendly message (no connection, session expired, server error) in the repository, so the ViewModel stays simple. - HTTP caching: ETags /
If-None-Matchto save data on refresh.
Pagination and Offline
- Use Paging 3 with a RemoteMediator: it loads pages from the API into Room, and the UI pages from Room. Scrolling works offline for cached pages.
- Queue user actions made offline (e.g., "archive conversation") with WorkManager, and retry when back online.
Testing
- ViewModel tests: fake the repository, and assert UiState transitions (loading → items; refresh error → error message).
- Repository tests: MockWebServer for the API, an in-memory Room DB.
- UI tests: Compose testing with fake state.
Wrap-UpWrap-up
Keep the View dumb, give each screen a ViewModel that exposes a single immutable UiState through StateFlow and launches work in viewModelScope, and put a repository in front of Retrofit and Room with Room as the single source of truth. Handle auth, timeouts, safe retries and error mapping in the data layer, use Paging 3 with RemoteMediator for pagination and offline, and inject dependencies with Hilt so every layer is testable in isolation.