Android Studio, Gradle & First Screen

~15 min read · Slice 2 of 10

Introduction

Last slice was the Kotlin language tour with no Android touched at all. This time we open Android Studio for the first time, scaffold a project for our boxercise drill timer, and make sense of what the Empty Activity template hands us before changing anything.

The Compose explanation here stays light, since slice 3 is the dedicated Compose slice and covers state, modifiers, and recomposition properly. The button shows up because we promised one in the slice description, but the work that matters in slice 2 is in the Android Studio tour, the Gradle primer, and the lifecycle behaviour, not in the UI itself.

Installing Android Studio

The first thing worth knowing coming from a JavaScript background is that Android Studio is IntelliJ underneath, which is the same JetBrains IDE that ships as WebStorm for the web world. If you've used either before, the muscle memory carries straight over, from the project view down to the keyboard shortcuts. The latest stable build at the time of writing is Panda 4 (2025.3.4), available from the official download page on the Android Developers site.

The installer bundles its own JDK, so there's no separate Java install to manage on the host machine. That's worth saying out loud, since older Android tutorials often start with a Java setup step that no longer applies. When you open the IDE for the first time it'll offer to download the SDK components it needs, and the defaults are sensible, the only thing worth checking is that the latest stable Android SDK platform (API 35) is ticked.

Creating the project

File > New > New Project opens the template picker. The one we want is Empty Activity under the Phone and Tablet category, which is mildly misleadingly named, since it's actually the Jetpack Compose starter, not a blank XML layout. There's a separate Empty Views Activity for the legacy view system, which we won't touch in this series since Compose has been the recommended UI toolkit for new Android apps for years now.

A few of the wizard fields are worth understanding before clicking through them:

  • Name: the display name shown in launchers.
  • Package name: reverse-DNS, for example com.joe.boxercise, used as your app's unique identifier on Google Play and as the Kotlin package prefix on your source files.
  • Save location: where the project root lands on disk.
  • Build configuration language: Kotlin DSL, which has been the default in 2026 and reads almost identically to your application source.
  • Minimum SDK: the oldest Android version your app will run on.

For minimum SDK, API 24 (Android 7.0) is a sensible default in 2026. It covers around 97% of active devices, which the wizard itself shows you on the right-hand side of the dialog, and it avoids the friction of supporting older versions where you need extra desugaring configuration for modern Java APIs. The target SDK should be the latest stable, API 35 at the time of writing, which Google Play now requires for any new submission.

A tour of the generated project

The Project panel on the left defaults to a flattened view that surfaces the most-edited files at the top. While you're still building a mental model it's worth switching it to the raw Project view (the dropdown at the top of the panel), since the flattened view hides quite a bit of the real layout. Here's the shape of the generated project, with what each file is doing alongside.

Project layout
boxercise/
  app/
    build.gradle.kts            # app module: dependencies, plugins, Android config
    src/
      main/
        AndroidManifest.xml     # what the app exposes to the OS
        java/com/joe/boxercise/
          MainActivity.kt       # entry point, the activity launched by the launcher
          ui/theme/
            Color.kt            # palette tokens
            Theme.kt            # MaterialTheme wrapper
            Type.kt             # typography
        res/                    # static resources, organised by qualifier
          drawable/             # vector drawables and bitmaps
          mipmap/               # launcher icons at various densities
          values/               # strings.xml, themes.xml
  build.gradle.kts              # top-level, kept minimal under AGP 9
  settings.gradle.kts           # module list and dependency repositories
  gradle/
    libs.versions.toml          # the version catalogue
    wrapper/                    # pins the Gradle version per project
  gradlew                       # wrapper script (Unix)
  gradlew.bat                   # wrapper script (Windows)

Mapping that onto the JavaScript world the way I had to in my head while I was reading through it the first time:

JavaScript world / Android world
JavaScript worldAndroid world
package.jsonapp/build.gradle.kts
node_modules/~/.gradle/caches
app entry (index.tsx)MainActivity.kt
public/index.htmlAndroidManifest.xml
npm installSync Project with Gradle Files
npm run devRun (Shift+F10)
npm run buildBuild > Generate Signed Bundle
.envgradle.properties and BuildConfig

A few notes that are not quite obvious from the table. The res/ folder is heavily convention-driven, since Android resolves resources by folder qualifier, for example drawable-night/ for dark-mode drawables or values-fr/ for French strings. The build system picks the right one at runtime based on the device configuration, which is why a rotation, a language change, or a dark-mode toggle counts as a configuration change, the OS needs to re-resolve resources against the new device state.

The ui/theme/ folder holds the Material 3 theming setup. Color.kt defines a small palette of tokens, Type.kt defines the typography scale, and Theme.kt stitches them into a MaterialTheme wrapper that every screen in the app composes inside. We'll come back to it in slice 3, but it's worth knowing the convention now so the generated MainActivity reads cleanly.

Gradle for npm devs

Definition

Gradle is the build system Android projects use. It manages dependencies, compiles Kotlin and Java, runs the Android-specific resource processing, and packages the final APK or App Bundle. It does the work that npm, Webpack, and a Make-style task runner would split between them in the JavaScript world.

The configuration files are Kotlin scripts (the .kts extension), so autocomplete and refactoring work the same way they do in your app's source code, and you can step through build logic with the IDE's debugger when something surprising happens. It's one of the bits of the Android experience that has improved a lot in the past few years, since the older Groovy DSL had stringly-typed configuration that the IDE couldn't really help you with.

The file you'll spend the most time in is the version catalogue at gradle/libs.versions.toml. It centralises every dependency version, plugin version, and library coordinate in one place, so that when you bump the Compose version you only do it once. Coming from package.json this maps onto the dependencies block, except plugins and libraries live in separate tables and you reference them from your build.gradle.kts through a generated libs.* accessor.

1[versions]
2agp = "9.2.0"
3kotlin = "2.1.20"
4composeBom = "2026.04.01"
5activityCompose = "1.10.1"
6
7[libraries]
8androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
9androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
10androidx-material3 = { module = "androidx.compose.material3:material3" }
11
12[plugins]
13android-application = { id = "com.android.application", version.ref = "agp" }
14kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

The wrapper (gradlew on Unix, gradlew.bat on Windows) pins the version of Gradle each project uses, so checking out an older project will not drag in a newer Gradle that might break the build. You'll see ./gradlew commands in CI configurations and READMEs across the Android world, and they're equivalent to running gradle directly except they guarantee the right version.

The three Gradle actions you will run constantly
ActionWhat it does
SyncRe-reads your Gradle files and refreshes the IDE's dependency view and classpath
BuildCompiles your code and packages an APK or App Bundle
RunBuild, install, and launch on the selected emulator or device, the closest equivalent to a dev loop

AGP 9 reduced the boilerplate in the app module's Gradle file by folding Kotlin support directly into the Android Gradle Plugin, which means the org.jetbrains.kotlin.android plugin no longer needs to be applied explicitly in new projects. The plugins block at the top of the app module now reads as a much shorter list, with only the Android plugin and the Compose compiler plugin actively applied.

1plugins {
2 alias(libs.plugins.android.application)
3 alias(libs.plugins.kotlin.compose)
4}
5
6android {
7 namespace = "com.joe.boxercise"
8 compileSdk = 35
9
10 defaultConfig {
11 applicationId = "com.joe.boxercise"
12 minSdk = 24
13 targetSdk = 35
14 versionCode = 1
15 versionName = "0.1.0"
16 }
17
18 buildFeatures {
19 compose = true
20 }
21}
22
23dependencies {
24 implementation(platform(libs.androidx.compose.bom))
25 implementation(libs.androidx.activity.compose)
26 implementation(libs.androidx.material3)
27}

A small note on the dependencies block, since the platform/implementation pairing is unusual coming from npm. The platform(libs.androidx.compose.bom) entry pulls in a Bill of Materials, which is a meta-dependency that pins versions for a whole family of related Compose libraries at once. The individual implementation entries underneath inherit their versions from the BOM, which means you bump the BOM version and every Compose library moves in lockstep without you listing every version individually.

AndroidManifest, briefly

The manifest is the file the OS reads to know what your app contains and what it's allowed to do. It declares every activity, service, broadcast receiver, and permission, and it nominates one activity as the launcher entry point through an intent filter on the MAIN action.

1<application
2 android:label="@string/app_name"
3 android:theme="@style/Theme.Boxercise">
4 <activity
5 android:name=".MainActivity"
6 android:exported="true"
7 android:theme="@style/Theme.Boxercise">
8 <intent-filter>
9 <action android:name="android.intent.action.MAIN" />
10 <category android:name="android.intent.category.LAUNCHER" />
11 </intent-filter>
12 </activity>
13</application>

The combination of action.MAIN and category.LAUNCHER is what tells the OS that this activity is the one to launch when the user taps the app icon. You can have multiple activities, and you'll declare each new one here, but for the boxercise app we'll mostly stay inside a single activity and let Compose handle the screen-to-screen navigation internally.

Running the app on an emulator

The emulator is managed through Tools > Device Manager. The first time you open it the IDE walks you through creating a Virtual Device, and a Pixel 8 running the latest stable system image (API 35) is a fine choice for general development, since it matches the target SDK we set in the manifest and gives you a realistic device shape.

With the device created and selected in the toolbar, Run (Shift+F10 on Windows and Linux, Control+R on macOS) builds the app, installs the APK on the device, and launches it. The first build will sit for a minute or two while Gradle downloads and caches dependencies, but subsequent builds are quick, since Gradle is aggressive about incremental compilation and the build cache.

The other panel that earns its keep almost immediately is Logcat, sitting along the bottom edge by default. Logcat is Android's streaming log viewer, the equivalent of your terminal showing console output from a Node process. We'll be using it heavily in the lifecycle section below.

The first screen

Opening MainActivity.kt shows the template Compose code. There's a fair amount happening at first glance, and slice 3 is dedicated to unpacking it properly, but the three pieces worth knowing right now are the @Composable annotation, the ComponentActivity base class, and the setContent bridge.

1package com.joe.boxercise
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.activity.enableEdgeToEdge
7import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.padding
9import androidx.compose.material3.Scaffold
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.tooling.preview.Preview
14import com.joe.boxercise.ui.theme.BoxerciseTheme
15
16class MainActivity : ComponentActivity() {
17 override fun onCreate(savedInstanceState: Bundle?) {
18 super.onCreate(savedInstanceState)
19 enableEdgeToEdge()
20 setContent {
21 BoxerciseTheme {
22 Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
23 Greeting(
24 name = "Android",
25 modifier = Modifier.padding(innerPadding),
26 )
27 }
28 }
29 }
30 }
31}
32
33@Composable
34fun Greeting(name: String, modifier: Modifier = Modifier) {
35 Text(text = "Hello $name!", modifier = modifier)
36}
37
38@Preview(showBackground = true)
39@Composable
40fun GreetingPreview() {
41 BoxerciseTheme {
42 Greeting("Android")
43 }
44}
Definition

A @Composable function describes UI by calling other composables, the same way a React function component describes UI by calling other React components. It does not return a UI value, it emits the tree by being called.

ComponentActivity is the modern base class for an activity that hosts Compose. It's the descendant of the older Activity class you'll see in legacy code, and it brings in the lifecycle hooks, the activity result APIs, and the support for enableEdgeToEdge(), which is the call that lets your content draw behind the system bars on modern Android versions.

setContent { ... } is the bridge from the activity (the lifecycle world) into Compose (the UI world). Anything inside the lambda becomes your app's UI tree, and it's the only place the two worlds touch in a Compose-first project, which is part of why Compose feels so much cleaner than the legacy findViewById-and-XML model that came before it.

Swapping the generated greeting for a Start Workout button is a small change. We replace the Greeting call with a new StartWorkoutButton composable that returns a Material 3 Button wrapping a Text.

1package com.joe.boxercise
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.activity.enableEdgeToEdge
7import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.padding
9import androidx.compose.material3.Button
10import androidx.compose.material3.Scaffold
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13import androidx.compose.ui.Modifier
14import com.joe.boxercise.ui.theme.BoxerciseTheme
15
16class MainActivity : ComponentActivity() {
17 override fun onCreate(savedInstanceState: Bundle?) {
18 super.onCreate(savedInstanceState)
19 enableEdgeToEdge()
20 setContent {
21 BoxerciseTheme {
22 Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
23 StartWorkoutButton(modifier = Modifier.padding(innerPadding))
24 }
25 }
26 }
27 }
28}
29
30@Composable
31fun StartWorkoutButton(modifier: Modifier = Modifier) {
32 Button(onClick = { }, modifier = modifier) {
33 Text("Start Workout")
34 }
35}

Running the app puts the button on screen. Tapping it does nothing yet, since the onClick lambda is empty, but that's fine for now. Slice 3 picks up the Compose mental model properly, introduces state and modifiers, and turns this into a real configuration screen.

The activity lifecycle

Activities have a lifecycle, a fixed set of callback methods that the OS calls as the activity transitions through states like created, started, resumed, paused, stopped, and destroyed. Compared to a React component lifecycle, where you usually only worry about mount and unmount, Android exposes a more granular view because the OS reserves the right to pause or tear down your app at any time to free memory, and it needs a structured way to tell you what it's about to do.

Activity lifecycle callbacks
CallbackWhat is happening
onCreateActivity is being created, set up things that live for its whole life
onStartActivity is becoming visible to the user
onResumeActivity is in the foreground and the user can interact with it
onPauseAnother activity is coming forward, the user is on their way out but might return
onStopActivity is no longer visible
onDestroyActivity is being destroyed by user navigation, configuration change, or OS pressure

To see the callbacks firing in real time, we can override each one in MainActivity and log to Logcat with Log.d(tag, message), which is the standard debug-level log call. Each override has to call its super method, since the parent class is doing important setup and teardown that your code shouldn't skip.

1package com.joe.boxercise
2
3import android.os.Bundle
4import android.util.Log
5import androidx.activity.ComponentActivity
6import androidx.activity.compose.setContent
7import androidx.activity.enableEdgeToEdge
8// (Compose imports omitted for brevity)
9
10private const val TAG = "Lifecycle"
11
12class MainActivity : ComponentActivity() {
13 override fun onCreate(savedInstanceState: Bundle?) {
14 super.onCreate(savedInstanceState)
15 Log.d(TAG, "onCreate")
16 enableEdgeToEdge()
17 setContent { /* same as before */ }
18 }
19
20 override fun onStart() {
21 super.onStart()
22 Log.d(TAG, "onStart")
23 }
24
25 override fun onResume() {
26 super.onResume()
27 Log.d(TAG, "onResume")
28 }
29
30 override fun onPause() {
31 super.onPause()
32 Log.d(TAG, "onPause")
33 }
34
35 override fun onStop() {
36 super.onStop()
37 Log.d(TAG, "onStop")
38 }
39
40 override fun onDestroy() {
41 super.onDestroy()
42 Log.d(TAG, "onDestroy")
43 }
44}

Running the app and switching to the Logcat tab shows the order you'd expect on a fresh launch, with the activity transitioning from created to started to resumed in quick succession.

1D Lifecycle: onCreate
2D Lifecycle: onStart
3D Lifecycle: onResume

Pressing the device's home button gives you the inverse sequence, since the activity is on its way out but hasn't been destroyed yet, the OS is keeping it in memory in case the user comes back.

1D Lifecycle: onPause
2D Lifecycle: onStop

Bringing the app back from the recents tray fires onStart and onResume again without an onCreate, since the activity instance is the same one and just needs to become visible and interactive again.

The interesting one, and the bit that catches every developer once, is rotation. Rotating the emulator with Ctrl+F11 on Windows and Linux, or Cmd+Left or Right Arrow on macOS, prints the full teardown and recreation cycle to Logcat.

1D Lifecycle: onPause
2D Lifecycle: onStop
3D Lifecycle: onDestroy
4D Lifecycle: onCreate
5D Lifecycle: onStart
6D Lifecycle: onResume

Why rotation recreates everything

Definition

A configuration change is any device-level shift, like rotation, language change, or dark-mode toggle, that the OS handles by destroying the current activity and creating a fresh one so resources can be re-resolved against the new state.

This is the bit that catches every developer once, and it's the reason ViewModel, onSaveInstanceState, and rememberSaveable exist in the Android world. Anything stored in a plain field on the activity, or in plain remember in Compose, is gone when the activity is destroyed. The fresh instance starts from scratch, with whatever defaults you've baked in.

There are three tools for handling it, and you'll see all three in real Android code:

  • onSaveInstanceState hands the OS a Bundle of primitive state that it'll give back to you on the next instance through savedInstanceState in onCreate. Suitable for small bits of UI state, like a scroll position or a form value, and limited to types the bundle knows how to serialise.
  • ViewModel is an architecture component owned by the activity but with a lifetime that spans configuration changes. State held in a ViewModel survives rotation without you having to serialise anything. Slice 4 in this series picks it up properly.
  • rememberSaveable is the Compose-flavoured wrapper around onSaveInstanceState, used the same way as remember but with automatic save and restore. Slice 3 introduces it alongside remember when we get to forms.

The reason this matters before we have any real state to persist is that it sets the right expectation. State that lives only in the activity, or only in a basic remember, is gone on rotation by design, and the design is sound, since it gives the OS the freedom to recycle resources and lets you handle the new configuration without bolted-on workarounds. It's a sharp departure from React on the web though, where the component tree is stable across viewport changes, so the first time it bites a JS developer crossing over to Android it tends to be a memorable one.

Recap

We installed Android Studio, scaffolded a project from the Empty Activity template, toured the generated files with the JavaScript analogue alongside, and got familiar with what Gradle is doing under the hood with a version catalogue and the wrapper. We replaced the generated greeting with a hardcoded Start Workout button, ran the app on an emulator, and watched the activity lifecycle callbacks fire on launch, on backgrounding, and on rotation, which is the bit that sets up everything from state-restoration onwards in the rest of the series.

Further reading

The official Android documentation has come a long way in the past few years and is now the reference I'd start with for almost any topic. These are the pages worth bookmarking for the areas we glossed over.

Try this for yourself

If you've followed along and have a project running with the lifecycle logs wired up, here's a small experiment that makes the configuration-change behaviour stick. Add a local field to the activity that holds a counter, increment it in onCreate, and log the value each time. Run the app, rotate the emulator a few times, and watch what happens to the counter.

Show the expected result
1D Lifecycle: onCreate, count=1
2D Lifecycle: onCreate, count=1
3D Lifecycle: onCreate, count=1

The field resets to its initial value on every rotation, since the activity instance is brand new each time. That's the gap ViewModel exists to fill, and slice 4 picks it up directly.

The next slice opens with the Compose mental model in full, then builds the workout configuration screen with three number inputs, state hoisting, and the previews that make iterating on Compose UI quick.