Modern Android complete course

A serious 0 to advanced Android course, not a checklist.

This page is structured as a complete course. Each module tells you what to build, which files to touch, which dependencies are needed, what code to write, how to test it, and what usually breaks. XML layouts are intentionally excluded; the path is Kotlin and Jetpack Compose first.

Course architecture

Build mental models before adding libraries.

StageModulesOutput
FoundationProject setup, Kotlin, Gradle, ComposeRunnable Compose app with local state.
Async stateCoroutines, Flow, StateFlow, LiveDataAsync app state that survives real loading/error behavior.
ArchitectureMVVM, MVI, repositoriesScreen behavior separated from data and reducers.
Dependency graphDagger 2, HiltConstructed graph and Android-integrated DI.
Advanced platformKMP, LiteRT/TensorFlowShared rules and on-device inference seam.
DeliveryTesting, CI/CDAutomated quality gate for every change.
Module 0

Project Setup, Gradle, and Course Rules

Build: a clean Compose app named ModernAndroidCourse. The first rule is simple: the app must compile after every module.

ModernAndroidCourse/
app/build.gradle.kts
app/src/main/java/com/example/modernandroid/
MainActivity.kt
model/
ui/
data/
architecture/
di/
ml/
app/src/test/java/com/example/modernandroid/
app/src/androidTest/java/com/example/modernandroid/

Baseline dependencies

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.example.modernandroid"
    compileSdk = 36

    defaultConfig {
        applicationId = "com.example.modernandroid"
        minSdk = 26
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"
    }
}

dependencies {
    implementation(platform("androidx.compose:compose-bom:2026.08.00"))
    implementation("androidx.activity:activity-compose:1.12.0")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")

    testImplementation("junit:junit:4.13.2")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}
Checkpoint: run the app before writing course code. If the starter fails, fix Gradle, SDK, or emulator first.
Version mismatchUse Android Studio's suggested stable versions if these versions are newer than your installed tooling supports.
Course ruleNever add Dagger, Hilt, KMP, or LiteRT before the simpler version works.
Module 1

Kotlin: Types, Data Classes, Null Safety, and Sealed Models

Build: a pure Kotlin expense model. This module has no Android UI. That is intentional: app correctness starts before the screen.

model/Expense.kt
model/ExpenseRules.kt
src/test/.../ExpenseRulesTest.kt
package com.example.modernandroid.model

data class Expense(
    val id: Long,
    val title: String,
    val amount: Double,
    val category: ExpenseCategory,
    val note: String? = null
)

sealed interface ExpenseCategory {
    data object Food : ExpenseCategory
    data object Travel : ExpenseCategory
    data object Medical : ExpenseCategory
    data object Education : ExpenseCategory
    data object Other : ExpenseCategory
}

sealed interface ValidationResult {
    data object Valid : ValidationResult
    data class Invalid(val reason: String) : ValidationResult
}

object ExpenseRules {
    fun validate(title: String, amount: Double): ValidationResult {
        return when {
            title.trim().length < 3 -> ValidationResult.Invalid("Title must have at least 3 characters")
            amount <= 0.0 -> ValidationResult.Invalid("Amount must be greater than zero")
            amount > 1_000_000.0 -> ValidationResult.Invalid("Amount is unusually high")
            else -> ValidationResult.Valid
        }
    }
}
class ExpenseRulesTest {
    @Test fun rejectsShortTitle() {
        val result = ExpenseRules.validate("No", 100.0)
        assertTrue(result is ValidationResult.Invalid)
    }

    @Test fun acceptsValidExpense() {
        val result = ExpenseRules.validate("Clinic visit", 800.0)
        assertEquals(ValidationResult.Valid, result)
    }
}
Expected result: Kotlin unit tests pass without launching an emulator.
Module 2

Jetpack Compose: Layout, Lists, Forms, and Events

Build: an expense entry screen with a form, list, empty state, and stable item keys.

ui/ExpenseScreen.kt
MainActivity.kt
@Composable
fun ExpenseScreen(
    expenses: List<Expense>,
    title: String,
    amount: String,
    onTitleChange: (String) -> Unit,
    onAmountChange: (String) -> Unit,
    onAdd: () -> Unit
) {
    Column(
        modifier = Modifier.padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        Text("Expense Tracker", style = MaterialTheme.typography.headlineMedium)
        OutlinedTextField(value = title, onValueChange = onTitleChange, label = { Text("Title") })
        OutlinedTextField(value = amount, onValueChange = onAmountChange, label = { Text("Amount") })
        Button(onClick = onAdd, enabled = title.length >= 3 && amount.toDoubleOrNull() != null) {
            Text("Add expense")
        }

        if (expenses.isEmpty()) {
            Text("No expenses yet.")
        } else {
            LazyColumn {
                items(expenses, key = { it.id }) { expense ->
                    ExpenseRow(expense)
                }
            }
        }
    }
}
@Composable
fun ExpenseRow(expense: Expense) {
    Card(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
        Column(modifier = Modifier.padding(12.dp)) {
            Text(expense.title, fontWeight = FontWeight.Bold)
            Text("₹${expense.amount}")
        }
    }
}
Checkpoint: the screen supports text input, disabled button for invalid values, empty state, and a visible list after adding data.
Module 3

Compose State: remember, State Hoisting, and Recomposition

Build: a local-state version before introducing ViewModel. This teaches what state is before architecture hides it.

@Composable
fun LocalExpenseApp() {
    var nextId by remember { mutableStateOf(1L) }
    var title by remember { mutableStateOf("") }
    var amount by remember { mutableStateOf("") }
    var expenses by remember { mutableStateOf(emptyList<Expense>()) }

    ExpenseScreen(
        expenses = expenses,
        title = title,
        amount = amount,
        onTitleChange = { title = it },
        onAmountChange = { amount = it },
        onAdd = {
            val parsedAmount = amount.toDoubleOrNull() ?: return@ExpenseScreen
            expenses = expenses + Expense(nextId++, title, parsedAmount, ExpenseCategory.Other)
            title = ""
            amount = ""
        }
    )
}
User types
State changes
Composable re-runs
UI updates
State disappearsYou used a plain local variable instead of remember state.
Child owns too muchHoist state when parent needs to coordinate multiple child composables.
Module 4

Coroutines: suspend, scopes, dispatchers, and structured concurrency

Build: a repository that simulates loading from disk or network without blocking the UI.

data/ExpenseRepository.kt
data/FakeExpenseRepository.kt
interface ExpenseRepository {
    suspend fun loadExpenses(): List<Expense>
    suspend fun saveExpense(expense: Expense)
}

class FakeExpenseRepository : ExpenseRepository {
    private val items = mutableListOf<Expense>()

    override suspend fun loadExpenses(): List<Expense> {
        delay(300)
        return items.toList()
    }

    override suspend fun saveExpense(expense: Expense) {
        delay(100)
        items += expense
    }
}
class ExpenseRepositoryTest {
    @Test fun saveThenLoadReturnsExpense() = runTest {
        val repository = FakeExpenseRepository()
        repository.saveExpense(Expense(1, "Bus ticket", 40.0, ExpenseCategory.Travel))

        val result = repository.loadExpenses()

        assertEquals("Bus ticket", result.single().title)
    }
}
Checkpoint: no `Thread.sleep()` appears in production code or tests.
Module 5

Flow, StateFlow, SharedFlow, and Channels

Build: a reactive store. Use StateFlow for current state. Use SharedFlow for one-off events such as snackbars. Use Channel only when exactly-one-consumer delivery is required.

class ExpenseStore {
    private val _expenses = MutableStateFlow<List<Expense>>(emptyList())
    val expenses: StateFlow<List<Expense>> = _expenses.asStateFlow()

    private val _messages = MutableSharedFlow<String>()
    val messages: SharedFlow<String> = _messages.asSharedFlow()

    suspend fun add(expense: Expense) {
        _expenses.update { current -> current + expense }
        _messages.emit("Expense added")
    }
}
@Test fun addEmitsUpdatedList() = runTest {
    val store = ExpenseStore()
    store.add(Expense(1, "Book", 300.0, ExpenseCategory.Education))

    assertEquals(1, store.expenses.value.size)
}
Checkpoint: explain why screen state uses StateFlow and snackbars use SharedFlow.
Module 6

LiveData: reading and maintaining existing Android apps

Build: a small LiveData bridge. You need this because many mature apps still expose LiveData from ViewModels or Room DAOs.

class LegacySummaryViewModel : ViewModel() {
    private val _total = MutableLiveData(0.0)
    val total: LiveData<Double> = _total

    fun update(expenses: List<Expense>) {
        _total.value = expenses.sumOf { it.amount }
    }
}

@Composable
fun TotalSummary(viewModel: LegacySummaryViewModel) {
    val total by viewModel.total.observeAsState(0.0)
    Text("Total: ₹$total")
}
Do not introduce LiveData into new Compose code just because it exists. Use it when integrating with lifecycle-aware existing code or APIs already built around it.
Module 7

MVVM: ViewModel, UI State, Repository

Build: production-shaped state ownership. The screen renders `ExpenseUiState`; the ViewModel owns transitions; repository owns data.

data class ExpenseUiState(
    val isLoading: Boolean = false,
    val title: String = "",
    val amount: String = "",
    val expenses: List<Expense> = emptyList(),
    val error: String? = null
)

class ExpenseViewModel(
    private val repository: ExpenseRepository
) : ViewModel() {
    private val _uiState = MutableStateFlow(ExpenseUiState())
    val uiState: StateFlow<ExpenseUiState> = _uiState.asStateFlow()

    fun load() = viewModelScope.launch {
        _uiState.update { it.copy(isLoading = true, error = null) }
        runCatching { repository.loadExpenses() }
            .onSuccess { items -> _uiState.update { it.copy(isLoading = false, expenses = items) } }
            .onFailure { error -> _uiState.update { it.copy(isLoading = false, error = error.message) } }
    }
}
Compose
ViewModel
Repository
Data source
Module 8

MVI: Intents, Reducer, Effects

Build: a reducer for screens with many user actions. MVI should make behavior easier to audit, not harder.

sealed interface ExpenseIntent {
    data class TitleChanged(val value: String) : ExpenseIntent
    data class AmountChanged(val value: String) : ExpenseIntent
    data object AddClicked : ExpenseIntent
}

sealed interface ExpenseEffect {
    data class ShowMessage(val text: String) : ExpenseEffect
}

fun reduce(state: ExpenseUiState, intent: ExpenseIntent): ExpenseUiState {
    return when (intent) {
        is ExpenseIntent.TitleChanged -> state.copy(title = intent.value)
        is ExpenseIntent.AmountChanged -> state.copy(amount = intent.value)
        ExpenseIntent.AddClicked -> state.copy(isLoading = true)
    }
}
@Test fun titleIntentUpdatesTitle() {
    val result = reduce(ExpenseUiState(), ExpenseIntent.TitleChanged("Clinic"))

    assertEquals("Clinic", result.title)
}
Checkpoint: all user actions can be listed as intents. Reducer tests do not need Android.
Module 9

Dagger 2: the dependency graph underneath Hilt

Build: a plain Dagger graph. This teaches what Hilt later automates.

@Module
class DataModule {
    @Provides
    fun provideExpenseRepository(): ExpenseRepository {
        return FakeExpenseRepository()
    }
}

@Component(modules = [DataModule::class])
interface AppComponent {
    fun expenseRepository(): ExpenseRepository
}
Provider method
Component
Repository
Consumer
Dagger feels abstractRead it as a compile-time object factory with a graph.
Hidden new callsIf classes still construct dependencies manually, the graph is incomplete.
Module 10

Hilt: Android dependency injection

Build: Android-integrated DI for Application, ViewModel, and repository.

@HiltAndroidApp
class ModernAndroidApp : Application()

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    fun provideExpenseRepository(): ExpenseRepository = FakeExpenseRepository()
}

@HiltViewModel
class ExpenseViewModel @Inject constructor(
    private val repository: ExpenseRepository
) : ViewModel()
Checkpoint: ViewModel constructor has dependencies; it does not instantiate repository, database, network client, or classifier.
Module 11

Kotlin Multiplatform: shared business rules

Build: a shared validation module. Do not start KMP by sharing UI. Start with deterministic rules that both Android and another platform can reuse.

shared/src/commonMain/kotlin/ExpenseRules.kt
shared/src/commonTest/kotlin/ExpenseRulesTest.kt
object SharedExpenseRules {
    fun validateAmount(value: Double): ValidationResult {
        return when {
            value <= 0.0 -> ValidationResult.Invalid("Amount must be positive")
            value > 1_000_000.0 -> ValidationResult.Invalid("Amount requires review")
            else -> ValidationResult.Valid
        }
    }
}
Expected result: common tests run without Android emulator. Android app calls the same validation rule.
Module 12

TensorFlow / LiteRT: on-device classification

Build: a classifier seam first, then plug in LiteRT. The interface matters because models change.

interface ExpenseClassifier {
    suspend fun classify(text: String): ExpenseLabel
}

enum class ExpenseLabel { Medical, Education, Travel, Food, General }

class RuleBasedExpenseClassifier : ExpenseClassifier {
    override suspend fun classify(text: String): ExpenseLabel {
        val value = text.lowercase()
        return when {
            "clinic" in value || "doctor" in value -> ExpenseLabel.Medical
            "school" in value || "course" in value -> ExpenseLabel.Education
            "bus" in value || "train" in value -> ExpenseLabel.Travel
            "hotel" in value || "meal" in value -> ExpenseLabel.Food
            else -> ExpenseLabel.General
        }
    }
}
class LiteRtExpenseClassifier(
    private val modelPath: String
) : ExpenseClassifier {
    override suspend fun classify(text: String): ExpenseLabel {
        // 1. Tokenize or vectorize input text.
        // 2. Run LiteRT interpreter or generated model API.
        // 3. Map model output index to ExpenseLabel.
        // Keep this class isolated from ViewModel and Compose.
        return ExpenseLabel.General
    }
}
Checkpoint: tests use fake/rule-based classifier; production wiring can later swap in LiteRT.
Module 13

Testing the whole stack

Build: a test matrix. A serious Android tutorial must say which concept is tested where.

LayerTestFailure caught
Kotlin rulesJVM unit testInvalid amount accepted.
ReducerPure unit testIntent creates wrong state.
Coroutine repository`runTest`Save/load behavior wrong.
Flow storeEmission testCollectors do not receive updates.
ViewModelFake repositoryLoading/error state regression.
Compose screenUI testButton enabled for invalid form.
DIFake module or constructor injectionReal dependency leaks into tests.
KMPcommonTestShared rule differs from Android rule.
MLFake classifier or model fixtureWrong label mapping.
@Test fun addButtonDisabledForInvalidAmount() {
    composeTestRule.setContent {
        ExpenseScreen(
            expenses = emptyList(),
            title = "Clinic",
            amount = "abc",
            onTitleChange = {},
            onAmountChange = {},
            onAdd = {}
        )
    }

    composeTestRule.onNodeWithText("Add expense").assertIsNotEnabled()
}
Module 14

CI/CD: automated verification

Build: a GitHub Actions workflow that compiles and runs unit tests. Add emulator UI tests only after the unit gate is stable.

.github/workflows/android.yml
name: Android CI

on:
  pull_request:
  push:
    branches: [ main, master ]

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17
      - uses: gradle/actions/setup-gradle@v4
      - run: ./gradlew testDebugUnitTest
      - run: ./gradlew assembleDebug
Checkpoint: intentionally break one unit test and confirm CI blocks the change.
Interactive labs

Quizzes, matching, and drag-and-drop code blocks

Multi-select: Which statements are correct?

Drag order: Arrange the MVI loop.

StateFlow emits new ExpenseUiState
User taps Add
Reducer creates updated state
Compose recomposes
ViewModel receives ExpenseIntent.AddClicked

Matching: Pick the best tool.

References

Primary sources used for factual grounding