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.

  • Mobile & Desktop
  • $android-architecture
  • 254 lines
  • 2 files
  • ~2,250 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- android-architecture

Installs to ~/.agents/skills/android-architecture/ for every project on this machine.

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

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.

~/.agents/skills/android-architecture/SKILL.md

--- name: android-architecture description: Structures Android apps along the official architecture guidance - UI layer with ViewModel and StateFlow UiState, optional domain use cases, data… license: MIT ---

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
RuleWhy
Repositories expose Flow from the local database, and network refreshes write into itOne source of truth; offline works by default
ViewModels never hold Context, View or ActivityLeaks and untestable code
Domain layer only when logic is reused by several ViewModels or is complexUse 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 SavedStateHandle so they survive process death.
  • Trigger initial loads in the ViewModel (init or a cold flow), not from a LaunchedEffect in 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 manual Migration.
  • Never ship fallbackToDestructiveMigration() in a release build without explicit agreement from the user: it wipes local data on upgrade.
  • Test every migration with MigrationTestHelper against 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).
  • @Binds for interface-to-implementation, @Provides for third-party builders.
  • Scope only what must be shared (@Singleton database, 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 :app or a small :feature:x:api module.
  • 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem