Android App Development

Chapter 7
Local Data Storage

Certificate in Web and Android Development
Android App Development   •   Certificate

Local Data Storage

Introduction

Most Android applications must retain information beyond the current screen or process. A shopping app stores a cart, a messaging app stores conversations, and a fitness app stores measurements. Local persistence allows an application to work offline, start quickly, and preserve user preferences. It also introduces responsibilities: selecting an appropriate storage mechanism, defining a reliable data model, handling schema changes, protecting sensitive information, and avoiding corruption or data loss.

This chapter examines three common approaches in Android: preferences, files, and Room databases. Preferences are appropriate for small key–value settings. Files are useful for documents, serialized objects, images, and other relatively unstructured data. Room provides a structured abstraction over SQLite and is generally the preferred solution for relational application data.

Learning Objectives

By the end of this chapter, you should be able to:

  • distinguish preferences, files, SQLite, and Room;
  • store and retrieve simple values using DataStore preferences;
  • create and manage application-private files;
  • model Room entities, primary keys, queries, and relationships;
  • use repositories and coroutines to access local data safely;
  • select a persistence approach according to data structure, sensitivity, and access requirements;
  • identify reliability, privacy, migration, and testing concerns.

1. Persistence as an Application Concern

In-memory variables exist only while the process is running. Local persistence writes data to storage so that it can survive activity recreation, process termination, and temporary loss of network access. Android application storage is normally sandboxed: an application can access its own private files and database, while other applications cannot ordinarily access them.

Persistence should be designed around the data rather than around a particular API. Consider five questions:

  1. What is the shape of the data? A single Boolean differs from a collection of related records.
  2. How much data is involved? Large media files should not be placed in preferences.
  3. How will it be accessed? Search, sorting, filtering, and joins indicate a database.
  4. How long must it survive? Temporary cache data has different requirements from user records.
  5. How sensitive is it? Credentials and health information require stronger safeguards than a theme preference.

A useful architectural rule is to keep storage details behind a repository or data-source layer. User-interface code requests operations such as observeTasks() or saveSettings() instead of directly manipulating database handles and files.

2. Choosing a Storage Mechanism

MechanismBest useStrengthsLimitations
Preferences/DataStoreSmall settings and flagsSimple, asynchronous, type-aware preferences APIPoor for collections and complex queries
App-private filesText, JSON, images, exports, documentsFlexible structure and streamingDeveloper manages format, indexing, and consistency
SQLite directlyRelational data with full SQL controlPowerful and widely supportedBoilerplate and greater error risk
RoomStructured relational application dataCompile-time query verification, entities, DAOs, migrationsRequires schema design and migration planning
Cache directoryRe-creatable temporary dataSystem may reclaim it; useful for cachingMust never be the only copy of important data

Internal storage is private and does not require storage permission. External or shared storage has broader visibility and additional platform rules; it should be used only when the user needs to access or share the file. Android's Storage Access Framework is appropriate for user-selected documents.

3. Preferences with DataStore

A preference is a named value such as dark_mode = true or sort_order = "date". Android's modern recommendation for small preference data is Jetpack DataStore. Preferences DataStore stores key–value pairs asynchronously and exposes data as a Kotlin Flow. It avoids many problems associated with directly using SharedPreferences, including blocking work on the main thread and unsafe multi-step updates.

A DataStore instance should normally be created once per file, commonly using a property delegate in a Kotlin file:

val Context.userPreferences: DataStore<Preferences> by preferencesDataStore(
    name = "user_preferences"
)

Keys are typed:

private object PreferenceKeys {
    val DARK_MODE = booleanPreferencesKey("dark_mode")
    val USERNAME = stringPreferencesKey("username")
}

A repository can expose a read-only stream and update values:

class SettingsRepository(private val context: Context) {
    val settings: Flow<Settings> = context.userPreferences.data
        .map { values ->
            Settings(
                darkMode = values[PreferenceKeys.DARK_MODE] ?: false,
                username = values[PreferenceKeys.USERNAME] ?: ""
            )
        }

    suspend fun setDarkMode(enabled: Boolean) {
        context.userPreferences.edit { values ->
            values[PreferenceKeys.DARK_MODE] = enabled
        }
    }
}

data class Settings(val darkMode: Boolean, val username: String)

data is a cold stream: collection reads the current file and emits updates. The edit operation is transactional for the preference update, so callers should perform related changes within one edit block when they must change together.

Preferences should not store passwords, large JSON documents, lists encoded as arbitrary strings, or data requiring queries. A preference file is not a relational database. If many independent records are being encoded into one value, the design is usually a sign that Room or a file format is more suitable.

DataStore reliability considerations

A single DataStore file should have one consistent instance within a process. Multiple instances pointing to the same file can cause corruption or runtime errors. Reads and writes should be collected or invoked from lifecycle-aware components, such as a ViewModel. Exceptions such as IOException should be handled deliberately; silently replacing all failures with defaults can conceal storage problems.

4. File Storage

Files are suitable when the data has a document-like or binary form. Examples include a downloaded PDF, a profile image, a JSON export, or a text log. Application-private files are normally accessed through filesDir; temporary files belong in cacheDir and may be deleted by the system when space is needed.

A simple text-file operation is:

suspend fun saveNote(context: Context, text: String) = withContext(Dispatchers.IO) {
    context.openFileOutput("note.txt", Context.MODE_PRIVATE).use { output ->
        output.bufferedWriter().use { writer ->
            writer.write(text)
        }
    }
}

suspend fun loadNote(context: Context): String = withContext(Dispatchers.IO) {
    try {
        context.openFileInput("note.txt").bufferedReader().use { it.readText() }
    } catch (error: FileNotFoundException) {
        ""
    }
}

File access is input/output work and should not block the main thread. MODE_PRIVATE replaces the existing file; use an explicit temporary file and rename strategy when an interrupted write must not leave a partially written document. For important structured documents, a common safe pattern is:

  1. serialize the complete new content;
  2. write it to a temporary file in the same directory;
  3. flush and close the stream;
  4. replace the old file with the temporary file.

The exact atomicity guarantees depend on the filesystem, so critical applications should test failure scenarios rather than assuming that every operation is fully atomic.

Serialization and file formats

A file format should be explicit and versioned. JSON is readable and convenient for interchange, whereas a binary format may be smaller or faster. A file that contains application records should include a schema version or a migration strategy. Never deserialize untrusted input without validating its size, fields, types, and permitted values.

File names should not be constructed directly from untrusted user input. Validate paths to prevent path traversal, and use application-private directories whenever possible. If a file must be shared, use a FileProvider content URI rather than exposing a raw filesystem path.

5. Room and Relational Persistence

Room is a Jetpack persistence library built over SQLite. It consists principally of:

  • entities, which describe tables;
  • DAOs (data access objects), which define database operations;
  • the database class, which connects entities and DAOs;
  • migrations, which evolve the schema while preserving data.

Room validates SQL queries at compile time and maps query results to Kotlin types. This reduces errors compared with manually assembling SQL strings, but it does not remove the need for sound relational design.

5.1 Entities and keys

An entity represents a table. Each row should have a stable primary key. Auto-generated integer keys are convenient for local records; a server identifier may be preferable when synchronizing with a remote system.

@Entity(tableName = "tasks")
data class Task(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val title: String,
    val completed: Boolean = false,
    val dueDateEpochMillis: Long? = null
)

Room supports common scalar types such as integers, strings, and booleans. For unsupported types, use a TypeConverter, but preserve a clear representation. For example, an instant may be stored as epoch milliseconds or an ISO-8601 string. The chosen representation should be consistent, documented, and interpreted in the correct time zone.

5.2 DAOs and queries

A DAO defines the operations that the application requires:

@Dao
interface TaskDao {
    @Query("SELECT * FROM tasks ORDER BY dueDateEpochMillis IS NULL, dueDateEpochMillis")
    fun observeTasks(): Flow<List<Task>>

    @Query("SELECT * FROM tasks WHERE id = :id")
    suspend fun findById(id: Long): Task?

    @Insert
    suspend fun insert(task: Task): Long

    @Update
    suspend fun update(task: Task)

    @Delete
    suspend fun delete(task: Task)
}

The query returns a Flow, allowing the user interface to react to changes. Suspend functions are appropriate for one-shot operations. Database work should not be performed on the main thread. Room can use transactions to group operations that must succeed or fail together:

@Transaction
suspend fun completeTaskAndRecordEvent(taskId: Long) {
    // update task and insert event in one transaction
}

The example above is conceptually useful, but a transaction method that performs writes normally belongs in a DAO containing the required update and insert statements. A transaction provides atomicity: other observers should not see an incomplete sequence. It does not automatically provide business correctness; constraints and validation remain necessary.

5.3 Database class

@Database(entities = [Task::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao
}

val database = Room.databaseBuilder(
    context,
    AppDatabase::class.java,
    "app_database"
).build()

In production, construct one database instance, commonly through dependency injection or an application-level provider. Closing and recreating the database for every query is inefficient and can cause concurrency problems.

6. Relationships and Normalized Design

Relational data often contains one-to-many or many-to-many relationships. Suppose a project has many tasks. The child table stores the parent key:

@Entity
data class Project(
    @PrimaryKey val projectId: Long,
    val name: String
)

@Entity(
    foreignKeys = [
        ForeignKey(
            entity = Project::class,
            parentColumns = ["projectId"],
            childColumns = ["projectOwnerId"],
            onDelete = ForeignKey.CASCADE
        )
    ],
    indices = [Index("projectOwnerId")]
)
data class ProjectTask(
    @PrimaryKey val taskId: Long,
    val projectOwnerId: Long,
    val title: String
)

data class ProjectWithTasks(
    @Embedded val project: Project,
    @Relation(
        parentColumn = "projectId",
        entityColumn = "projectOwnerId"
    )
    val tasks: List<ProjectTask>
)

A foreign key maintains referential integrity: a task's project identifier must refer to an existing project, subject to the configured actions. Indexing the child foreign key improves common lookups and can make deletion checks more efficient.

A many-to-many relationship uses a junction entity. For example, students and courses may be connected through StudentCourse(studentId, courseId). The pair should generally be a composite primary key so that the same enrolment cannot be inserted twice.

Normalization reduces duplicated facts and update anomalies. If a customer's address is copied into every order, changing the address may require many updates and can produce inconsistent historical data. However, deliberate denormalization can improve read performance or preserve historical snapshots. The design should reflect the meaning of the data: an order may correctly retain the shipping address used at the time of purchase even if the customer's current address changes.

7. Architecture and Data Flow

A practical Android architecture separates responsibilities:

UI ──> ViewModel ──> Repository ──> DAO / DataStore / File source
│          ▲                         │
└──────────┴────── observable state ─┘

The ViewModel coordinates screen state and survives configuration changes. The repository chooses or combines data sources, such as a Room database for cached records and a network service for synchronization. The DAO handles database operations; it should not contain UI concerns.

When both local and remote data exist, a local-first design can provide responsive offline behaviour. The application writes locally, displays local state, and synchronizes later. This requires conflict rules, timestamps or versions, retry handling, and a definition of which source is authoritative. A simple last-write-wins policy is easy to implement but can overwrite legitimate changes; domain-specific conflict resolution may be safer.

8. Worked Example: Offline Task List

Consider a task application with the following requirements:

  • retain tasks after the app closes;
  • show tasks immediately without network access;
  • filter completed tasks;
  • remember the user's preferred display mode.

The task records are structured, searchable, and potentially numerous, so Room is appropriate. The display mode is one small setting, so DataStore is appropriate. A temporary exported report belongs in a file. This is a polyglot persistence design: different types of data use different stores rather than forcing all information into one mechanism.

A filtered DAO method might be:

@Query("SELECT * FROM tasks WHERE completed = :completed ORDER BY title COLLATE NOCASE")
suspend fun findByCompletion(completed: Boolean): List<Task>

The repository can expose a stream:

class TaskRepository(private val dao: TaskDao) {
    fun tasks(): Flow<List<Task>> = dao.observeTasks()

    suspend fun add(title: String) {
        require(title.isNotBlank()) { "Title must not be blank" }
        dao.insert(Task(title = title.trim()))
    }
}

The require check is a domain validation rule. It prevents invalid data from entering the database, but a complete application should also provide user-friendly validation feedback. The database may additionally enforce constraints where possible.

If a user deletes a project, the intended task behaviour must be decided explicitly. CASCADE removes child tasks, whereas RESTRICT prevents deletion while children exist. Neither is universally correct; the choice is a business rule.

9. Case Study: Campus Equipment Checkout

The following is an illustrative case.

Background

A college lends cameras and audio equipment to students. The initial Android prototype stores the current student's name and a JSON string containing all loans in preferences.

Problem

As the number of loans grows, the app becomes slow when filtering by return date. Concurrent updates can overwrite one another, and a malformed JSON value can make all loans unavailable. The prototype also stores a student email in an unprotected plain-text preference.

Analysis

The student name and notification preference are small settings and can remain in DataStore. Loans have fields such as loan identifier, equipment identifier, borrower identifier, checkout time, return deadline, and returned status. They require filtering, sorting, constraints, and relationships. They belong in Room. An attached equipment inspection photograph is binary document data and belongs in an app-private file, with its path or content identifier stored in Room.

Approach

The team creates Equipment, Student, and Loan entities. Loan contains foreign keys to the other two entities and an index on returnDeadline. A unique constraint prevents an equipment item from being checked out twice while it is already active. A Room transaction inserts the loan and marks the equipment unavailable. DataStore stores non-sensitive display preferences. The photograph is written to a temporary file and renamed after a successful write.

Sensitive identifiers are minimized. If authentication tokens are required, they are handled using an appropriate secure storage design rather than ordinary preferences. The database schema is exported and migration tests are added before release.

Outcome

Filtering becomes a database query rather than repeated JSON parsing. Offline check-in remains possible, and a synchronization component can upload completed changes later. The separation of concerns also makes it possible to replace the inspection-image format without redesigning the loan tables.

Learning points

The important decision was not simply to replace one API with another. It was to classify each data item by structure, access pattern, lifetime, and sensitivity. Room solved relational access; DataStore solved preferences; files solved binary content. Clear ownership and transactions addressed consistency, while migration and security planning addressed long-term operation.

10. Reliability, Security, and Governance

Consistency and failure

Design for process death, low storage, interrupted writes, duplicate events, and partially completed synchronization. Use transactions for related database changes, idempotent synchronization operations where possible, and explicit retry policies. Do not treat a cache as the only copy of user-created data.

Schema evolution

A database schema changes as features evolve. Increasing the Room version requires a migration from the previous schema. A migration should preserve existing information, transform it correctly, and be tested with representative old databases. Destructive migration may be acceptable for disposable cache data, but it is inappropriate for valuable user records unless the user has explicitly accepted data loss.

Privacy and security

Collect and retain only data required for the stated purpose. Explain collection and retention in the privacy notice, and provide deletion mechanisms where applicable. Avoid logging personal data, tokens, or complete database contents. Android sandboxing is useful but is not a complete defence against a compromised device or careless backup policy. Encryption, key management, access control, and secure export procedures must be selected according to the sensitivity and regulatory context of the application.

Testing

Persistence tests should include:

  • insertion, update, deletion, and query correctness;
  • empty and large datasets;
  • foreign-key and uniqueness violations;
  • transaction rollback after an induced failure;
  • migration from every supported earlier schema;
  • file-write interruption and malformed input;
  • DataStore default values and update behaviour;
  • rotation, process recreation, and offline operation.

Use an in-memory Room database for fast unit tests when appropriate, but also test the real SQLite configuration because query plans, migrations, and filesystem behaviour may differ.

11. Contemporary Android Practice

Modern Android applications commonly combine Room, DataStore, Kotlin coroutines, Flow, lifecycle-aware collection, and dependency injection. Compose does not change persistence principles: composables should observe state rather than open files or execute SQL directly. WorkManager is appropriate for deferrable, persistent background synchronization, not for replacing a database. Cloud databases may support multi-device access, but they do not eliminate the value of a local cache or the need for privacy and conflict policies.

AI-assisted code generation can produce plausible entities and queries, but generated persistence code must be reviewed for SQL correctness, nullability, migrations, security, and lifecycle behaviour. A compiler check is valuable, but it cannot determine whether a data-retention policy or relationship reflects the application's real-world meaning.

Best-Practice Checklist

  • Choose storage from data shape and access pattern.
  • Keep preferences small and typed.
  • Perform file and database work away from the main thread.
  • Use stable primary keys and explicit foreign keys.
  • Index columns used frequently for filtering, joining, or ordering.
  • Use transactions for logically atomic operations.
  • Keep one database and one DataStore instance per application file.
  • Version schemas and test migrations before release.
  • Validate serialized input and avoid untrusted file paths.
  • Minimize sensitive data and protect it appropriately.
  • Separate repositories and data sources from the user interface.
  • Treat cache data as disposable and user data as valuable.

Chapter Summary

Local persistence enables Android applications to survive process death, function offline, and provide a consistent user experience. DataStore preferences are suited to small typed key–value settings, while application-private files support documents, text, images, and other unstructured or binary content. Room provides entities, DAOs, SQL queries, relationships, transactions, and migrations for structured relational data.

Good persistence design begins with data classification and continues through schema modelling, architecture, validation, concurrency, privacy, and testing. Primary keys identify records; foreign keys express relationships; indexes support common access patterns; and transactions preserve atomicity across related changes. Repositories prevent storage details from leaking into UI code. In professional applications, reliability and governance are as important as the ability to write and read a record.

Key Terms

DataStore: Jetpack API for asynchronous typed or preference-based key–value storage.

Preference: A small named value such as a Boolean, string, or number.

File storage: Persistence of text, serialized documents, images, or binary content.

Internal storage: Application-private storage that normally requires no storage permission.

Cache: Re-creatable temporary data that may be deleted by the system.

SQLite: Embedded relational database engine used by Android.

Room: Jetpack abstraction over SQLite with compile-time query validation.

Entity: Kotlin class mapped by Room to a database table.

Primary key: Column or set of columns that uniquely identifies a row.

DAO: Data access object defining database operations.

Foreign key: Constraint linking a child row to a parent row.

Index: Data structure that accelerates selected lookups and ordering operations.

Transaction: Atomic group of database operations.

Migration: Controlled transformation from one database schema version to another.

Repository: Abstraction that coordinates data sources for the application layer.

Flow: Kotlin asynchronous stream that can emit changing values.

Normalization: Relational design practice that reduces redundancy and update anomalies.

Serialization: Conversion of an object to a storable or transferable representation.

Referential integrity: Requirement that relationships between related rows remain valid.

Review Questions

Short Answer Questions

  1. What type of data is most appropriate for DataStore preferences?
  2. Why should file and database operations avoid the main thread?
  3. What is the role of a primary key in a Room entity?
  4. What problem does a foreign key address?
  5. Why are database migrations necessary?

Long Answer Questions

  1. Compare DataStore, files, and Room in terms of structure, access patterns, and limitations.
  2. Explain how entities, DAOs, and a Room database work together.
  3. Describe how a one-to-many relationship is modelled in Room.
  4. Discuss the reliability and privacy risks of storing sensitive information locally.
  5. Explain why repositories and lifecycle-aware streams are useful in Android architecture.

Analytical / Application Questions

  1. An app stores 50,000 searchable products in one JSON preference value. Evaluate the design and propose a replacement.
  2. A checkout operation must both create a loan and mark equipment unavailable. Explain how you would prevent an inconsistent partial update.
  3. Design a storage strategy for an offline photo-journal app containing display settings, journal text, photographs, and searchable tags. Justify each choice.

Multiple Choice Questions with Answers

Question 1

Which storage mechanism is most appropriate for a small setting such as a dark-mode preference?

A. DataStore preferences

B. A Room many-to-many table

C. The cache directory

D. A raw image file

Correct Answer: A. DataStore preferences

Question 2

What is the primary purpose of a Room DAO?

A. To render Compose screens

B. To define database access operations

C. To encrypt the Android device

D. To request external storage permission

Correct Answer: B. To define database access operations

Question 3

Which feature links a child row to a valid parent row?

A. A serialization format

B. A cache

C. A foreign key

D. A preference key

Correct Answer: C. A foreign key

Question 4

Why should a database transaction be used when two related updates must succeed together?

A. It guarantees faster network access

B. It provides atomicity for the operations

C. It converts files into preferences

D. It removes the need for validation

Correct Answer: B. It provides atomicity for the operations

Question 5

Where should a temporary, re-creatable downloaded preview normally be stored?

A. The cache directory

B. A primary-key column

C. A DataStore Boolean

D. A foreign-key constraint

Correct Answer: A. The cache directory

Question 6

Which design is most suitable for thousands of records requiring filtering and sorting?

A. One preference containing comma-separated values

B. A relational Room database

C. A separate Boolean for every record

D. A log printed to Logcat

Correct Answer: B. A relational Room database

Question 7

What is the main purpose of a database migration?

A. To change the schema while preserving existing data

B. To delete all user data on every launch

C. To move UI code into a DAO

D. To replace all queries with network calls

Correct Answer: A. To change the schema while preserving existing data

Question 8

Which statement about application-private internal storage is correct?

A. It is ordinarily accessible to every installed application

B. It normally requires a public storage permission

C. It is intended for data belonging to the application

D. It can store only Boolean values

Correct Answer: C. It is intended for data belonging to the application

Question 9

What is a suitable Room representation for a many-to-many relationship?

A. A junction entity

B. A single Boolean preference

C. A cache filename

D. A database version number only

Correct Answer: A. A junction entity

Question 10

Why should a DataStore file normally have one consistent instance in a process?

A. Multiple instances can cause conflicts or corruption

B. Only one preference can ever be stored

C. DataStore supports no asynchronous operations

D. It prevents all schema migrations

Correct Answer: A. Multiple instances can cause conflicts or corruption

Android App Development
Chapter 7 of 15