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.
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 |
|---|---|
package.json | app/build.gradle.kts |
node_modules/ | ~/.gradle/caches |
app entry (index.tsx) | MainActivity.kt |
public/index.html | AndroidManifest.xml |
npm install | Sync Project with Gradle Files |
npm run dev | Run (Shift+F10) |
npm run build | Build > Generate Signed Bundle |
.env | gradle.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
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"67[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" }1112[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.
| Action | What it does |
|---|---|
| Sync | Re-reads your Gradle files and refreshes the IDE's dependency view and classpath |
| Build | Compiles your code and packages an APK or App Bundle |
| Run | Build, 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}56android {7 namespace = "com.joe.boxercise"8 compileSdk = 35910 defaultConfig {11 applicationId = "com.joe.boxercise"12 minSdk = 2413 targetSdk = 3514 versionCode = 115 versionName = "0.1.0"16 }1718 buildFeatures {19 compose = true20 }21}2223dependencies {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<application2 android:label="@string/app_name"3 android:theme="@style/Theme.Boxercise">4 <activity5 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.boxercise23import android.os.Bundle4import androidx.activity.ComponentActivity5import androidx.activity.compose.setContent6import androidx.activity.enableEdgeToEdge7import androidx.compose.foundation.layout.fillMaxSize8import androidx.compose.foundation.layout.padding9import androidx.compose.material3.Scaffold10import androidx.compose.material3.Text11import androidx.compose.runtime.Composable12import androidx.compose.ui.Modifier13import androidx.compose.ui.tooling.preview.Preview14import com.joe.boxercise.ui.theme.BoxerciseTheme1516class 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}3233@Composable34fun Greeting(name: String, modifier: Modifier = Modifier) {35 Text(text = "Hello $name!", modifier = modifier)36}3738@Preview(showBackground = true)39@Composable40fun GreetingPreview() {41 BoxerciseTheme {42 Greeting("Android")43 }44}
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.boxercise23import android.os.Bundle4import androidx.activity.ComponentActivity5import androidx.activity.compose.setContent6import androidx.activity.enableEdgeToEdge7import androidx.compose.foundation.layout.fillMaxSize8import androidx.compose.foundation.layout.padding9import androidx.compose.material3.Button10import androidx.compose.material3.Scaffold11import androidx.compose.material3.Text12import androidx.compose.runtime.Composable13import androidx.compose.ui.Modifier14import com.joe.boxercise.ui.theme.BoxerciseTheme1516class 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}2930@Composable31fun 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.
| Callback | What is happening |
|---|---|
onCreate | Activity is being created, set up things that live for its whole life |
onStart | Activity is becoming visible to the user |
onResume | Activity is in the foreground and the user can interact with it |
onPause | Another activity is coming forward, the user is on their way out but might return |
onStop | Activity is no longer visible |
onDestroy | Activity 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.boxercise23import android.os.Bundle4import android.util.Log5import androidx.activity.ComponentActivity6import androidx.activity.compose.setContent7import androidx.activity.enableEdgeToEdge8// (Compose imports omitted for brevity)910private const val TAG = "Lifecycle"1112class 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 }1920 override fun onStart() {21 super.onStart()22 Log.d(TAG, "onStart")23 }2425 override fun onResume() {26 super.onResume()27 Log.d(TAG, "onResume")28 }2930 override fun onPause() {31 super.onPause()32 Log.d(TAG, "onPause")33 }3435 override fun onStop() {36 super.onStop()37 Log.d(TAG, "onStop")38 }3940 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: onCreate2D Lifecycle: onStart3D 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: onPause2D 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: onPause2D Lifecycle: onStop3D Lifecycle: onDestroy4D Lifecycle: onCreate5D Lifecycle: onStart6D Lifecycle: onResume
Why rotation recreates everything
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.
- Meet Android Studio: the orientation page covering project structure, the build output, and the most-used IDE panels.
- Migrate your build to Kotlin DSL: for older projects still on Groovy, this is the reference for moving across to .kts.
- Migrate your build to version catalogues: the official guide for adopting libs.versions.toml.
- AGP 9.0 release notes: the changes that affect new projects, including built-in Kotlin support and the bumped minimum Java requirement.
- Set up Jetpack Compose: if you want a head start on slice 3, this is the official quick start.
- Understand the activity lifecycle: the canonical reference for each callback, with a useful state diagram.
- Handling lifecycles with lifecycle-aware components: the modern observer-based approach, using DefaultLifecycleObserver, which is the route you reach for once your codebase grows past a single activity.
- Display content edge to edge: background on the enableEdgeToEdge() call we saw in the template and the rules Google Play enforces for new submissions.
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=12D Lifecycle: onCreate, count=13D 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.