agentleFS
GuillemRocaGuillemRoca/agent-skills-android/skills/android-data-persistence/SKILL.md

android-data-persistence

Skill2 starsChanged 40 days agoMITView on GitHub

Use when implementing local data storage with Room, DataStore, or offline-first patterns. Covers entities, DAOs, migrations, Paging3, and the repository pattern for data management.

The file

We split this skill into the rules it gives an agent

1 rule. About 0 are common, 1 is distinctive, and 0 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 0

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    No error handling in repository sync operations

    line 352

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Android Data Persistence

Overview

Reliable data persistence is critical for Android apps. This skill covers Room (SQLite abstraction), DataStore (key-value and proto), offline-first architecture, Paging3 for large datasets, and the repository pattern that abstracts data sources from the rest of the app.

When to Use

  • Setting up or modifying a Room database
  • Writing database migrations
  • Implementing offline-first data access
  • Adding pagination for large datasets
  • Choosing between Room, DataStore, and SharedPreferences
  • Implementing the repository pattern

Skip when: Data is purely in-memory or comes only from a remote API with no caching.

Data Storage Decision Guide

RequirementSolution
Structured data with relationsRoom
User preferences (key-value)DataStore (Preferences)
Typed settings with schemaDataStore (Proto)
Large datasets with paginationRoom + Paging3
Simple flags or tokensDataStore (Preferences)
Never useSharedPreferences (for new code)

Core Process

Step 1: Room Setup

  1. Define entities:
@Entity(
    tableName = "tasks",
    indices = [Index(value = ["created_at"])],
)
data class TaskEntity(
    @PrimaryKey
    val id: String,
    @ColumnInfo(name = "title")
    val title: String,
    @ColumnInfo(name = "description")
    val description: String?,
    @ColumnInfo(name = "completed")
    val completed: Boolean = false,
    @ColumnInfo(name = "created_at")
    val createdAt: Long = System.currentTimeMillis(),
    @ColumnInfo(name = "updated_at")
    val updatedAt: Long = System.currentTimeMillis(),
)
  1. Define DAOs:
@Dao
interface TaskDao {
    @Query("SELECT * FROM tasks ORDER BY created_at DESC")
    fun observeAll(): Flow<List<TaskEntity>>

    @Query("SELECT * FROM tasks WHERE id = :taskId")
    suspend fun getById(taskId: String): TaskEntity?

    @Query("SELECT * FROM tasks WHERE completed = :completed ORDER BY created_at DESC")
    fun observeByStatus(completed: Boolean): Flow<List<TaskEntity>>

    @Upsert
    suspend fun upsert(task: TaskEntity)

    @Upsert
    suspend fun upsertAll(tasks: List<TaskEntity>)

    @Query("DELETE FROM tasks WHERE id = :taskId")
    suspend fun deleteById(taskId: String)

    @Query("DELETE FROM tasks WHERE completed = 1")
    suspend fun deleteCompleted()

    @Transaction
    suspend fun replaceAll(tasks: List<TaskEntity>) {
        deleteAll()
        upsertAll(tasks)
    }

    @Query("DELETE FROM tasks")
    suspend fun deleteAll()
}
  1. Define the database:
@Database(
    entities = [TaskEntity::class],
    version = 1,
    exportSchema = true, // Always export for migration testing
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao
}

// Hilt module
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
        Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
            .addMigrations(MIGRATION_1_2)
            .build()

    @Provides
    fun provideTaskDao(database: AppDatabase): TaskDao = database.taskDao()
}

Step 2: Migrations

  1. Always write migrations (never fallbackToDestructiveMigration):
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL(
            "ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0"
        )
    }
}
  1. Test migrations:
@RunWith(AndroidJUnit4::class)
class MigrationTest {
    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java,
    )

    @Test
    fun migration1To2_addsPriorityColumn() {
        // Create database at version 1
        helper.createDatabase("test-db", 1).apply {
            execSQL("INSERT INTO tasks (id, title, completed, created_at, updated_at) VALUES ('1', 'Test', 0, 0, 0)")
            close()
        }

        // Migrate to version 2
        val db = helper.runMigrationsAndValidate("test-db", 2, true, MIGRATION_1_2)

        // Verify new column has default value
        val cursor = db.query("SELECT priority FROM tasks WHERE id = '1'")
        cursor.moveToFirst()
        assertEquals(0, cursor.getInt(0))
        cursor.close()
    }
}

Step 3: DataStore

  1. Preferences DataStore (key-value):
// Define keys
object PreferencesKeys {
    val DARK_MODE = booleanPreferencesKey("dark_mode")
    val SORT_ORDER = stringPreferencesKey("sort_order")
    val ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
}

// Create DataStore
val Context.settingsDataStore by preferencesDataStore(name = "settings")

// Read
val darkMode: Flow<Boolean> = context.settingsDataStore.data
    .map { preferences -> preferences[PreferencesKeys.DARK_MODE] ?: false }

// Write
suspend fun setDarkMode(enabled: Boolean) {
    context.settingsDataStore.edit { preferences ->
        preferences[PreferencesKeys.DARK_MODE] = enabled
    }
}
  1. Proto DataStore (typed schema):
// Define proto schema (settings.proto)
// syntax = "proto3";
// message AppSettings {
//     bool dark_mode = 1;
//     string sort_order = 2;
//     int32 items_per_page = 3;
// }

object AppSettingsSerializer : Serializer<AppSettings> {
    override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
    override suspend fun readFrom(input: InputStream): AppSettings =
        AppSettings.parseFrom(input)
    override suspend fun writeTo(t: AppSettings, output: OutputStream) =
        t.writeTo(output)
}

Step 4: Repository Pattern

  1. Single source of truth (offline-first):
class TaskRepositoryImpl @Inject constructor(
    private val taskDao: TaskDao,
    private val taskApi: TaskApi,
    private val mapper: TaskMapper,
) : TaskRepository {

    // Local database is the single source of truth
    override fun getTasks(): Flow<List<Task>> =
        taskDao.observeAll().map { entities ->
            entities.map(mapper::toDomain)
        }

    // Sync: fetch remote → update local → UI observes local
    override suspend fun syncTasks() {
        val remoteTasks = taskApi.getTasks()
        val entities = remoteTasks.map(mapper::toEntity)
        taskDao.upsertAll(entities)
        // No return value — UI observes the Flow from getTasks()
    }

    override suspend fun addTask(task: Task) {
        val entity = mapper.toEntity(task)
        taskDao.upsert(entity)
        // Optionally sync to remote
        try {
            taskApi.createTask(mapper.toNetwork(task))
        } catch (e: IOException) {
            // Queued for retry — local is source of truth
        }
    }
}

Step 5: Paging3

  1. Paginate large datasets:
// PagingSource from Room (automatic)
@Dao
interface TaskDao {
    @Query("SELECT * FROM tasks ORDER BY created_at DESC")
    fun pagingSource(): PagingSource<Int, TaskEntity>
}

// In ViewModel
val pagedTasks: Flow<PagingData<Task>> = Pager(
    config = PagingConfig(
        pageSize = 20,
        prefetchDistance = 5,
        enablePlaceholders = false,
    ),
    pagingSourceFactory = { taskDao.pagingSource() }
).flow
    .map { pagingData -> pagingData.map(mapper::toDomain) }
    .cachedIn(viewModelScope)

// In Compose
@Composable
fun TaskListPaged(tasks: LazyPagingItems<Task>) {
    LazyColumn {
        items(
            count = tasks.itemCount,
            key = tasks.itemKey { it.id },
        ) { index ->
            val task = tasks[index]
            if (task != null) {
                TaskItem(task = task)
            }
        }

        // Loading states
        when (tasks.loadState.refresh) {
            is LoadState.Loading -> item { LoadingIndicator() }
            is LoadState.Error -> item { ErrorRetry(onRetry = { tasks.retry() }) }
            else -> {}
        }
    }
}

Step 6: Data Mappers

  1. Separate data models per layer:
// Entity (data layer) — database schema
data class TaskEntity(val id: String, val title: String, ...)

// Domain model (domain layer) — business logic
data class Task(val id: String, val title: String, ...)

// Network model (data layer) — API contract
@Serializable
data class TaskResponse(val id: String, val title: String, ...)

// Mapper
class TaskMapper {
    fun toDomain(entity: TaskEntity): Task = Task(
        id = entity.id,
        title = entity.title,
    )
    fun toEntity(domain: Task): TaskEntity = TaskEntity(
        id = domain.id,
        title = domain.title,
    )
}

Common Rationalizations

ShortcutWhy It Fails
"fallbackToDestructiveMigration is fine for now"Users lose their data. Write proper migrations from day one.
"SharedPreferences works fine"SharedPreferences isn't type-safe, isn't coroutine-friendly, and has known bugs with apply().
"We don't need offline support"Users on subways, elevators, and airplanes disagree. Cache early.
"One model for all layers is simpler"Coupling DB schema to UI makes both harder to change.
"Paging is overkill"Loading 10,000 items into memory causes OOM. Paginate datasets over ~100 items.

Red Flags

  • fallbackToDestructiveMigration() in production code
  • SharedPreferences in new code
  • No migration tests
  • Room entities used directly in UI layer
  • suspend functions where Flow should be used (for observable data)
  • No error handling in repository sync operations
  • Loading entire dataset into memory without pagination

Verification

  • Room entities, DAOs, and database defined correctly
  • exportSchema = true set on @Database
  • Migrations written and tested for every schema change
  • DataStore used for preferences (not SharedPreferences)
  • Repository pattern abstracts data sources
  • Offline-first: local DB is single source of truth
  • Paging3 used for large datasets
  • Separate data models per layer (Entity, Domain, Network)
  • ./gradlew test and ./gradlew connectedAndroidTest pass

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.