Overview

ViewModels in Android are hard to test and hard to use in Compose previews because they carry real dependencies. The conventional solution is to extract an interface and maintain a hand-written stub — work that is repetitive and easy to let drift out of sync with the real implementation.

viewmodel-stub automates this. Annotate your ViewModel implementation class with @ViewModelStub, and the plugin generates two files on every build:

  • An interface exposing the public API surface.
  • A stub class implementing that interface with sensible default values for all state properties and no-op method bodies.

The generated stub is ready to drop into a Compose @Preview or a JUnit test without any manual maintenance.

Interface extraction

Public properties and methods become an interface you can inject against.

Smart defaults

StateFlow, LiveData, collections — all get correct default values.

suspend support

Suspend functions are preserved with the correct signature in generated code.

Build integration

Runs before every Kotlin compile. Output is always fresh.

Installation

Add Maven Central to your plugin repositories in settings.gradle.kts:

settings.gradle.kts
pluginManagement {
    repositories {
        mavenCentral()
        gradlePluginPortal()
    }
}

Then apply the plugin in your Android module's build.gradle.kts:

build.gradle.kts
plugins {
    id("com.rohittp.plugables.viewmodel-stub") version "1.0.0"
}

No DSL block is required if your Kotlin sources are in the default location (src/main/kotlin). The output directory also has a sensible default.

Requires AGP 7.2+

Generated sources are wired via the Android Variant API (AndroidComponentsExtension), which requires Android Gradle Plugin 7.2 or higher.

To customise either path, add a viewModelStub block:

build.gradle.kts
viewModelStub {
    // Optional — defaults shown
    sourceDir.set(file("src/main/kotlin"))
    outputDir.set(layout.buildDirectory.dir("generated/source/viewModelStubs/main"))
}

The @ViewModelStub Annotation

The @ViewModelStub annotation is generated by the plugin itself on first run. You do not need to add any dependency to use it — it will appear in the generated sources directory and be available to the compiler automatically.

kotlin
// Generated — do not edit
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class ViewModelStub

Naming convention required

The annotated class must have a name ending in Impl (e.g., HomeViewModelImpl). The plugin derives the interface name by stripping the suffix, and the stub name by replacing it with Stub.

Name derivation

Annotated class Generated interface Generated stub
HomeViewModelImpl HomeViewModel HomeViewModelStub
ProfileViewModelImpl ProfileViewModel ProfileViewModelStub
SearchViewModelImpl SearchViewModel SearchViewModelStub

How It Works

The plugin registers a single Gradle task, generateViewModelStubs, which runs before any Kotlin compilation. The task performs four steps:

Scan *.kt files
Parse @ViewModelStub
Analyse properties & methods
Generate interface + stub

The parser is regex-based and intentionally lightweight — it does not invoke the Kotlin compiler. It extracts the public API surface of the annotated class: all public val/var properties with explicit type annotations, and all public (including suspend) functions.

The output directory is wired into all Android build variants using the Variant API (AndroidComponentsExtension.onVariants), making generated classes available to the compiler immediately without afterEvaluate.

Configuration

The viewModelStub DSL block exposes two properties:

Property Type Default Description
sourceDir DirectoryProperty src/main/kotlin Root directory scanned for *.kt files.
outputDir DirectoryProperty build/generated/source/viewModelStubs/main Where generated .kt files are written. Automatically added to the AGP main source set.

Full Example

Here is a complete walkthrough from annotated ViewModel to generated output.

Input ViewModel

Write your ViewModel implementation class as normal. Add @ViewModelStub above the class declaration.

kotlin
package com.example.ui.home

import com.rohittp.plugables.viewmodelstub.ViewModelStub
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

@ViewModelStub
class HomeViewModelImpl : ViewModel() {

    override val isLoading: StateFlow<Boolean> = MutableStateFlow(false)
    override val errorMessage: String? = null
    override val items: StateFlow<List<Item>> = MutableStateFlow(emptyList())

    override fun loadData(userId: String) {
        /* ... real implementation ... */
    }

    override suspend fun refresh() {
        /* ... real implementation ... */
    }
}

Generated Interface

The plugin writes HomeViewModel.kt to the output directory. The interface mirrors every public property and method.

generated kotlin
// GENERATED by GenerateViewModelStubsTask — do not edit.
package com.example.ui.home

import kotlinx.coroutines.flow.StateFlow

interface HomeViewModel {
    val isLoading: StateFlow<Boolean>
    val errorMessage: String?
    val items: StateFlow<List<Item>>

    fun loadData(
        userId: String
    )
    suspend fun refresh()
}

Generated Stub

The plugin also writes HomeViewModelStub.kt. Every property has a sensible default; every method has a no-op body (or returns a default value). The stub is immediately usable in previews and tests.

generated kotlin
// GENERATED by GenerateViewModelStubsTask — do not edit.
package com.example.ui.home

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

class HomeViewModelStub(
    override val isLoading: StateFlow<Boolean> = MutableStateFlow(false),
    override val errorMessage: String? = null,
    override val items: StateFlow<List<Item>> = MutableStateFlow(emptyList()),
) : HomeViewModel {
    override fun loadData(userId: String) {}
    override suspend fun refresh() {}
}

Using the stub in a Compose preview

kotlin
@Preview
@Composable
fun HomeScreenPreview() {
    HomeScreen(viewModel = HomeViewModelStub())
}

// Or customise the stub for a specific preview state
@Preview
@Composable
fun HomeScreenLoadingPreview() {
    HomeScreen(
        viewModel = HomeViewModelStub(
            isLoading = MutableStateFlow(true),
        )
    )
}

Parser Behavior

The parser is intentionally simple and regex-based — it does not resolve types or run the Kotlin compiler. Understanding its scope helps you use the plugin effectively.

What is extracted

  • All public (and package-private) val / var properties with an explicit type annotation.
  • All public and suspend functions at the class body level.
  • Package declaration and all import statements (for use in generated files).

What is skipped

  • private, protected, and internal members.
  • Properties without an explicit type (e.g., val x = 42 — no type).
  • Companion objects, init blocks, nested classes, inner classes.
  • Members at depth > 1 (nested in lambdas, inner blocks, etc.).

Limitation

Because the parser does not resolve types, it copies import statements verbatim. If a type used in the ViewModel is not imported (e.g., it is in the same package), the generated files will compile fine. If a type is imported but unused after extraction, the Kotlin compiler will issue a warning.

Type Defaults

The stub generator maps each property type to a default value expression. The following table shows the built-in mappings:

Type Default expression
T?null
Booleanfalse
Int0
Long0L
Float0f
Double0.0
String""
StateFlow<T>MutableStateFlow(<default for T>)
MutableStateFlow<T>MutableStateFlow(<default for T>)
SharedFlow<T>MutableSharedFlow()
LiveData<T>MutableLiveData(<default for T>)
List<T>emptyList()
Map<K, V>emptyMap()
Set<T>emptySet()
CoroutineScopeCoroutineScope(SupervisorJob())
IntRange0..0
ClosedFloatingPointRange<T>0f..1f
anything elseTODO("Provide default for T")

For types that fall through to TODO(), the stub will still compile but will throw a NotImplementedError if that property is accessed during a test or preview. Replace it by providing the value via the constructor parameter.