Chapter 5: Data Storage
Introduction: Why Store Data?
An app that forgets everything when you close it isn't very useful. **Data persistence** (saving data) is a fundamental part of almost every application. Whether it's saving the user's name, their high score in a game, a list of to-do items, or 1000s of chat messages, you need a way to store this data on the device.
Android provides several different ways to store data, each designed for a specific purpose. Choosing the *right* storage solution is a key skill for a developer. Storing a simple "true/false" setting in a complex database is inefficient, and storing a million database records in a simple text file is a recipe for disaster.
This chapter will cover the three main categories of data storage on Android, focusing on the modern, **Jetpack-recommended** libraries:
- Key-Value Storage (Jetpack DataStore): For simple user settings (like "dark mode on/off", "user's auth token").
- Structured Database (Room): For large amounts of complex, structured, and queryable data (like a list of users, products, or messages).
- File Storage (Scoped Storage): For large, unstructured files (like photos, videos, PDFs, or log files).
Part 1: Jetpack DataStore (for Settings)
This is the modern, recommended way to store simple key-value pairs.
What is Jetpack DataStore?
DataStore is a data storage solution that allows you to store key-value pairs (like user settings) or typed objects with protocol buffers. It is part of the Jetpack library suite and is the modern replacement for the old SharedPreferences API.
Crucially, DataStore uses **Kotlin Coroutines and Flow** to store data **asynchronously**. This is its biggest advantage.
Why NOT `SharedPreferences`? (The Old Way)
For over a decade, developers used SharedPreferences to save user settings. However, it has several major flaws that DataStore solves:
- It's Synchronous:
SharedPreferences.edit().commit()runs on the **UI thread**. If the disk is slow, this can block the UI and cause your app to "freeze" or "jank" (ANR - Application Not Responding). - No Type Safety: You retrieve data with a key (e.g.,
prefs.getInt("USER_SCORE", 0)). If you accidentally typeprefs.getString("USER_SCORE", ""), it will compile but will crash your app at runtime. - No Error Handling: If reading or writing fails,
SharedPreferencesdoesn't provide an easy way to know.
The Two Types of DataStore
DataStore provides two different implementations:
- Preferences DataStore: This is the one you will use most often. It is the direct replacement for
SharedPreferences. It stores simple key-value pairs (Int, String, Boolean). It is *not* type-safe but is fully asynchronous. - Proto DataStore: This is the advanced, type-safe version. It stores data as custom objects using **Protocol Buffers**. This requires you to define a schema (a
.protofile), but in return, it guarantees that you can't read anIntas aString.
We will cover Preferences DataStore in detail, as it's the most common.
Deep Dive: How to use Preferences DataStore
Let's build a simple settings manager that saves a user's name and a "dark mode" preference.
Step 1: Add Gradle Dependencies
In your build.gradle.kts (Module: :app) file, add the DataStore library:
dependencies {
// ... other dependencies
implementation("androidx.datastore:datastore-preferences:1.0.0")
// (Optional but recommended: Lifecycle for CoroutineScope)
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
}
Don't forget to click **"Sync Now"** in Android Studio.
Step 2: Create the DataStore
You only need **one instance** of DataStore for your entire app. The easiest way to create it is as a top-level property in a new Kotlin file, e.g., SettingsDataStore.kt.
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
// 1. Create a Context extension property for the DataStore
// "settings" is the name of the .preferences_pb file that will be created.
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
Step 3: Define the Keys
To avoid typing "user_name" (a string) everywhere (which can lead to typos), we define typed keys. It's best to put these in a companion object or a separate object.
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
object SettingsKeys {
val USER_NAME = stringPreferencesKey("user_name")
val IS_DARK_MODE = booleanPreferencesKey("is_dark_mode")
}
Step 4: Writing Data (Asynchronously)
To write data, you must use a **Coroutine**. DataStore's .edit() function is a suspend function, which means it *must* be called from a coroutine (like lifecycleScope.launch in an Activity) to avoid blocking the UI thread.
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.datastore.preferences.core.edit
// Inside your Activity or ViewModel
private suspend fun saveSettings(name: String, isDark: Boolean) {
// 'this' refers to the Context (e.g., your Activity)
dataStore.edit { settings ->
settings[SettingsKeys.USER_NAME] = name
settings[SettingsKeys.IS_DARK_MODE] = isDark
}
}
// How to call it from your Activity (e.g., in a button click)
mySaveButton.setOnClickListener {
lifecycleScope.launch {
saveSettings("MSMAXPRO", true)
Log.d("DataStore", "Settings saved!")
}
}
Step 5: Reading Data (Asynchronously with Flow)
This is the most different part from SharedPreferences. DataStore doesn't just *give* you the data. It gives you a **Flow**. A Flow is a stream of data (from Kotlin Coroutines) that automatically emits the *newest* value whenever the data changes.
You "collect" this flow to get the values.
MainActivity.ktimport kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.collect
// A class to hold our settings
data class UserSettings(val name: String, val isDark: Boolean)
// 1. Create a Flow that maps the preferences to our data class
val userSettingsFlow: Flow<UserSettings> = dataStore.data
.map { preferences ->
// Use the Elvis operator (?:) to provide default values
val name = preferences[SettingsKeys.USER_NAME] ?: "Guest"
val isDark = preferences[SettingsKeys.IS_DARK_MODE] ?: false
UserSettings(name, isDark)
}
// 2. In your Activity's onCreate, launch a coroutine to *collect* the flow
override fun onCreate(savedInstanceState: Bundle?) {
// ...
lifecycleScope.launch {
userSettingsFlow.collect { settings ->
Log.d("DataStore", "Current name: ${settings.name}")
Log.d("DataStore", "Is dark mode: ${settings.isDark}")
// Update your UI here
myTextView.text = settings.name
}
}
}
The best part? If you call saveSettings() from somewhere else, this collect block will **automatically run again** and update your UI with the new name. This is called *reactive programming*.
Part 2: Room (for Structured Databases)
When you need to store large amounts of *structured* data—data that has relationships, can be queried, or needs to be sorted and filtered—you need a database. DataStore is not for this; it's only for settings.
What is SQLite?
Every single Android phone (and iPhone) comes with a built-in, lightweight database engine called **SQLite**. It's a file-based database, meaning your entire database (all tables, rows, and data) is stored in a single file (.db) in your app's private storage.
What is Room? (The Modern Way)
For years, developers had to write raw SQL queries ("SELECT * FROM users WHERE id = ?") and manually parse the results (a Cursor object) to use SQLite. This was time-consuming, ugly, and error-prone. If you misspelled a column name in your SQL string, your app would compile fine but crash at runtime.
To fix this, Google created **Room**. Room is an **ORM (Object-Relational Mapper)**. It's an "abstraction layer" that sits *on top* of SQLite, making it incredibly easy to use.
Why Room is Better than Raw SQLite:
- Compile-Time Query Validation: Room checks your SQL queries *when you compile* your app. If you write
"SELECT * FROM usrs"(typo), your code **will not compile**, saving you from runtime crashes. - No Boilerplate Code: You don't need to write code to convert a
Cursorinto a KotlinUserobject. Room does this for you automatically. - Reactive (Returns Flow):** Just like DataStore, Room can return your data as a
Flow. When you insert a new user, your UI (which is collecting the flow) will automatically update. - Easy Migrations: Room provides a simple system for "migrating" (upgrading) your database schema when you add or change columns.
The 3 Core Components of Room
To use Room, you must define 3 components:
- Entity (The Table): A Kotlin
data classthat defines a table and its columns. - DAO (Data Access Object): An
interfacethat defines *how* you access the data (your SQL queries). - Database (The Main Class): An
abstract classthat holds the database instance and connects all the Entities and DAOs.
Deep Dive: How to use Room
Let's build a simple database to store a list of Note objects.
Step 1: Add Gradle Dependencies
Room uses **KSP (Kotlin Symbol Processing)** to generate code for you. You need to add three libraries to your build.gradle.kts (Module: :app) file.
plugins {
// ...
id("com.google.devtools.ksp") // KSP plugin
}
// ...
dependencies {
// ... other dependencies
val room_version = "2.6.1"
implementation("androidx.room:room-runtime:$room_version")
implementation("androidx.room:room-ktx:$room_version") // For Coroutines & Flow support
ksp("androidx.room:room-compiler:$room_version") // The code generator
}
Click **"Sync Now"**. You may need to "Build > Rebuild Project" for KSP to generate the code.
Step 2: Create the Entity (The Table)
Create a new file, Note.kt. This is a data class annotated with @Entity.
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.ColumnInfo
@Entity(tableName = "notes_table")
data class Note(
// This will be the primary key (e.g., 1, 2, 3...)
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
// By default, the column name is the variable name (e.g., "title")
val title: String,
@ColumnInfo(name = "note_content") // Custom column name
val content: String,
val timestamp: Long = System.currentTimeMillis()
)
Step 3: Create the DAO (The Queries)
Create a new file, NoteDao.kt. This is an interface annotated with @Dao.
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface NoteDao {
// Insert a new note. If it conflicts (same id), replace it.
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(note: Note)
@Update
suspend fun update(note: Note)
@Delete
suspend fun delete(note: Note)
// A query to get all notes, ordered by timestamp
// It returns a Flow, so the UI will auto-update
@Query("SELECT * FROM notes_table ORDER BY timestamp DESC")
fun getAllNotes(): Flow<List<Note>>
// A query to get a single note by its ID
@Query("SELECT * FROM notes_table WHERE id = :noteId")
suspend fun getNoteById(noteId: Int): Note?
}
suspend: We mark functions assuspendto tell Room they should be run on a background thread (using coroutines).Flow: By returning aFlow, we get a reactive data stream. We don't need to re-fetch data; Room will automatically send us the new list when a note is inserted or deleted.
Step 4: Create the Database Class
Create AppDatabase.kt. This class ties everything together. We use a **Singleton pattern** here to ensure we only ever create *one* instance of the database for the entire app.
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import android.content.Context
@Database(entities = [Note::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
// Room will auto-generate the code for this function
abstract fun noteDao(): NoteDao
// This is the Singleton pattern
companion object {
// @Volatile ensures this variable is always up-to-date for all threads
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
// Return the instance if it exists
return INSTANCE ?: synchronized(this) {
// If not, create the database
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"notes_database" // Name of the .db file
)
.build()
INSTANCE = instance
instance
}
}
}
}
Step 5: Using the Database
Now, from your Activity or (even better) your ViewModel, you can get the database instance and call the DAO methods.
// In your Activity or ViewModel
val db = AppDatabase.getDatabase(applicationContext)
val noteDao = db.noteDao()
// --- To READ data (Reactive) ---
// This Flow will be collected in the UI
val allNotes: Flow<List<Note>> = noteDao.getAllNotes()
// --- To WRITE data (in a Coroutine) ---
viewModelScope.launch(Dispatchers.IO) { // Use IO thread for disk access
val newNote = Note(title = "Test", content = "This is a test note.")
noteDao.insert(newNote)
}
Database Migrations (Critical for Updates)
What happens when you release v1.0, and in v2.0 you want to add a new "priority" column to your Note table? If you just change the Entity class and increase the @Database(version = 2), your app will **CRASH** for all existing users. Why? Because Room doesn't know *how* to update the old table to the new schema.
You must provide a Migration class.
// 1. Define the Migration
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
// Run the SQL command to add the new column
database.execSQL("ALTER TABLE notes_table ADD COLUMN priority INTEGER NOT NULL DEFAULT 0")
}
}
// 2. Update the Database class
@Database(entities = [Note::class], version = 2) // <-- Version incremented to 2
abstract class AppDatabase : RoomDatabase() {
// ...
companion object {
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"notes_database"
)
// 3. Add the migration to the builder
.addMigrations(MIGRATION_1_2)
.build()
INSTANCE = instance
instance
}
}
}
}
Read More about Room Database →
Part 3: File Storage & Scoped Storage
What if your data isn't structured? What if it's just a file, like a photo, a video, a PDF, or a large JSON log? For this, you use File Storage.
**WARNING:** This is one of the most *confusing* parts of modern Android. Starting with **Android 10 (API 29)**, Google introduced **"Scoped Storage"**, which completely changed how apps access files. The old permissions (READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE) **no longer work** for most cases.
The Two Types of File Storage
1. App-Specific Storage (Your App's "Sandbox")
This is storage that is **private to your app**. No other app can access this data. When the user uninstalls your app, this data is **automatically deleted**. This is the **default and recommended** place to store any file that *only* your app needs.
- Internal Storage:
context.filesDir- Theory: 100% private, always available, and *not* visible to the user.
- Use Case: Storing sensitive data like session tokens, small encrypted files, or data you don't want the user to see.
- Code:
val file = File(context.filesDir, "my_secret_data.txt") file.writeText("This is private data.") val content = file.readText()
- External Storage (App-specific):
context.getExternalFilesDir()- Theory: Also private to your app and deleted on uninstall, but it's on the "external" (shared) partition. It's *technically* accessible by other apps with special permissions, but this is rare.
- Use Case: Storing large, non-sensitive files that your app needs, like downloaded images, audio files, or logs.
- Code:
// Pass in a type, like Pictures, to organize val picturesDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) val imageFile = File(picturesDir, "my_app_photo.jpg")
- Cache:
context.cacheDir- Theory: For temporary files (e.g., image thumbnails from the network). The Android system can **delete** these files at any time if it's running low on storage. Never store critical data here.
- Code:
val cacheFile = File(context.cacheDir, "temp_thumbnail.jpg")
2. Shared Storage (The Public Space)
This is for files you want to *share* with other apps or save *permanently* (even if your app is uninstalled). This includes common collections like **"Downloads," "Pictures," "Music,"** and **"Documents."**
With Scoped Storage (Android 10+), you **cannot directly access these folders** with file paths anymore. You must use one of two modern APIs:
A. The MediaStore (for Media)
If your file is a Photo, Video, or Audio file, you *must* use the MediaStore API. This is a Content Provider (see Chapter 4) that acts as the "librarian" for all media on the device.
Example: Saving a Downloaded Image to the "Pictures" Gallery
import android.content.ContentValues
import android.provider.MediaStore
suspend fun saveBitmapToGallery(context: Context, bitmap: Bitmap) {
val resolver = context.contentResolver
// 1. Define the file's metadata
val imageDetails = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "my_cool_photo.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
}
// 2. Get a URI (a "placeholder" file path) from the MediaStore
val imageUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageDetails)
// 3. Write the image data into that placeholder
imageUri?.let { uri ->
// 'use' automatically closes the stream
resolver.openOutputStream(uri)?.use { stream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream)
}
}
}
B. The Storage Access Framework (SAF) (for everything else)
If your file is **not** media (e.g., a .pdf, .txt, .zip), you *must* use the Storage Access Framework (SAF). SAF does *not* give you file access. Instead, it opens a system **file picker** (like "Save As..." on a computer) and lets the *user* choose where to save the file. Your app then gets a temporary URI to write to.
This is complex, as it uses Activity Result APIs.
// --- In your Activity or Fragment ---
// 1. Register an "Activity Result Launcher" (do this in onCreate or as a property)
val createFileLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/pdf")
) { uri: Uri? ->
// This lambda is called when the user picks a location
uri?.let {
writePdfToUri(it)
}
}
// 2. When the user clicks your "Save PDF" button:
mySaveButton.setOnClickListener {
// This opens the system file picker
createFileLauncher.launch("my_report.pdf")
}
// 3. A helper function to write your data to the URI
private fun writePdfToUri(uri: Uri) {
try {
contentResolver.openOutputStream(uri)?.use { stream ->
// Your logic to get the PDF bytes
val pdfBytes: ByteArray = generateMyPdf()
stream.write(pdfBytes)
}
} catch (e: Exception) {
Log.e("Storage", "Failed to save file", e)
}
}
Read More about Data and File Storage →