Android Interview Bootstrap
Generic scaffolding for an unknown Android interview prompt. Encode stack + structure + restraint — not a specific product (chat, feed, etc.).
Design philosophy (non-negotiable)
Prefer code a teammate can read in one pass. In a timed interview, aim for
production taste at speed: explicit boundaries (e.g. Result from repos),
clear UiState, no architecture cosplay.
Do
- Concrete classes until a second implementation is real
- One repository per feature concern when networking/DB exists
Result(or smallAppError) at repository public APIs- Tests for real business logic; screenshot tests for critical UI
- Small modules; add modules only when a boundary is earned
Do not
- Interface + fake + MockK stack for every collaborator “for testability”
- Domain/data/presentation module explosion for a 5-hour app
- Wrapper types / mappers that only rename fields
- Test slop: brittle Mockito/MockK ceremony, testing framework wiring, or 1:1 mirror tests that assert nothing meaningful
If a seam isn’t buying clarity or a real second implementation, delete it.
Locked stack
| Area | Choice |
|---|---|
| UI | Jetpack Compose + Material 3 |
| Architecture | MVVM + single UiState per screen |
| DI | Hilt |
| Modules | Minimal multi-module (see below) |
| Navigation | Navigation Compose — add when 2+ screens |
| HTTP | Retrofit (OkHttp directly if streaming/SSE needed) |
| JSON | Moshi |
| Persistence | Progressive: none → DataStore → Room |
| Async | Coroutines + Flow + collectAsStateWithLifecycle |
Add when the prompt needs them (don’t scaffold early): Room, DataStore, Paging, WorkManager, OkHttp streaming.
Skip unless asked: CI pipelines, Compose Multiplatform, custom design systems, dynamic feature modules, heavy static analysis setup.
Minimal module graph
Start here; rename :feature:<name> from the prompt:
:app // @HiltAndroidApp, NavHost, theme, root composition
:feature:<name> // first feature (screens, VM, feature repo)
:core:model // shared types (keep Android-free when possible)
:core:network // Retrofit/Moshi/OkHttp — only once network exists
Optional later:
:core:database— Room when entities are clear:core:ui— shared Compose comps when a second feature needs them:core:common— coroutines dispatchers / small utils if duplicated
Dependency rule: app → feature → core. Never core → feature or
feature → app.
Do not create :domain / :data / :presentation modules by default.
Packages inside a feature
Flat until ~6 files, then layer:
feature/<name>/
ui/ // screens, small composables
viewmodel/ // ViewModel + UiState
data/ // repository, local/remote data sources (concrete)
di/ // @Module for this feature
Skip empty layers. No domain/ package until there’s real domain logic.
Day-of bootstrap checklist
Bootstrap:
- [ ] New Empty Activity (Compose) project; confirm it runs
- [ ] Split into minimal modules (:app, :feature:<name>, :core:model)
- [ ] Add Hilt plugins + @HiltAndroidApp + @AndroidEntryPoint
- [ ] Wire feature dependency into :app; show one real screen
- [ ] Add :core:network only when API appears (Retrofit + Moshi)
- [ ] Add NavHost only when second screen appears
- [ ] Add Room/DataStore/Paging/WM only when required
- [ ] Re-run on emulator before deep feature work
Timebox: running skeleton ≤ 20–25 min. If module Gradle fights you >10 min, collapse to fewer modules and re-split later.
Hilt essentials (muscle memory)
AGP 9 notes (Android Studio 2026 templates):
Do not apply
org.jetbrains.kotlin.android— Kotlin is built into AGPUse Hilt ≥ 2.59.2 and KSP ≥ 2.3.6 (older KSP breaks built-in Kotlin)
Plugins on app/feature modules:
android.*,kotlin.compose(if Compose),hilt,kspApplication:
@HiltAndroidAppActivity:
@AndroidEntryPointViewModel:
@HiltViewModel+@Inject constructorModules:
@Module+@InstallIn(SingletonComponent::class)(orViewModelComponentwhen scoped)Provide Retrofit/Room/DataStore in
:core:*modules; bind feature repos in feature modules only if an interface is justified (default:@Injectthe concrete repo)
Prefer constructor injection on concrete classes over interface + @Binds
pairs.
MVVM pattern
data class FeatureUiState(
val items: List<Item> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)
@HiltViewModel
class FeatureViewModel @Inject constructor(
private val repository: FeatureRepository,
) : ViewModel() {
private val _state = MutableStateFlow(FeatureUiState())
val state: StateFlow<FeatureUiState> = _state.asStateFlow()
// intents: onAppear, onRetry, onAction...
}
UI collects with collectAsStateWithLifecycle(). ViewModels never hold
Context/Views.
Error handling
Prefer explicit Result at the repository boundary. ViewModels should not be
the first place that learns a call failed via an uncaught exception.
Layers
| Layer | Responsibility |
|---|---|
| Retrofit / Room / IO | May throw (HttpException, IOException, etc.) |
| Repository | try/catch → return Result<T> (or a small sealed error) |
| ViewModel | result.fold / onSuccess / onFailure → update UiState |
| UI | Render error + Retry; no try/catch |
Exceptions inside the repo are fine; crossing out of the repo as exceptions is what we avoid.
Repository returns Result
@Singleton
class FeatureRepository @Inject constructor(
private val api: FeatureApi,
) {
suspend fun loadItems(): Result<List<Item>> = try {
Result.success(api.getItems().map { it.toItem() })
} catch (t: CancellationException) {
throw t // cancellation is control flow, not an app failure
} catch (t: Throwable) {
Result.failure(t.toAppError()) // or Result.failure(t) at first
}
}
Prefer explicit try/catch: Kotlin runCatching also catches
CancellationException, so it is unsafe around suspend work unless cancellation
is rethrown.
Use Kotlin’s Result until you need UI-specific branches; then introduce a small
sealed type:
sealed class AppError : Exception() {
data object Network : AppError()
data class Http(val code: Int) : AppError()
data class Message(override val message: String) : AppError()
}
fun Throwable.toAppError(): AppError = when (this) {
is AppError -> this
is IOException -> AppError.Network // covers UnknownHostException etc.
is HttpException -> AppError.Http(code())
else -> AppError.Message(message ?: "Something went wrong.")
}
Placement: AppError + toUserMessage() live in :core:model (no Android
deps). toAppError() references Retrofit's HttpException, so it lives in
:core:network.
Only add AppError when the UI (or VM) branches on kind — not for every project
on day one. Result.failure(Throwable) + toUserMessage() is enough at first.
ViewModel maps Result → UiState
fun refresh() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
repository.loadItems()
.onSuccess { items ->
_state.update { it.copy(items = items, isLoading = false) }
}
.onFailure { t ->
_state.update {
it.copy(isLoading = false, error = t.toUserMessage())
}
}
}
}
UI behavior
| Situation | UI |
|---|---|
| First load failed, no data | Full-screen / centered error + Retry |
| Refresh failed, data exists | Keep list; Snackbar or inline banner |
| Empty success | Empty state (not an error) |
| Loading | Progress; disable conflicting actions |
Clear error on retry / next success.
toUserMessage()
fun Throwable.toUserMessage(): String = when (this) {
is AppError.Network -> "Network unavailable. Try again."
is AppError.Http -> if (code in 400..499) "Something’s wrong with the request."
else "Server error. Try again."
is AppError.Message -> message
else -> message?.takeIf { it.isNotBlank() } ?: "Something went wrong."
}
(If repos always map via toAppError(), the fallback branch rarely fires.)
Log the throwable; show only the short string in UI.
When to upgrade
- Sealed
AppErrorwhen UI branches (auth vs retry vs blocking) - Per-field validation as separate state, not screen
error - One-shot Snackbar events via
Channel/SharedFlow— optional; stickyerroron state covers most interview screens
Avoid
- Leaking raw Retrofit/Room exceptions into the ViewModel / UI unchecked
- Empty
catch/ swallowing failures - Try/catch in Composables
- Global
CoroutineExceptionHandleras the primary UX path Resulton every tiny private helper (keep it at public repo / use-case APIs)- Giant
Either/Resourceframeworks with 6 loading subtypes on day one
Resource.Loading | Success | Error is optional; usually isLoading +
error + data on one UiState is clearer and less verbose.
Networking
- Retrofit + Moshi converter for normal JSON APIs
- Define API as Retrofit interface; repository calls it
- For streaming/SSE: use OkHttp (same client Retrofit uses) — don’t force streams through Retrofit if it slows you down
- Put base URL / keys via BuildConfig (see Secure / config)
Coroutines
- UI / VM work:
viewModelScope.launch(cancels when VM cleared) - Repo suspend functions: main-safe; Retrofit/Room suspend already off-main
- Use
withContext(Dispatchers.IO)only for blocking work you own (java.io, heavy compute). Don’t wrap every Retrofit call in IO “just in case” - Prefer structured concurrency; don’t create ad-hoc
CoroutineScope()in singletons without a clear lifecycle - Expose
StateFlow/Flowfrom VM; UI collects with lifecycle awareness - Inject
CoroutineDispatcheronly when a test needs to swap it — otherwise skip
Compose state hygiene
- Single source of truth: screen state in ViewModel
UiState; Composables are functions of state + event lambdas - Collect with
collectAsStateWithLifecycle()(not barecollectAsState) - Hoist:
Routegets VM / state; innerScreenis stateless + previewable LazyColumn/LazyRow: always pass stablekey- Don’t do network/DB/heavy work during composition
- Avoid holding
NavControllerdeep in children — pass lambdas/onNavigate
Side effects / animations: use sparingly here. For LaunchedEffect,
one-shot events, and motion patterns, see
../android-compose/SKILL.md.
Persistence
| Need | Tool |
|---|---|
| Nothing durable | skip |
| Flags / tokens / simple prefs | DataStore |
| Entities + queries | Room (:core:database or feature-local if tiny) |
| Large lists from network/DB | Paging 3 when scrolling performance matters |
| Deferrable background work | WorkManager |
Room cheat-sheet
Add when entities are clear — not during empty bootstrap. These snippets use Android-only Room 2.8.x. Room 3 uses a new package and driver architecture; do not switch during the interview unless required.
@Entity(tableName = "items")
data class ItemEntity(
@PrimaryKey val id: String,
val title: String,
)
@Dao
interface ItemDao {
@Query("SELECT * FROM items")
fun observeAll(): Flow<List<ItemEntity>>
@Upsert
suspend fun upsertAll(items: List<ItemEntity>)
}
@Database(entities = [ItemEntity::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun itemDao(): ItemDao
}
Hilt:
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides @Singleton
fun db(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()
@Provides fun itemDao(db: AppDatabase): ItemDao = db.itemDao()
}
- KSP:
ksp(libs.androidx.room.compiler)+ Room runtime/ktx deps - Repo returns
Result/Flow; mapEntity→ model at repo edge - Use
.fallbackToDestructiveMigration(dropAllTables = true)only when interview data is explicitly disposable; otherwise write a realMigrationor keep schema version 1 - Fuller snippets: reference.md
Paging 3
Use when lists are large / infinite scroll is required — not for 20 static items.
PagingSource(network or RoomPagingSource)- VM:
Pager(PagingConfig(pageSize = 20)) { source }.flow.cachedIn(viewModelScope) - UI:
val items = flow.collectAsLazyPagingItems()+LazyColumn { items(...) } - Load states:
loadState.refresh/appendfor loading + error + retry - Sketch: reference.md
WorkManager
Use for deferrable work that must survive process death (upload, sync), not for “call API on button tap” (use coroutines).
CoroutineWorker+doWork(): Result- Enqueue
OneTimeWorkRequest/PeriodicWorkRequestwith constraints - Hilt:
HiltWorkerFactory+@HiltWorker/@AssistedInjectwhen needed - Sketch: reference.md
Image loading
Default: Coil 3 (AsyncImage / rememberAsyncImagePainter).
- Add
coil-composeandcoil-network-okhttp; Coil 3 does not bundle a network fetcher - Placeholder + error painter; size appropriately in lists
- Don’t download bitmaps manually unless asked
Secure / config
- Never hardcode API keys or secrets in source
local.properties→BuildConfigprevents source-control leaks but does not make a key secret (it is extractable from the APK). Use a backend proxy when the key must remain confidentialINTERNETpermission when networking; avoidusesCleartextTrafficunless HTTP is required (then limit to debug)- Log sparingly; don’t log tokens/PII
Accessibility / polish
Minimum bar that reads production-aware:
- Meaningful
contentDescriptionon icon buttons / images (null on decorative) - Loading / empty / error triad for every primary screen
- Enough contrast; don’t rely on color alone for errors
- Touch targets ~48dp; support TalkBack on primary actions
- Prefer
Scaffold+ clear titles over mystery icon-only nav
Navigation
Default to one host activity for a Compose-first app. Add another activity only
when the prompt or an isolated external flow genuinely benefits from it. No
NavHost for a single screen.
@Serializable data object Home
@Serializable data class Detail(val id: String)
NavHost(navController, startDestination = Home) {
composable<Home> { HomeRoute(onOpen = { navController.navigate(Detail(it)) }) }
composable<Detail> { entry ->
val route = entry.toRoute<Detail>()
DetailRoute(id = route.id)
}
}
Prefer typed routes (Navigation 2.8+) when arguments exist; they require the Kotlin serialization plugin. Navigation Compose is the nav graph (Kotlin DSL, not XML fragments).
Gradle / version catalog muscle memory
AGP 9 templates: no org.jetbrains.kotlin.android. Kotlin is built-in.
Root build.gradle.kts: application, library, compose, hilt, ksp — all
apply false.
:app plugins: android.application, kotlin.compose, hilt, ksp
Feature (Compose) plugins: android.library, kotlin.compose, hilt, ksp:core:network / db: android.library, hilt, ksp (compose only if needed)
Versions that matter on AGP 9:
- Hilt ≥ 2.59.2
- KSP ≥ 2.3.6
Catalog pattern: versions → libraries → plugins; modules use
alias(libs.plugins.*) and libs.*. Copy-paste recipes:
reference.md.
Testing (quality over quantity)
Write
- Unit tests for real logic: mapping, validation, state transitions, repository policy, agent/tool loop reducers if any
- Screenshot / Paparazzi (or studio equivalent) for 1–3 critical screens/states
Avoid
- Mocking every collaborator to assert that a method called another method
- Interfaces invented only so MockK can stub them
- Giant fakes hierarchies
- Testing Compose fluff that duplicates screenshot coverage
Default: inject concrete fakes only at boundaries you already own (e.g. in-memory DAO, fake API facade) when that clarifies the test.
Vertical slice order (any prompt)
- Running UI shell for the primary screen
- ViewModel + UiState with fake in-memory data
- Real data path (network and/or Room)
- Navigation / secondary screens
- Paging, WorkManager, streaming only if required
- Targeted tests + light polish
Under time pressure: a working narrow app beats a broken broad one — cut scope, not correctness. Commit at every green milestone.
Related
- Compose side effects / animations: ../android-compose/SKILL.md
- LLM tool-loop / harness-server client (only if prompt needs it): ../android-agent-harness/SKILL.md
- Voice input/output (OpenAI STT/TTS, recording, playback): ../android-voice/SKILL.md
- Longer snippets (Room, Paging, WM, Gradle): reference.md