This track builds one real Android app from the ground up — a pizza ordering app with a menu, a pizza builder, a cart that survives the phone being switched off, card payments, accounts, and a test suite. Every code sample is lifted from that app, which builds, passes its tests and was driven end to end against a live backend before a word of this was written.
It is the Android twin of the iOS track: the same product, the same layering, the same backend. Where Android forces a different design, the lessons say so — and say why.
This first lesson is the unglamorous one: what you need installed, what Gradle is doing on your behalf, and what each file in a new project is for.
What you need
- Android Studio. It bundles a JDK, the Android SDK manager and the emulator. You can build without it — this app's CI does — but you will not want to learn without it.
- An emulator image or a phone. Any recent API level works. This app was run on an API 35 emulator.
- Nothing else. No Kotlin compiler, no Gradle install. The project carries a Gradle wrapper that downloads the exact Gradle version it was written for, so two developers can never build it with two different Gradles.
The versions used throughout, read off Google's Maven repository in October 2026: Kotlin 2.4, the Android Gradle Plugin (AGP) 9.4, Gradle 9.8, and a Compose bill of materials from September 2026. Android moves quickly; the concepts here move slowly.
What Gradle is for
An Android app is not compiled so much as assembled. Kotlin is compiled to JVM bytecode, the bytecode is converted to Android's DEX format, XML resources are compiled and given numeric IDs, the manifests of every library are merged into one, the result is shrunk, packaged and signed. Gradle is the build tool that runs that pipeline, and the Android Gradle Plugin is what teaches Gradle the Android-specific steps.
Everything you configure lives in a handful of Kotlin script files — .gradle.kts.
The older Groovy .gradle files still work and still fill Stack Overflow; new projects
use Kotlin, which gives you autocompletion and type errors in the build script itself.
The project, file by file
pizza-android-mobile/
├── settings.gradle.kts which modules exist, and where dependencies come from
├── build.gradle.kts plugins declared once for every module
├── gradle.properties settings for Gradle itself
├── gradle/libs.versions.toml every dependency version, in one file
├── local.properties per-machine values — NOT committed
└── app/
├── build.gradle.kts the app module: SDK levels, build types, dependencies
└── src/
├── main/ AndroidManifest.xml, java/ (Kotlin lives here too), res/
├── debug/ files that exist only in debug builds
└── test/ JVM unit tests
settings.gradle.kts
dependencyResolutionManagement {
// A module declaring its own repository is a build that resolves differently on each
// machine. Failing on it keeps every dependency coming from the two places listed here.
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "pizza-android-mobile"
include(":app")
Two jobs. It lists the modules — here just :app; a large app splits into dozens —
and it says where libraries are downloaded from. google() is Google's Maven repository,
home of AndroidX and the Gradle plugin; mavenCentral() has everything else.
FAIL_ON_PROJECT_REPOS forbids a module from quietly adding a third source, so every
machine resolves the same artifacts.
The version catalog
Dependency versions used to be scattered across build files as string literals, which is why nobody upgraded them. A version catalog puts every one in a single TOML file:
[versions]
agp = "9.4.1"
kotlin = "2.4.21"
ksp = "2.3.12"
hilt = "2.60.1"
androidx-hilt = "1.4.0"
compose-bom = "2026.09.00"
activity = "1.13.0"
core-ktx = "1.19.1"
lifecycle = "2.11.0"
navigation = "2.10.2"
datastore = "1.2.1"
coroutines = "1.11.0"
serialization = "1.11.0"
retrofit = "3.0.0"
okhttp = "5.5.0"
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
Build scripts then refer to libs.compose.material3 with autocompletion, and an
upgrade is a one-line diff. Note that the Compose libraries carry no version of their own: they take
it from the BOM — a bill of materials, one artifact that pins a set of versions known to
work together.
The root build.gradle.kts
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}
apply false declares a plugin and its version without applying it to the root
project, which has no code. Each module then applies what it needs. Declaring them once at the top
puts every plugin on one classpath, so two modules cannot end up with two versions of the same
plugin.
The app module
This is the file you will edit most. Its first block applies the plugins:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
If you have read older tutorials, something is missing: org.jetbrains.kotlin.android.
Since AGP 9 the Android plugin compiles Kotlin itself, and applying the Kotlin plugin as well fails
the build. The Compose, serialization and parcelize entries are compiler plugins — they
generate code at compile time and are still applied explicitly. KSP is the annotation processor that
runs Hilt, which gets a lesson of its own.
Then the android block, the heart of the file:
android {
namespace = "com.lovemesomecoding.pizza"
compileSdk = 37
defaultConfig {
applicationId = "com.lovemesomecoding.pizza.android"
minSdk = 26
targetSdk = 37
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// …
Five numbers that confuse everyone at first, so it is worth being precise:
namespaceis the Kotlin package where generated code —RandBuildConfig— is placed.applicationIdis the app's identity on a device and on the Play Store. It can differ from the namespace, and once published it can never change.compileSdkis which Android API you compile against — the newest APIs you are allowed to call. It changes nothing at runtime. The 2026 versions of AndroidX require 37; AGP downloads the platform the first time you build.minSdkis the oldest Android version the app will install on. 26 is Android 8.0, which in 2026 covers the overwhelming majority of active devices and gives you adaptive icons and a modern Keystore for free.targetSdkis the version whose behaviour you have tested against. Android keeps old apps working by applying compatibility shims; raising the target is you opting out of them. Google Play requires it to stay recent.
versionCode is an integer the Play Store uses to order releases and must go up with
every upload; versionName is the string customers see.
The manifest
Every app has an AndroidManifest.xml that tells the operating system what is inside
the APK:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".PizzaApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Pizza">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Pizza.Starting"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
The things to notice:
- Permissions are declared. Without
INTERNETevery network call fails with aSecurityException. It is a "normal" permission, granted at install without a prompt; dangerous ones like the camera also need a runtime request. android:name=".PizzaApplication"names a customApplicationclass — the object that exists once per process. Hilt needs one.- One Activity, with the intent filter that makes it the launcher icon.
exported="true"is required for any Activity another app (the launcher) can start. windowSoftInputMode="adjustResize"lets the layout shrink when the keyboard opens, which the forms lesson relies on.
The manifest you write is not the manifest that ships. Every library has one too, and the build merges them — which is how Stripe's payment screens end up declared in your app without you writing a line. Android Studio's "Merged Manifest" tab shows the result.
One Activity, many screens
An Activity is the OS's unit of "a window the user can see". Older apps had one per
screen. A Compose app usually has exactly one, and every screen is a composable function inside
it:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val appViewModel: AppViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().setKeepOnScreenCondition { appViewModel.isLaunching.value }
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PizzaTheme { PizzaApp(appViewModel) }
}
}
}
setContent is the bridge from the Android view system to Compose: everything inside
that lambda is Compose. enableEdgeToEdge() lets the app draw behind the status and
navigation bars, which Android 15 made the default for apps targeting it. The splash screen line and
the ViewModel are covered in the lifecycle lesson; for now, read them as "do not show the app until
we know who is signed in".
Running it
From Android Studio, the green Run button builds the debug variant and installs it on
the selected device. From a terminal it is the wrapper:
./gradlew installDebug # build and install on the running emulator
./gradlew testDebugUnitTest # the JVM test suite
./gradlew assembleRelease # the minified release build
The first build downloads Gradle, every dependency and possibly an SDK platform. Expect minutes. Later builds take seconds, because Gradle caches each task's output and skips anything whose inputs did not change.
Why the emulator calls your Mac 10.0.2.2
The backend for this app runs on your machine at localhost:8085. Point the app at
localhost and every request fails — because on the emulator, localhost is
the emulator. It is a separate virtual device with its own network stack. Android provides the alias
10.0.2.2 for "the machine running me", and that is what a debug build uses:
// 10.0.2.2 is the emulator's alias for the Mac's loopback. `localhost` on the emulator
// is the emulator itself, where nothing is listening.
buildConfigField(
"String",
"API_BASE_URL",
"\"${localValue("pizza.apiBaseUrl", "http://10.0.2.2:8085/")}\"",
)
applicationIdSuffix = ".debug"
// …
The value comes from local.properties when one is set, which is how a developer on a
physical phone points the app at their machine's Wi-Fi address without editing code.
local.properties is git-ignored by every Android project template, and it is also where
Android Studio records your SDK path — it is per-machine by definition.
applicationIdSuffix = ".debug" gives the debug build a different identity from the
release one, so both can be installed side by side on the same phone.
Where things live in this app
All the Kotlin sits under one package, split by layer rather than by screen type:
com.lovemesomecoding.pizza
├── app/ the root composable, routes, tabs, sheets, toasts
├── core/ networking, storage, the design system — knows nothing about pizza
├── domain/ models, the cart rules, repository interfaces
├── data/ the HTTP implementations of those interfaces
├── di/ Hilt modules
└── feature/ home, menu, cart, checkout, orders, auth, profile
Dependencies point inwards: a feature may use the domain, the domain uses nothing. The architecture lesson argues for that shape; you will see it in every lesson before then.
Next
Before any Compose, the language. The next lesson is the Kotlin that an Android app leans on every day — null safety, data classes, sealed types, extension functions, lambdas — using this app's own models as the examples.