/* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package androidx.room; import android.content.Context; import android.os.Build; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.room.util.CopyLock; import androidx.room.util.DBUtil; import androidx.room.util.FileUtil; import androidx.sqlite.db.SupportSQLiteDatabase; import androidx.sqlite.db.SupportSQLiteOpenHelper; import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.channels.Channels; import java.nio.channels.FileChannel; import java.nio.channels.ReadableByteChannel; import java.util.concurrent.Callable; /** * An open helper that will copy & open a pre-populated database if it doesn't exists in internal * storage. */ class SQLiteCopyOpenHelper implements SupportSQLiteOpenHelper, DelegatingOpenHelper { @NonNull private final Context mContext; @Nullable private final String mCopyFromAssetPath; @Nullable private final File mCopyFromFile; @Nullable private final Callable mCopyFromInputStream; private final int mDatabaseVersion; @NonNull private final SupportSQLiteOpenHelper mDelegate; @Nullable private DatabaseConfiguration mDatabaseConfiguration; private boolean mVerified; SQLiteCopyOpenHelper( @NonNull Context context, @Nullable String copyFromAssetPath, @Nullable File copyFromFile, @Nullable Callable copyFromInputStream, int databaseVersion, @NonNull SupportSQLiteOpenHelper supportSQLiteOpenHelper) { mContext = context; mCopyFromAssetPath = copyFromAssetPath; mCopyFromFile = copyFromFile; mCopyFromInputStream = copyFromInputStream; mDatabaseVersion = databaseVersion; mDelegate = supportSQLiteOpenHelper; } @Override public String getDatabaseName() { return mDelegate.getDatabaseName(); } @Override @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN) public void setWriteAheadLoggingEnabled(boolean enabled) { mDelegate.setWriteAheadLoggingEnabled(enabled); } @Override public synchronized SupportSQLiteDatabase getWritableDatabase() { if (!mVerified) { verifyDatabaseFile(true); mVerified = true; } return mDelegate.getWritableDatabase(); } @Override public synchronized SupportSQLiteDatabase getReadableDatabase() { if (!mVerified) { verifyDatabaseFile(false); mVerified = true; } return mDelegate.getReadableDatabase(); } @Override public synchronized void close() { mDelegate.close(); mVerified = false; } @Override @NonNull public SupportSQLiteOpenHelper getDelegate() { return mDelegate; } // Can't be constructor param because the factory is needed by the database builder which in // turn is the one that actually builds the configuration. void setDatabaseConfiguration(@Nullable DatabaseConfiguration databaseConfiguration) { mDatabaseConfiguration = databaseConfiguration; } private void verifyDatabaseFile(boolean writable) { String databaseName = getDatabaseName(); File databaseFile = mContext.getDatabasePath(databaseName); boolean processLevelLock = mDatabaseConfiguration == null || mDatabaseConfiguration.multiInstanceInvalidation; CopyLock copyLock = new CopyLock(databaseName, mContext.getFilesDir(), processLevelLock); try { // Acquire a copy lock, this lock works across threads and processes, preventing // concurrent copy attempts from occurring. copyLock.lock(); if (!databaseFile.exists()) { try { // No database file found, copy and be done. copyDatabaseFile(databaseFile, writable); return; } catch (IOException e) { throw new RuntimeException("Unable to copy database file.", e); } } if (mDatabaseConfiguration == null) { return; } // A database file is present, check if we need to re-copy it. int currentVersion; try { currentVersion = DBUtil.readVersion(databaseFile); } catch (IOException e) { Log.w(Room.LOG_TAG, "Unable to read database version.", e); return; } if (currentVersion == mDatabaseVersion) { return; } if (mDatabaseConfiguration.isMigrationRequired(currentVersion, mDatabaseVersion)) { // From the current version to the desired version a migration is required, i.e. // we won't be performing a copy destructive migration. return; } if (mContext.deleteDatabase(databaseName)) { try { copyDatabaseFile(databaseFile, writable); } catch (IOException e) { // We are more forgiving copying a database on a destructive migration since // there is already a database file that can be opened. Log.w(Room.LOG_TAG, "Unable to copy database file.", e); } } else { Log.w(Room.LOG_TAG, "Failed to delete database file (" + databaseName + ") for a copy destructive migration."); } } finally { copyLock.unlock(); } } private void copyDatabaseFile(File destinationFile, boolean writable) throws IOException { ReadableByteChannel input; if (mCopyFromAssetPath != null) { input = Channels.newChannel(mContext.getAssets().open(mCopyFromAssetPath)); } else if (mCopyFromFile != null) { input = new FileInputStream(mCopyFromFile).getChannel(); } else if (mCopyFromInputStream != null) { final InputStream inputStream; try { inputStream = mCopyFromInputStream.call(); } catch (Exception e) { throw new IOException("inputStreamCallable exception on call", e); } input = Channels.newChannel(inputStream); } else { throw new IllegalStateException("copyFromAssetPath, copyFromFile and " + "copyFromInputStream are all null!"); } // An intermediate file is used so that we never end up with a half-copied database file // in the internal directory. //File intermediateFile = File.createTempFile( // "room-copy-helper", ".tmp", mContext.getCacheDir()); File dir = new File(mContext.getFilesDir(), "room"); if (!dir.exists() && !dir.mkdirs()) throw new IOException("Failed to create=" + dir); File intermediateFile = new File(dir, "room-copy-helper.tmp"); intermediateFile.deleteOnExit(); FileChannel output = new FileOutputStream(intermediateFile).getChannel(); FileUtil.copy(input, output); File parent = destinationFile.getParentFile(); if (parent != null && !parent.exists() && !parent.mkdirs()) { throw new IOException("Failed to create directories for " + destinationFile.getAbsolutePath()); } // Temporarily open intermediate database file using FrameworkSQLiteOpenHelper and dispatch // the open pre-packaged callback. If it fails then intermediate file won't be copied making // invoking pre-packaged callback a transactional operation. dispatchOnOpenPrepackagedDatabase(intermediateFile, writable); if (!intermediateFile.renameTo(destinationFile)) { throw new IOException("Failed to move intermediate file (" + intermediateFile.getAbsolutePath() + ") to destination (" + destinationFile.getAbsolutePath() + ")."); } } private void dispatchOnOpenPrepackagedDatabase(File databaseFile, boolean writable) { if (mDatabaseConfiguration == null || mDatabaseConfiguration.prepackagedDatabaseCallback == null) { return; } SupportSQLiteOpenHelper helper = createFrameworkOpenHelper(databaseFile); try { SupportSQLiteDatabase db = writable ? helper.getWritableDatabase() : helper.getReadableDatabase(); mDatabaseConfiguration.prepackagedDatabaseCallback.onOpenPrepackagedDatabase(db); } finally { // Close the db and let Room re-open it through a normal path helper.close(); } } private SupportSQLiteOpenHelper createFrameworkOpenHelper(File databaseFile) { final int version; try { version = DBUtil.readVersion(databaseFile); } catch (IOException e) { throw new RuntimeException("Malformed database file, unable to read version.", e); } FrameworkSQLiteOpenHelperFactory factory = new FrameworkSQLiteOpenHelperFactory(); Configuration configuration = Configuration.builder(mContext) .name(databaseFile.getAbsolutePath()) .callback(new Callback(Math.max(version, 1)) { @Override public void onCreate(@NonNull SupportSQLiteDatabase db) { } @Override public void onUpgrade(@NonNull SupportSQLiteDatabase db, int oldVersion, int newVersion) { } @Override public void onOpen(@NonNull SupportSQLiteDatabase db) { // If pre-packaged database has a version < 1 we will open it as if it was // version 1 because the framework open helper does not allow version < 1. // The database will be considered as newly created and onCreate() will be // invoked, but we do nothing and reset the version back so Room later runs // migrations as usual. if (version < 1) { db.setVersion(version); } } }) .build(); return factory.create(configuration); } }