Modern Android 0 to 1

Build one Android app while learning the full modern stack.

This tutorial ignores XML layout paths and teaches Kotlin, Jetpack Compose, Coroutines, Flow, LiveData, MVVM, MVI, Dagger 2, Hilt, KMP, on-device ML with LiteRT/TensorFlow, testing, and CI/CD through one capstone app: Field Notes.

Learning map

Everything is taught through one build path.

01 KotlinTypes, functions, data classes, sealed interfaces.
02 ComposeComposable UI, state, layout, navigation.
03 AsyncCoroutines, Flow, StateFlow, LiveData bridge.
04 ArchitectureMVVM, MVI, repositories, state reducers.
05 DIDagger 2 concepts, Hilt implementation.
06 TestingUnit, coroutine, ViewModel, Compose UI tests.
07 KMPShared validation and business rules.
08 ML + CILiteRT inference seam and GitHub Actions pipeline.
One app end to end

The whole tutorial builds Field Notes incrementally.

Field Notes is a field-worker note app. A user creates notes, labels them, syncs them through a repository boundary, observes state reactively, validates business rules from shared KMP code, classifies note content on-device, and protects the workflow with tests and CI.

Milestone 1

Kotlin model plus Compose note list and form.

Compose
ViewModel
Repository
Classifier

Milestone 2

MVVM/MVI, coroutines, Flow, DI, and fake ML seam.

shared/NoteRules.kt
app/ml/NoteClassifier.kt
app/di/AppModule.kt
.github/workflows/android.yml

Milestone 3

KMP validation, LiteRT integration point, tests, and CI/CD.

Feature added to Field NotesConcept learnedProof
Create note modelKotlin data classes, sealed interfacesPure validation function and unit test.
Render note listJetpack Compose, state, LazyColumnForm, add button, empty state, stable row keys.
Load/save notes asynchronouslyCoroutinesNo blocking UI while repository simulates latency.
Observe note updatesFlow and StateFlowAdding a note updates all collectors.
Support existing architectureLiveData bridgeLegacy LiveData state can still render in Compose.
Own screen behaviorMVVM and MVIState, intents, reducer, and ViewModel are explicit.
Inject dependenciesDagger 2 concepts and Hilt implementationViewModel receives repository/classifier instead of creating them.
Share rulesKMPValidation lives in common code and is tested outside Android.
Auto-label notesTensorFlow/LiteRT seamFake classifier first, real model later behind same interface.
Protect deliveryTesting and CI/CDUnit, coroutine, ViewModel, Compose UI tests run in GitHub Actions.
Module 0

Project setup and file map

Create a new Android Studio project using Empty Activity, Kotlin, and Jetpack Compose. Name it FieldNotes. Run the default app once before adding architecture, networking, DI, KMP, or ML.

FieldNotes/
app/src/main/java/com/example/fieldnotes/
MainActivity.kt
ui/NotesScreen.kt
ui/NotesViewModel.kt
state/NotesContract.kt
data/NotesRepository.kt
di/AppModule.kt
ml/NoteClassifier.kt
shared/src/commonMain/kotlin/
NoteRules.kt
.github/workflows/android.yml
Expected result: the starter app runs. If Gradle sync or emulator launch fails, fix that first. Do not debug tutorial code inside a broken environment.
The app should remain runnable after every module. If a module breaks the build, fix it before moving ahead. That is the discipline this tutorial is trying to teach.
Module 1

Kotlin fundamentals for Android

Start with the smallest data model. This teaches immutable values, data classes, sealed interfaces, and pure functions.

data class FieldNote(
    val id: Long,
    val title: String,
    val body: String,
    val status: NoteStatus = NoteStatus.Open
)

sealed interface NoteStatus {
    data object Open : NoteStatus
    data object Done : NoteStatus
    data class Blocked(val reason: String) : NoteStatus
}

fun FieldNote.isValid(): Boolean {
    return title.trim().length >= 3 && body.trim().isNotEmpty()
}
Checkpoint: create three notes in a list, filter valid notes, and print their titles. No Android UI required yet.
Module 2

Jetpack Compose UI, state, and navigation

Compose is declarative: state goes in, UI comes out. Build a list screen before adding architecture.

@Composable
fun NotesScreen(
    notes: List<FieldNote>,
    title: String,
    onTitleChange: (String) -> Unit,
    onAdd: () -> Unit
) {
    Column(modifier = Modifier.padding(16.dp)) {
        OutlinedTextField(
            value = title,
            onValueChange = onTitleChange,
            label = { Text("Note title") }
        )
        Button(enabled = title.trim().length >= 3, onClick = onAdd) {
            Text("Add note")
        }
        LazyColumn {
            items(notes, key = { it.id }) { note ->
                Text(note.title)
            }
        }
    }
}
State
Composable
Events
New state
Checkpoint: the screen must show input, disabled/enabled button, and a stable keyed list.
Module 3

Coroutines: async work without blocking UI

Use suspending functions for work that may take time. Keep blocking work away from the main thread.

interface NotesRepository {
    suspend fun loadNotes(): List<FieldNote>
    suspend fun saveNote(note: FieldNote)
}

class FakeNotesRepository : NotesRepository {
    private val notes = mutableListOf<FieldNote>()

    override suspend fun loadNotes(): List<FieldNote> {
        delay(300)
        return notes.toList()
    }

    override suspend fun saveNote(note: FieldNote) {
        delay(100)
        notes += note
    }
}
The app can simulate slow work while the UI remains responsive.
Module 4

Flow and StateFlow: reactive app data

Use Flow for streams of values and StateFlow for current UI state. A ViewModel can collect repository changes and expose a stable state object.

class ReactiveNotesRepository {
    private val notes = MutableStateFlow<List<FieldNote>>(emptyList())

    fun observeNotes(): StateFlow<List<FieldNote>> = notes.asStateFlow()

    suspend fun add(note: FieldNote) {
        notes.update { current -> current + note }
    }
}

class NotesViewModel(
    private val repository: ReactiveNotesRepository
) : ViewModel() {
    val notes: StateFlow<List<FieldNote>> = repository.observeNotes()
}
Checkpoint: adding a note updates every active collector without manually refreshing the screen.
Module 5

LiveData: lifecycle-aware observable state

Modern Compose apps usually prefer StateFlow, but LiveData still appears in many Android codebases. Learn it so you can work with existing apps.

class LegacyNotesViewModel : ViewModel() {
    private val _title = MutableLiveData("")
    val title: LiveData<String> = _title

    fun updateTitle(value: String) {
        _title.value = value
    }
}

@Composable
fun LegacyTitle(viewModel: LegacyNotesViewModel) {
    val title by viewModel.title.observeAsState("")
    Text("Current title: $title")
}
Use LiveData when maintaining lifecycle-aware existing architecture. Prefer StateFlow for new coroutine-first Compose code unless project constraints say otherwise.
Module 6

MVVM: screen state with ViewModel

MVVM keeps UI rendering separate from data decisions. The screen emits events; the ViewModel changes state.

data class NotesUiState(
    val title: String = "",
    val notes: List<FieldNote> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null
)

class NotesViewModel(
    private val repository: ReactiveNotesRepository
) : ViewModel() {
    private val _uiState = MutableStateFlow(NotesUiState())
    val uiState = _uiState.asStateFlow()

    fun onTitleChange(value: String) {
        _uiState.update { it.copy(title = value) }
    }
}
Screen
ViewModel
Repository
StateFlow
Module 7

MVI: intents, reducer, and one-way state

MVI is useful when screen behavior becomes complex. Define user intents, reduce them into state, and keep side effects explicit.

sealed interface NotesIntent {
    data class TitleChanged(val value: String) : NotesIntent
    data object AddClicked : NotesIntent
    data class NoteClicked(val id: Long) : NotesIntent
}

fun reduce(state: NotesUiState, intent: NotesIntent): NotesUiState {
    return when (intent) {
        is NotesIntent.TitleChanged -> state.copy(title = intent.value)
        NotesIntent.AddClicked -> state.copy(isLoading = true)
        is NotesIntent.NoteClicked -> state
    }
}
Checkpoint: every UI event is represented as an intent. State changes are traceable.
Module 8

Dagger 2 and Hilt: dependency injection

Dagger 2 teaches the underlying dependency graph. Hilt is the recommended Android integration for most apps because it reduces manual component wiring.

// Dagger-style concept
@Module
class RepositoryModule {
    @Provides
    fun provideRepository(): NotesRepository {
        return FakeNotesRepository()
    }
}

@Component(modules = [RepositoryModule::class])
interface AppComponent {
    fun repository(): NotesRepository
}
// Hilt-style Android implementation
@HiltAndroidApp
class FieldNotesApp : Application()

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    fun provideRepository(): NotesRepository = FakeNotesRepository()
}

@HiltViewModel
class NotesViewModel @Inject constructor(
    private val repository: NotesRepository
) : ViewModel()
Checkpoint: ViewModel no longer creates its repository. The graph provides it.
Module 9

Testing: Kotlin, coroutines, ViewModel, Compose, DI

Test the behavior that can break product flows. Do not test every line mechanically.

@Test
fun titleChangeUpdatesUiState() = runTest {
    val viewModel = NotesViewModel(FakeNotesRepository())

    viewModel.onTitleChange("Visit clinic")

    assertEquals("Visit clinic", viewModel.uiState.value.title)
}

@Test
fun invalidShortTitleIsRejected() {
    val note = FieldNote(1, "No", "Body")

    assertFalse(note.isValid())
}
@Test
fun addButtonIsDisabledForShortTitle() {
    composeTestRule.setContent {
        NotesScreen(notes = emptyList(), title = "No", onTitleChange = {}, onAdd = {})
    }

    composeTestRule.onNodeWithText("Add note").assertIsNotEnabled()
}
Checkpoint: you have one pure Kotlin test, one coroutine/ViewModel test, one Compose UI test, and one DI replacement/fake test.
Module 10

KMP: share business rules, not Android UI

Kotlin Multiplatform is strongest when shared code is stable business logic. Do not start by sharing Compose UI unless the product and team are ready for that complexity.

// shared/src/commonMain/kotlin/NoteRules.kt
object NoteRules {
    fun validateTitle(value: String): ValidationResult {
        return when {
            value.trim().isEmpty() -> ValidationResult.Invalid("Title is required")
            value.trim().length < 3 -> ValidationResult.Invalid("Use at least 3 characters")
            else -> ValidationResult.Valid
        }
    }
}

sealed interface ValidationResult {
    data object Valid : ValidationResult
    data class Invalid(val reason: String) : ValidationResult
}
Android, iOS, and tests can reuse note validation without duplicating business rules.
Module 11

TensorFlow / LiteRT: on-device note classification

For Android apps, TensorFlow Lite is now presented through Google AI Edge LiteRT docs. Keep ML behind an interface so the rest of the app does not depend on model runtime details.

interface NoteClassifier {
    suspend fun classify(text: String): NoteLabel
}

enum class NoteLabel { Health, Education, Finance, General }

class FakeNoteClassifier : NoteClassifier {
    override suspend fun classify(text: String): NoteLabel {
        return when {
            "clinic" in text.lowercase() -> NoteLabel.Health
            "school" in text.lowercase() -> NoteLabel.Education
            "tax" in text.lowercase() -> NoteLabel.Finance
            else -> NoteLabel.General
        }
    }
}
First build the seam with a fake classifier. Add the real LiteRT model only after the app behavior and tests are stable.
Module 12

CI/CD: build and test every change

A basic CI pipeline should compile, run unit tests, and upload reports. Release signing and Play deployment come later after the quality gate is stable.

name: Android CI

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

jobs:
  test:
    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: a bad ViewModel test fails CI before code merges.
Interactive labs

Quizzes and drag-and-drop code ordering

Multi-select: Which choices are correct for this modern Android path?

Drag order: Arrange the MVVM data flow.

Repository returns Result<List<FieldNote>>
User taps Add note
ViewModel updates NotesUiState
ViewModel calls repository.saveNote()
Compose recomposes from new state

Matching: Pick the right tool for the job.

References

Primary sources used for factual grounding