Android – Getting Started

August 5, 20268 min readUpdated 10/11/2026

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:

  • namespace is the Kotlin package where generated code — R and BuildConfig — is placed.
  • applicationId is 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.
  • compileSdk is 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.
  • minSdk is 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.
  • targetSdk is 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 INTERNET every network call fails with a SecurityException. 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 custom Application class — 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.