# Android Interview Bootstrap

> Bootstraps a generic Kotlin Jetpack Compose Android app with Hilt and minimal multi-module structure. Use when starting an Android app from scratch, scaffolding MVVM features, Retrofit/Room/Navigation Compose, or timed interview / take-home Android builds. Prefer pragmatic code over verbose clean-architecture theater.

- Skill: `kathleenmaas/android-interview-bootstrap` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add kathleenmaas/android-interview-bootstrap`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kathleenmaas/android-interview-bootstrap/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: KathleenMaas (https://skillmd.com/u/kathleenmaas)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/kathleenmaas/android-interview-bootstrap

---


# 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 small `AppError`) 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 AGP
- Use **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`, `ksp`

- Application: `@HiltAndroidApp`
- Activity: `@AndroidEntryPoint`
- ViewModel: `@HiltViewModel` + `@Inject constructor`
- Modules: `@Module` + `@InstallIn(SingletonComponent::class)` (or
  `ViewModelComponent` when scoped)
- Provide Retrofit/Room/DataStore in `:core:*` modules; bind feature repos in
  feature modules **only if** an interface is justified (default: `@Inject` the
  concrete repo)

Prefer constructor injection on concrete classes over `interface` + `@Binds`
pairs.

## MVVM pattern

```kotlin
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`

```kotlin
@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:

```kotlin
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`

```kotlin
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()`

```kotlin
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 `AppError`** when UI branches (auth vs retry vs blocking)
- **Per-field validation** as separate state, not screen `error`
- **One-shot Snackbar events** via `Channel`/`SharedFlow` — optional; sticky
  `error` on 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 `CoroutineExceptionHandler` as the primary UX path
- `Result` on every tiny private helper (keep it at **public repo / use-case** APIs)
- Giant `Either`/`Resource` frameworks 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` / `Flow` from VM; UI collects with lifecycle awareness
- Inject `CoroutineDispatcher` only 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 bare `collectAsState`)
- Hoist: `Route` gets VM / state; inner `Screen` is stateless + previewable
- `LazyColumn` / `LazyRow`: always pass stable `key`
- Don’t do network/DB/heavy work during composition
- Avoid holding `NavController` deep in children — pass lambdas/`onNavigate`

**Side effects / animations:** use sparingly here. For `LaunchedEffect`,
one-shot events, and motion patterns, see
[../android-compose/SKILL.md](../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.

```kotlin
@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:

```kotlin
@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`; map `Entity` → model at repo edge
- Use `.fallbackToDestructiveMigration(dropAllTables = true)` only when
  interview data is explicitly disposable; otherwise write a real `Migration`
  or keep schema version 1
- Fuller snippets: [reference.md](reference.md#room)

## Paging 3

Use when lists are large / infinite scroll is required — not for 20 static items.

- `PagingSource` (network or Room `PagingSource`)
- VM: `Pager(PagingConfig(pageSize = 20)) { source }.flow.cachedIn(viewModelScope)`
- UI: `val items = flow.collectAsLazyPagingItems()` + `LazyColumn { items(...) }`
- Load states: `loadState.refresh` / `append` for loading + error + retry
- Sketch: [reference.md](reference.md#paging-3)

## 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` / `PeriodicWorkRequest` with constraints
- Hilt: `HiltWorkerFactory` + `@HiltWorker` / `@AssistedInject` when needed
- Sketch: [reference.md](reference.md#workmanager)

## Image loading

Default: **Coil 3** (`AsyncImage` / `rememberAsyncImagePainter`).

- Add `coil-compose` **and** `coil-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` → `BuildConfig` prevents 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 confidential
- `INTERNET` permission when networking; avoid `usesCleartextTraffic` unless HTTP
  is required (then limit to debug)
- Log sparingly; don’t log tokens/PII

## Accessibility / polish

Minimum bar that reads production-aware:

- Meaningful `contentDescription` on 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.

```kotlin
@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](reference.md#gradle--version-catalog).

## 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)

1. Running UI shell for the primary screen
2. ViewModel + UiState with fake in-memory data
3. Real data path (network and/or Room)
4. Navigation / secondary screens
5. Paging, WorkManager, streaming only if required
6. 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](../android-compose/SKILL.md)
- LLM tool-loop / harness-server client (only if prompt needs it):
  [../android-agent-harness/SKILL.md](../android-agent-harness/SKILL.md)
- Voice input/output (OpenAI STT/TTS, recording, playback):
  [../android-voice/SKILL.md](../android-voice/SKILL.md)
- Longer snippets (Room, Paging, WM, Gradle): [reference.md](reference.md)

