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:

  1. Key-Value Storage (Jetpack DataStore): For simple user settings (like "dark mode on/off", "user's auth token").
  2. Structured Database (Room): For large amounts of complex, structured, and queryable data (like a list of users, products, or messages).
  3. 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 type prefs.getString("USER_SCORE", ""), it will compile but will crash your app at runtime.
  • No Error Handling: If reading or writing fails, SharedPreferences doesn't provide an easy way to know.

The Two Types of DataStore

DataStore provides two different implementations:

  1. 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.
  2. 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 .proto file), but in return, it guarantees that you can't read an Int as a String.

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.

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.

SettingsDataStore.kt (continued)
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.

MainActivity.kt
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.kt
import 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*.

Read More about Jetpack DataStore →

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 Cursor into a Kotlin User object. 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:

  1. Entity (The Table): A Kotlin data class that defines a table and its columns.
  2. DAO (Data Access Object): An interface that defines *how* you access the data (your SQL queries).
  3. Database (The Main Class): An abstract class that 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.

Note.kt
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.

NoteDao.kt
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 as suspend to tell Room they should be run on a background thread (using coroutines).
  • Flow: By returning a Flow, 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.

AppDatabase.kt
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.

AppDatabase.kt (Updated)
// 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 →