The first successful Run click is a lovely moment: a project that was just folders and unfamiliar Gradle files becomes something you can touch. I prefer a first app that does slightly more than print static text, so this one remembers a name while the screen is alive, updates immediately, and gives us enough behavior to preview, test, debug, and package.

What you will build

  • A single-activity Kotlin app using Jetpack Compose.

  • An outlined name field and a greeting derived from current UI state.

  • A preview that renders without launching an emulator.

  • A pure formatting function with a local unit test.

  • A debug build installed on an emulator or USB/Wi-Fi-connected device.

  • A reproducible Gradle wrapper build and a known APK output path.

Before creating the project

  • Install a current stable Android Studio from the official source and complete its Setup Wizard.

  • Install the Android SDK platform and tools selected by the project template.

  • Keep enough disk/RAM for Gradle dependencies and an emulator image; a physical device is a good alternative on constrained machines.

  • Use version control from the first working project and exclude generated/local files through the template .gitignore.

  • Choose an application ID/package namespace you control for a real product; com.example is fine only for learning.

  • Decide the oldest Android version your users need before choosing Minimum SDK; a lower minimum expands reach but increases compatibility work.

1. Create an Empty Activity project

  1. Launch Android Studio and select New Project.

  2. Under Phone and Tablet, choose Empty Activity—the Compose template, not an older Views template with a similar name.

  3. Set Name to First Compose App.

  4. Use a learning namespace such as com.example.firstcomposeapp, or your organization’s reverse-domain namespace.

  5. Choose a local project directory that is backed up and does not synchronize generated files poorly.

  6. Keep Kotlin as the language and select a Minimum SDK that matches your target audience and dependency requirements.

  7. Finish and wait for indexing plus Gradle sync to complete before editing generated versions.

2. Read the generated project before changing it

  • settings.gradle.kts names the build and includes modules such as :app.

  • The root and module build.gradle.kts files configure plugins, Android options, Compose, and dependencies.

  • gradle/libs.versions.toml may centralize plugin and library versions in current templates.

  • app/src/main/AndroidManifest.xml declares application components and capabilities.

  • app/src/main/java/.../MainActivity.kt contains the activity and starter composables.

  • app/src/main/res/ stores resources such as strings, icons, colors, and XML configuration.

  • app/src/test/ contains local JVM tests; app/src/androidTest/ contains device/emulator tests.

  • gradle/wrapper/gradle-wrapper.properties pins the Gradle distribution used by gradlew.

3. Build a stateful Compose screen

app/src/main/java/com/example/firstcomposeapp/MainActivity.ktkotlin
package com.example.firstcomposeapp
 
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.firstcomposeapp.ui.theme.FirstComposeAppTheme
 
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            FirstComposeAppTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    GreetingScreen(
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}
 
fun greetingFor(name: String): String {
    val trimmed = name.trim()
    return if (trimmed.isEmpty()) "Hello, Android!" else "Hello, $trimmed!"
}
 
@Composable
fun GreetingScreen(modifier: Modifier = Modifier) {
    var name by rememberSaveable { mutableStateOf("") }
 
    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(24.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        Text(
            text = greetingFor(name),
            style = MaterialTheme.typography.headlineMedium,
        )
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            modifier = Modifier.fillMaxWidth(),
            label = { Text("Your name") },
            singleLine = true,
        )
    }
}
 
@Preview(showBackground = true)
@Composable
private fun GreetingScreenPreview() {
    FirstComposeAppTheme {
        GreetingScreen()
    }
}

The UI is a function of state

  • ComponentActivity hosts Compose content instead of inflating an XML layout.

  • setContent defines the root composition; the generated Material theme supplies typography and colors.

  • Scaffold provides content insets, and applying innerPadding keeps edge-to-edge UI clear of system bars.

  • rememberSaveable retains the text across recomposition and supported state restoration such as rotation; it is not permanent database storage.

  • Changing name schedules recomposition of code that reads it.

  • OutlinedTextField receives state and emits edits through onValueChange, demonstrating unidirectional data flow.

  • The pure greetingFor function has no Android dependency and is easy to unit test.

  • @Preview renders the composable in Android Studio without installing the app.

4. Move visible text into resources for a real app

Hard-coded English keeps the first code block readable, but production UI should use string resources so translation, accessibility review, and consistency are manageable. Compose reads them with stringResource(). Dynamic values belong in formatted resources rather than string concatenation when localization matters.

app/src/main/res/values/strings.xmlxml
<resources>
    <string name="app_name">First Compose App</string>
    <string name="name_label">Your name</string>
    <string name="greeting_default">Hello, Android!</string>
    <string name="greeting_named">Hello, %1$s!</string>
</resources>

Resources separate language from layout code

  • Each string has a stable resource name and a default-locale value.

  • %1$s is a positional placeholder that translators can reorder.

  • User input passed to stringResource(R.string.greeting_named, trimmed) remains data, not a resource identifier.

  • Add locale-specific values-xx directories only with reviewed translations.

  • Keep the application label in resources so the manifest and UI can share it.

5. Render and interact with the preview

  1. Open the Kotlin file and select Split or Design mode when the Compose preview pane is available.

  2. Build/refresh the preview after Gradle sync and code compilation finish.

  3. Use interactive preview to type into the field, while remembering it is not a full device runtime.

  4. Add previews for light/dark themes, font scaling, long names, and different widths as the screen grows.

  5. Treat preview compilation errors like normal Kotlin/build errors; read the first relevant diagnostic.

6. Create an emulator or connect a phone

  • In Device Manager, create an Android Virtual Device using a representative phone and stable system image.

  • Test more than one API level and screen size rather than treating one emulator as the Android ecosystem.

  • For a phone, enable Developer options and USB debugging, connect over USB or supported Wi-Fi pairing, and accept the host authorization prompt.

  • Use a data-capable cable and current SDK Platform Tools if the device does not appear.

  • Always test on real hardware before release; emulators cannot reproduce every vendor, sensor, thermal, camera, radio, and performance behavior.

7. Run from Android Studio

  1. Choose the app run configuration.

  2. Select the AVD or authorized physical device in the target menu.

  3. Click Run to compile, install the debug variant, and launch its activity.

  4. Type a name and rotate the device to observe saveable UI state.

  5. Open Logcat for the app process and inspect crashes or system messages.

  6. Use Debug with breakpoints when you need to inspect control flow and values.

8. Verify the same build from the command line

Android project root containing gradlewbash
./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug
BUILD SUCCESSFUL

The wrapper is the reproducible entry point

  • ./gradlew uses the Gradle version pinned in the project instead of an arbitrary system installation.

  • assembleDebug compiles and packages the debug APK.

  • testDebugUnitTest runs local JVM tests for the debug variant.

  • lintDebug performs Android-specific static analysis and should be reviewed, not blindly silenced.

  • Task names change with module names, product flavors, and custom variants; list tasks when necessary.

  • A clean build is not required before every invocation because Gradle tracks inputs and outputs.

9. Add a local unit test

app/src/test/java/com/example/firstcomposeapp/GreetingTest.ktkotlin
package com.example.firstcomposeapp
 
import org.junit.Assert.assertEquals
import org.junit.Test
 
class GreetingTest {
    @Test
    fun blankNameUsesAndroidFallback() {
        assertEquals("Hello, Android!", greetingFor("   "))
    }
 
    @Test
    fun nameIsTrimmedBeforeFormatting() {
        assertEquals("Hello, Ada!", greetingFor("  Ada  "))
    }
}

Pure logic gives fast feedback

  • A local test runs on the development JVM and needs no emulator.

  • The tests document both blank-input fallback and whitespace normalization.

  • JUnit assertions compare exact outputs, making regressions obvious.

  • Compose behavior itself belongs in androidTest with Compose testing APIs and a device/emulator.

  • Do not force Android framework code into local tests merely to avoid instrumentation; separate concerns instead.

10. Install through Gradle or ADB

Android project root with one target device selectedbash
adb devices -l
./gradlew :app:installDebug
List of devices attached
emulator-5554 device ...

BUILD SUCCESSFUL

Resolve the target before installing

  • adb devices -l distinguishes authorized devices, offline devices, and emulators.

  • With multiple targets, choose explicitly through Android Studio or ADB/Gradle-supported device selection rather than guessing.

  • installDebug builds and installs the debug variant but does not necessarily launch it.

  • The debug certificate is for development and must never become the production signing identity.

  • Installing an app with the same application ID but an incompatible signing certificate fails until the conflict is resolved; uninstalling also removes that app’s local data.

Where the debug APK is written

Android project rootbash
find app/build/outputs/apk/debug -maxdepth 1 -type f -name '*.apk' -print
app/build/outputs/apk/debug/app-debug.apk

An APK is a build artifact, not the project

  • The conventional app-module debug output is app/build/outputs/apk/debug/app-debug.apk; variants may produce different paths/names.

  • Debug APKs are automatically signed with a development key and are suitable for testing.

  • The Android Studio Run action may produce a testOnly artifact intended for ADB installation.

  • Generated output should not be committed to source control.

  • Rebuild from versioned source and pinned tooling rather than treating one APK as the source of truth.

Debugging the first failures

  • Gradle sync fails: read the first dependency, proxy, JDK, SDK, or plugin compatibility error; do not delete every cache immediately.

  • Preview is blank: compile the module, inspect preview errors, and confirm the composable has @Preview plus a supported theme/context.

  • No device appears: verify the emulator is booted or the phone is authorized, then update Platform Tools and check cable/USB rules.

  • App installs but crashes: filter Logcat to the app process and start at the first FATAL EXCEPTION cause.

  • Changes do not appear: confirm the selected variant/device and whether Apply Changes could apply the edit; rerun when uncertain.

  • Text disappears after process death: rememberSaveable is limited state restoration, not durable persistence; use ViewModel/SavedStateHandle or storage according to the data.

  • Build succeeds only in Android Studio: compare the IDE Gradle JDK and environment with the wrapper build used in CI.

Permissions and privacy come later—only when needed

This greeting app needs no dangerous permissions, network access, analytics, or user account. That is a feature. Add a capability only when the product requires it, request runtime permission in context, explain the benefit, handle denial, minimize retained data, and update privacy disclosures and store declarations.

Debug APK versus release app bundle

  • The debug build is debuggable and signed with an SDK-generated debug key; it is not publishable as your production identity.

  • Google Play normally expects a signed Android App Bundle (.aab) and generates optimized APKs for devices.

  • A directly distributed APK is useful for testing or approved non-Play channels, but still needs a controlled release signature.

  • Protect the upload/release key in managed secret storage, restrict access, and never commit passwords or keystores to a public repository.

  • Set version code/name, test the release variant, inspect shrinking/obfuscation behavior, and preserve mapping/native symbols where applicable.

  • Use Android Studio’s Generate Signed Bundle/APK workflow or a secured CI release pipeline only after the app is ready.

A first-app completion checklist

  • Project syncs using the committed Gradle Wrapper.

  • The Compose preview renders in at least one theme/configuration.

  • The app runs on an emulator and a physical device.

  • State updates correctly and important configuration changes are tested.

  • Local tests and Android lint pass with reviewed output.

  • Logcat contains no crash, strict-mode, or repeated unexpected error from the app flow.

  • The debug APK can be reproduced from a clean checkout.

  • Application ID, minimum/target SDK strategy, and release signing ownership are documented before publication.

  • Accessibility, localization, privacy, offline/error states, and device diversity are part of the next iteration—not afterthoughts.

Official Android learning path

Good next experiments

  • Move visible text into string resources and add a second locale.

  • Add a Compose UI test that types a name and verifies the greeting.

  • Introduce a ViewModel when state must survive beyond the screen or coordinate business logic.

  • Test the layout with large font scaling, dark theme, rotation, and a compact emulator.