Android Architecture skill for Codex
ViewModel, Hilt and Room wired the testable way. Codex builds Android features along the official layered architecture: ViewModels expose one StateFlow of immutable UI state, repositories make Room the single source of truth and refresh it from the network, and Hilt wires it all with KSP. Every Room version gets an exported schema and a migration test, and ViewModels are tested with fakes and Turbine instead of mocks.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- android-architecture Installs to ~/.agents/skills/android-architecture/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install android-architecture The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project android-architecture Installs to .agents/skills/android-architecture/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/android-architecture.zipunzip -o android-architecture.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) android-architecture PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $android-architecture, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Structures Android apps along the official architecture guidance - UI layer with ViewModel and StateFlow UiState, optional domain use cases, data layer with repositories over Room and Retrofit or Ktor, Hilt dependency injection with KSP, Room schemas and migrations, offline-first sync with WorkManager, and Gradle modularization with version catalogs and convention plugins. Use when setting up a new Android app's layers, adding a feature end to end, introducing Hilt or Room, writing a Room migration, splitting a monolithic app module, or making ViewModels testable.
Use it when
- Laying out layers for a new Android app or feature
- Introducing or fixing Hilt dependency injection
- Changing a Room schema that has shipped
- Breaking a monolithic app module into Gradle modules
Use something else when
- The task is purely Compose UI, effects or theming: use android-jetpack-compose
- You need cross-platform layering advice for several mobile stacks: use mobile-app-architecture
How to check the result
Unit tests cover each ViewModel state with fakes, MigrationTestHelper passes for every schema version, and a release build with R8 runs offline from cached data.
The mistake it prevents
Shipping fallbackToDestructiveMigration so a schema change silently deletes every user's local data on upgrade.
Done well means: data flows one way (data layer -> ViewModel -> UI as immutable state, UI -> ViewModel as events), every layer depends only on the one below through interfaces, Room schemas are exported and every version bump has a tested migration, and ViewModels are unit-tested with fakes and no Android framework.
Inspect first
cat AGENTS.md 2>/dev/null
cat settings.gradle.kts gradle/libs.versions.toml
ls build-logic 2>/dev/null # convention plugins
grep -rlE '@HiltAndroidApp|@HiltViewModel|@Database|@Dao' --include='*.kt' . | head
./gradlew projects -q # module graph (needs dependencies resolved)
Follow the existing pattern if it is consistent. Introducing a second DI framework or a second persistence library is a team decision, not a refactor side effect.
Layers
UI layer Composable / Fragment <- UiState (StateFlow) -> events
ViewModel -> calls use cases or repositories
Domain (opt.) UseCase -> combines repositories, pure Kotlin
Data layer Repository -> single source of truth, exposes Flow
DataSources -> Room DAO, Retrofit/Ktor API, DataStore
| Rule | Why |
|---|---|
Repositories expose Flow from the local database, and network refreshes write into it | One source of truth; offline works by default |
ViewModels never hold Context, View or Activity | Leaks and untestable code |
| Domain layer only when logic is reused by several ViewModels or is complex | Use cases that just forward a call are boilerplate |
Models per layer when they differ (OrderEntity, OrderDto, Order) | API or schema changes do not ripple into UI |
ViewModel
sealed interface OrdersUiState {
data object Loading : OrdersUiState
data class Success(val orders: List<Order>, val isRefreshing: Boolean) : OrdersUiState
data class Error(val message: String) : OrdersUiState
}
@HiltViewModel
class OrdersViewModel @Inject constructor(
private val repository: OrdersRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val status: String = savedStateHandle["status"] ?: "open"
private val refreshing = MutableStateFlow(false)
val uiState: StateFlow<OrdersUiState> =
combine(repository.observeOrders(status), refreshing) { orders, isRefreshing ->
OrdersUiState.Success(orders, isRefreshing) as OrdersUiState
}
.catch { emit(OrdersUiState.Error(it.message ?: "Something went wrong")) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), OrdersUiState.Loading)
init { refresh() }
fun refresh() {
viewModelScope.launch {
refreshing.value = true
try {
repository.refresh(status)
} catch (e: IOException) {
// offline: cached rows still show; surface a transient message if needed
} finally {
refreshing.value = false
}
}
}
}
WhileSubscribed(5_000)keeps the upstream alive across configuration changes but stops it when the screen is gone.- Keep navigation arguments and small user input in
SavedStateHandleso they survive process death. - Trigger initial loads in the ViewModel (
initor a cold flow), not from aLaunchedEffectin the UI, which reruns after every configuration change.
Repository and Room
@Entity(tableName = "orders", indices = [Index("status")])
data class OrderEntity(
@PrimaryKey val id: String,
val status: String,
val totalCents: Long,
val updatedAt: Long,
)
@Dao
interface OrderDao {
@Query("SELECT * FROM orders WHERE status = :status ORDER BY updatedAt DESC")
fun observe(status: String): Flow<List<OrderEntity>>
@Upsert
suspend fun upsertAll(orders: List<OrderEntity>)
}
class DefaultOrdersRepository @Inject constructor(
private val dao: OrderDao,
private val api: OrdersApi,
@IoDispatcher private val io: CoroutineDispatcher,
) : OrdersRepository {
override fun observeOrders(status: String) =
dao.observe(status).map { rows -> rows.map(OrderEntity::toDomain) }
override suspend fun refresh(status: String) = withContext(io) {
dao.upsertAll(api.orders(status).map(OrderDto::toEntity))
}
}
Room setup and migrations:
// module build.gradle.kts
plugins { alias(libs.plugins.ksp); alias(libs.plugins.room) }
room { schemaDirectory("$projectDir/schemas") } // commit these JSON files
@Database(
entities = [OrderEntity::class],
version = 3,
autoMigrations = [AutoMigration(from = 1, to = 2)],
)
abstract class AppDatabase : RoomDatabase() { abstract fun orderDao(): OrderDao }
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE orders ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD'")
}
}
- Auto-migrations handle added tables/columns; renames and deletes need an
AutoMigrationSpec(@RenameColumn,@DeleteColumn); data transforms need a manualMigration. - Never ship
fallbackToDestructiveMigration()in a release build without explicit agreement from the user: it wipes local data on upgrade. - Test every migration with
MigrationTestHelperagainst the exported schemas.
Hilt
@HiltAndroidApp class AcmeApp : Application()
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds abstract fun ordersRepository(impl: DefaultOrdersRepository): OrdersRepository
}
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides @Singleton
fun db(@ApplicationContext ctx: Context): AppDatabase =
Room.databaseBuilder(ctx, AppDatabase::class.java, "acme.db")
.addMigrations(MIGRATION_2_3)
.build()
@Provides fun orderDao(db: AppDatabase) = db.orderDao()
}
- Use KSP, not kapt, for Hilt and Room (Hilt supports KSP since 2.48).
@Bindsfor interface-to-implementation,@Providesfor third-party builders.- Scope only what must be shared (
@Singletondatabase, HTTP client). Unscoped is the default and cheaper. - Activities and Fragments using injected ViewModels need
@AndroidEntryPoint.
Background sync
Use WorkManager for deferrable, guaranteed work (periodic refresh, uploading queued changes) with constraints:
val request = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork("sync", ExistingPeriodicWorkPolicy.KEEP, request)
Inject dependencies into workers with @HiltWorker and HiltWorkerFactory. Conflict
resolution strategies belong to $mobile-offline-sync.
Modularization
:app wiring, navigation graph, Application
:feature:orders UI + ViewModels for one feature (depends on core only)
:core:data repositories
:core:database Room
:core:network Retrofit/Ktor
:core:model plain Kotlin models
:core:designsystem theme, shared composables
:core:testing fakes, test rules
build-logic/convention Gradle convention plugins (android library, compose, hilt)
- Features never depend on other features; navigate through routes defined in
:appor a small:feature:x:apimodule. - Put shared Gradle setup in convention plugins, not copy-pasted
android { }blocks. - Split when build times or ownership boundaries justify it, not preemptively for a three-screen app.
Testing
class MainDispatcherRule(private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()) : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description) = Dispatchers.resetMain()
}
class OrdersViewModelTest {
@get:Rule val main = MainDispatcherRule()
@Test fun emitsOrdersFromRepository() = runTest {
val repo = FakeOrdersRepository(listOf(order1))
val vm = OrdersViewModel(repo, SavedStateHandle(mapOf("status" to "open")))
vm.uiState.test { // Turbine
assertEquals(OrdersUiState.Success(listOf(order1), false), awaitItem())
}
}
}
Fakes implementing the repository interface beat mocks: they behave like the real thing and survive refactors.
Verification
./gradlew testDebugUnitTest
./gradlew :core:database:connectedDebugAndroidTest # MigrationTestHelper runs on device/emulator
./gradlew lint
./gradlew :app:assembleRelease # R8 shrinking catches missing keep rules
Done when unit tests cover each ViewModel state, every Room version has a migration test, schemas are committed, and a release build installs and runs offline from cached data.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Android Architecture"
short_description: "ViewModel, Hilt and Room wired the testable way"
default_prompt: "$android-architecture Add an orders feature with a ViewModel, repository, Room cache and Retrofit API" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.