About this course
<p>Most Android tutorials stop at "it runs on the emulator." But getting an app from a blank project to a listing that real people can install from Google Play involves a second half that's rarely discussed in one place: release builds, signing keys, app bundles, store listings, policy forms, and a mandatory testing period for new developer accounts.</p>
<p>Android development has also changed a lot in the last few years. Jetpack Compose is now the default UI toolkit, XML layouts are legacy, Kotlin is the only language the official templates use, and Google Play has tightened its requirements for new developers.</p>
<p>Older guides that start with <code>findViewById</code> or end with uploading an APK no longer match how things work.</p>
<p>This handbook walks through the whole path. You'll build a small but complete notes app with Jetpack Compose, Room, ViewModel, and Navigation 3, write a couple of tests, produce a signed release bundle, and then take it through the Google Play Console all the way to a production release.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-you-will-build">What You Will Build</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-your-development-environment">How to Set Up Your Development Environment</a></p>
</li>
<li><p><a href="#heading-how-to-create-a-new-compose-project">How to Create a New Compose Project</a></p>
</li>
<li><p><a href="#heading-how-to-configure-dependencies-with-a-version-catalog">How to Configure Dependencies with a Version Catalog</a></p>
</li>
<li><p><a href="#heading-how-jetpack-compose-thinks-about-ui">How Jetpack Compose Thinks About UI</a></p>
</li>
<li><p><a href="#heading-how-to-structure-the-app">How to Structure the App</a></p>
</li>
<li><p><a href="#heading-how-to-store-data-locally-with-room">How to Store Data Locally with Room</a></p>
</li>
<li><p><a href="#heading-how-to-expose-data-with-a-repository-and-application-class">How to Expose Data with a Repository and Application Class</a></p>
</li>
<li><p><a href="#heading-how-to-manage-ui-state-with-a-viewmodel">How to Manage UI State with a ViewModel</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-note-list-screen">How to Build the Note List Screen</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-note-editor-screen">How to Build the Note Editor Screen</a></p>
</li>
<li><p><a href="#heading-how-to-add-navigation-with-navigation-3">How to Add Navigation with Navigation 3</a></p>
</li>
<li><p><a href="#heading-how-to-handle-theming-and-edge-to-edge-display">How to Handle Theming and Edge-to-Edge Display</a></p>
</li>
<li><p><a href="#heading-how-to-test-your-composables">How to Test Your Composables</a></p>
</li>
<li><p><a href="#heading-how-to-prepare-the-app-for-release">How to Prepare the App for Release</a></p>
</li>
<li><p><a href="#heading-how-to-create-an-upload-key-and-sign-your-app">How to Create an Upload Key and Sign Your App</a></p>
</li>
<li><p><a href="#heading-how-to-build-an-android-app-bundle">How to Build an Android App Bundle</a></p>
</li>
<li><p><a href="#heading-how-to-create-a-google-play-developer-account">How to Create a Google Play Developer Account</a></p>
</li>
<li><p><a href="#heading-how-to-create-your-app-in-the-play-console">How to Create Your App in the Play Console</a></p>
</li>
<li><p><a href="#heading-how-to-complete-the-app-content-declarations">How to Complete the App Content Declarations</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-your-store-listing">How to Set Up Your Store Listing</a></p>
</li>
<li><p><a href="#heading-how-to-run-internal-and-closed-testing">How to Run Internal and Closed Testing</a></p>
</li>
<li><p><a href="#heading-how-to-publish-to-production">How to Publish to Production</a></p>
</li>
<li><p><a href="#heading-how-to-ship-updates-after-launch">How to Ship Updates After Launch</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should be comfortable with basic programming concepts and have at least read some Kotlin before. You don't need prior Android experience. Every Android-specific concept is explained as it comes up.</p>
<p>For tools, you need:</p>
<ul>
<li><p>A computer running macOS, Windows, or Linux with at least 16 GB of RAM (8 GB works, but the emulator will be slow)</p>
</li>
<li><p>The latest stable version of Android Studio</p>
</li>
<li><p>A physical Android phone (optional for development, but required later for Play Console account verification)</p>
</li>
<li><p>A Google account and 25 USD for the one-time Google Play developer registration fee</p>
</li>
</ul>
<h2 id="heading-what-you-will-build">What You Will Build</h2>
<p>The sample app is called Quick Notes. It shows a list of notes sorted by the last time they were edited, and it lets you create a new note with a floating action button, tap a note to edit it, and delete notes. Everything is stored locally on the device in a SQLite database, so the app works offline and needs no backend.</p>
<p>A notes app sounds simple, but it touches every piece a real app needs: a list screen and a detail screen, navigation between them with a working back button, persistent storage, state that survives configuration changes such as screen rotation, and user input. Once you understand how these pieces fit together, you can swap the notes for anything else.</p>
<p>Here's the high level architecture you'll end up with:</p>
<pre><code class="language-plaintext">+-------------------------------------------------------------+
| UI Layer |
| MainActivity -> NavDisplay -> NoteListScreen |
| -> NoteEditorScreen |
| observes StateFlow, sends events |
+------------------------------+------------------------------+
|
v
+-------------------------------------------------------------+
| NotesViewModel |
| holds UI state, survives rotation, launches coroutines |
+------------------------------+------------------------------+
|
v
+-------------------------------------------------------------+
| Data Layer |
| NoteRepository -> NoteDao -> NotesDatabase (Room/SQLite) |
+-------------------------------------------------------------+
</code></pre>
<p>The diagram shows three layers stacked vertically, with arrows pointing down to show the direction of dependencies.</p>
<p>The UI layer at the top contains the activity and the two screens. It only talks to the ViewModel, never directly to the database.</p>
<p>The ViewModel in the middle owns the state that the screens display and survives events like rotation that would otherwise destroy the screen.</p>
<p>The data layer at the bottom wraps Room, which is a typed layer on top of SQLite. Data flows back up the stack as a <code>Flow</code> of notes, so when the database changes, the UI updates automatically.</p>
<p>This is the layered architecture that Google recommends in its official app architecture guide.</p>
<h2 id="heading-how-to-set-up-your-development-environment">How to Set Up Your Development Environment</h2>
<p>Download Android Studio from <a href="https://developer.android.com/studio">developer.android.com/studio</a> and run the installer. On first launch, the setup wizard downloads the Android SDK, the platform tools (including <code>adb</code>), and a system image for the emulator. Accept the defaults unless you have a reason not to. Android Studio bundles its own JDK, so you don't need to install Java separately.</p>
<p>Once setup finishes, open the SDK Manager from <strong>Tools > SDK Manager</strong> and confirm that the latest stable Android platform is installed. At the time of writing, that means at least Android 16 (API level 36).</p>
<p>Google Play requires new apps and app updates to target a recent API level, and the requirement moves forward every year, usually at the end of August. Check the <a href="https://developer.android.com/google/play/requirements/target-sdk">target API level requirements page</a> before you start so you know which number to aim for.</p>
<p>Next, create an emulator from <strong>Tools > Device Manager</strong>. Pick a recent Pixel phone profile and the newest system image with Google Play services.</p>
<p>If you would rather test on a physical phone, enable Developer Options on the device by tapping <strong>Build number</strong> seven times in <strong>Settings > About phone</strong>, then turn on <strong>USB debugging</strong> (or <strong>Wireless debugging</strong>) in the Developer Options menu.</p>
<h2 id="heading-how-to-create-a-new-compose-project">How to Create a New Compose Project</h2>
<p>In Android Studio, choose <strong>File > New > New Project</strong> and select the <strong>Empty Activity</strong> template. This template is Compose-based. The similarly named "Empty Views Activity" uses the older XML layout system, so make sure you pick the right one.</p>
<p>Fill in the project details. The Name is what users see (Quick Notes). The Package name is the permanent, globally unique identifier for your app on Google Play, written in reverse domain format such as <code>com.yourname.quicknotes</code>. Choose it carefully, because you can't change it after you publish. Set Minimum SDK to API 24, which covers nearly every active Android device, and leave the build configuration language as Kotlin DSL.</p>
<p>Click <strong>Finish</strong> and wait for the first Gradle sync to complete. The project you get looks roughly like this:</p>
<pre><code class="language-plaintext">QuickNotes/
├── app/
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/com/yourname/quicknotes/
│ │ │ ├── MainActivity.kt
│ │ │ └── ui/theme/
│ │ └── res/
│ ├── test/
│ └── androidTest/
├── gradle/
│ └── libs.versions.toml
├── build.gradle.kts
└── settings.gradle.kts
</code></pre>
<p>The tree shows the important files in a fresh project.</p>
<p>The <code>app</code> folder is the single module that becomes your installable app, and its <code>build.gradle.kts</code> holds the Android configuration (SDK levels, version numbers, dependencies).</p>
<p><code>src/main</code> contains production code, the <code>AndroidManifest.xml</code> that declares your app's components to the system, and the <code>res</code> folder for resources such as icons and strings.</p>
<p>The <code>test</code> folder is for unit tests that run on your computer, while <code>androidTest</code> is for tests that run on a device or emulator.</p>
<p>At the root, <code>gradle/libs.versions.toml</code> is the version catalog where all dependency versions live, and <code>settings.gradle.kts</code> lists the modules in the project. Kotlin files sit under a folder named <code>java</code> for historical reasons, which is normal.</p>
<p>Press the green <strong>Run</strong> button. After a short build, the emulator should show "Hello Android!" on screen. That confirms your toolchain works.</p>
<h2 id="heading-how-to-configure-dependencies-with-a-version-catalog">How to Configure Dependencies with a Version Catalog</h2>
<p>Modern Android projects keep every library version in one file, <code>gradle/libs.versions.toml</code>, and refer to those libraries by alias from the build scripts.</p>
<p>Open the catalog and replace its contents with the following:</p>
<pre><code class="language-toml">[versions]
agp = "9.0.0"
kotlin = "2.3.0"
ksp = "2.3.4"
composeBom = "2026.01.00"
activityCompose = "1.12.2"
lifecycle = "2.10.0"
navigation3 = "1.0.0"
room = "2.8.4"
serialization = "1.9.0"
junit = "4.13.2"
androidxJunit = "1.3.0"
[libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "serialization" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
room = { id = "androidx.room", version.ref = "room" }
</code></pre>
<p>The catalog has three sections: <code>[versions]</code> names each version number once. <code>[libraries]</code> maps a short alias to a Maven coordinate, and <code>[plugins]</code> does the same for Gradle plugins.</p>
<p>Gradle turns the dashes in each alias into dots, so <code>androidx-room-runtime</code> becomes <code>libs.androidx.room.runtime</code> in your build scripts. The Compose libraries have no version of their own because they get it from the Compose BOM (bill of materials), which pins a set of Compose libraries that are tested together. The Kotlin Compose compiler plugin shares its version with Kotlin itself, which has been the case since Kotlin 2.0.</p>
<p>KSP is the annotation processor Room uses to generate database code. The version numbers here were current when this article was written. Android Studio highlights outdated entries in the catalog, and you should accept its suggestions to move to the latest stable versions.</p>
<p>Now update the root <code>build.gradle.kts</code> so the plugins are available to the app module:</p>
<pre><code class="language-kotlin">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.ksp) apply false
alias(libs.plugins.room) apply false
}
</code></pre>
<p>This file declares each plugin with <code>apply false</code>, which puts the plugin on the build classpath without applying it to the root project. Each module then opts into the plugins it actually needs.</p>
<p>You might notice there's no <code>org.jetbrains.kotlin.android</code> plugin. Starting with Android Gradle Plugin 9, Kotlin support is built into the Android plugin, so a separate Kotlin Android plugin is no longer required. If you're working in an older project on AGP 8, you'll still see that plugin applied, and that's fine.</p>
<p>Then replace the contents of <code>app/build.gradle.kts</code>:</p>
<pre><code class="language-kotlin">plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.room)
}
android {
namespace = "com.yourname.quicknotes"
compileSdk = 36
defaultConfig {
applicationId = "com.yourname.quicknotes"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
buildFeatures {
compose = true
}
}
room {
schemaDirectory("$projectDir/schemas")
}
dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
implementation(libs.kotlinx.serialization.core)
ksp(libs.androidx.room.compiler)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
</code></pre>
<p>This is the most important configuration file in the project, so read it carefully.</p>
<p>The <code>namespace</code> is the package used for the generated <code>R</code> class, while <code>applicationId</code> is the identity of the app on devices and on Google Play. They usually match.</p>
<p><code>compileSdk</code> is the API level your code is compiled against, and <code>targetSdk</code> tells Android which platform behaviors your app has been tested with. When you raise <code>targetSdk</code>, newer behaviors such as enforced edge-to-edge drawing switch on.</p>
<p><code>versionCode</code> is an integer that must increase with every upload to Play, while <code>versionName</code> is the human-readable string users see.</p>
<p>The <code>release</code> block turns on R8, which shrinks, obfuscates, and optimizes your code, and <code>isShrinkResources</code> removes unused resources.</p>
<p>The <code>room</code> block tells the Room plugin where to write JSON snapshots of your database schema, which you'll need later when you write migrations.</p>
<p>In the dependencies, <code>platform(libs.androidx.compose.bom)</code> applies the BOM so the Compose libraries line up, and <code>ksp(...)</code> runs the Room compiler at build time. The <code>debugImplementation</code> lines include Compose preview tooling and a test manifest only in debug builds, so they never ship to users.</p>
<p>Click <strong>Sync Now</strong> in the banner at the top of the editor. If the sync succeeds, you're ready to write code.</p>
<h2 id="heading-how-jetpack-compose-thinks-about-ui">How Jetpack Compose Thinks About UI</h2>
<p>Before writing screens, it helps to understand the model Compose is built on, because it's different from the older View system.</p>
<p>In Compose, UI is a function of state. You write functions annotated with <code>@Composable</code> that describe what the screen should look like for the current data. You never reach into a view and call <code>setText</code> on it. Instead, when the data changes, Compose calls your function again with the new data and updates only the parts of the screen that actually changed. This re-running is called recomposition.</p>
<p>Here's the smallest useful example:</p>
<pre><code class="language-kotlin">@Composable
fun Counter() {
var count by remember { mutableIntStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}
</code></pre>
<p>The <code>Counter</code> function declares a piece of state with <code>mutableIntStateOf(0)</code>. Compose watches every read of that state. When the button's <code>onClick</code> lambda increments <code>count</code>, Compose knows that <code>Counter</code> read the old value, so it schedules <code>Counter</code> to run again, and the <code>Text</code> shows the new number.</p>
<p>The <code>remember</code> call is what keeps the value alive across recompositions. Without it, <code>count</code> would reset to zero every time the function ran.</p>
<p>The <code>by</code> keyword is Kotlin property delegation, which lets you read and write <code>count</code> like a regular variable instead of writing <code>count.value</code>.</p>
<p><code>remember</code> survives recomposition but not configuration changes such as rotating the phone, because Android destroys and recreates the activity in that case. For simple values that must survive rotation, use <code>rememberSaveable</code> instead. For anything more substantial, such as data loaded from a database, keep the state in a ViewModel, which outlives the activity. You'll use both in this app.</p>
<p>The other core idea is state hoisting. A composable that owns its own state is hard to reuse and hard to test. A better pattern is to pass state in as parameters and pass events out as lambdas, so the composable only displays what it's given and reports what the user did. The caller decides where the state lives. You'll see this pattern in the list screen, where it makes testing straightforward.</p>
<h2 id="heading-how-to-structure-the-app">How to Structure the App</h2>
<p>Create the following packages under <code>com.yourname.quicknotes</code> by right-clicking the package in the Project panel and choosing <strong>New > Package</strong>:</p>
<pre><code class="language-plaintext">com.yourname.quicknotes
├── data/
│ ├── Note.kt
│ ├── NoteDao.kt
│ ├── NotesDatabase.kt
│ └── NoteRepository.kt
├── ui/
│ ├── NotesViewModel.kt
│ ├── NoteListScreen.kt
│ ├── NoteEditorScreen.kt
│ └── theme/
├── navigation/
│ └── NavKeys.kt
├── MainActivity.kt
└── NotesApp.kt
</code></pre>
<p>The layout groups files by layer.</p>
<p>The <code>data</code> package holds everything related to storage: the <code>Note</code> entity, the DAO (data access object) that defines queries, the Room database class, and a repository that the rest of the app uses instead of talking to Room directly.</p>
<p>The <code>ui</code> package contains the ViewModel and the two screens, plus the theme files the template generated.</p>
<p>The <code>navigation</code> package holds the keys that identify each screen. <code>MainActivity</code> is the single activity that hosts all of the Compose UI, and <code>NotesApp</code> is a custom <code>Application</code> class used to create the database once for the whole app.</p>
<p>For a larger app, you would likely split these into separate Gradle modules, but packages are enough here.</p>
<h2 id="heading-how-to-store-data-locally-with-room">How to Store Data Locally with Room</h2>
<p>Room is the Jetpack persistence library. You describe tables as Kotlin classes and queries as annotated interface methods, and Room generates the SQLite code at compile time. Because queries are checked during the build, a typo in a column name becomes a compiler error instead of a crash on a user's phone.</p>
<p>Start with the entity in <code>data/Note.kt</code>:</p>
<pre><code class="language-kotlin">package com.yourname.quicknotes.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val body: String,
val updatedAt: Long = System.currentTimeMillis()
)
</code></pre>
<p><code>@Entity</code> tells Room that this class maps to a table named <code>notes</code>, and each property becomes a column.</p>
<p>The <code>id</code> field is the primary key, and <code>autoGenerate = true</code> asks SQLite to assign a new ID when you insert a row whose ID is <code>0</code>. That's why <code>id</code> defaults to <code>0</code>: a new note doesn't have a real ID until it's saved.</p>
<p>The <code>updatedAt</code> property stores a timestamp in milliseconds and defaults to the current time whenever a <code>Note</code> object is created, which is how the app knows which notes were edited most recently.</p>
<p>Using a plain <code>data class</code> gives you <code>equals</code>, <code>hashCode</code>, and <code>copy</code> for free, which Compose relies on to decide whether a list item has changed.</p>
<p>Next, define the queries in <code>data/NoteDao.kt</code>:</p>
<pre><code class="language-kotlin">package com.yourname.quicknotes.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface NoteDao {
@Query("SELECT * FROM notes ORDER BY updatedAt DESC")
fun observeAll(): Flow<List<Note>>
@Query("SELECT * FROM notes WHERE id = :id")
suspend fun getById(id: Long): Note?
@Upsert
suspend fun upsert(note: Note)
@Delete
suspend fun delete(note: Note)
}
</code></pre>
<p>A DAO is an interface whose implementation Room writes for you. The <code>observeAll</code> method returns a <code>Flow<List<Note>></code> rather than a plain list. A <code>Flow</code> is a stream from Kotlin coroutines that emits a new value whenever the underlying data changes, so any time a note is inserted, updated, or deleted, Room automatically re-runs the query and emits the fresh list. This is what keeps the list screen in sync without any manual refresh logic.</p>
<p>The other methods are marked <code>suspend</code>, which means they must be called from a coroutine. Room runs them on a background thread, so database work never blocks the main thread and freezes the UI.</p>
<p><code>@Upsert</code> inserts a row if its primary key doesn't exist and updates it if it does, which lets one method handle both creating and editing a note.</p>
<p>The <code>:id</code> syntax in the second query binds the method parameter to the SQL statement safely, with no string concatenation.</p>
<p>Now create the database in <code>data/NotesDatabase.kt</code>:</p>
<pre><code class="language-kotlin">package com.yourname.quicknotes.data
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [Note::class], version = 1, exportSchema = true)
abstract class NotesDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
}
</code></pre>
<p>The <code>@Database</code> annotation lists every entity in the database and a schema version number. Room generates a concrete subclass of this abstract class, including the implementation of <code>noteDao()</code>.</p>
<p>The <code>version</code> field matters once your app is live. If you later add a column to <code>Note</code>, you must bump the version and provide a migration, or Room will refuse to open the existing database on users' devices.</p>
<p><code>exportSchema = true</code> writes a JSON description of each schema version to the <code>schemas</code> folder you configured in Gradle. Commit that folder to version control, because Room uses those files to verify and auto-generate migrations.</p>
<h2 id="heading-how-to-expose-data-with-a-repository-and-application-class">How to Expose Data with a Repository and Application Class</h2>
<p>The repository is a thin layer between the ViewModel and Room. In this app it mostly forwards calls, but it gives you one place to add caching, network sync, or validation later without touching the UI code. Create <code>data/NoteRepository.kt</code>:</p>
<pre><code class="language-kotlin">package com.yourname.quicknotes.data
import kotlinx.coroutines.flow.Flow
class NoteRepository(private val noteDao: NoteDao) {
fun observeNotes(): Flow<List<Note>> = noteDao.observeAll()
suspend fun getNote(id: Long): Note? = noteDao.getById(id)
suspend fun save(note: Note) = noteDao.upsert(note)
suspend fun delete(note: Note) = noteDao.delete(note)
}
</code></pre>
<p>The repository takes a <code>NoteDao</code> in its constructor instead of creating one itself. This is dependency injection in its simplest form. Because the dependency is passed in, a test could hand the repository a fake DAO. The public methods use domain language (<code>save</code>, <code>observeNotes</code>) rather than database language (<code>upsert</code>, <code>observeAll</code>), which keeps Room details from leaking into the rest of the app.</p>
<p>The database should be created exactly once per app process, because opening SQLite is expensive and multiple instances can conflict. A common way to do that without a dependency injection framework is a custom <code>Application</code> subclass. Create <code>NotesApp.kt</code> in the root package:</p>
<pre><code class="language-kotlin">package com.yourname.quicknotes
import android.app.Application
import androidx.room.Room
import com.yourname.quicknotes.data.NoteRepository
import com.yourname.quicknotes.data.NotesDatabase
class NotesApp : Application() {
private val database: NotesDatabase by lazy {
Room.databaseBuilder(this, NotesDatabase::class.java, "notes.db").build()
}
val repository: NoteRepository by lazy {
NoteRepository(database.noteDao())
}
}
</code></pre>
<p>Android creates one instance of the <code>Application</code> class before any activity starts, and keeps it for the lifetime of the process. That makes it a natural home for app-wide singletons.</p>
<p>The <code>by lazy</code> delegate defers creating the database until the first time <code>repository</code> is accessed, which keeps app startup fast.</p>
<p><code>Room.databaseBuilder</code> takes a context, the database class, and a file name, and <code>build()</code> returns the generated implementation. For larger apps, Hilt or Koin can manage this wiring for you, but the manual approach shows exactly what those frameworks automate.</p>
<p>For Android to use this class, register it in <code>AndroidManifest.xml</code> by adding the <code>android:name</code> attribute to the <code><application></code> tag:</p>
<pre><code class="language-xml"><application
android:name=".NotesApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.QuickNotes">
</code></pre>
<p>The only line you're adding is <code>android:name=".NotesApp"</code>. The leading dot is shorthand for your app's namespace, so Android resolves it to <code>com.yourname.quicknotes.NotesApp</code>.</p>
<p>The other attributes came from the template: the launcher icon, the app label shown under the icon, right-to-left layout support, and the base theme used before Compose draws its first frame. If you forget the <code>android:name</code> attribute, Android uses the default <code>Application</code> class and your app crashes the first time it tries to cast it to <code>NotesApp</code>.</p>
<h2 id="heading-how-to-manage-ui-state-with-a-viewmodel">How to Manage UI State with a ViewModel</h2>
<p>A <code>ViewModel</code> holds the state for a screen and survives configuration changes. When the user rotates the phone, the activity is destroyed and recreated, but the ViewModel stays in memory, so the list of notes doesn't need to be reloaded. Create <code>ui/NotesViewModel.kt</code>:</p>
<pre><code class="language-kotlin">package com.yourname.quicknotes.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.yourname.quicknotes.NotesApp
import com.yourname.quicknotes.data.Note
import com.yourname.quicknotes.data.NoteRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class NotesViewModel(private val repository: NoteRepository) : ViewModel() {
val notes: StateFlow<List<Note>> = repository.observeNotes()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
suspend fun getNote(id: Long): Note? = repository.getNote(id)
fun saveNote(id: Long?, title: String, body: String) {
viewModelScope.launch {
repository.save(Note(id = id ?: 0, title = title.trim(), body = body.trim()))
}
}
fun deleteNote(note: Note) {
viewModelScope.launch {
repository.delete(note)
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as NotesApp
NotesViewModel(app.repository)
}
}
}
}
</code></pre>
<p>There are three ideas packed into this class.</p>
<p>The first is the <code>notes</code> property. <code>repository.observeNotes()</code> returns a cold <code>Flow</code>, which only does work while someone is collecting it. The <code>stateIn</code> operator converts it into a <code>StateFlow</code>, which always holds a current value (starting with an empty list) that the UI can read immediately. <code>SharingStarted.WhileSubscribed(5_000)</code> keeps the database query active while the UI is observing and for five seconds after it stops. That five second grace period means a quick rotation doesn't tear down and restart the query, while backgrounding the app for longer does release it.</p>
<p>The second idea is <code>viewModelScope</code>. Saving and deleting are <code>suspend</code> functions, so they need a coroutine. <code>viewModelScope</code> is a coroutine scope tied to the ViewModel's lifetime, and any work launched in it is cancelled automatically when the ViewModel is cleared.</p>
<p>In <code>saveNote</code>, a <code>null</code> ID means "this is a new note," so it becomes <code>0</code> and Room generates a fresh ID. A non-null ID updates the existing row. Because a new <code>Note</code> object is created on every save, <code>updatedAt</code> is refreshed to the current time.</p>
<p>The third idea is the <code>Factory</code>. Android creates ViewModels for you so it can keep them alive across rotations, which means you can't call the constructor directly.</p>
<p>A factory tells Android how to build the ViewModel when it needs one. The <code>viewModelFactory</code> and <code>initializer</code> builders read the <code>Application</code> instance out of the creation extras, cast it to <code>NotesApp</code>, and pass its repository into the constructor.</p>
<h2 id="heading-how-to-build-the-note-list-screen">How to Build the Note List Screen</h2>
<p>The list screen is split into two composables. A stateful wrapper connects to the ViewModel, and a stateless content composable does the actual drawing. Create <code>ui/NoteListScreen.kt</code>:</p>
<pre><code class="language-kotlin">package com.yourname.quicknotes.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.yourname.quicknotes.data.Note
@Composable
fun NoteListScreen(
viewModel: NotesViewModel,
onAddNote: () -> Unit,
onOpenNote: (Long) -> Unit
) {
val notes by viewModel.notes.collectAsStateWithLifecycle()
NoteListContent(
notes = notes,
onAddNote = onAddNote,
onOpenNote = onOpenNote,
onDeleteNote = viewModel::deleteNote
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteListContent(
notes: List<Note>,
onAddNote: () -> Unit,
onOpenNote: (Long) -> Unit,
onDeleteNote: (Note) -> Unit
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Quick Notes") }) },
floatingActionButton = {
FloatingActionButton(onClick = onAddNote) {
Icon(Icons.Default.Add, contentDescription = "Add note")
}
}
) { innerPadding ->
if (notes.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center
) {
Text("No notes yet", style = MaterialTheme.typography.bodyLarge)
}
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize().padding(innerPadding)
) {
items(notes, key = { it.id }) { note ->
NoteCard(
note = note,
onClick = { onOpenNote(note.id) },
onDelete = { onDeleteNote(note) }
)
}
}
}
}
}
@Composable
private fun NoteCard(note: Note, onClick: () -> Unit, onDelete: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(note.title, style = MaterialTheme.typography.titleMedium)
if (note.body.isNotBlank()) {
Text(
note.body,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Delete ${note.title}")
}
}
}
}
</code></pre>
<p><code>NoteListScreen</code> is the only part that knows about the ViewModel. It calls <code>collectAsStateWithLifecycle()</code>, which turns the <code>StateFlow</code> into Compose state and, unlike the plain <code>collectAsState()</code>, stops collecting when the app moves to the background. That saves battery and avoids doing database work for a screen nobody can see. It then passes the plain list and some lambdas into <code>NoteListContent</code>.</p>
<p>The <code>viewModel::deleteNote</code> syntax is a function reference, a compact way of writing <code>{ note -> viewModel.deleteNote(note) }</code>.</p>
<p><code>NoteListContent</code> is stateless. It receives everything as parameters and reports clicks through lambdas, which is state hoisting in practice. Inside it, <code>Scaffold</code> is a Material 3 layout that provides slots for a top app bar and a floating action button and calculates <code>innerPadding</code> so your content doesn't end up behind those bars or behind the system status and navigation bars.</p>
<p>When the list is empty, the screen shows a centered message. Otherwise it uses <code>LazyColumn</code>, which only composes the rows that are currently visible, like <code>RecyclerView</code> in the old View system.</p>
<p>The <code>key = { it.id }</code> argument gives each row a stable identity, so when a note is deleted Compose knows exactly which row to remove instead of redrawing everything below it.</p>
<p><code>NoteCard</code> draws one note. <code>Modifier</code> chains configure layout and behavior in order: <code>fillMaxWidth()</code> stretches the card across the screen and <code>clickable</code> makes the whole card respond to taps.</p>
<p>Inside the <code>Row</code>, <code>Modifier.weight(1f)</code> tells the text column to take all horizontal space left over after the delete button is measured. The <code>contentDescription</code> values on icons are read aloud by TalkBack, Android's screen reader. Including the note title in the delete button's description means a TalkBack user hears "Delete Groceries" instead of a row of identical "Delete" buttons.</p>
<h2 id="heading-how-to-build-the-note-editor-screen">How to Build the Note Editor Screen</h2>
<p>The editor handles both new notes and existing ones. Create <code>ui/NoteEditorScreen.kt</code>:</p>
<pre><code class="language-kotlin">package com.yourname.quicknotes.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteEditorScreen(
noteId: Long?,
viewModel: NotesViewModel,
onDone: () -> Unit
) {
var title by rememberSaveable { mutableStateOf("") }
var body by rememberSaveable { mutableStateOf("") }
var loaded by rememberSaveable { mutableStateOf(noteId == null) }
LaunchedEffect(noteId) {
if (noteId != null && !loaded) {
viewModel.getNote(noteId)?.let {
title = it.title
body = it.body
}
loaded = true
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (noteId == null) "New note" else "Edit note") },
navigationIcon = {
IconButton(onClick = onDone) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
TextButton(
onClick = {
viewModel.saveNote(noteId, title, body)
onDone()
},
enabled = title.isNotBlank()
) {
Text("Save")
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.imePadding()
.padding(16.dp)
) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = body,
onValueChange = { body = it },
label = { Text("Note") },
modifier = Modifier.fillMaxWidth().weight(1f)
)
}
}
}
</code></pre>
<p>The editor keeps the text the user is typing in local state with <code>rememberSaveable</code>, so a half-written note survives a rotation. The <code>loaded</code> flag tracks whether the existing note has already been read from the database. It starts as <code>true</code> for new notes (nothing to load) and <code>false</code> for existing ones.</p>
<p><code>LaunchedEffect(noteId)</code> runs a coroutine when the composable first appears and again only if <code>noteId</code> changes. Inside it, the screen asks the ViewModel for the note and copies its title and body into the text fields.</p>
<p>Checking <code>loaded</code> before loading is important. Without that check, rotating the screen would run the effect again and overwrite whatever the user had typed with the saved version from the database.</p>
<p><code>OutlinedTextField</code> is a Material 3 text input. Compose text fields are controlled components: the field shows whatever <code>value</code> you pass in, and when the user types, it calls <code>onValueChange</code> with the new text. If you forget to update <code>title</code> in that callback, the field appears frozen because Compose keeps redrawing the old value. The Save button is disabled while the title is blank, and pressing it saves through the ViewModel and calls <code>onDone</code> to go back.</p>
<p><code>imePadding()</code> adds space at the bottom equal to the height of the on-screen keyboard, so the body field shrinks instead of hiding behind the keyboard. The back arrow uses the <code>AutoMirrored</code> icon set, which flips automatically in right-to-left languages such as Arabic and Hebrew.</p>
<h2 id="heading-how-to-add-navigation-with-navigation-3">How to Add Navigation with Navigation 3</h2>
<p>Navigation 3 is the Compose-first navigation library from the Jetpack team. The earlier Navigation Compose library was adapted from a system designed for fragments and treated the back stack as something it owned internally.</p>
<p>Navigation 3 takes the opposite approach: the back stack is a plain list that you own, and navigating means adding or removing items from that list. The library observes the list and shows the screen for whatever key is on top.</p>
<p>First, define a key for each screen in <code>navigation/NavKeys.kt</code>:</p>
<pre><code class="language-kotlin">package com.yourname.quicknotes.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
data object NoteList : NavKey
@Serializable
data class NoteEditor(val noteId: Long? = null) : NavKey
</code></pre>
<p>Each destination is a type that implements <code>NavKey</code>. <code>NoteList</code> takes no arguments, so it's a <code>data object</code>, a singleton with a readable <code>toString</code>.</p>
<p><code>NoteEditor</code> carries an optional note ID, where <code>null</code> means "create a new note." Arguments are typed properties instead of strings embedded in a URL route, so the compiler catches mistakes such as passing the wrong type.</p>
<p>The <code>@Serializable</code> annotation lets Navigation 3 save the back stack when Android kills your app in the background and restore it when the user comes back, which is why the serialization plugin was added earlier.</p>
<p>Now replace <code>MainActivity.kt</code> with the following:</p>
<pre><code class="language-kotlin">package com.yourname.quicknotes
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.yourname.quicknotes.navigation.NoteEditor
import com.yourname.quicknotes.navigation.NoteList
import com.yourname.quicknotes.ui.NoteEditorScreen
import com.yourname.quicknotes.ui.NoteListScreen
import com.yourname.quicknotes.ui.NotesViewModel
import com.yourname.quicknotes.ui.theme.QuickNotesTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
QuickNotesTheme {
QuickNotesNavHost()
}
}
}
}
@Composable
fun QuickNotesNavHost() {
val backStack = rememberNavBackStack(NoteList)
val notesViewModel: NotesViewModel = viewModel(factory = NotesViewModel.Factory)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<NoteList> {
NoteListScreen(
viewModel = notesViewModel,
onAddNote = { backStack.add(NoteEditor()) },
onOpenNote = { id -> backStack.add(NoteEditor(id)) }
)
}
entry<NoteEditor> { key ->
NoteEditorScreen(
noteId = key.noteId,
viewModel = notesViewModel,
onDone = { backStack.removeLastOrNull() }
)
}
}
)
}
</code></pre>
<p><code>MainActivity</code> is now just a host. <code>enableEdgeToEdge()</code> lets the app draw behind the status bar and navigation bar (more on that in the next section), and <code>setContent</code> replaces the traditional XML layout with a Compose tree wrapped in the theme the template generated.</p>
<p><code>QuickNotesNavHost</code> is where navigation happens. <code>rememberNavBackStack(NoteList)</code> creates a saveable back stack that starts with the list screen. The ViewModel is created once with the factory you wrote earlier and shared by both screens, since they work with the same notes.</p>
<p><code>NavDisplay</code> renders the entry for the key at the top of the stack. The <code>entryProvider</code> block maps each key type to its composable with <code>entry<T></code>. For <code>NoteEditor</code>, the lambda receives the key instance, so <code>key.noteId</code> gives you the typed argument directly.</p>
<p>Navigating forward is <code>backStack.add(...)</code> and going back is <code>backStack.removeLastOrNull()</code>. That's the whole API for basic navigation.</p>
<p><code>onBack</code> is called when the user presses the system back button or makes the back gesture, and it removes the top entry. Navigation 3 also supports predictive back, the animation where the previous screen peeks in as the user swipes, without any extra code from you.</p>
<p>Run the app now. You should be able to add notes, see them appear in the list, tap one to edit it, delete notes, rotate the device without losing anything, and close and reopen the app with all notes still there.</p>
<h2 id="heading-how-to-handle-theming-and-edge-to-edge-display">How to Handle Theming and Edge-to-Edge Display</h2>
<p>The template created a <code>ui/theme</code> package with <code>Color.kt</code>, <code>Type.kt</code>, and <code>Theme.kt</code>. The theme function looks roughly like this:</p>
<pre><code class="language-kotlin">@Composable
fun QuickNotesTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
</code></pre>
<p><code>MaterialTheme</code> provides colors and typography to every Material 3 component below it, which is why calls such as <code>MaterialTheme.typography.titleMedium</code> in your screens work.</p>
<p>The <code>when</code> block picks a color scheme. On Android 12 (API 31, <code>VERSION_CODES.S</code>) and newer, dynamic color generates a palette from the user's wallpaper so your app matches the rest of their phone. On older versions, or if you set <code>dynamicColor</code> to <code>false</code>, it falls back to the fixed <code>LightColorScheme</code> and <code>DarkColorScheme</code> defined in <code>Color.kt</code>. <code>isSystemInDarkTheme()</code> follows the system dark mode setting.</p>
<p>If you want a consistent brand color everywhere, set <code>dynamicColor = false</code> and edit the colors in <code>Color.kt</code>. The <a href="https://material-foundation.github.io/material-theme-builder/">Material Theme Builder</a> can generate a full light and dark scheme from a single seed color.</p>
<p>Edge-to-edge means your app draws behind the system bars instead of inside a box between them. Apps that target Android 15 or higher are edge-to-edge by default, and starting with Android 16 the opt-out is gone.</p>
<p>The <code>enableEdgeToEdge()</code> call in <code>MainActivity</code> makes the behavior consistent on older Android versions too and sets the system bar icons to the correct light or dark color. Because both screens use <code>Scaffold</code> and apply its <code>innerPadding</code>, the content already avoids the bars.</p>
<p>When you build screens without <code>Scaffold</code>, use modifiers such as <code>Modifier.safeDrawingPadding()</code> or <code>Modifier.systemBarsPadding()</code> to keep text and buttons out from under the status bar and gesture area.</p>
<h2 id="heading-how-to-test-your-composables">How to Test Your Composables</h2>
<p>Because <code>NoteListContent</code> is stateless, you can test it without a database or ViewModel by passing in a fake list. Create <code>app/src/androidTest/java/com/yourname/quicknotes/NoteListContentTest.kt</code>:</p>
<pre><code class="language-kotlin">package com.yourname.quicknotes
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.yourname.quicknotes.data.Note
import com.yourname.quicknotes.ui.NoteListContent
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class NoteListContentTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun showsEmptyMessageWhenThereAreNoNotes() {
composeRule.setContent {
NoteListContent(notes = emptyList(), onAddNote = {}, onOpenNote = {}, onDeleteNote = {})
}
composeRule.onNodeWithText("No notes yet").assertIsDisplayed()
}
@Test
fun tappingANoteReportsItsId() {
var openedId: Long? = null
val notes = listOf(Note(id = 42, title = "Groceries", body = "Milk, eggs"))
composeRule.setContent {
NoteListContent(
notes = notes,
onAddNote = {},
onOpenNote = { openedId = it },
onDeleteNote = {}
)
}
composeRule.onNodeWithText("Groceries").performClick()
assertEquals(42L, openedId)
}
@Test
fun deleteButtonIsLabelledForScreenReaders() {
val notes = listOf(Note(id = 1, title = "Groceries", body = ""))
composeRule.setContent {
NoteListContent(notes = notes, onAddNote = {}, onOpenNote = {}, onDeleteNote = {})
}
composeRule.onNodeWithContentDescription("Delete Groceries").assertIsDisplayed()
}
}
</code></pre>
<p><code>createComposeRule()</code> sets up an empty activity where <code>setContent</code> can render any composable in isolation. Compose tests find elements through the semantics tree, the same structure accessibility services read. <code>onNodeWithText</code> finds a node by its visible text and <code>onNodeWithContentDescription</code> finds one by its accessibility label.</p>
<p>The first test confirms the empty state appears when the list is empty. The second test passes a lambda that records the ID it receives, taps the note, and asserts that the screen reported the correct ID. The third test checks that the delete button has a meaningful label, which doubles as an accessibility check. None of these tests touch Room or the ViewModel, which is the practical payoff of hoisting state out of <code>NoteListContent</code>.</p>
<p>Run the tests by right-clicking the file and choosing <strong>Run 'NoteListContentTest'</strong>, or from the terminal:</p>
<pre><code class="language-bash">./gradlew connectedDebugAndroidTest
</code></pre>
<p>This Gradle task builds a debug version of the app and a separate test APK, installs both on the connected emulator or phone, runs every test in <code>androidTest</code>, and writes an HTML report to <code>app/build/reports/androidTests/connected/</code>. Instrumented tests need a running device. For logic that doesn't depend on Android, such as validation rules or formatting helpers, put unit tests in the <code>test</code> folder and run them with <code>./gradlew testDebugUnitTest</code>, which runs on your computer in a few seconds.</p>
<h2 id="heading-how-to-prepare-the-app-for-release">How to Prepare the App for Release</h2>
<p>A debug build is fine for development, but there are several things to take care of before anyone else installs your app.</p>
<h3 id="heading-replace-the-default-app-icon">Replace the Default App Icon</h3>
<p>The template ships with the generic green Android icon. Right-click the <code>res</code> folder and choose <strong>New > Image Asset</strong>. Image Asset Studio generates an adaptive icon, which has a separate foreground and background layer so launchers can mask it into circles, squircles, or other shapes.</p>
<p>Keep the important part of your artwork inside the central safe zone shown in the preview, because the edges get cropped on some launchers. Also provide a monochrome layer. Android 13 and newer use it for themed icons, where the launcher tints every icon to match the wallpaper, and an app without one looks out of place on those phones.</p>
<h3 id="heading-move-hardcoded-strings-into-resources">Move Hardcoded Strings into Resources</h3>
<p>The screens in this article use hardcoded strings like <code>"Quick Notes"</code> and <code>"No notes yet"</code> to keep the code readable. In a real app, you'd want to move them into <code>res/values/strings.xml</code> and read them with <code>stringResource(R.string.no_notes)</code>.</p>
<p>That is what makes translation possible, since Android picks the right <code>strings.xml</code> for the user's language automatically. Android Studio flags hardcoded strings with a lint warning and can extract them for you with the <strong>Extract string resource</strong> quick fix.</p>
<h3 id="heading-check-the-release-build-with-r8">Check the Release Build with R8</h3>
<p>You enabled <code>isMinifyEnabled</code> in the release build type, which turns on R8. R8 removes unused code, shortens class and method names, and optimizes what remains. That typically makes an app noticeably smaller and faster, but it can break code that relies on reflection, because R8 can't see that a class is used if nothing references it directly.</p>
<p>Room, Compose, and kotlinx.serialization ship their own keep rules, so this app needs no extra configuration. If you add libraries that use reflection, check their documentation for rules to add to <code>proguard-rules.pro</code>.</p>
<p>The way to catch R8 problems is to actually run a release build before uploading it. Select the <code>release</code> variant from <strong>Build > Select Build Variant</strong> (it will use a debug signing key until you set up release signing in the next section), install it, and click through every screen. A crash that only appears in release builds is almost always a missing keep rule.</p>
<h3 id="heading-remove-unnecessary-permissions">Remove Unnecessary Permissions</h3>
<p>Open your merged manifest (the <strong>Merged Manifest</strong> tab at the bottom of <code>AndroidManifest.xml</code>) and look at the permissions listed. Libraries can add permissions you never asked for, and every permission you declare has to be justified in Play Console declarations. Quick Notes stores everything locally, so it shouldn't need any permissions at all, not even <code>INTERNET</code>. Fewer permissions also make the Data safety form in Play Console simpler to fill out.</p>
<h2 id="heading-how-to-create-an-upload-key-and-sign-your-app">How to Create an Upload Key and Sign Your App</h2>
<p>Every Android app must be cryptographically signed before it can be installed. Google Play uses a two-key system called Play App Signing. You sign your uploads with an upload key that you create and keep. Google verifies the upload, strips your signature, and re-signs the app with an app signing key that Google stores in its own secure infrastructure. Users only ever see the app signing key.</p>
<p>The benefit of this split is recovery. If you lose your upload key or it leaks, you can ask Google to reset it from Play Console, and your users keep receiving updates. Before Play App Signing existed, losing your only signing key meant you could never update your app again.</p>
<p>Play App Signing is mandatory for new apps, and it's also what makes Android App Bundles possible, since Google needs the signing key to generate optimized APKs for each device.</p>
<p>Create an upload key from the terminal with <code>keytool</code>, which comes with the JDK bundled in Android Studio:</p>
<pre><code class="language-bash">keytool -genkeypair -v \
-keystore ~/keys/quicknotes-upload.jks \
-alias upload \
-keyalg RSA -keysize 2048 \
-validity 10000
</code></pre>
<p>This command creates a Java keystore file at <code>~/keys/quicknotes-upload.jks</code> containing one key pair with the alias <code>upload</code>. <code>-keyalg RSA -keysize 2048</code> sets the key type and strength Google Play expects, and <code>-validity 10000</code> makes the key valid for roughly 27 years. Google Play requires keys to be valid until at least October 2033.</p>
<p>The tool prompts for a keystore password and asks for your name and organization, which are stored in the certificate. If <code>keytool</code> isn't on your path, Android Studio's <strong>Build > Generate Signed App Bundle or APK</strong> wizard can create the same key through a dialog.</p>
<p>Store the keystore somewhere outside your project folder and back it up to a password manager or encrypted storage. Never commit it to Git.</p>
<p>Next, create a <code>keystore.properties</code> file in your project root to hold the signing details:</p>
<pre><code class="language-properties">storeFile=/Users/yourname/keys/quicknotes-upload.jks
storePassword=your-keystore-password
keyAlias=upload
keyPassword=your-key-password
</code></pre>
<p>This file maps the four values Gradle needs to sign a build: the path to the keystore, the keystore password, the alias of the key inside it, and the password for that key. Keeping them in a separate file lets you share the project without sharing secrets. Add <code>keystore.properties</code> and <code>*.jks</code> to your <code>.gitignore</code> right away.</p>
<p>Then load the file in <code>app/build.gradle.kts</code> and create a release signing configuration:</p>
<pre><code class="language-kotlin">import java.util.Properties
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { load(it) }
}
}
android {
// namespace, compileSdk, defaultConfig as before
signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.findByName("release")
}
}
}
</code></pre>
<p>The <code>import</code> must go at the very top of the file, above the <code>plugins</code> block. The script reads <code>keystore.properties</code> into a <code>Properties</code> object if the file exists.</p>
<p>Inside <code>signingConfigs</code>, it creates a configuration named <code>release</code> from those values, but only when the file is present. That guard matters because a teammate cloning the repository, or a CI server running tests, won't have your keystore, and the build should still work for debug tasks.</p>
<p>The release build type then uses <code>findByName("release")</code>, which returns <code>null</code> instead of failing when the config doesn't exist. In a CI pipeline, you would typically read these values from environment variables or a secrets manager instead of a file.</p>
<h2 id="heading-how-to-build-an-android-app-bundle">How to Build an Android App Bundle</h2>
<p>Google Play doesn't accept APKs for new apps. You upload an Android App Bundle (AAB) instead. A bundle contains all of your compiled code and resources for every screen density, CPU architecture, and language. When a user installs the app, Google Play generates an APK containing only what that specific device needs. A phone with an English locale and a high density screen doesn't download Japanese strings or low density images, so the download is smaller.</p>
<p>Build the bundle from the terminal:</p>
<pre><code class="language-bash">./gradlew bundleRelease
</code></pre>
<p>This task compiles the release variant, runs R8, signs the result with your upload key, and writes the bundle to <code>app/build/outputs/bundle/release/app-release.aab</code>. You can also use <strong>Build > Generate Signed App Bundle or APK</strong> in Android Studio, which does the same thing through a wizard. If the build fails with a signing error, double-check the path and passwords in <code>keystore.properties</code>.</p>
<p>Before uploading, increase <code>versionCode</code> for every new build you send to Play. The first upload can use <code>1</code>, the next must be <code>2</code> or higher, and so on. Play rejects any bundle whose <code>versionCode</code> it has already seen, even on a different testing track. <code>versionName</code> can be anything, but semantic versions such as <code>1.0.0</code>, <code>1.0.1</code>, and <code>1.1.0</code> are easy for users and for you to follow.</p>
<h2 id="heading-how-to-create-a-google-play-developer-account">How to Create a Google Play Developer Account</h2>
<p>Go to <a href="https://play.google.com/console">play.google.com/console</a> and sign in with the Google account you want to own the developer account. Consider creating a dedicated account for this rather than using your personal Gmail, since transferring ownership later is awkward.</p>
<p>You'll be asked to choose between a personal account and an organization account. A personal account is for individuals. An organization account is for registered businesses and requires a D-U-N-S number, a free business identifier from Dun and Bradstreet that can take a week or more to obtain.</p>
<p>The choice affects more than paperwork. New personal accounts must complete a closed testing period before they can publish to production (covered below), while organization accounts are exempt from that requirement. Your developer name and, for organizations and monetized personal accounts, your address are shown publicly on your store listing.</p>
<p>Pay the one-time 25 USD registration fee, then complete identity verification. Google asks for a legal name and address matching a government ID, verifies your contact email and phone number, and for personal accounts asks you to confirm that you have access to a physical Android device by installing the Play Console app and signing in. Verification usually takes a few days. You can't publish until it's complete, so start this step early, even before your app is finished.</p>
<p>Identity verification is becoming more important across the Android ecosystem. Google has begun rolling out developer verification for all apps installed on certified Android devices, including those distributed outside Google Play, starting with a handful of countries in late 2026. If you distribute through Play and have completed Play Console verification, you're already covered, but it's one more reason to make sure your account details are accurate.</p>
<h2 id="heading-how-to-create-your-app-in-the-play-console">How to Create Your App in the Play Console</h2>
<p>Once your account is verified, click <strong>Create app</strong> on the Play Console home page. Enter the app name as it should appear on Google Play (up to 30 characters), select the default language, choose <strong>App</strong> rather than <strong>Game</strong>, and choose <strong>Free</strong> or <strong>Paid</strong>.</p>
<p>Be careful here. You can change a paid app to free later, but a free app can never become paid. If you want to charge money, use in-app purchases or subscriptions instead. Accept the Developer Program Policies and US export law declarations and click <strong>Create app</strong>.</p>
<p>You land on the app dashboard, which shows a checklist of tasks grouped into sections. Play Console walks you through them in a sensible order, but the next two sections cover the parts that confuse people most.</p>
<h2 id="heading-how-to-complete-the-app-content-declarations">How to Complete the App Content Declarations</h2>
<p>Under <strong>Policy and programs > App content</strong> (also linked from the dashboard checklist), Google asks a series of questions about your app. Every app must complete all of them before it can be released to any track, including internal testing.</p>
<p>The <strong>Privacy policy</strong> requires a public URL to a page describing what data your app collects and how it's used. This is required even if your app collects nothing. For Quick Notes, a short page stating that notes are stored only on the device and no data is collected or shared is enough. You can host it with GitHub Pages, Google Sites, or your own website, but it must be a working public web page, not a PDF or a Google Doc that requires sign-in.</p>
<p><strong>App access</strong> asks whether parts of the app require login. If they do, you must provide test credentials so reviewers can reach every screen. Quick Notes has no login, so select that all functionality is available without special access.</p>
<p><strong>Ads</strong> asks whether your app contains ads, including ads from SDKs you didn't write yourself. Answer honestly, because apps with ads get a "Contains ads" label on the store listing and reviewers check.</p>
<p><strong>Content rating</strong> is an IARC questionnaire about violence, sexual content, language, controlled substances, user interaction, and similar topics. Your answers produce age ratings for regions around the world (ESRB in North America, PEGI in Europe, and so on). A notes app with no user-generated sharing will receive the lowest rating everywhere.</p>
<p><strong>Target audience and content</strong> asks which age groups your app is designed for. If you include children under 13, your app falls under the Families policy, which adds requirements around ads, data collection, and SDKs. For a general purpose utility, choose 18 and over or a range starting at 13, unless children really are your audience.</p>
<p><strong>Data safety</strong> is the most detailed form. It produces the "Data safety" section on your store listing and asks whether your app collects or shares each type of user data, such as location, contacts, personal info, and app activity, and for what purpose. The trap is that you're responsible for data collected by every library in your app, not only your own code. Analytics, crash reporting, and ad SDKs all collect data and must be declared.</p>
<p>Most major SDKs publish a page explaining exactly what to declare. Quick Notes uses no such libraries and makes no network calls, so the honest answer is that no data is collected or shared.</p>
<p>The remaining declarations (news apps, government apps, financial features, health apps, and others) are yes or no questions. For a notes app, the answer to each is no.</p>
<h2 id="heading-how-to-set-up-your-store-listing">How to Set Up Your Store Listing</h2>
<p>The store listing, under <strong>Grow users > Store presence > Main store listing</strong>, is what people see before they install. It needs the following text and graphics.</p>
<table>
<thead>
<tr>
<th>Asset</th>
<th>Requirement</th>
</tr>
</thead>
<tbody><tr>
<td>App name</td>
<td>Up to 30 characters</td>
</tr>
<tr>
<td>Short description</td>
<td>Up to 80 characters</td>
</tr>
<tr>
<td>Full description</td>
<td>Up to 4000 characters</td>
</tr>
<tr>
<td>App icon</td>
<td>512 x 512 px PNG, 32-bit with alpha, up to 1 MB</td>
</tr>
<tr>
<td>Feature graphic</td>
<td>1024 x 500 px JPEG or 24-bit PNG, no alpha</td>
</tr>
<tr>
<td>Phone screenshots</td>
<td>2 to 8 images, 16:9 or 9:16, each side between 320 and 3840 px</td>
</tr>
</tbody></table>
<p>The left column lists each required asset and the right column lists Google's current limits.</p>
<p>The short description appears directly under your app name in search results, so treat it as your one-line pitch and put your most important keyword in it. The full description is where you explain features in plain language. Google Play indexes it for search, but stuffing it with keywords violates the metadata policy and can get your listing rejected.</p>
<p>The 512 px icon is separate from the launcher icon inside the app, though it should look the same. Google applies the rounded mask itself, so upload a full square without pre-rounded corners or drop shadows. The feature graphic appears at the top of your listing in some layouts and when your app is promoted.</p>
<p>Screenshots are the biggest factor in whether someone installs. Capture them from the emulator using the camera button in the emulator toolbar, and consider adding short captions that explain what each screen does. If you want your app to show up well on tablets and Chromebooks, Play Console has separate slots for tablet screenshots, and providing them improves your listing on those devices.</p>
<p>Also fill in the <strong>Store settings</strong> page with an app category (Productivity fits Quick Notes), tags, and a contact email address. The contact email is shown publicly on the listing.</p>
<h2 id="heading-how-to-run-internal-and-closed-testing">How to Run Internal and Closed Testing</h2>
<p>Google Play has several release tracks. Internal testing is for up to 100 testers you add by email, and builds are usually available within minutes with minimal review. Closed testing is for a larger group you control, either by email list or Google Group. Open testing lets anyone join from the store listing. Production is the public release.</p>
<p>Start with internal testing. Go to <strong>Test and release > Testing > Internal testing</strong>, create an email list of testers (you can include yourself), and click <strong>Create new release</strong>. The first time, Play Console asks you to confirm Play App Signing. Accept the default, which lets Google generate and protect the app signing key. Then upload your <code>app-release.aab</code>, enter release notes, and roll out the release.</p>
<p>Share the opt-in link from the <strong>Testers</strong> tab with your testers. They accept the invitation in a browser and can then install the app from Google Play like any other app. Installing through Play, instead of sideloading, proves the bundle works end to end with Play App Signing.</p>
<p>If you created a personal developer account, you can't publish to production right away. Personal accounts created after November 13, 2023 must run a closed test with at least 12 testers who have stayed opted in for the last 14 days continuously. Google introduced this rule to cut down on low-quality and abandoned apps, and it's the step that catches the most first-time developers by surprise.</p>
<p>Set this up under <strong>Test and release > Testing > Closed testing</strong>. Create a track, add testers through an email list or a Google Group, and upload a release (you can promote the same build from internal testing).</p>
<p>Recruit testers early. Friends, family, coworkers, and developer communities are the usual sources. The 12 testers must actually opt in through the link, and the count is of testers who remain opted in, so if someone leaves on day 10, your clock is affected. Plan for more than 12 to give yourself a buffer.</p>
<p>Use the 14 days productively. Ask testers to send feedback, watch for crashes in <strong>Monitor and improve > Android vitals</strong>, and push updated builds as you fix problems. Each update needs a higher <code>versionCode</code>. When Google reviews your production access request, it looks at whether you used the test to improve the app, not only whether time passed.</p>
<p>When the 14 days are up, the dashboard shows an <strong>Apply for production</strong> button. The application asks how you recruited testers, what feedback you received, what you changed as a result, and whether the app is ready for release. Answer specifically. Vague answers like "testers liked it" are a common reason for rejection. Approval typically takes up to a week.</p>
<h2 id="heading-how-to-publish-to-production">How to Publish to Production</h2>
<p>Once you have production access, go to <strong>Test and release > Production</strong> and click <strong>Create new release</strong>. You can upload a new bundle or promote one from a testing track. Add release notes in the "What's new" field. Users see these on the store listing and in the update notification.</p>
<p>Before rolling out, choose which countries and regions to make the app available in, under the <strong>Countries / regions</strong> tab of the Production page. Then review the release summary. Play Console shows any errors that block the release (such as an incomplete declaration) and warnings that do not (such as missing translations). Fix the errors, read the warnings, and click <strong>Send for review</strong>.</p>
<p>Every new app and every update goes through review. For a brand new app, the first review can take up to seven days and occasionally longer. Later updates usually take a day or two. If the app is rejected, Play Console and your developer email explain which policy was violated. Fix the issue, increment the <code>versionCode</code>, and resubmit.</p>
<p>By default, approved releases go live as soon as they pass review. If you want to control the launch date, turn on <strong>Managed publishing</strong> in the <strong>Publishing overview</strong> page. Approved changes then wait until you click <strong>Publish</strong>, which is useful when you're coordinating with a blog post or a marketing date.</p>
<h2 id="heading-how-to-ship-updates-after-launch">How to Ship Updates After Launch</h2>
<p>Getting your app live is the start of maintenance, not the end of the project. Three habits make updates go smoothly.</p>
<p>The first is staged rollouts. When you release an update to production, Play Console lets you choose a percentage of users to receive it, such as 10 percent. Watch crash and ANR (App Not Responding) rates in Android vitals for a day or two, then increase the percentage. If something goes wrong, you can halt the rollout, and the remaining users never get the broken version.</p>
<p>Google uses Android vitals thresholds to decide whether to show warnings on your listing or reduce its visibility, so catching problems at 10 percent matters.</p>
<p>The second is keeping your target SDK current. Google Play raises the minimum <code>targetSdk</code> for updates every year. If your app falls behind, you can no longer publish updates, and after a grace period new users on recent Android versions may not be able to find it.</p>
<p>Raising <code>targetSdk</code> isn't just changing a number. Read the behavior changes page for each new Android version and test your app against them. A good routine is to install the Android beta on a test device or emulator each spring so the August deadline is never a surprise.</p>
<p>The third is planning for database changes. When you add a column to <code>Note</code> or create a new entity, increment the <code>version</code> in <code>@Database</code> and give Room a migration. For simple changes, such as adding a column with a default value, Room can generate the migration if you declare <code>autoMigrations = [AutoMigration(from = 1, to = 2)]</code> in the annotation, using the schema JSON files you exported. Skipping this step causes a crash for every existing user the moment they open the updated app, and it's one of the most common causes of one-star reviews after an update.</p>
<h2 id="heading-summary">Summary</h2>
<p>In this handbook, you built a complete native Android app with the tools Google currently recommends.</p>
<p>Jetpack Compose describes the UI as functions of state, and the state hoisting pattern keeps screens such as <code>NoteListContent</code> simple to test. Room provides compile-time checked storage that streams updates through a <code>Flow</code>, and a ViewModel exposes that data as a <code>StateFlow</code> that survives rotation. Navigation 3 treats the back stack as an ordinary list you own, so navigating forward and back are just <code>add</code> and <code>removeLastOrNull</code>.</p>
<p>On the release side, you configured R8 to shrink the app, created an upload key and stored it safely outside the project, and built a signed Android App Bundle. Play App Signing means Google holds the final signing key, so losing your upload key is recoverable instead of fatal.</p>
<p>Publishing to Google Play takes more than an upload. You verified your developer identity, filled in the privacy policy, content rating, and Data safety declarations honestly (including data collected by third party libraries), built a store listing, and, for a personal account, ran a 14 day closed test with at least 12 testers before applying for production access. Planning for that testing window from the start is the biggest thing you can do to avoid a delayed launch.</p>
<p>From here, good next steps are adding a dependency injection framework such as Hilt as the app grows, syncing notes to a backend, adding a second language through <code>strings.xml</code>, and setting up a CI pipeline that runs your tests and builds a signed bundle on every tagged release. The full flow you followed in this handbook stays the same no matter how large the app becomes.</p>