Search code examples
androidandroid-sqliteandroid-roomdagger-hiltandroid-room-prepackageddatabase

Room create from asset + fallbackToDestructiveMigration recreating the database on each request


Using Room, I load my database from assets (an already populated database).

I do plan to update the database content very often, so every time the version is increased, the old database the user has installed in their storage must be removed and generated again (by simply copying the new database into the storage).

This process is supposed to be done automatically by Room when fallbackToDestructiveMigration() is enabled. However, I've found that when enabling fallbackToDestructiveMigration() along with createFromAsset(), every time the database is requested, Room deletes the old database even if it's version is already the latest and generates it again as It would do if a migration was required.

@Module
@InstallIn(SingletonComponent.class)
public class TestRoomModule {

    private static final String DATABASE_NAME = "test.db";

    @Provides
    public TestDatabase provideDatabase(@ApplicationContext Context context) {
        return Room.databaseBuilder(
                        context.getApplicationContext(),
                        TestDatabase.class,
                        DATABASE_NAME)
                .createFromAsset("databases/test.db")
                .fallbackToDestructiveMigration()
                .allowMainThreadQueries()
                .build();
    }
}

Like : this issue mentioned here

The solution proposed by the previous post is simply remove "fallbackToDestructiveMigration" but this is not a solution just a workaround since If I update my Database and my Database version, the app will throw an exception since no migration were provided.

@Database(entities = {
    version = 1)
public abstract class TestDatabase extends RoomDatabase {

Updating to version 2:

@Database(entities = {
    version = 2)
public abstract class TestDatabase extends RoomDatabase {

Will throw: A migration from 1 to 2 was required but not found. Please provide the necessary Migration path via RoomDatabase

How can I fix this so the fallbackToDestructiveMigration() is triggered ONLY when the database version has actually changed?


Solution

  • The solution proposed by the previous post is simply remove "fallbackToDestructiveMigration" but this is not a solution just a workaround since If I update my Database and my Database version, the app will throw an exception since no migration were provided.

    Removing fallbackToDestructiveMigration will solve the problem of rewriting/recreating the database on each request.

    But now you won't be able to do a database migration in the next release because you don't specify a migration plan with addMigrations(), otherwise you'll get IllegalStateException.

    Trying to catch that exception will fail as Room won't allow that, something like:

    @Provides
    public TestDatabase provideDatabase(@ApplicationContext Context context) {
        
        TestDatabase database;
        
        try {
            database = Room.databaseBuilder(
                        context.getApplicationContext(),
                        TestDatabase.class,
                        DATABASE_NAME)
                .createFromAsset("databases/test.db")
                .allowMainThreadQueries()
                .build();
    
        } catch (IllegalStateException e) {
            database = Room.databaseBuilder(
                        context.getApplicationContext(),
                        TestDatabase.class,
                        DATABASE_NAME)
                .createFromAsset("databases/test.db")
                .fallbackToDestructiveMigration()
                .allowMainThreadQueries()
                .build();
        }
        
        return database;
    
    }
    

    So, as you don't have migration plans with addMigrations(), the only way is to manipulate using the fallbackToDestructiveMigration by some flag, so we call fallbackToDestructiveMigration conditionally upon the need.

    A possible solution is to control that with two integers, one indicates the current database version, and the other indicates the new database version. Either SharedPrefs or DataStore are typical tools for that:

    Assuming the current database version is 1, and in the new version is 2:

    Create SharedPrefs helper methods:

    static String SHARED_PREFS_NAME = "SHARED_PREFS_NAME";
    static String CURRENT_DB_VERSION = "CURRENT_DB_VERSION";
    static String NEW_DB_VERSION = "NEW_DB_VERSION";
    
    public static int getCurrentDBVersion(Context context) {
        SharedPreferences prefs = context.getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
        return prefs.getInt(CURRENT_DB_VERSION , 1);
    }
    
    public static void setCurrentDBVersion(Context context, int version) {
        SharedPreferences prefs = context.getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
        SharedPreferences.Editor editor = prefs.edit();
        editor.putInt(CURRENT_DB_VERSION , version);
        editor.apply();
    }
    
    public static int getNewDBVersion(Context context) {
        SharedPreferences prefs = context.getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
        return prefs.getInt(NEW_DB_VERSION, 1);
    }
    
    public static void setNewDBVersion(Context context, int version) {
        SharedPreferences prefs = context.getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
        SharedPreferences.Editor editor = prefs.edit();
        editor.putInt(NEW_DB_VERSION, version);
        editor.apply();
    }
    

    Then check the conditional in the Database class:

    @Database(entities = {...},
        version = 2)
    public abstract class TestDatabase extends RoomDatabase {
    
        @Provides
        public TestDatabase provideDatabase(@ApplicationContext Context context) {
    
            TestDatabase database;
    
            int newDBVersion = getNewDBVersion(context);
            int currentDBVersion = getNewDBVersion(context);
    
            if (newDBVersion == currentDBVersion) { // No migration required
                database = Room.databaseBuilder(
                            context.getApplicationContext(),
                            TestDatabase.class,
                            DATABASE_NAME)
                    .createFromAsset("databases/test.db")
                    .allowMainThreadQueries()
                    .build();
                    
            } else { // Migration is needed (call fallbackToDestructiveMigration)
                database = Room.databaseBuilder(
                            context.getApplicationContext(),
                            TestDatabase.class,
                            DATABASE_NAME)
                    .createFromAsset("databases/test.db")
                    .fallbackToDestructiveMigration()
                    .allowMainThreadQueries()
                    .build();
                
                // set the current version to the new version as the migration is done.
                setCurrentDBVersion(context, newDBVersion);
    
            }
    
            return database;
        }
    }   
    

    And whenever you have a release that requires a database migration, you have to set the new database version to the new version, it'd be in the activity onCreate() callback:

     setNewDBVersion(this, 2);
    

    Make sure to set that before accessing the database.

    If there is no migration required in some release, keep the setNewDBVersion(this, 1) with the current release.


    Another Approach for conditional calling of the fallbackToDestructiveMigration()

    Instead of maintaining database versions in some storage (SharedPrefs/DataStore), we would check the database version:

    Quote from this answer

    A changed (increased) USER_VERSION can be detected by just accessing the files (the current database and the asset) and reading the first 100 bytes and then extracting the 4 bytes at offset 60 (68 for the Application Id).

    And that answer referenced the below method for that purpose:

    private static boolean isNewAsset(Context context, String asset, String dbname) {
        File current_Db = context.getDatabasePath(dbname);
        if(!current_Db.exists()) return false; /* No Database then nothing to do */
        int current_Db_version = getDBVersion(current_Db);
        Log.d("DBINFO","isNewAsset has determined that the current database version is " + current_Db_version);
        if (current_Db_version < 0) return false; /* No valid version */
        int asssetVersion = getAssetVersion(context,asset);
        Log.d("DBINFO","isNewAsset has determined that the asset version is " + asssetVersion);
        if (asssetVersion > current_Db_version) {
            Log.d("DBINFO","isNewAsset has found that the asset version is greater than the current db version " + current_Db_version);
            return true;
        } else {
            Log.d("DBINFO","isNewAsset has found that the asset version is unchanged " + current_Db_version);
        }
        return false;
    }
    

    Side Notes:

    • allowMainThreadQueries() should not be used in a production app, just can be used for testing and simplicity purpose.

    • Personally, I'd prefer to add database migrations instead of using fallbackToDestructiveMigration() even for prepopulated databases; probably as the later is destructive :).

    If there are no schema (tables/columns) changes; i.e., just need to update the database entries (rows); we can just have an empty migration plan, and just delete the database on any upgrade with deleteDatabase() on Context to refresh the database in the assets:

    @Database(entities = {...},
        version = 2)
    public abstract class TestDatabase extends RoomDatabase {
    
        @Provides
        public TestDatabase provideDatabase(@ApplicationContext Context context) {
    
            int newDBVersion = getNewDBVersion(context);
            int currentDBVersion = getNewDBVersion(context);
    
            if (newDBVersion != currentDBVersion) { // Migration required
                
                // delete the database to refresh it on the assets and remove any cached version
                context.deleteDatabase("databases/test.db");
                
                // set the current version to the new version as the migration is done.
                setCurrentDBVersion(context, newDBVersion);
            }
            
            return Room.databaseBuilder(
                            context.getApplicationContext(),
                            TestDatabase.class,
                            DATABASE_NAME)
                    .createFromAsset("databases/test.db")
                    .allowMainThreadQueries() 
                    .addMigrations(migration_v1_v2)
                    .build();
        }
    
        // Empty Migration plan
        static Migration migration_v1_v2 = new Migration(1, 2) {
            @Override
            public void migrate(@NonNull SupportSQLiteDatabase database) {
                Log.d("TAG","Empty Migration plan for data insertion, deletion, or update - Migration from V1 to V2");
            }
        };
    }