← Back to all posts

10 Xpense

Migrating to Room

Xpense Dev Retrospective


Room, Briefly

Room is Android's own layer on top of SQLite. Instead of writing raw SQL by hand and manually mapping result columns back into Kotlin objects, Room lets the data model be declared as plain Kotlin classes and generates the actual database code underneath at compile time, using KSP, as covered in the Gradle post.


The Three Pieces

An Entity (Expense, Category) is a Kotlin data class describing one row. A DAO (ExpenseDao, CategoryDao) declares the actual queries allowed against that table. A Repository (ExpenseRepository, CategoryRepository) sits between Activities and the DAO, so an Activity never talks to Room directly.


The Problem: Real Users Already Had Real Data

Shipping Room wasn't just "add a database and start using it." Anyone who already had Xpense installed had real expenses sitting in expenses.json and real categories in SharedPreferences. The update had to carry all of that into Room automatically, the very first time the new version launched, with the person never noticing it happened.


LegacyMigration: The Actual Bridge

data/LegacyMigration.kt
object LegacyMigration {

    private const val KEY_MIGRATED = "migrated_to_room_v1"

    fun migrateIfNeeded(context: Context, db: AppDatabase) {
        val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
        if (prefs.getBoolean(KEY_MIGRATED, false)) return

        // read expenses.json, parse it, insert into Room
        // read the legacy categories key, parse it, insert into Room

        prefs.edit().putBoolean(KEY_MIGRATED, true).apply()
    }
}

The whole thing hinges on one flag, migrated_to_room_v1, checked at the very top. If it's already true, the function returns instantly and does nothing. This is what makes it safe to call unconditionally on every single app launch, it only ever does real work exactly once per install.

Where it gets called from: once, early, before any screen tries to read expense data, right after AppDatabase.getInstance(this) is obtained. That ordering matters, a screen reading from Room before migration has run would just see an empty database and assume the user has no expenses at all.


insertIgnoringConflicts: The Detail That Makes This Safe to Retry

Every legacy expense gets inserted with a DAO method specifically built for this, not a plain insert:

LegacyMigration.kt
legacyExpenses?.forEach { db.expenseDao().insertIgnoringConflicts(it) }

A regular insert throws if a row with the same primary key already exists. insertIgnoringConflicts just silently skips that row instead. That single choice is what makes it safe if migration somehow runs twice, or partially completes and retries, existing rows just get ignored on the second pass rather than causing a crash or duplicate entries.


Preserving Onboarding State Correctly

LegacyMigration.kt
val editor = prefs.edit().putBoolean(KEY_MIGRATED, true)
if (hadLegacyData) editor.putBoolean(SettingsManager.KEY_ONBOARDING_COMPLETE, true)
editor.apply()

Why this line specifically exists: someone who already used v1.x had already been through onboarding once, back when onboarding didn't even exist as a concept yet. Without this check, every existing user upgrading to v2.0.0 would get shown the brand new onboarding flow on their very next launch, despite already being an established user with months of expense history. hadLegacyData is what tells the difference between an upgrade and a genuinely fresh install.


Closing

This is what shipped. It's also not the whole story, the version of this file that shipped first didn't have the conflict-safe insert or the try/catch around the whole block, and that gap almost cost real users their data. That's the next post.

← Previous 09 - The Old Storage System Next → 11 - The Bug That Almost Wiped Real User Data