diff --git a/app/build.gradle b/app/build.gradle index 2fc147223d..2ef95f5970 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -40,6 +40,11 @@ android { abortOnError false } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + packagingOptions { exclude 'META-INF/LICENSE.txt' exclude 'META-INF/README.md' @@ -132,6 +137,10 @@ repositories { } configurations.all { + // Workaround https://issuetracker.google.com/issues/138441698 + // Support @69c481c39a17d4e1e44a4eb298bb81c48f226eef + exclude group: "androidx.room", module: "room-runtime" + // Workaround https://issuetracker.google.com/issues/134685570 exclude group: "androidx.lifecycle", module: "lifecycle-livedata" } @@ -195,6 +204,9 @@ dependencies { // https://mvnrepository.com/artifact/androidx.room/room-runtime implementation "androidx.room:room-runtime:$room_version" + implementation "androidx.room:room-common:$room_version" // because of exclude + // https://mvnrepository.com/artifact/androidx.sqlite/sqlite-framework + implementation "androidx.sqlite:sqlite-framework:2.0.1" // because of exclude annotationProcessor "androidx.room:room-compiler:$room_version" // https://mvnrepository.com/artifact/androidx.paging/paging-runtime diff --git a/app/src/main/aidl/androidx/room/IMultiInstanceInvalidationCallback.aidl b/app/src/main/aidl/androidx/room/IMultiInstanceInvalidationCallback.aidl new file mode 100644 index 0000000000..7c702fff1b --- /dev/null +++ b/app/src/main/aidl/androidx/room/IMultiInstanceInvalidationCallback.aidl @@ -0,0 +1,33 @@ +/* + * Copyright 2018 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; + +/** + * RPC Callbacks for {@link IMultiInstanceInvalidationService}. + * + * @hide + */ +interface IMultiInstanceInvalidationCallback { + + /** + * Called when invalidation is detected in another instance of the same database. + * + * @param tables List of invalidated table names + */ + oneway void onInvalidation(in String[] tables); + +} diff --git a/app/src/main/aidl/androidx/room/IMultiInstanceInvalidationService.aidl b/app/src/main/aidl/androidx/room/IMultiInstanceInvalidationService.aidl new file mode 100644 index 0000000000..3b2c18c5ab --- /dev/null +++ b/app/src/main/aidl/androidx/room/IMultiInstanceInvalidationService.aidl @@ -0,0 +1,61 @@ +/* + * Copyright 2018 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 androidx.room.IMultiInstanceInvalidationCallback; + +/** + * RPC Service that controls interaction about multi-instance invalidation. + * + * @hide + */ +interface IMultiInstanceInvalidationService { + + /** + * Registers a new {@link IMultiInstanceInvalidationCallback} as a client of this service. + * + * @param callback The RPC callback. + * @param name The name of the database file as it is passed to {@link RoomDatabase.Builder}. + * @return A new client ID. The client needs to hold on to this ID and pass it to the service + * for subsequent calls. + */ + int registerCallback(IMultiInstanceInvalidationCallback callback, String name); + + /** + * Unregisters the specified {@link IMultiInstanceInvalidationCallback} from this service. + *

+ * Clients might die without explicitly calling this method. In that case, the service should + * handle the clean up. + * + * @param callback The RPC callback. + * @param clientId The client ID returned from {@link #registerCallback}. + */ + void unregisterCallback(IMultiInstanceInvalidationCallback callback, int clientId); + + /** + * Broadcasts invalidation of database tables to other clients registered to this service. + *

+ * The broadcast is delivered to {@link IMultiInstanceInvalidationCallback#onInvalidation} of + * the registered clients. The client calling this method will not receive its own broadcast. + * Clients that are associated with a different database file will not be notified. + * + * @param clientId The client ID returned from {@link #registerCallback}. + * @param tables The names of invalidated tables. + */ + oneway void broadcastInvalidation(int clientId, in String[] tables); + +} diff --git a/app/src/main/java/androidx/room/DatabaseConfiguration.java b/app/src/main/java/androidx/room/DatabaseConfiguration.java new file mode 100644 index 0000000000..f994445016 --- /dev/null +++ b/app/src/main/java/androidx/room/DatabaseConfiguration.java @@ -0,0 +1,294 @@ +/* + * Copyright (C) 2016 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 androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import androidx.sqlite.db.SupportSQLiteOpenHelper; + +import java.io.File; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executor; + +/** + * Configuration class for a {@link RoomDatabase}. + */ +@SuppressWarnings("WeakerAccess") +public class DatabaseConfiguration { + + /** + * The factory to use to access the database. + */ + @NonNull + public final SupportSQLiteOpenHelper.Factory sqliteOpenHelperFactory; + /** + * The context to use while connecting to the database. + */ + @NonNull + public final Context context; + /** + * The name of the database file or null if it is an in-memory database. + */ + @Nullable + public final String name; + + /** + * Collection of available migrations. + */ + @NonNull + public final RoomDatabase.MigrationContainer migrationContainer; + + @Nullable + public final List callbacks; + + /** + * Whether Room should throw an exception for queries run on the main thread. + */ + public final boolean allowMainThreadQueries; + + /** + * The journal mode for this database. + */ + public final RoomDatabase.JournalMode journalMode; + + /** + * The Executor used to execute asynchronous queries. + */ + @NonNull + public final Executor queryExecutor; + + /** + * The Executor used to execute asynchronous transactions. + */ + @NonNull + public final Executor transactionExecutor; + + /** + * If true, table invalidation in an instance of {@link RoomDatabase} is broadcast and + * synchronized with other instances of the same {@link RoomDatabase} file, including those + * in a separate process. + */ + public final boolean multiInstanceInvalidation; + + /** + * If true, Room should crash if a migration is missing. + */ + public final boolean requireMigration; + + /** + * If true, Room should perform a destructive migration when downgrading without an available + * migration. + */ + public final boolean allowDestructiveMigrationOnDowngrade; + + /** + * The collection of schema versions from which migrations aren't required. + */ + private final Set mMigrationNotRequiredFrom; + + /** + * The assets path to a pre-packaged database to copy from. + */ + @Nullable + public final String copyFromAssetPath; + + /** + * The pre-packaged database file to copy from. + */ + @Nullable + public final File copyFromFile; + + + /** + * Creates a database configuration with the given values. + * + * @deprecated Use {@link #DatabaseConfiguration(Context, String, + * SupportSQLiteOpenHelper.Factory, RoomDatabase.MigrationContainer, List, boolean, + * RoomDatabase.JournalMode, Executor, Executor, boolean, boolean, boolean, Set, String, File)} + * + * @param context The application context. + * @param name Name of the database, can be null if it is in memory. + * @param sqliteOpenHelperFactory The open helper factory to use. + * @param migrationContainer The migration container for migrations. + * @param callbacks The list of callbacks for database events. + * @param allowMainThreadQueries Whether to allow main thread reads/writes or not. + * @param journalMode The journal mode. This has to be either TRUNCATE or WRITE_AHEAD_LOGGING. + * @param queryExecutor The Executor used to execute asynchronous queries. + * @param requireMigration True if Room should require a valid migration if version changes, + * instead of recreating the tables. + * @param migrationNotRequiredFrom The collection of schema versions from which migrations + * aren't required. + * + * @hide + */ + @Deprecated + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + public DatabaseConfiguration(@NonNull Context context, @Nullable String name, + @NonNull SupportSQLiteOpenHelper.Factory sqliteOpenHelperFactory, + @NonNull RoomDatabase.MigrationContainer migrationContainer, + @Nullable List callbacks, + boolean allowMainThreadQueries, + RoomDatabase.JournalMode journalMode, + @NonNull Executor queryExecutor, + boolean requireMigration, + @Nullable Set migrationNotRequiredFrom) { + this(context, name, sqliteOpenHelperFactory, migrationContainer, callbacks, + allowMainThreadQueries, journalMode, queryExecutor, queryExecutor, false, + requireMigration, false, migrationNotRequiredFrom, null, null); + } + + /** + * Creates a database configuration with the given values. + * + * @deprecated Use {@link #DatabaseConfiguration(Context, String, + * SupportSQLiteOpenHelper.Factory, RoomDatabase.MigrationContainer, List, boolean, + * RoomDatabase.JournalMode, Executor, Executor, boolean, boolean, boolean, Set, String, File)} + * + * @param context The application context. + * @param name Name of the database, can be null if it is in memory. + * @param sqliteOpenHelperFactory The open helper factory to use. + * @param migrationContainer The migration container for migrations. + * @param callbacks The list of callbacks for database events. + * @param allowMainThreadQueries Whether to allow main thread reads/writes or not. + * @param journalMode The journal mode. This has to be either TRUNCATE or WRITE_AHEAD_LOGGING. + * @param queryExecutor The Executor used to execute asynchronous queries. + * @param transactionExecutor The Executor used to execute asynchronous transactions. + * @param multiInstanceInvalidation True if Room should perform multi-instance invalidation. + * @param requireMigration True if Room should require a valid migration if version changes, + * @param allowDestructiveMigrationOnDowngrade True if Room should recreate tables if no + * migration is supplied during a downgrade. + * @param migrationNotRequiredFrom The collection of schema versions from which migrations + * aren't required. + * + * @hide + */ + @Deprecated + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + public DatabaseConfiguration(@NonNull Context context, @Nullable String name, + @NonNull SupportSQLiteOpenHelper.Factory sqliteOpenHelperFactory, + @NonNull RoomDatabase.MigrationContainer migrationContainer, + @Nullable List callbacks, + boolean allowMainThreadQueries, + RoomDatabase.JournalMode journalMode, + @NonNull Executor queryExecutor, + @NonNull Executor transactionExecutor, + boolean multiInstanceInvalidation, + boolean requireMigration, + boolean allowDestructiveMigrationOnDowngrade, + @Nullable Set migrationNotRequiredFrom) { + this(context, name, sqliteOpenHelperFactory, migrationContainer, callbacks, + allowMainThreadQueries, journalMode, queryExecutor, transactionExecutor, + multiInstanceInvalidation, requireMigration, allowDestructiveMigrationOnDowngrade, + migrationNotRequiredFrom, null, null); + } + + /** + * Creates a database configuration with the given values. + * + * @param context The application context. + * @param name Name of the database, can be null if it is in memory. + * @param sqliteOpenHelperFactory The open helper factory to use. + * @param migrationContainer The migration container for migrations. + * @param callbacks The list of callbacks for database events. + * @param allowMainThreadQueries Whether to allow main thread reads/writes or not. + * @param journalMode The journal mode. This has to be either TRUNCATE or WRITE_AHEAD_LOGGING. + * @param queryExecutor The Executor used to execute asynchronous queries. + * @param transactionExecutor The Executor used to execute asynchronous transactions. + * @param multiInstanceInvalidation True if Room should perform multi-instance invalidation. + * @param requireMigration True if Room should require a valid migration if version changes, + * @param allowDestructiveMigrationOnDowngrade True if Room should recreate tables if no + * migration is supplied during a downgrade. + * @param migrationNotRequiredFrom The collection of schema versions from which migrations + * aren't required. + * @param copyFromAssetPath The assets path to the pre-packaged database. + * @param copyFromFile The pre-packaged database file. + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + public DatabaseConfiguration(@NonNull Context context, @Nullable String name, + @NonNull SupportSQLiteOpenHelper.Factory sqliteOpenHelperFactory, + @NonNull RoomDatabase.MigrationContainer migrationContainer, + @Nullable List callbacks, + boolean allowMainThreadQueries, + RoomDatabase.JournalMode journalMode, + @NonNull Executor queryExecutor, + @NonNull Executor transactionExecutor, + boolean multiInstanceInvalidation, + boolean requireMigration, + boolean allowDestructiveMigrationOnDowngrade, + @Nullable Set migrationNotRequiredFrom, + @Nullable String copyFromAssetPath, + @Nullable File copyFromFile) { + this.sqliteOpenHelperFactory = sqliteOpenHelperFactory; + this.context = context; + this.name = name; + this.migrationContainer = migrationContainer; + this.callbacks = callbacks; + this.allowMainThreadQueries = allowMainThreadQueries; + this.journalMode = journalMode; + this.queryExecutor = queryExecutor; + this.transactionExecutor = transactionExecutor; + this.multiInstanceInvalidation = multiInstanceInvalidation; + this.requireMigration = requireMigration; + this.allowDestructiveMigrationOnDowngrade = allowDestructiveMigrationOnDowngrade; + this.mMigrationNotRequiredFrom = migrationNotRequiredFrom; + this.copyFromAssetPath = copyFromAssetPath; + this.copyFromFile = copyFromFile; + } + + /** + * Returns whether a migration is required from the specified version. + * + * @param version The schema version. + * @return True if a valid migration is required, false otherwise. + * + * @deprecated Use {@link #isMigrationRequired(int, int)} which takes + * {@link #allowDestructiveMigrationOnDowngrade} into account. + */ + @Deprecated + public boolean isMigrationRequiredFrom(int version) { + return isMigrationRequired(version, version + 1); + } + + /** + * Returns whether a migration is required between two versions. + * + * @param fromVersion The old schema version. + * @param toVersion The new schema version. + * @return True if a valid migration is required, false otherwise. + */ + public boolean isMigrationRequired(int fromVersion, int toVersion) { + // Migrations are not required if its a downgrade AND destructive migration during downgrade + // has been allowed. + final boolean isDowngrade = fromVersion > toVersion; + if (isDowngrade && allowDestructiveMigrationOnDowngrade) { + return false; + } + + // Migrations are required between the two versions if we generally require migrations + // AND EITHER there are no exceptions OR the supplied fromVersion is not one of the + // exceptions. + return requireMigration + && (mMigrationNotRequiredFrom == null + || !mMigrationNotRequiredFrom.contains(fromVersion)); + } +} diff --git a/app/src/main/java/androidx/room/EntityDeletionOrUpdateAdapter.java b/app/src/main/java/androidx/room/EntityDeletionOrUpdateAdapter.java new file mode 100644 index 0000000000..154103cfcb --- /dev/null +++ b/app/src/main/java/androidx/room/EntityDeletionOrUpdateAdapter.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2016 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 androidx.annotation.RestrictTo; +import androidx.sqlite.db.SupportSQLiteStatement; + +/** + * Implementations of this class knows how to delete or update a particular entity. + *

+ * This is an internal library class and all of its implementations are auto-generated. + * + * @param The type parameter of the entity to be deleted + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +@SuppressWarnings({"WeakerAccess", "unused"}) +public abstract class EntityDeletionOrUpdateAdapter extends SharedSQLiteStatement { + /** + * Creates a DeletionOrUpdateAdapter that can delete or update the entity type T on the given + * database. + * + * @param database The database to delete / update the item in. + */ + public EntityDeletionOrUpdateAdapter(RoomDatabase database) { + super(database); + } + + /** + * Create the deletion or update query + * + * @return An SQL query that can delete or update instances of T. + */ + @Override + protected abstract String createQuery(); + + /** + * Binds the entity into the given statement. + * + * @param statement The SQLite statement that prepared for the query returned from + * createQuery. + * @param entity The entity of type T. + */ + protected abstract void bind(SupportSQLiteStatement statement, T entity); + + /** + * Deletes or updates the given entities in the database and returns the affected row count. + * + * @param entity The entity to delete or update + * @return The number of affected rows + */ + public final int handle(T entity) { + final SupportSQLiteStatement stmt = acquire(); + try { + bind(stmt, entity); + return stmt.executeUpdateDelete(); + } finally { + release(stmt); + } + } + + /** + * Deletes or updates the given entities in the database and returns the affected row count. + * + * @param entities Entities to delete or update + * @return The number of affected rows + */ + public final int handleMultiple(Iterable entities) { + final SupportSQLiteStatement stmt = acquire(); + try { + int total = 0; + for (T entity : entities) { + bind(stmt, entity); + total += stmt.executeUpdateDelete(); + } + return total; + } finally { + release(stmt); + } + } + + /** + * Deletes or updates the given entities in the database and returns the affected row count. + * + * @param entities Entities to delete or update + * @return The number of affected rows + */ + public final int handleMultiple(T[] entities) { + final SupportSQLiteStatement stmt = acquire(); + try { + int total = 0; + for (T entity : entities) { + bind(stmt, entity); + total += stmt.executeUpdateDelete(); + } + return total; + } finally { + release(stmt); + } + } +} diff --git a/app/src/main/java/androidx/room/EntityInsertionAdapter.java b/app/src/main/java/androidx/room/EntityInsertionAdapter.java new file mode 100644 index 0000000000..3046d6cd0b --- /dev/null +++ b/app/src/main/java/androidx/room/EntityInsertionAdapter.java @@ -0,0 +1,251 @@ +/* + * Copyright (C) 2016 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 androidx.annotation.RestrictTo; +import androidx.sqlite.db.SupportSQLiteStatement; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * Implementations of this class knows how to insert a particular entity. + *

+ * This is an internal library class and all of its implementations are auto-generated. + * + * @param The type parameter of the entity to be inserted + * @hide + */ +@SuppressWarnings({"WeakerAccess", "unused"}) +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +public abstract class EntityInsertionAdapter extends SharedSQLiteStatement { + /** + * Creates an InsertionAdapter that can insert the entity type T into the given database. + * + * @param database The database to insert into. + */ + public EntityInsertionAdapter(RoomDatabase database) { + super(database); + } + + /** + * Binds the entity into the given statement. + * + * @param statement The SQLite statement that prepared for the query returned from + * createInsertQuery. + * @param entity The entity of type T. + */ + protected abstract void bind(SupportSQLiteStatement statement, T entity); + + /** + * Inserts the entity into the database. + * + * @param entity The entity to insert + */ + public final void insert(T entity) { + final SupportSQLiteStatement stmt = acquire(); + try { + bind(stmt, entity); + stmt.executeInsert(); + } finally { + release(stmt); + } + } + + /** + * Inserts the given entities into the database. + * + * @param entities Entities to insert + */ + public final void insert(T[] entities) { + final SupportSQLiteStatement stmt = acquire(); + try { + for (T entity : entities) { + bind(stmt, entity); + stmt.executeInsert(); + } + } finally { + release(stmt); + } + } + + /** + * Inserts the given entities into the database. + * + * @param entities Entities to insert + */ + public final void insert(Iterable entities) { + final SupportSQLiteStatement stmt = acquire(); + try { + for (T entity : entities) { + bind(stmt, entity); + stmt.executeInsert(); + } + } finally { + release(stmt); + } + } + + /** + * Inserts the given entity into the database and returns the row id. + * + * @param entity The entity to insert + * @return The SQLite row id or -1 if no row is inserted + */ + public final long insertAndReturnId(T entity) { + final SupportSQLiteStatement stmt = acquire(); + try { + bind(stmt, entity); + return stmt.executeInsert(); + } finally { + release(stmt); + } + } + + /** + * Inserts the given entities into the database and returns the row ids. + * + * @param entities Entities to insert + * @return The SQLite row ids, for entities that are not inserted the row id returned will be -1 + */ + public final long[] insertAndReturnIdsArray(Collection entities) { + final SupportSQLiteStatement stmt = acquire(); + try { + final long[] result = new long[entities.size()]; + int index = 0; + for (T entity : entities) { + bind(stmt, entity); + result[index] = stmt.executeInsert(); + index++; + } + return result; + } finally { + release(stmt); + } + } + + /** + * Inserts the given entities into the database and returns the row ids. + * + * @param entities Entities to insert + * @return The SQLite row ids, for entities that are not inserted the row id returned will be -1 + */ + public final long[] insertAndReturnIdsArray(T[] entities) { + final SupportSQLiteStatement stmt = acquire(); + try { + final long[] result = new long[entities.length]; + int index = 0; + for (T entity : entities) { + bind(stmt, entity); + result[index] = stmt.executeInsert(); + index++; + } + return result; + } finally { + release(stmt); + } + } + + /** + * Inserts the given entities into the database and returns the row ids. + * + * @param entities Entities to insert + * @return The SQLite row ids, for entities that are not inserted the row id returned will be -1 + */ + public final Long[] insertAndReturnIdsArrayBox(Collection entities) { + final SupportSQLiteStatement stmt = acquire(); + try { + final Long[] result = new Long[entities.size()]; + int index = 0; + for (T entity : entities) { + bind(stmt, entity); + result[index] = stmt.executeInsert(); + index++; + } + return result; + } finally { + release(stmt); + } + } + + /** + * Inserts the given entities into the database and returns the row ids. + * + * @param entities Entities to insert + * @return The SQLite row ids, for entities that are not inserted the row id returned will be -1 + */ + public final Long[] insertAndReturnIdsArrayBox(T[] entities) { + final SupportSQLiteStatement stmt = acquire(); + try { + final Long[] result = new Long[entities.length]; + int index = 0; + for (T entity : entities) { + bind(stmt, entity); + result[index] = stmt.executeInsert(); + index++; + } + return result; + } finally { + release(stmt); + } + } + + /** + * Inserts the given entities into the database and returns the row ids. + * + * @param entities Entities to insert + * @return The SQLite row ids, for entities that are not inserted the row id returned will be -1 + */ + public final List insertAndReturnIdsList(T[] entities) { + final SupportSQLiteStatement stmt = acquire(); + try { + final List result = new ArrayList<>(entities.length); + int index = 0; + for (T entity : entities) { + bind(stmt, entity); + result.add(index, stmt.executeInsert()); + index++; + } + return result; + } finally { + release(stmt); + } + } + + /** + * Inserts the given entities into the database and returns the row ids. + * + * @param entities Entities to insert + * @return The SQLite row ids, for entities that are not inserted the row id returned will be -1 + */ + public final List insertAndReturnIdsList(Collection entities) { + final SupportSQLiteStatement stmt = acquire(); + try { + final List result = new ArrayList<>(entities.size()); + int index = 0; + for (T entity : entities) { + bind(stmt, entity); + result.add(index, stmt.executeInsert()); + index++; + } + return result; + } finally { + release(stmt); + } + } +} diff --git a/app/src/main/java/androidx/room/InvalidationLiveDataContainer.java b/app/src/main/java/androidx/room/InvalidationLiveDataContainer.java new file mode 100644 index 0000000000..e1e8155f8a --- /dev/null +++ b/app/src/main/java/androidx/room/InvalidationLiveDataContainer.java @@ -0,0 +1,59 @@ +/* + * Copyright 2018 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 androidx.annotation.VisibleForTesting; +import androidx.lifecycle.LiveData; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; +import java.util.concurrent.Callable; + +/** + * A helper class that maintains {@link RoomTrackingLiveData} instances for an + * {@link InvalidationTracker}. + *

+ * We keep a strong reference to active LiveData instances to avoid garbage collection in case + * developer does not hold onto the returned LiveData. + */ +class InvalidationLiveDataContainer { + @SuppressWarnings("WeakerAccess") + @VisibleForTesting + final Set mLiveDataSet = Collections.newSetFromMap( + new IdentityHashMap() + ); + private final RoomDatabase mDatabase; + + InvalidationLiveDataContainer(RoomDatabase database) { + mDatabase = database; + } + + LiveData create(String[] tableNames, boolean inTransaction, + Callable computeFunction) { + return new RoomTrackingLiveData<>(mDatabase, this, inTransaction, computeFunction, + tableNames); + } + + void onActive(LiveData liveData) { + mLiveDataSet.add(liveData); + } + + void onInactive(LiveData liveData) { + mLiveDataSet.remove(liveData); + } +} diff --git a/app/src/main/java/androidx/room/InvalidationTracker.java b/app/src/main/java/androidx/room/InvalidationTracker.java new file mode 100644 index 0000000000..f35b7f9550 --- /dev/null +++ b/app/src/main/java/androidx/room/InvalidationTracker.java @@ -0,0 +1,853 @@ +/* + * Copyright (C) 2017 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.annotation.SuppressLint; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; +import androidx.arch.core.internal.SafeIterableMap; +import androidx.lifecycle.LiveData; +import androidx.sqlite.db.SimpleSQLiteQuery; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.sqlite.db.SupportSQLiteStatement; + +import java.lang.ref.WeakReference; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Lock; + +/** + * InvalidationTracker keeps a list of tables modified by queries and notifies its callbacks about + * these tables. + */ +// Some details on how the InvalidationTracker works: +// * An in memory table is created with (table_id, invalidated) table_id is a hardcoded int from +// initialization, while invalidated is a boolean bit to indicate if the table has been invalidated. +// * ObservedTableTracker tracks list of tables we should be watching (e.g. adding triggers for). +// * Before each beginTransaction, RoomDatabase invokes InvalidationTracker to sync trigger states. +// * After each endTransaction, RoomDatabase invokes InvalidationTracker to refresh invalidated +// tables. +// * Each update (write operation) on one of the observed tables triggers an update into the +// memory table table, flipping the invalidated flag ON. +// * When multi-instance invalidation is turned on, MultiInstanceInvalidationClient will be created. +// It works as an Observer, and notifies other instances of table invalidation. +public class InvalidationTracker { + + private static final String[] TRIGGERS = new String[]{"UPDATE", "DELETE", "INSERT"}; + + private static final String UPDATE_TABLE_NAME = "room_table_modification_log"; + + private static final String TABLE_ID_COLUMN_NAME = "table_id"; + + private static final String INVALIDATED_COLUMN_NAME = "invalidated"; + + private static final String CREATE_TRACKING_TABLE_SQL = "CREATE TEMP TABLE " + UPDATE_TABLE_NAME + + "(" + TABLE_ID_COLUMN_NAME + " INTEGER PRIMARY KEY, " + + INVALIDATED_COLUMN_NAME + " INTEGER NOT NULL DEFAULT 0)"; + + @VisibleForTesting + static final String RESET_UPDATED_TABLES_SQL = "UPDATE " + UPDATE_TABLE_NAME + + " SET " + INVALIDATED_COLUMN_NAME + " = 0 WHERE " + INVALIDATED_COLUMN_NAME + " = 1 "; + + @VisibleForTesting + static final String SELECT_UPDATED_TABLES_SQL = "SELECT * FROM " + UPDATE_TABLE_NAME + + " WHERE " + INVALIDATED_COLUMN_NAME + " = 1;"; + + @NonNull + @VisibleForTesting + final HashMap mTableIdLookup; + final String[] mTableNames; + + @NonNull + private Map> mViewTables; + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + final RoomDatabase mDatabase; + + AtomicBoolean mPendingRefresh = new AtomicBoolean(false); + + private volatile boolean mInitialized = false; + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + volatile SupportSQLiteStatement mCleanupStatement; + + private ObservedTableTracker mObservedTableTracker; + + private final InvalidationLiveDataContainer mInvalidationLiveDataContainer; + + // should be accessed with synchronization only. + @VisibleForTesting + @SuppressLint("RestrictedApi") + final SafeIterableMap mObserverMap = new SafeIterableMap<>(); + + private MultiInstanceInvalidationClient mMultiInstanceInvalidationClient; + + /** + * Used by the generated code. + * + * @hide + */ + @SuppressWarnings("WeakerAccess") + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + public InvalidationTracker(RoomDatabase database, String... tableNames) { + this(database, new HashMap(), Collections.>emptyMap(), + tableNames); + } + + /** + * Used by the generated code. + * + * @hide + */ + @SuppressWarnings("WeakerAccess") + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + public InvalidationTracker(RoomDatabase database, Map shadowTablesMap, + Map> viewTables, String... tableNames) { + mDatabase = database; + mObservedTableTracker = new ObservedTableTracker(tableNames.length); + mTableIdLookup = new HashMap<>(); + mViewTables = viewTables; + mInvalidationLiveDataContainer = new InvalidationLiveDataContainer(mDatabase); + final int size = tableNames.length; + mTableNames = new String[size]; + for (int id = 0; id < size; id++) { + final String tableName = tableNames[id].toLowerCase(Locale.US); + mTableIdLookup.put(tableName, id); + String shadowTableName = shadowTablesMap.get(tableNames[id]); + if (shadowTableName != null) { + mTableNames[id] = shadowTableName.toLowerCase(Locale.US); + } else { + mTableNames[id] = tableName; + } + } + // Adjust table id lookup for those tables whose shadow table is another already mapped + // table (e.g. external content fts tables). + for (Map.Entry shadowTableEntry : shadowTablesMap.entrySet()) { + String shadowTableName = shadowTableEntry.getValue().toLowerCase(Locale.US); + if (mTableIdLookup.containsKey(shadowTableName)) { + String tableName = shadowTableEntry.getKey().toLowerCase(Locale.US); + mTableIdLookup.put(tableName, mTableIdLookup.get(shadowTableName)); + } + } + } + + /** + * Internal method to initialize table tracking. + *

+ * You should never call this method, it is called by the generated code. + */ + void internalInit(SupportSQLiteDatabase database) { + synchronized (this) { + if (mInitialized) { + Log.e(Room.LOG_TAG, "Invalidation tracker is initialized twice :/."); + return; + } + + // These actions are not in a transaction because temp_store is not allowed to be + // performed on a transaction, and recursive_triggers is not affected by transactions. + database.execSQL("PRAGMA temp_store = MEMORY;"); + database.execSQL("PRAGMA recursive_triggers='ON';"); + database.execSQL(CREATE_TRACKING_TABLE_SQL); + syncTriggers(database); + mCleanupStatement = database.compileStatement(RESET_UPDATED_TABLES_SQL); + mInitialized = true; + } + } + + void startMultiInstanceInvalidation(Context context, String name) { + mMultiInstanceInvalidationClient = new MultiInstanceInvalidationClient(context, name, this, + mDatabase.getQueryExecutor()); + } + + void stopMultiInstanceInvalidation() { + if (mMultiInstanceInvalidationClient != null) { + mMultiInstanceInvalidationClient.stop(); + mMultiInstanceInvalidationClient = null; + } + } + + private static void appendTriggerName(StringBuilder builder, String tableName, + String triggerType) { + builder.append("`") + .append("room_table_modification_trigger_") + .append(tableName) + .append("_") + .append(triggerType) + .append("`"); + } + + private void stopTrackingTable(SupportSQLiteDatabase writableDb, int tableId) { + final String tableName = mTableNames[tableId]; + StringBuilder stringBuilder = new StringBuilder(); + for (String trigger : TRIGGERS) { + stringBuilder.setLength(0); + stringBuilder.append("DROP TRIGGER IF EXISTS "); + appendTriggerName(stringBuilder, tableName, trigger); + writableDb.execSQL(stringBuilder.toString()); + } + } + + private void startTrackingTable(SupportSQLiteDatabase writableDb, int tableId) { + writableDb.execSQL( + "INSERT OR IGNORE INTO " + UPDATE_TABLE_NAME + " VALUES(" + tableId + ", 0)"); + final String tableName = mTableNames[tableId]; + StringBuilder stringBuilder = new StringBuilder(); + for (String trigger : TRIGGERS) { + stringBuilder.setLength(0); + stringBuilder.append("CREATE TEMP TRIGGER IF NOT EXISTS "); + appendTriggerName(stringBuilder, tableName, trigger); + stringBuilder.append(" AFTER ") + .append(trigger) + .append(" ON `") + .append(tableName) + .append("` BEGIN UPDATE ") + .append(UPDATE_TABLE_NAME) + .append(" SET ").append(INVALIDATED_COLUMN_NAME).append(" = 1") + .append(" WHERE ").append(TABLE_ID_COLUMN_NAME).append(" = ").append(tableId) + .append(" AND ").append(INVALIDATED_COLUMN_NAME).append(" = 0") + .append("; END"); + writableDb.execSQL(stringBuilder.toString()); + } + } + + /** + * Adds the given observer to the observers list and it will be notified if any table it + * observes changes. + *

+ * Database changes are pulled on another thread so in some race conditions, the observer might + * be invoked for changes that were done before it is added. + *

+ * If the observer already exists, this is a no-op call. + *

+ * If one of the tables in the Observer does not exist in the database, this method throws an + * {@link IllegalArgumentException}. + * + * @param observer The observer which listens the database for changes. + */ + @SuppressLint("RestrictedApi") + @WorkerThread + public void addObserver(@NonNull Observer observer) { + final String[] tableNames = resolveViews(observer.mTables); + int[] tableIds = new int[tableNames.length]; + final int size = tableNames.length; + + for (int i = 0; i < size; i++) { + Integer tableId = mTableIdLookup.get(tableNames[i].toLowerCase(Locale.US)); + if (tableId == null) { + throw new IllegalArgumentException("There is no table with name " + tableNames[i]); + } + tableIds[i] = tableId; + } + ObserverWrapper wrapper = new ObserverWrapper(observer, tableIds, tableNames); + ObserverWrapper currentObserver; + synchronized (mObserverMap) { + currentObserver = mObserverMap.putIfAbsent(observer, wrapper); + } + if (currentObserver == null && mObservedTableTracker.onAdded(tableIds)) { + syncTriggers(); + } + } + + private String[] validateAndResolveTableNames(String[] tableNames) { + String[] resolved = resolveViews(tableNames); + for (String tableName : resolved) { + if (!mTableIdLookup.containsKey(tableName.toLowerCase(Locale.US))) { + throw new IllegalArgumentException("There is no table with name " + tableName); + } + } + return resolved; + } + + /** + * Resolves the list of tables and views into a list of unique tables that are underlying them. + * + * @param names The names of tables or views. + * @return The names of the underlying tables. + */ + private String[] resolveViews(String[] names) { + Set tables = new HashSet<>(); + for (String name : names) { + final String lowercase = name.toLowerCase(Locale.US); + if (mViewTables.containsKey(lowercase)) { + tables.addAll(mViewTables.get(lowercase)); + } else { + tables.add(name); + } + } + return tables.toArray(new String[tables.size()]); + } + + /** + * Adds an observer but keeps a weak reference back to it. + *

+ * Note that you cannot remove this observer once added. It will be automatically removed + * when the observer is GC'ed. + * + * @param observer The observer to which InvalidationTracker will keep a weak reference. + * @hide + */ + @SuppressWarnings("unused") + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + public void addWeakObserver(Observer observer) { + addObserver(new WeakObserver(this, observer)); + } + + /** + * Removes the observer from the observers list. + * + * @param observer The observer to remove. + */ + @SuppressLint("RestrictedApi") + @SuppressWarnings("WeakerAccess") + @WorkerThread + public void removeObserver(@NonNull final Observer observer) { + ObserverWrapper wrapper; + synchronized (mObserverMap) { + wrapper = mObserverMap.remove(observer); + } + if (wrapper != null && mObservedTableTracker.onRemoved(wrapper.mTableIds)) { + syncTriggers(); + } + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + boolean ensureInitialization() { + if (!mDatabase.isOpen()) { + return false; + } + if (!mInitialized) { + // trigger initialization + mDatabase.getOpenHelper().getWritableDatabase(); + } + if (!mInitialized) { + Log.e(Room.LOG_TAG, "database is not initialized even though it is open"); + return false; + } + return true; + } + + @VisibleForTesting + Runnable mRefreshRunnable = new Runnable() { + @Override + public void run() { + final Lock closeLock = mDatabase.getCloseLock(); + Set invalidatedTableIds = null; + try { + closeLock.lock(); + + if (!ensureInitialization()) { + return; + } + + if (!mPendingRefresh.compareAndSet(true, false)) { + // no pending refresh + return; + } + + if (mDatabase.inTransaction()) { + // current thread is in a transaction. when it ends, it will invoke + // refreshRunnable again. mPendingRefresh is left as false on purpose + // so that the last transaction can flip it on again. + return; + } + + if (mDatabase.mWriteAheadLoggingEnabled) { + // This transaction has to be on the underlying DB rather than the RoomDatabase + // in order to avoid a recursive loop after endTransaction. + SupportSQLiteDatabase db = mDatabase.getOpenHelper().getWritableDatabase(); + db.beginTransaction(); + try { + invalidatedTableIds = checkUpdatedTable(); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } else { + invalidatedTableIds = checkUpdatedTable(); + } + } catch (IllegalStateException | SQLiteException exception) { + // may happen if db is closed. just log. + Log.e(Room.LOG_TAG, "Cannot run invalidation tracker. Is the db closed?", + exception); + } finally { + closeLock.unlock(); + } + if (invalidatedTableIds != null && !invalidatedTableIds.isEmpty()) { + synchronized (mObserverMap) { + for (Map.Entry entry : mObserverMap) { + entry.getValue().notifyByTableInvalidStatus(invalidatedTableIds); + } + } + } + } + + private Set checkUpdatedTable() { + HashSet invalidatedTableIds = new HashSet<>(); + Cursor cursor = mDatabase.query(new SimpleSQLiteQuery(SELECT_UPDATED_TABLES_SQL)); + //noinspection TryFinallyCanBeTryWithResources + try { + while (cursor.moveToNext()) { + final int tableId = cursor.getInt(0); + invalidatedTableIds.add(tableId); + } + } finally { + cursor.close(); + } + if (!invalidatedTableIds.isEmpty()) { + mCleanupStatement.executeUpdateDelete(); + } + return invalidatedTableIds; + } + }; + + /** + * Enqueues a task to refresh the list of updated tables. + *

+ * This method is automatically called when {@link RoomDatabase#endTransaction()} is called but + * if you have another connection to the database or directly use {@link + * SupportSQLiteDatabase}, you may need to call this manually. + */ + @SuppressWarnings("WeakerAccess") + public void refreshVersionsAsync() { + // TODO we should consider doing this sync instead of async. + if (mPendingRefresh.compareAndSet(false, true)) { + mDatabase.getQueryExecutor().execute(mRefreshRunnable); + } + } + + /** + * Check versions for tables, and run observers synchronously if tables have been updated. + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + @WorkerThread + public void refreshVersionsSync() { + syncTriggers(); + mRefreshRunnable.run(); + } + + /** + * Notifies all the registered {@link Observer}s of table changes. + *

+ * This can be used for notifying invalidation that cannot be detected by this + * {@link InvalidationTracker}, for example, invalidation from another process. + * + * @param tables The invalidated tables. + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) + public void notifyObserversByTableNames(String... tables) { + synchronized (mObserverMap) { + for (Map.Entry entry : mObserverMap) { + if (!entry.getKey().isRemote()) { + entry.getValue().notifyByTableNames(tables); + } + } + } + } + + void syncTriggers(SupportSQLiteDatabase database) { + if (database.inTransaction()) { + // we won't run this inside another transaction. + return; + } + try { + // This method runs in a while loop because while changes are synced to db, another + // runnable may be skipped. If we cause it to skip, we need to do its work. + while (true) { + Lock closeLock = mDatabase.getCloseLock(); + closeLock.lock(); + try { + // there is a potential race condition where another mSyncTriggers runnable + // can start running right after we get the tables list to sync. + final int[] tablesToSync = mObservedTableTracker.getTablesToSync(); + if (tablesToSync == null) { + return; + } + final int limit = tablesToSync.length; + database.beginTransaction(); + try { + for (int tableId = 0; tableId < limit; tableId++) { + switch (tablesToSync[tableId]) { + case ObservedTableTracker.ADD: + startTrackingTable(database, tableId); + break; + case ObservedTableTracker.REMOVE: + stopTrackingTable(database, tableId); + break; + } + } + database.setTransactionSuccessful(); + } finally { + database.endTransaction(); + } + mObservedTableTracker.onSyncCompleted(); + } finally { + closeLock.unlock(); + } + } + } catch (IllegalStateException | SQLiteException exception) { + // may happen if db is closed. just log. + Log.e(Room.LOG_TAG, "Cannot run invalidation tracker. Is the db closed?", + exception); + } + } + + /** + * Called by RoomDatabase before each beginTransaction call. + *

+ * It is important that pending trigger changes are applied to the database before any query + * runs. Otherwise, we may miss some changes. + *

+ * This api should eventually be public. + */ + void syncTriggers() { + if (!mDatabase.isOpen()) { + return; + } + syncTriggers(mDatabase.getOpenHelper().getWritableDatabase()); + } + + /** + * Creates a LiveData that computes the given function once and for every other invalidation + * of the database. + *

+ * Holds a strong reference to the created LiveData as long as it is active. + * + * @deprecated Use {@link #createLiveData(String[], boolean, Callable)} + * + * @param computeFunction The function that calculates the value + * @param tableNames The list of tables to observe + * @param The return type + * @return A new LiveData that computes the given function when the given list of tables + * invalidates. + * @hide + */ + @Deprecated + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + public LiveData createLiveData(String[] tableNames, Callable computeFunction) { + return createLiveData(tableNames, false, computeFunction); + } + + /** + * Creates a LiveData that computes the given function once and for every other invalidation + * of the database. + *

+ * Holds a strong reference to the created LiveData as long as it is active. + * + * @param tableNames The list of tables to observe + * @param inTransaction True if the computeFunction will be done in a transaction, false + * otherwise. + * @param computeFunction The function that calculates the value + * @param The return type + * @return A new LiveData that computes the given function when the given list of tables + * invalidates. + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + public LiveData createLiveData(String[] tableNames, boolean inTransaction, + Callable computeFunction) { + return mInvalidationLiveDataContainer.create( + validateAndResolveTableNames(tableNames), inTransaction, computeFunction); + } + + /** + * Wraps an observer and keeps the table information. + *

+ * Internally table ids are used which may change from database to database so the table + * related information is kept here rather than in the Observer. + */ + @SuppressWarnings("WeakerAccess") + static class ObserverWrapper { + final int[] mTableIds; + private final String[] mTableNames; + final Observer mObserver; + private final Set mSingleTableSet; + + ObserverWrapper(Observer observer, int[] tableIds, String[] tableNames) { + mObserver = observer; + mTableIds = tableIds; + mTableNames = tableNames; + if (tableIds.length == 1) { + HashSet set = new HashSet<>(); + set.add(mTableNames[0]); + mSingleTableSet = Collections.unmodifiableSet(set); + } else { + mSingleTableSet = null; + } + } + + /** + * Notifies the underlying {@link #mObserver} if any of the observed tables are invalidated + * based on the given invalid status set. + * + * @param invalidatedTablesIds The table ids of the tables that are invalidated. + */ + void notifyByTableInvalidStatus(Set invalidatedTablesIds) { + Set invalidatedTables = null; + final int size = mTableIds.length; + for (int index = 0; index < size; index++) { + final int tableId = mTableIds[index]; + if (invalidatedTablesIds.contains(tableId)) { + if (size == 1) { + // Optimization for a single-table observer + invalidatedTables = mSingleTableSet; + } else { + if (invalidatedTables == null) { + invalidatedTables = new HashSet<>(size); + } + invalidatedTables.add(mTableNames[index]); + } + } + } + if (invalidatedTables != null) { + mObserver.onInvalidated(invalidatedTables); + } + } + + /** + * Notifies the underlying {@link #mObserver} if it observes any of the specified + * {@code tables}. + * + * @param tables The invalidated table names. + */ + void notifyByTableNames(String[] tables) { + Set invalidatedTables = null; + if (mTableNames.length == 1) { + for (String table : tables) { + if (table.equalsIgnoreCase(mTableNames[0])) { + // Optimization for a single-table observer + invalidatedTables = mSingleTableSet; + break; + } + } + } else { + HashSet set = new HashSet<>(); + for (String table : tables) { + for (String ourTable : mTableNames) { + if (ourTable.equalsIgnoreCase(table)) { + set.add(ourTable); + break; + } + } + } + if (set.size() > 0) { + invalidatedTables = set; + } + } + if (invalidatedTables != null) { + mObserver.onInvalidated(invalidatedTables); + } + } + } + + /** + * An observer that can listen for changes in the database. + */ + public abstract static class Observer { + final String[] mTables; + + /** + * Observes the given list of tables and views. + * + * @param firstTable The name of the table or view. + * @param rest More names of tables or views. + */ + @SuppressWarnings("unused") + protected Observer(@NonNull String firstTable, String... rest) { + mTables = Arrays.copyOf(rest, rest.length + 1); + mTables[rest.length] = firstTable; + } + + /** + * Observes the given list of tables and views. + * + * @param tables The list of tables or views to observe for changes. + */ + public Observer(@NonNull String[] tables) { + // copy tables in case user modifies them afterwards + mTables = Arrays.copyOf(tables, tables.length); + } + + /** + * Called when one of the observed tables is invalidated in the database. + * + * @param tables A set of invalidated tables. This is useful when the observer targets + * multiple tables and you want to know which table is invalidated. This will + * be names of underlying tables when you are observing views. + */ + public abstract void onInvalidated(@NonNull Set tables); + + boolean isRemote() { + return false; + } + } + + /** + * Keeps a list of tables we should observe. Invalidation tracker lazily syncs this list w/ + * triggers in the database. + *

+ * This class is thread safe + */ + static class ObservedTableTracker { + static final int NO_OP = 0; // don't change trigger state for this table + static final int ADD = 1; // add triggers for this table + static final int REMOVE = 2; // remove triggers for this table + + // number of observers per table + final long[] mTableObservers; + // trigger state for each table at last sync + // this field is updated when syncAndGet is called. + final boolean[] mTriggerStates; + // when sync is called, this field is returned. It includes actions as ADD, REMOVE, NO_OP + final int[] mTriggerStateChanges; + + boolean mNeedsSync; + + /** + * After we return non-null value from getTablesToSync, we expect a onSyncCompleted before + * returning any non-null value from getTablesToSync. + * This allows us to workaround any multi-threaded state syncing issues. + */ + boolean mPendingSync; + + ObservedTableTracker(int tableCount) { + mTableObservers = new long[tableCount]; + mTriggerStates = new boolean[tableCount]; + mTriggerStateChanges = new int[tableCount]; + Arrays.fill(mTableObservers, 0); + Arrays.fill(mTriggerStates, false); + } + + /** + * @return true if # of triggers is affected. + */ + boolean onAdded(int... tableIds) { + boolean needTriggerSync = false; + synchronized (this) { + for (int tableId : tableIds) { + final long prevObserverCount = mTableObservers[tableId]; + mTableObservers[tableId] = prevObserverCount + 1; + if (prevObserverCount == 0) { + mNeedsSync = true; + needTriggerSync = true; + } + } + } + return needTriggerSync; + } + + /** + * @return true if # of triggers is affected. + */ + boolean onRemoved(int... tableIds) { + boolean needTriggerSync = false; + synchronized (this) { + for (int tableId : tableIds) { + final long prevObserverCount = mTableObservers[tableId]; + mTableObservers[tableId] = prevObserverCount - 1; + if (prevObserverCount == 1) { + mNeedsSync = true; + needTriggerSync = true; + } + } + } + return needTriggerSync; + } + + /** + * If this returns non-null, you must call onSyncCompleted. + * + * @return int[] An int array where the index for each tableId has the action for that + * table. + */ + @Nullable + int[] getTablesToSync() { + synchronized (this) { + if (!mNeedsSync || mPendingSync) { + return null; + } + final int tableCount = mTableObservers.length; + for (int i = 0; i < tableCount; i++) { + final boolean newState = mTableObservers[i] > 0; + if (newState != mTriggerStates[i]) { + mTriggerStateChanges[i] = newState ? ADD : REMOVE; + } else { + mTriggerStateChanges[i] = NO_OP; + } + mTriggerStates[i] = newState; + } + mPendingSync = true; + mNeedsSync = false; + return mTriggerStateChanges; + } + } + + /** + * if getTablesToSync returned non-null, the called should call onSyncCompleted once it + * is done. + */ + void onSyncCompleted() { + synchronized (this) { + mPendingSync = false; + } + } + } + + /** + * An Observer wrapper that keeps a weak reference to the given object. + *

+ * This class will automatically unsubscribe when the wrapped observer goes out of memory. + */ + static class WeakObserver extends Observer { + final InvalidationTracker mTracker; + final WeakReference mDelegateRef; + + WeakObserver(InvalidationTracker tracker, Observer delegate) { + super(delegate.mTables); + mTracker = tracker; + mDelegateRef = new WeakReference<>(delegate); + } + + @Override + public void onInvalidated(@NonNull Set tables) { + final Observer observer = mDelegateRef.get(); + if (observer == null) { + mTracker.removeObserver(this); + } else { + observer.onInvalidated(tables); + } + } + } +} diff --git a/app/src/main/java/androidx/room/MultiInstanceInvalidationClient.java b/app/src/main/java/androidx/room/MultiInstanceInvalidationClient.java new file mode 100644 index 0000000000..3aeddcc66a --- /dev/null +++ b/app/src/main/java/androidx/room/MultiInstanceInvalidationClient.java @@ -0,0 +1,200 @@ +/* + * Copyright 2018 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.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Handles all the communication from {@link RoomDatabase} and {@link InvalidationTracker} to + * {@link MultiInstanceInvalidationService}. + */ +class MultiInstanceInvalidationClient { + + /** + * The application context. + */ + // synthetic access + @SuppressWarnings("WeakerAccess") + final Context mAppContext; + + /** + * The name of the database file. + */ + // synthetic access + @SuppressWarnings("WeakerAccess") + final String mName; + + /** + * The client ID assigned by {@link MultiInstanceInvalidationService}. + */ + // synthetic access + @SuppressWarnings("WeakerAccess") + int mClientId; + + // synthetic access + @SuppressWarnings("WeakerAccess") + final InvalidationTracker mInvalidationTracker; + + // synthetic access + @SuppressWarnings("WeakerAccess") + final InvalidationTracker.Observer mObserver; + + // synthetic access + @SuppressWarnings("WeakerAccess") + @Nullable + IMultiInstanceInvalidationService mService; + + // synthetic access + @SuppressWarnings("WeakerAccess") + final Executor mExecutor; + + // synthetic access + @SuppressWarnings("WeakerAccess") + final IMultiInstanceInvalidationCallback mCallback = + new IMultiInstanceInvalidationCallback.Stub() { + @Override + public void onInvalidation(final String[] tables) { + mExecutor.execute(new Runnable() { + @Override + public void run() { + mInvalidationTracker.notifyObserversByTableNames(tables); + } + }); + } + }; + + // synthetic access + @SuppressWarnings("WeakerAccess") + final AtomicBoolean mStopped = new AtomicBoolean(false); + + // synthetic access + @SuppressWarnings("WeakerAccess") + final ServiceConnection mServiceConnection = new ServiceConnection() { + + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + mService = IMultiInstanceInvalidationService.Stub.asInterface(service); + mExecutor.execute(mSetUpRunnable); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mExecutor.execute(mRemoveObserverRunnable); + mService = null; + } + + }; + + // synthetic access + @SuppressWarnings("WeakerAccess") + final Runnable mSetUpRunnable = new Runnable() { + @Override + public void run() { + try { + final IMultiInstanceInvalidationService service = mService; + if (service != null) { + mClientId = service.registerCallback(mCallback, mName); + mInvalidationTracker.addObserver(mObserver); + } + } catch (RemoteException e) { + Log.w(Room.LOG_TAG, "Cannot register multi-instance invalidation callback", e); + } + } + }; + + // synthetic access + @SuppressWarnings("WeakerAccess") + final Runnable mRemoveObserverRunnable = new Runnable() { + @Override + public void run() { + mInvalidationTracker.removeObserver(mObserver); + } + }; + + private final Runnable mTearDownRunnable = new Runnable() { + @Override + public void run() { + mInvalidationTracker.removeObserver(mObserver); + try { + final IMultiInstanceInvalidationService service = mService; + if (service != null) { + service.unregisterCallback(mCallback, mClientId); + } + } catch (RemoteException e) { + Log.w(Room.LOG_TAG, "Cannot unregister multi-instance invalidation callback", e); + } + mAppContext.unbindService(mServiceConnection); + } + }; + + /** + * @param context The Context to be used for binding + * {@link IMultiInstanceInvalidationService}. + * @param name The name of the database file. + * @param invalidationTracker The {@link InvalidationTracker} + * @param executor The background executor. + */ + MultiInstanceInvalidationClient(Context context, String name, + InvalidationTracker invalidationTracker, Executor executor) { + mAppContext = context.getApplicationContext(); + mName = name; + mInvalidationTracker = invalidationTracker; + mExecutor = executor; + mObserver = new InvalidationTracker.Observer(invalidationTracker.mTableNames) { + @Override + public void onInvalidated(@NonNull Set tables) { + if (mStopped.get()) { + return; + } + try { + final IMultiInstanceInvalidationService service = mService; + if (service != null) { + service.broadcastInvalidation(mClientId, tables.toArray(new String[0])); + } + } catch (RemoteException e) { + Log.w(Room.LOG_TAG, "Cannot broadcast invalidation", e); + } + } + + @Override + boolean isRemote() { + return true; + } + }; + Intent intent = new Intent(mAppContext, MultiInstanceInvalidationService.class); + mAppContext.bindService(intent, mServiceConnection, Context.BIND_AUTO_CREATE); + } + + void stop() { + if (mStopped.compareAndSet(false, true)) { + mExecutor.execute(mTearDownRunnable); + } + } +} diff --git a/app/src/main/java/androidx/room/MultiInstanceInvalidationService.java b/app/src/main/java/androidx/room/MultiInstanceInvalidationService.java new file mode 100644 index 0000000000..2e98f120ff --- /dev/null +++ b/app/src/main/java/androidx/room/MultiInstanceInvalidationService.java @@ -0,0 +1,134 @@ +/* + * Copyright 2018 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.app.Service; +import android.content.Intent; +import android.os.IBinder; +import android.os.RemoteCallbackList; +import android.os.RemoteException; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; + +import java.util.HashMap; + +/** + * A {@link Service} for remote invalidation among multiple {@link InvalidationTracker} instances. + * This service runs in the main app process. All the instances of {@link InvalidationTracker} + * (potentially in other processes) has to connect to this service. + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +public class MultiInstanceInvalidationService extends Service { + + // synthetic access + @SuppressWarnings("WeakerAccess") + int mMaxClientId = 0; + + // synthetic access + @SuppressWarnings("WeakerAccess") + final HashMap mClientNames = new HashMap<>(); + + // synthetic access + @SuppressWarnings("WeakerAccess") + final RemoteCallbackList mCallbackList = + new RemoteCallbackList() { + @Override + public void onCallbackDied(IMultiInstanceInvalidationCallback callback, + Object cookie) { + mClientNames.remove((int) cookie); + } + }; + + private final IMultiInstanceInvalidationService.Stub mBinder = + new IMultiInstanceInvalidationService.Stub() { + + // Assigns a client ID to the client. + @Override + public int registerCallback(IMultiInstanceInvalidationCallback callback, + String name) { + if (name == null) { + return 0; + } + synchronized (mCallbackList) { + int clientId = ++mMaxClientId; + // Use the client ID as the RemoteCallbackList cookie. + if (mCallbackList.register(callback, clientId)) { + mClientNames.put(clientId, name); + return clientId; + } else { + --mMaxClientId; + return 0; + } + } + } + + // Explicitly removes the client. + // The client can die without calling this. In that case, mCallbackList + // .onCallbackDied() can take care of removal. + @Override + public void unregisterCallback(IMultiInstanceInvalidationCallback callback, + int clientId) { + synchronized (mCallbackList) { + mCallbackList.unregister(callback); + mClientNames.remove(clientId); + } + } + + // Broadcasts table invalidation to other instances of the same database file. + // The broadcast is not sent to the caller itself. + @Override + public void broadcastInvalidation(int clientId, String[] tables) { + synchronized (mCallbackList) { + String name = mClientNames.get(clientId); + if (name == null) { + Log.w(Room.LOG_TAG, "Remote invalidation client ID not registered"); + return; + } + int count = mCallbackList.beginBroadcast(); + try { + for (int i = 0; i < count; i++) { + int targetClientId = (int) mCallbackList.getBroadcastCookie(i); + String targetName = mClientNames.get(targetClientId); + if (clientId == targetClientId // This is the caller itself. + || !name.equals(targetName)) { // Not the same file. + continue; + } + try { + IMultiInstanceInvalidationCallback callback = + mCallbackList.getBroadcastItem(i); + callback.onInvalidation(tables); + } catch (RemoteException e) { + Log.w(Room.LOG_TAG, "Error invoking a remote callback", e); + } + } + } finally { + mCallbackList.finishBroadcast(); + } + } + } + }; + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } +} diff --git a/app/src/main/java/androidx/room/Room.java b/app/src/main/java/androidx/room/Room.java new file mode 100644 index 0000000000..2e4dedc7e4 --- /dev/null +++ b/app/src/main/java/androidx/room/Room.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2016 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 androidx.annotation.NonNull; + +/** + * Utility class for Room. + */ +@SuppressWarnings("unused") +public class Room { + static final String LOG_TAG = "ROOM"; + /** + * The master table where room keeps its metadata information. + */ + public static final String MASTER_TABLE_NAME = RoomMasterTable.TABLE_NAME; + private static final String CURSOR_CONV_SUFFIX = "_CursorConverter"; + + /** + * Creates a RoomDatabase.Builder for a persistent database. Once a database is built, you + * should keep a reference to it and re-use it. + * + * @param context The context for the database. This is usually the Application context. + * @param klass The abstract class which is annotated with {@link Database} and extends + * {@link RoomDatabase}. + * @param name The name of the database file. + * @param The type of the database class. + * @return A {@code RoomDatabaseBuilder} which you can use to create the database. + */ + @SuppressWarnings("WeakerAccess") + @NonNull + public static RoomDatabase.Builder databaseBuilder( + @NonNull Context context, @NonNull Class klass, @NonNull String name) { + //noinspection ConstantConditions + if (name == null || name.trim().length() == 0) { + throw new IllegalArgumentException("Cannot build a database with null or empty name." + + " If you are trying to create an in memory database, use Room" + + ".inMemoryDatabaseBuilder"); + } + return new RoomDatabase.Builder<>(context, klass, name); + } + + /** + * Creates a RoomDatabase.Builder for an in memory database. Information stored in an in memory + * database disappears when the process is killed. + * Once a database is built, you should keep a reference to it and re-use it. + * + * @param context The context for the database. This is usually the Application context. + * @param klass The abstract class which is annotated with {@link Database} and extends + * {@link RoomDatabase}. + * @param The type of the database class. + * @return A {@code RoomDatabaseBuilder} which you can use to create the database. + */ + @NonNull + public static RoomDatabase.Builder inMemoryDatabaseBuilder( + @NonNull Context context, @NonNull Class klass) { + return new RoomDatabase.Builder<>(context, klass, null); + } + + @SuppressWarnings({"TypeParameterUnusedInFormals", "ClassNewInstance"}) + @NonNull + static T getGeneratedImplementation(Class klass, String suffix) { + final String fullPackage = klass.getPackage().getName(); + String name = klass.getCanonicalName(); + final String postPackageName = fullPackage.isEmpty() + ? name + : (name.substring(fullPackage.length() + 1)); + final String implName = postPackageName.replace('.', '_') + suffix; + //noinspection TryWithIdenticalCatches + try { + + @SuppressWarnings("unchecked") + final Class aClass = (Class) Class.forName( + fullPackage.isEmpty() ? implName : fullPackage + "." + implName); + return aClass.newInstance(); + } catch (ClassNotFoundException e) { + throw new RuntimeException("cannot find implementation for " + + klass.getCanonicalName() + ". " + implName + " does not exist"); + } catch (IllegalAccessException e) { + throw new RuntimeException("Cannot access the constructor" + + klass.getCanonicalName()); + } catch (InstantiationException e) { + throw new RuntimeException("Failed to create an instance of " + + klass.getCanonicalName()); + } + } + + /** @deprecated This type should not be instantiated as it contains only static methods. */ + @Deprecated + @SuppressWarnings("PrivateConstructorForUtilityClass") + public Room() { + } +} diff --git a/app/src/main/java/androidx/room/RoomDatabase.java b/app/src/main/java/androidx/room/RoomDatabase.java new file mode 100644 index 0000000000..2e88a2891d --- /dev/null +++ b/app/src/main/java/androidx/room/RoomDatabase.java @@ -0,0 +1,1065 @@ +/* + * Copyright (C) 2016 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.annotation.SuppressLint; +import android.app.ActivityManager; +import android.content.Context; +import android.database.Cursor; +import android.os.Build; +import android.os.Looper; +import android.util.Log; + +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.annotation.WorkerThread; +import androidx.arch.core.executor.ArchTaskExecutor; +import androidx.room.migration.Migration; +import androidx.room.util.SneakyThrow; +import androidx.sqlite.db.SimpleSQLiteQuery; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.sqlite.db.SupportSQLiteOpenHelper; +import androidx.sqlite.db.SupportSQLiteQuery; +import androidx.sqlite.db.SupportSQLiteStatement; +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * Base class for all Room databases. All classes that are annotated with {@link Database} must + * extend this class. + *

+ * RoomDatabase provides direct access to the underlying database implementation but you should + * prefer using {@link Dao} classes. + * + * @see Database + */ +public abstract class RoomDatabase { + private static final String DB_IMPL_SUFFIX = "_Impl"; + /** + * Unfortunately, we cannot read this value so we are only setting it to the SQLite default. + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + public static final int MAX_BIND_PARAMETER_CNT = 999; + /** + * Set by the generated open helper. + * + * @deprecated Will be hidden in the next release. + */ + @Deprecated + protected volatile SupportSQLiteDatabase mDatabase; + private Executor mQueryExecutor; + private Executor mTransactionExecutor; + private SupportSQLiteOpenHelper mOpenHelper; + private final InvalidationTracker mInvalidationTracker; + private boolean mAllowMainThreadQueries; + boolean mWriteAheadLoggingEnabled; + + /** + * @deprecated Will be hidden in the next release. + */ + @Nullable + @Deprecated + protected List mCallbacks; + + private final ReentrantReadWriteLock mCloseLock = new ReentrantReadWriteLock(); + + /** + * {@link InvalidationTracker} uses this lock to prevent the database from closing while it is + * querying database updates. + *

+ * The returned lock is reentrant and will allow multiple threads to acquire the lock + * simultaneously until {@link #close()} is invoked in which the lock becomes exclusive as + * a way to let the InvalidationTracker finish its work before closing the database. + * + * @return The lock for {@link #close()}. + */ + Lock getCloseLock() { + return mCloseLock.readLock(); + } + + /** + * This id is only set on threads that are used to dispatch coroutines within a suspending + * database transaction. + */ + private final ThreadLocal mSuspendingTransactionId = new ThreadLocal<>(); + + /** + * Gets the suspending transaction id of the current thread. + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + ThreadLocal getSuspendingTransactionId() { + return mSuspendingTransactionId; + } + + + private final Map mBackingFieldMap = new ConcurrentHashMap<>(); + + /** + * Gets the map for storing extension properties of Kotlin type. + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + Map getBackingFieldMap() { + return mBackingFieldMap; + } + + /** + * Creates a RoomDatabase. + *

+ * You cannot create an instance of a database, instead, you should acquire it via + * {@link Room#databaseBuilder(Context, Class, String)} or + * {@link Room#inMemoryDatabaseBuilder(Context, Class)}. + */ + public RoomDatabase() { + mInvalidationTracker = createInvalidationTracker(); + } + + /** + * Called by {@link Room} when it is initialized. + * + * @param configuration The database configuration. + */ + @CallSuper + public void init(@NonNull DatabaseConfiguration configuration) { + mOpenHelper = createOpenHelper(configuration); + if (mOpenHelper instanceof SQLiteCopyOpenHelper) { + SQLiteCopyOpenHelper copyOpenHelper = (SQLiteCopyOpenHelper) mOpenHelper; + copyOpenHelper.setDatabaseConfiguration(configuration); + } + boolean wal = false; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + wal = configuration.journalMode == JournalMode.WRITE_AHEAD_LOGGING; + mOpenHelper.setWriteAheadLoggingEnabled(wal); + } + mCallbacks = configuration.callbacks; + mQueryExecutor = configuration.queryExecutor; + mTransactionExecutor = new TransactionExecutor(configuration.transactionExecutor); + mAllowMainThreadQueries = configuration.allowMainThreadQueries; + mWriteAheadLoggingEnabled = wal; + if (configuration.multiInstanceInvalidation) { + mInvalidationTracker.startMultiInstanceInvalidation(configuration.context, + configuration.name); + } + } + + /** + * Returns the SQLite open helper used by this database. + * + * @return The SQLite open helper used by this database. + */ + @NonNull + public SupportSQLiteOpenHelper getOpenHelper() { + return mOpenHelper; + } + + /** + * Creates the open helper to access the database. Generated class already implements this + * method. + * Note that this method is called when the RoomDatabase is initialized. + * + * @param config The configuration of the Room database. + * @return A new SupportSQLiteOpenHelper to be used while connecting to the database. + */ + @NonNull + protected abstract SupportSQLiteOpenHelper createOpenHelper(DatabaseConfiguration config); + + /** + * Called when the RoomDatabase is created. + *

+ * This is already implemented by the generated code. + * + * @return Creates a new InvalidationTracker. + */ + @NonNull + protected abstract InvalidationTracker createInvalidationTracker(); + + /** + * Deletes all rows from all the tables that are registered to this database as + * {@link Database#entities()}. + *

+ * This does NOT reset the auto-increment value generated by {@link PrimaryKey#autoGenerate()}. + *

+ * After deleting the rows, Room will set a WAL checkpoint and run VACUUM. This means that the + * data is completely erased. The space will be reclaimed by the system if the amount surpasses + * the threshold of database file size. + * + * @see Database File Format + */ + @WorkerThread + public abstract void clearAllTables(); + + /** + * Returns true if database connection is open and initialized. + * + * @return true if the database connection is open, false otherwise. + */ + public boolean isOpen() { + final SupportSQLiteDatabase db = mDatabase; + return db != null && db.isOpen(); + } + + /** + * Closes the database if it is already open. + */ + public void close() { + if (isOpen()) { + final Lock closeLock = mCloseLock.writeLock(); + try { + closeLock.lock(); + mInvalidationTracker.stopMultiInstanceInvalidation(); + mOpenHelper.close(); + } finally { + closeLock.unlock(); + } + } + } + + /** + * Asserts that we are not on the main thread. + * + * @hide + */ + @SuppressWarnings("WeakerAccess") + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + // used in generated code + public void assertNotMainThread() { + if (mAllowMainThreadQueries) { + return; + } + if (isMainThread()) { + throw new IllegalStateException("Cannot access database on the main thread since" + + " it may potentially lock the UI for a long period of time."); + } + } + + /** + * Asserts that we are not on a suspending transaction. + * + * @hide + */ + @SuppressWarnings("WeakerAccess") + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + // used in generated code + public void assertNotSuspendingTransaction() { + if (!inTransaction() && mSuspendingTransactionId.get() != null) { + throw new IllegalStateException("Cannot access database on a different coroutine" + + " context inherited from a suspending transaction."); + } + } + + // Below, there are wrapper methods for SupportSQLiteDatabase. This helps us track which + // methods we are using and also helps unit tests to mock this class without mocking + // all SQLite database methods. + + /** + * Convenience method to query the database with arguments. + * + * @param query The sql query + * @param args The bind arguments for the placeholders in the query + * @return A Cursor obtained by running the given query in the Room database. + */ + public Cursor query(String query, @Nullable Object[] args) { + return mOpenHelper.getWritableDatabase().query(new SimpleSQLiteQuery(query, args)); + } + + /** + * Wrapper for {@link SupportSQLiteDatabase#query(SupportSQLiteQuery)}. + * + * @param query The Query which includes the SQL and a bind callback for bind arguments. + * @return Result of the query. + */ + public Cursor query(SupportSQLiteQuery query) { + assertNotMainThread(); + assertNotSuspendingTransaction(); + return mOpenHelper.getWritableDatabase().query(query); + } + + /** + * Wrapper for {@link SupportSQLiteDatabase#compileStatement(String)}. + * + * @param sql The query to compile. + * @return The compiled query. + */ + public SupportSQLiteStatement compileStatement(@NonNull String sql) { + assertNotMainThread(); + assertNotSuspendingTransaction(); + return mOpenHelper.getWritableDatabase().compileStatement(sql); + } + + /** + * Wrapper for {@link SupportSQLiteDatabase#beginTransaction()}. + * + * @deprecated Use {@link #runInTransaction(Runnable)} + */ + @Deprecated + public void beginTransaction() { + assertNotMainThread(); + SupportSQLiteDatabase database = mOpenHelper.getWritableDatabase(); + mInvalidationTracker.syncTriggers(database); + database.beginTransaction(); + } + + /** + * Wrapper for {@link SupportSQLiteDatabase#endTransaction()}. + * + * @deprecated Use {@link #runInTransaction(Runnable)} + */ + @Deprecated + public void endTransaction() { + mOpenHelper.getWritableDatabase().endTransaction(); + if (!inTransaction()) { + // enqueue refresh only if we are NOT in a transaction. Otherwise, wait for the last + // endTransaction call to do it. + mInvalidationTracker.refreshVersionsAsync(); + } + } + + /** + * @return The Executor in use by this database for async queries. + */ + @NonNull + public Executor getQueryExecutor() { + return mQueryExecutor; + } + + /** + * @return The Executor in use by this database for async transactions. + */ + @NonNull + public Executor getTransactionExecutor() { + return mTransactionExecutor; + } + + /** + * Wrapper for {@link SupportSQLiteDatabase#setTransactionSuccessful()}. + * + * @deprecated Use {@link #runInTransaction(Runnable)} + */ + @Deprecated + public void setTransactionSuccessful() { + mOpenHelper.getWritableDatabase().setTransactionSuccessful(); + } + + /** + * Executes the specified {@link Runnable} in a database transaction. The transaction will be + * marked as successful unless an exception is thrown in the {@link Runnable}. + *

+ * Room will only perform at most one transaction at a time. + * + * @param body The piece of code to execute. + */ + @SuppressWarnings("deprecation") + public void runInTransaction(@NonNull Runnable body) { + beginTransaction(); + try { + body.run(); + setTransactionSuccessful(); + } finally { + endTransaction(); + } + } + + /** + * Executes the specified {@link Callable} in a database transaction. The transaction will be + * marked as successful unless an exception is thrown in the {@link Callable}. + *

+ * Room will only perform at most one transaction at a time. + * + * @param body The piece of code to execute. + * @param The type of the return value. + * @return The value returned from the {@link Callable}. + */ + @SuppressWarnings("deprecation") + public V runInTransaction(@NonNull Callable body) { + beginTransaction(); + try { + V result = body.call(); + setTransactionSuccessful(); + return result; + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + SneakyThrow.reThrow(e); + return null; // Unreachable code, but compiler doesn't know it. + } finally { + endTransaction(); + } + } + + /** + * Called by the generated code when database is open. + *

+ * You should never call this method manually. + * + * @param db The database instance. + */ + protected void internalInitInvalidationTracker(@NonNull SupportSQLiteDatabase db) { + mInvalidationTracker.internalInit(db); + } + + /** + * Returns the invalidation tracker for this database. + *

+ * You can use the invalidation tracker to get notified when certain tables in the database + * are modified. + * + * @return The invalidation tracker for the database. + */ + @NonNull + public InvalidationTracker getInvalidationTracker() { + return mInvalidationTracker; + } + + /** + * Returns true if current thread is in a transaction. + * + * @return True if there is an active transaction in current thread, false otherwise. + * @see SupportSQLiteDatabase#inTransaction() + */ + @SuppressWarnings("WeakerAccess") + public boolean inTransaction() { + return mOpenHelper.getWritableDatabase().inTransaction(); + } + + /** + * Journal modes for SQLite database. + * + * @see RoomDatabase.Builder#setJournalMode(JournalMode) + */ + public enum JournalMode { + + /** + * Let Room choose the journal mode. This is the default value when no explicit value is + * specified. + *

+ * The actual value will be {@link #TRUNCATE} when the device runs API Level lower than 16 + * or it is a low-RAM device. Otherwise, {@link #WRITE_AHEAD_LOGGING} will be used. + */ + AUTOMATIC, + + /** + * Truncate journal mode. + */ + TRUNCATE, + + /** + * Write-Ahead Logging mode. + */ + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) + WRITE_AHEAD_LOGGING; + + /** + * Resolves {@link #AUTOMATIC} to either {@link #TRUNCATE} or + * {@link #WRITE_AHEAD_LOGGING}. + */ + @SuppressLint("NewApi") + JournalMode resolve(Context context) { + if (this != AUTOMATIC) { + return this; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + ActivityManager manager = (ActivityManager) + context.getSystemService(Context.ACTIVITY_SERVICE); + if (manager != null && !isLowRamDevice(manager)) { + return WRITE_AHEAD_LOGGING; + } + } + return TRUNCATE; + } + + private static boolean isLowRamDevice(@NonNull ActivityManager activityManager) { + if (Build.VERSION.SDK_INT >= 19) { + return activityManager.isLowRamDevice(); + } + return false; + } + } + + /** + * Builder for RoomDatabase. + * + * @param The type of the abstract database class. + */ + public static class Builder { + private final Class mDatabaseClass; + private final String mName; + private final Context mContext; + private ArrayList mCallbacks; + + /** The Executor used to run database queries. This should be background-threaded. */ + private Executor mQueryExecutor; + /** The Executor used to run database transactions. This should be background-threaded. */ + private Executor mTransactionExecutor; + private SupportSQLiteOpenHelper.Factory mFactory; + private boolean mAllowMainThreadQueries; + private JournalMode mJournalMode; + private boolean mMultiInstanceInvalidation; + private boolean mRequireMigration; + private boolean mAllowDestructiveMigrationOnDowngrade; + /** + * Migrations, mapped by from-to pairs. + */ + private final MigrationContainer mMigrationContainer; + private Set mMigrationsNotRequiredFrom; + /** + * Keeps track of {@link Migration#startVersion}s and {@link Migration#endVersion}s added in + * {@link #addMigrations(Migration...)} for later validation that makes those versions don't + * match any versions passed to {@link #fallbackToDestructiveMigrationFrom(int...)}. + */ + private Set mMigrationStartAndEndVersions; + + private String mCopyFromAssetPath; + private File mCopyFromFile; + + Builder(@NonNull Context context, @NonNull Class klass, @Nullable String name) { + mContext = context; + mDatabaseClass = klass; + mName = name; + mJournalMode = JournalMode.AUTOMATIC; + mRequireMigration = true; + mMigrationContainer = new MigrationContainer(); + } + + /** + * Configures Room to create and open the database using a pre-packaged database located in + * the application 'assets/' folder. + *

+ * Room does not open the pre-packaged database, instead it copies it into the internal + * app database folder and then opens it. The pre-packaged database file must be located in + * the "assets/" folder of your application. For example, the path for a file located in + * "assets/databases/products.db" would be "databases/products.db". + *

+ * The pre-packaged database schema will be validated. It might be best to create your + * pre-packaged database schema utilizing the exported schema files generated when + * {@link Database#exportSchema()} is enabled. + *

+ * This method is not supported for an in memory database {@link Builder}. + * + * @param databaseFilePath The file path within the 'assets/' directory of where the + * database file is located. + * + * @return This {@link Builder} instance. + */ + @NonNull + public Builder createFromAsset(@NonNull String databaseFilePath) { + mCopyFromAssetPath = databaseFilePath; + return this; + } + + /** + * Configures Room to create and open the database using a pre-packaged database file. + *

+ * Room does not open the pre-packaged database, instead it copies it into the internal + * app database folder and then opens it. The given file must be accessible and the right + * permissions must be granted for Room to copy the file. + *

+ * The pre-packaged database schema will be validated. It might be best to create your + * pre-packaged database schema utilizing the exported schema files generated when + * {@link Database#exportSchema()} is enabled. + *

+ * This method is not supported for an in memory database {@link Builder}. + * + * @param databaseFile The database file. + * + * @return This {@link Builder} instance. + */ + @NonNull + public Builder createFromFile(@NonNull File databaseFile) { + mCopyFromFile = databaseFile; + return this; + } + + /** + * Sets the database factory. If not set, it defaults to + * {@link FrameworkSQLiteOpenHelperFactory}. + * + * @param factory The factory to use to access the database. + * @return This {@link Builder} instance. + */ + @NonNull + public Builder openHelperFactory(@Nullable SupportSQLiteOpenHelper.Factory factory) { + mFactory = factory; + return this; + } + + /** + * Adds a migration to the builder. + *

+ * Each Migration has a start and end versions and Room runs these migrations to bring the + * database to the latest version. + *

+ * If a migration item is missing between current version and the latest version, Room + * will clear the database and recreate so even if you have no changes between 2 versions, + * you should still provide a Migration object to the builder. + *

+ * A migration can handle more than 1 version (e.g. if you have a faster path to choose when + * going version 3 to 5 without going to version 4). If Room opens a database at version + * 3 and latest version is >= 5, Room will use the migration object that can migrate from + * 3 to 5 instead of 3 to 4 and 4 to 5. + * + * @param migrations The migration object that can modify the database and to the necessary + * changes. + * @return This {@link Builder} instance. + */ + @NonNull + public Builder addMigrations(@NonNull Migration... migrations) { + if (mMigrationStartAndEndVersions == null) { + mMigrationStartAndEndVersions = new HashSet<>(); + } + for (Migration migration : migrations) { + mMigrationStartAndEndVersions.add(migration.startVersion); + mMigrationStartAndEndVersions.add(migration.endVersion); + } + + mMigrationContainer.addMigrations(migrations); + return this; + } + + /** + * Disables the main thread query check for Room. + *

+ * Room ensures that Database is never accessed on the main thread because it may lock the + * main thread and trigger an ANR. If you need to access the database from the main thread, + * you should always use async alternatives or manually move the call to a background + * thread. + *

+ * You may want to turn this check off for testing. + * + * @return This {@link Builder} instance. + */ + @NonNull + public Builder allowMainThreadQueries() { + mAllowMainThreadQueries = true; + return this; + } + + /** + * Sets the journal mode for this database. + * + *

+ * This value is ignored if the builder is initialized with + * {@link Room#inMemoryDatabaseBuilder(Context, Class)}. + *

+ * The journal mode should be consistent across multiple instances of + * {@link RoomDatabase} for a single SQLite database file. + *

+ * The default value is {@link JournalMode#AUTOMATIC}. + * + * @param journalMode The journal mode. + * @return This {@link Builder} instance. + */ + @NonNull + public Builder setJournalMode(@NonNull JournalMode journalMode) { + mJournalMode = journalMode; + return this; + } + + /** + * Sets the {@link Executor} that will be used to execute all non-blocking asynchronous + * queries and tasks, including {@code LiveData} invalidation, {@code Flowable} scheduling + * and {@code ListenableFuture} tasks. + *

+ * When both the query executor and transaction executor are unset, then a default + * {@code Executor} will be used. The default {@code Executor} allocates and shares threads + * amongst Architecture Components libraries. If the query executor is unset but a + * transaction executor was set, then the same {@code Executor} will be used for queries. + *

+ * For best performance the given {@code Executor} should be bounded (max number of threads + * is limited). + *

+ * The input {@code Executor} cannot run tasks on the UI thread. + ** + * @return This {@link Builder} instance. + * + * @see #setTransactionExecutor(Executor) + */ + @NonNull + public Builder setQueryExecutor(@NonNull Executor executor) { + mQueryExecutor = executor; + return this; + } + + /** + * Sets the {@link Executor} that will be used to execute all non-blocking asynchronous + * transaction queries and tasks, including {@code LiveData} invalidation, {@code Flowable} + * scheduling and {@code ListenableFuture} tasks. + *

+ * When both the transaction executor and query executor are unset, then a default + * {@code Executor} will be used. The default {@code Executor} allocates and shares threads + * amongst Architecture Components libraries. If the transaction executor is unset but a + * query executor was set, then the same {@code Executor} will be used for transactions. + *

+ * If the given {@code Executor} is shared then it should be unbounded to avoid the + * possibility of a deadlock. Room will not use more than one thread at a time from this + * executor since only one transaction at a time can be executed, other transactions will + * be queued on a first come, first serve order. + *

+ * The input {@code Executor} cannot run tasks on the UI thread. + * + * @return This {@link Builder} instance. + * + * @see #setQueryExecutor(Executor) + */ + @NonNull + public Builder setTransactionExecutor(@NonNull Executor executor) { + mTransactionExecutor = executor; + return this; + } + + /** + * Sets whether table invalidation in this instance of {@link RoomDatabase} should be + * broadcast and synchronized with other instances of the same {@link RoomDatabase}, + * including those in a separate process. In order to enable multi-instance invalidation, + * this has to be turned on both ends. + *

+ * This is not enabled by default. + *

+ * This does not work for in-memory databases. This does not work between database instances + * targeting different database files. + * + * @return This {@link Builder} instance. + */ + @NonNull + public Builder enableMultiInstanceInvalidation() { + mMultiInstanceInvalidation = mName != null; + return this; + } + + /** + * Allows Room to destructively recreate database tables if {@link Migration}s that would + * migrate old database schemas to the latest schema version are not found. + *

+ * When the database version on the device does not match the latest schema version, Room + * runs necessary {@link Migration}s on the database. + *

+ * If it cannot find the set of {@link Migration}s that will bring the database to the + * current version, it will throw an {@link IllegalStateException}. + *

+ * You can call this method to change this behavior to re-create the database instead of + * crashing. + *

+ * If the database was create from an asset or a file then Room will try to use the same + * file to re-create the database, otherwise this will delete all of the data in the + * database tables managed by Room. + *

+ * To let Room fallback to destructive migration only during a schema downgrade then use + * {@link #fallbackToDestructiveMigrationOnDowngrade()}. + * + * @return This {@link Builder} instance. + * + * @see #fallbackToDestructiveMigrationOnDowngrade() + */ + @NonNull + public Builder fallbackToDestructiveMigration() { + mRequireMigration = false; + mAllowDestructiveMigrationOnDowngrade = true; + return this; + } + + /** + * Allows Room to destructively recreate database tables if {@link Migration}s are not + * available when downgrading to old schema versions. + * + * @return This {@link Builder} instance. + * + * @see Builder#fallbackToDestructiveMigration() + */ + @NonNull + public Builder fallbackToDestructiveMigrationOnDowngrade() { + mRequireMigration = true; + mAllowDestructiveMigrationOnDowngrade = true; + return this; + } + + /** + * Informs Room that it is allowed to destructively recreate database tables from specific + * starting schema versions. + *

+ * This functionality is the same as that provided by + * {@link #fallbackToDestructiveMigration()}, except that this method allows the + * specification of a set of schema versions for which destructive recreation is allowed. + *

+ * Using this method is preferable to {@link #fallbackToDestructiveMigration()} if you want + * to allow destructive migrations from some schema versions while still taking advantage + * of exceptions being thrown due to unintentionally missing migrations. + *

+ * Note: No versions passed to this method may also exist as either starting or ending + * versions in the {@link Migration}s provided to {@link #addMigrations(Migration...)}. If a + * version passed to this method is found as a starting or ending version in a Migration, an + * exception will be thrown. + * + * @param startVersions The set of schema versions from which Room should use a destructive + * migration. + * @return This {@link Builder} instance. + */ + @NonNull + public Builder fallbackToDestructiveMigrationFrom(int... startVersions) { + if (mMigrationsNotRequiredFrom == null) { + mMigrationsNotRequiredFrom = new HashSet<>(startVersions.length); + } + for (int startVersion : startVersions) { + mMigrationsNotRequiredFrom.add(startVersion); + } + return this; + } + + /** + * Adds a {@link Callback} to this database. + * + * @param callback The callback. + * @return This {@link Builder} instance. + */ + @NonNull + public Builder addCallback(@NonNull Callback callback) { + if (mCallbacks == null) { + mCallbacks = new ArrayList<>(); + } + mCallbacks.add(callback); + return this; + } + + /** + * Creates the databases and initializes it. + *

+ * By default, all RoomDatabases use in memory storage for TEMP tables and enables recursive + * triggers. + * + * @return A new database instance. + */ + @SuppressLint("RestrictedApi") + @NonNull + public T build() { + //noinspection ConstantConditions + if (mContext == null) { + throw new IllegalArgumentException("Cannot provide null context for the database."); + } + //noinspection ConstantConditions + if (mDatabaseClass == null) { + throw new IllegalArgumentException("Must provide an abstract class that" + + " extends RoomDatabase"); + } + if (mQueryExecutor == null && mTransactionExecutor == null) { + mQueryExecutor = mTransactionExecutor = ArchTaskExecutor.getIOThreadExecutor(); + } else if (mQueryExecutor != null && mTransactionExecutor == null) { + mTransactionExecutor = mQueryExecutor; + } else if (mQueryExecutor == null && mTransactionExecutor != null) { + mQueryExecutor = mTransactionExecutor; + } + + if (mMigrationStartAndEndVersions != null && mMigrationsNotRequiredFrom != null) { + for (Integer version : mMigrationStartAndEndVersions) { + if (mMigrationsNotRequiredFrom.contains(version)) { + throw new IllegalArgumentException( + "Inconsistency detected. A Migration was supplied to " + + "addMigration(Migration... migrations) that has a start " + + "or end version equal to a start version supplied to " + + "fallbackToDestructiveMigrationFrom(int... " + + "startVersions). Start version: " + + version); + } + } + } + + if (mFactory == null) { + mFactory = new FrameworkSQLiteOpenHelperFactory(); + } + + if (mCopyFromAssetPath != null || mCopyFromFile != null) { + if (mName == null) { + throw new IllegalArgumentException("Cannot create from asset or file for an " + + "in-memory database."); + } + if (mCopyFromAssetPath != null && mCopyFromFile != null) { + throw new IllegalArgumentException("Both createFromAsset() and " + + "createFromFile() was called on this Builder but the database can " + + "only be created using one of the two configurations."); + } + mFactory = new SQLiteCopyOpenHelperFactory(mCopyFromAssetPath, mCopyFromFile, + mFactory); + } + DatabaseConfiguration configuration = + new DatabaseConfiguration( + mContext, + mName, + mFactory, + mMigrationContainer, + mCallbacks, + mAllowMainThreadQueries, + mJournalMode.resolve(mContext), + mQueryExecutor, + mTransactionExecutor, + mMultiInstanceInvalidation, + mRequireMigration, + mAllowDestructiveMigrationOnDowngrade, + mMigrationsNotRequiredFrom, + mCopyFromAssetPath, + mCopyFromFile); + T db = Room.getGeneratedImplementation(mDatabaseClass, DB_IMPL_SUFFIX); + db.init(configuration); + return db; + } + } + + /** + * A container to hold migrations. It also allows querying its contents to find migrations + * between two versions. + */ + public static class MigrationContainer { + private HashMap> mMigrations = new HashMap<>(); + + /** + * Adds the given migrations to the list of available migrations. If 2 migrations have the + * same start-end versions, the latter migration overrides the previous one. + * + * @param migrations List of available migrations. + */ + public void addMigrations(@NonNull Migration... migrations) { + for (Migration migration : migrations) { + addMigration(migration); + } + } + + private void addMigration(Migration migration) { + final int start = migration.startVersion; + final int end = migration.endVersion; + TreeMap targetMap = mMigrations.get(start); + if (targetMap == null) { + targetMap = new TreeMap<>(); + mMigrations.put(start, targetMap); + } + Migration existing = targetMap.get(end); + if (existing != null) { + Log.w(Room.LOG_TAG, "Overriding migration " + existing + " with " + migration); + } + targetMap.put(end, migration); + } + + /** + * Finds the list of migrations that should be run to move from {@code start} version to + * {@code end} version. + * + * @param start The current database version + * @param end The target database version + * @return An ordered list of {@link Migration} objects that should be run to migrate + * between the given versions. If a migration path cannot be found, returns {@code null}. + */ + @SuppressWarnings("WeakerAccess") + @Nullable + public List findMigrationPath(int start, int end) { + if (start == end) { + return Collections.emptyList(); + } + boolean migrateUp = end > start; + List result = new ArrayList<>(); + return findUpMigrationPath(result, migrateUp, start, end); + } + + private List findUpMigrationPath(List result, boolean upgrade, + int start, int end) { + while (upgrade ? start < end : start > end) { + TreeMap targetNodes = mMigrations.get(start); + if (targetNodes == null) { + return null; + } + // keys are ordered so we can start searching from one end of them. + Set keySet; + if (upgrade) { + keySet = targetNodes.descendingKeySet(); + } else { + keySet = targetNodes.keySet(); + } + boolean found = false; + for (int targetVersion : keySet) { + final boolean shouldAddToPath; + if (upgrade) { + shouldAddToPath = targetVersion <= end && targetVersion > start; + } else { + shouldAddToPath = targetVersion >= end && targetVersion < start; + } + if (shouldAddToPath) { + result.add(targetNodes.get(targetVersion)); + start = targetVersion; + found = true; + break; + } + } + if (!found) { + return null; + } + } + return result; + } + } + + /** Returns true if the calling thread is the main thread. */ + private static boolean isMainThread() { + return Looper.getMainLooper().getThread() == Thread.currentThread(); + } + + /** + * Callback for {@link RoomDatabase}. + */ + public abstract static class Callback { + + /** + * Called when the database is created for the first time. This is called after all the + * tables are created. + * + * @param db The database. + */ + public void onCreate(@NonNull SupportSQLiteDatabase db) { + } + + /** + * Called when the database has been opened. + * + * @param db The database. + */ + public void onOpen(@NonNull SupportSQLiteDatabase db) { + } + + /** + * Called after the database was destructively migrated + * + * @param db The database. + */ + public void onDestructiveMigration(@NonNull SupportSQLiteDatabase db){ + } + } +} diff --git a/app/src/main/java/androidx/room/RoomOpenHelper.java b/app/src/main/java/androidx/room/RoomOpenHelper.java new file mode 100644 index 0000000000..3dbe7ddeac --- /dev/null +++ b/app/src/main/java/androidx/room/RoomOpenHelper.java @@ -0,0 +1,277 @@ +/* + * Copyright (C) 2017 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.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SimpleSQLiteQuery; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.sqlite.db.SupportSQLiteOpenHelper; + +import java.util.List; + +/** + * An open helper that holds a reference to the configuration until the database is opened. + * + * @hide + */ +@SuppressWarnings("unused") +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +public class RoomOpenHelper extends SupportSQLiteOpenHelper.Callback { + @Nullable + private DatabaseConfiguration mConfiguration; + @NonNull + private final Delegate mDelegate; + @NonNull + private final String mIdentityHash; + /** + * Room v1 had a bug where the hash was not consistent if fields are reordered. + * The new has fixes it but we still need to accept the legacy hash. + */ + @NonNull // b/64290754 + private final String mLegacyHash; + + public RoomOpenHelper(@NonNull DatabaseConfiguration configuration, @NonNull Delegate delegate, + @NonNull String identityHash, @NonNull String legacyHash) { + super(delegate.version); + mConfiguration = configuration; + mDelegate = delegate; + mIdentityHash = identityHash; + mLegacyHash = legacyHash; + } + + public RoomOpenHelper(@NonNull DatabaseConfiguration configuration, @NonNull Delegate delegate, + @NonNull String legacyHash) { + this(configuration, delegate, "", legacyHash); + } + + @Override + public void onConfigure(SupportSQLiteDatabase db) { + super.onConfigure(db); + } + + @Override + public void onCreate(SupportSQLiteDatabase db) { + boolean isEmptyDatabase = hasEmptySchema(db); + mDelegate.createAllTables(db); + if (!isEmptyDatabase) { + // A 0 version pre-populated database goes through the create path because the + // framework's SQLiteOpenHelper thinks the database was just created from scratch. If we + // find the database not to be empty, then it is a pre-populated, we must validate it to + // see if its suitable for usage. + ValidationResult result = mDelegate.onValidateSchema(db); + if (!result.isValid) { + throw new IllegalStateException("Pre-packaged database has an invalid schema: " + + result.expectedFoundMsg); + } + } + updateIdentity(db); + mDelegate.onCreate(db); + } + + @Override + public void onUpgrade(SupportSQLiteDatabase db, int oldVersion, int newVersion) { + boolean migrated = false; + if (mConfiguration != null) { + List migrations = mConfiguration.migrationContainer.findMigrationPath( + oldVersion, newVersion); + if (migrations != null) { + mDelegate.onPreMigrate(db); + for (Migration migration : migrations) { + migration.migrate(db); + } + ValidationResult result = mDelegate.onValidateSchema(db); + if (!result.isValid) { + throw new IllegalStateException("Migration didn't properly handle: " + + result.expectedFoundMsg); + } + mDelegate.onPostMigrate(db); + updateIdentity(db); + migrated = true; + } + } + if (!migrated) { + if (mConfiguration != null + && !mConfiguration.isMigrationRequired(oldVersion, newVersion)) { + mDelegate.dropAllTables(db); + mDelegate.createAllTables(db); + } else { + throw new IllegalStateException("A migration from " + oldVersion + " to " + + newVersion + " was required but not found. Please provide the " + + "necessary Migration path via " + + "RoomDatabase.Builder.addMigration(Migration ...) or allow for " + + "destructive migrations via one of the " + + "RoomDatabase.Builder.fallbackToDestructiveMigration* methods."); + } + } + } + + @Override + public void onDowngrade(SupportSQLiteDatabase db, int oldVersion, int newVersion) { + onUpgrade(db, oldVersion, newVersion); + } + + @Override + public void onOpen(SupportSQLiteDatabase db) { + super.onOpen(db); + checkIdentity(db); + mDelegate.onOpen(db); + // there might be too many configurations etc, just clear it. + mConfiguration = null; + } + + private void checkIdentity(SupportSQLiteDatabase db) { + if (hasRoomMasterTable(db)) { + String identityHash = null; + Cursor cursor = db.query(new SimpleSQLiteQuery(RoomMasterTable.READ_QUERY)); + //noinspection TryFinallyCanBeTryWithResources + try { + if (cursor.moveToFirst()) { + identityHash = cursor.getString(0); + } + } finally { + cursor.close(); + } + if (!mIdentityHash.equals(identityHash) && !mLegacyHash.equals(identityHash)) { + throw new IllegalStateException("Room cannot verify the data integrity. Looks like" + + " you've changed schema but forgot to update the version number. You can" + + " simply fix this by increasing the version number."); + } + } else { + // No room_master_table, this might an a pre-populated DB, we must validate to see if + // its suitable for usage. + ValidationResult result = mDelegate.onValidateSchema(db); + if (!result.isValid) { + throw new IllegalStateException("Pre-packaged database has an invalid schema: " + + result.expectedFoundMsg); + } + mDelegate.onPostMigrate(db); + updateIdentity(db); + } + } + + private void updateIdentity(SupportSQLiteDatabase db) { + createMasterTableIfNotExists(db); + db.execSQL(RoomMasterTable.createInsertQuery(mIdentityHash)); + } + + private void createMasterTableIfNotExists(SupportSQLiteDatabase db) { + db.execSQL(RoomMasterTable.CREATE_QUERY); + } + + private static boolean hasRoomMasterTable(SupportSQLiteDatabase db) { + Cursor cursor = db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name='" + + RoomMasterTable.TABLE_NAME + "'"); + //noinspection TryFinallyCanBeTryWithResources + try { + return cursor.moveToFirst() && cursor.getInt(0) != 0; + } finally { + cursor.close(); + } + } + + private static boolean hasEmptySchema(SupportSQLiteDatabase db) { + Cursor cursor = db.query( + "SELECT count(*) FROM sqlite_master WHERE name != 'android_metadata'"); + //noinspection TryFinallyCanBeTryWithResources + try { + return cursor.moveToFirst() && cursor.getInt(0) == 0; + } finally { + cursor.close(); + } + } + + /** + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + public abstract static class Delegate { + public final int version; + + public Delegate(int version) { + this.version = version; + } + + protected abstract void dropAllTables(SupportSQLiteDatabase database); + + protected abstract void createAllTables(SupportSQLiteDatabase database); + + protected abstract void onOpen(SupportSQLiteDatabase database); + + protected abstract void onCreate(SupportSQLiteDatabase database); + + /** + * Called after a migration run to validate database integrity. + * + * @param db The SQLite database. + * + * @deprecated Use {@link #onValidateSchema(SupportSQLiteDatabase)} + */ + @Deprecated + protected void validateMigration(SupportSQLiteDatabase db) { + throw new UnsupportedOperationException("validateMigration is deprecated"); + } + + /** + * Called after a migration run or pre-package database copy to validate database integrity. + * + * @param db The SQLite database. + */ + @SuppressWarnings("deprecation") + @NonNull + protected ValidationResult onValidateSchema(@NonNull SupportSQLiteDatabase db) { + validateMigration(db); + return new ValidationResult(true, null); + } + + /** + * Called before migrations execute to perform preliminary work. + * @param database The SQLite database. + */ + protected void onPreMigrate(SupportSQLiteDatabase database) { + + } + + /** + * Called after migrations execute to perform additional work. + * @param database The SQLite database. + */ + protected void onPostMigrate(SupportSQLiteDatabase database) { + + } + } + + /** + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + public static class ValidationResult { + + public final boolean isValid; + @Nullable + public final String expectedFoundMsg; + + public ValidationResult(boolean isValid, @Nullable String expectedFoundMsg) { + this.isValid = isValid; + this.expectedFoundMsg = expectedFoundMsg; + } + } +} diff --git a/app/src/main/java/androidx/room/RoomSQLiteQuery.java b/app/src/main/java/androidx/room/RoomSQLiteQuery.java new file mode 100644 index 0000000000..689352c032 --- /dev/null +++ b/app/src/main/java/androidx/room/RoomSQLiteQuery.java @@ -0,0 +1,299 @@ +/* + * Copyright (C) 2017 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 androidx.annotation.IntDef; +import androidx.annotation.RestrictTo; +import androidx.annotation.VisibleForTesting; +import androidx.sqlite.db.SupportSQLiteProgram; +import androidx.sqlite.db.SupportSQLiteQuery; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import java.util.Iterator; +import java.util.Map; +import java.util.TreeMap; + +/** + * This class is used as an intermediate place to keep binding arguments so that we can run + * Cursor queries with correct types rather than passing everything as a string. + *

+ * Because it is relatively a big object, they are pooled and must be released after each use. + * + * @hide + */ +@SuppressWarnings("unused") +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +public class RoomSQLiteQuery implements SupportSQLiteQuery, SupportSQLiteProgram { + @SuppressWarnings("WeakerAccess") + @VisibleForTesting + // Maximum number of queries we'll keep cached. + static final int POOL_LIMIT = 15; + @SuppressWarnings("WeakerAccess") + @VisibleForTesting + // Once we hit POOL_LIMIT, we'll bring the pool size back to the desired number. We always + // clear the bigger queries (# of arguments). + static final int DESIRED_POOL_SIZE = 10; + private volatile String mQuery; + @SuppressWarnings("WeakerAccess") + @VisibleForTesting + final long[] mLongBindings; + @SuppressWarnings("WeakerAccess") + @VisibleForTesting + final double[] mDoubleBindings; + @SuppressWarnings("WeakerAccess") + @VisibleForTesting + final String[] mStringBindings; + @SuppressWarnings("WeakerAccess") + @VisibleForTesting + final byte[][] mBlobBindings; + + @Binding + private final int[] mBindingTypes; + @SuppressWarnings("WeakerAccess") + @VisibleForTesting + final int mCapacity; + // number of arguments in the query + @SuppressWarnings("WeakerAccess") + @VisibleForTesting + int mArgCount; + + + @SuppressWarnings("WeakerAccess") + @VisibleForTesting + static final TreeMap sQueryPool = new TreeMap<>(); + + /** + * Copies the given SupportSQLiteQuery and converts it into RoomSQLiteQuery. + * + * @param supportSQLiteQuery The query to copy from + * @return A new query copied from the provided one. + */ + public static RoomSQLiteQuery copyFrom(SupportSQLiteQuery supportSQLiteQuery) { + final RoomSQLiteQuery query = RoomSQLiteQuery.acquire( + supportSQLiteQuery.getSql(), + supportSQLiteQuery.getArgCount()); + supportSQLiteQuery.bindTo(new SupportSQLiteProgram() { + @Override + public void bindNull(int index) { + query.bindNull(index); + } + + @Override + public void bindLong(int index, long value) { + query.bindLong(index, value); + } + + @Override + public void bindDouble(int index, double value) { + query.bindDouble(index, value); + } + + @Override + public void bindString(int index, String value) { + query.bindString(index, value); + } + + @Override + public void bindBlob(int index, byte[] value) { + query.bindBlob(index, value); + } + + @Override + public void clearBindings() { + query.clearBindings(); + } + + @Override + public void close() { + // ignored. + } + }); + return query; + } + + /** + * Returns a new RoomSQLiteQuery that can accept the given number of arguments and holds the + * given query. + * + * @param query The query to prepare + * @param argumentCount The number of query arguments + * @return A RoomSQLiteQuery that holds the given query and has space for the given number of + * arguments. + */ + @SuppressWarnings("WeakerAccess") + public static RoomSQLiteQuery acquire(String query, int argumentCount) { + synchronized (sQueryPool) { + final Map.Entry entry = + sQueryPool.ceilingEntry(argumentCount); + if (entry != null) { + sQueryPool.remove(entry.getKey()); + final RoomSQLiteQuery sqliteQuery = entry.getValue(); + sqliteQuery.init(query, argumentCount); + return sqliteQuery; + } + } + RoomSQLiteQuery sqLiteQuery = new RoomSQLiteQuery(argumentCount); + sqLiteQuery.init(query, argumentCount); + return sqLiteQuery; + } + + private RoomSQLiteQuery(int capacity) { + mCapacity = capacity; + // because, 1 based indices... we don't want to offsets everything with 1 all the time. + int limit = capacity + 1; + //noinspection WrongConstant + mBindingTypes = new int[limit]; + mLongBindings = new long[limit]; + mDoubleBindings = new double[limit]; + mStringBindings = new String[limit]; + mBlobBindings = new byte[limit][]; + } + + @SuppressWarnings("WeakerAccess") + void init(String query, int argCount) { + mQuery = query; + mArgCount = argCount; + } + + /** + * Releases the query back to the pool. + *

+ * After released, the statement might be returned when {@link #acquire(String, int)} is called + * so you should never re-use it after releasing. + */ + @SuppressWarnings("WeakerAccess") + public void release() { + synchronized (sQueryPool) { + sQueryPool.put(mCapacity, this); + prunePoolLocked(); + } + } + + private static void prunePoolLocked() { + if (sQueryPool.size() > POOL_LIMIT) { + int toBeRemoved = sQueryPool.size() - DESIRED_POOL_SIZE; + final Iterator iterator = sQueryPool.descendingKeySet().iterator(); + while (toBeRemoved-- > 0) { + iterator.next(); + iterator.remove(); + } + } + } + + @Override + public String getSql() { + return mQuery; + } + + @Override + public int getArgCount() { + return mArgCount; + } + + @Override + public void bindTo(SupportSQLiteProgram program) { + for (int index = 1; index <= mArgCount; index++) { + switch (mBindingTypes[index]) { + case NULL: + program.bindNull(index); + break; + case LONG: + program.bindLong(index, mLongBindings[index]); + break; + case DOUBLE: + program.bindDouble(index, mDoubleBindings[index]); + break; + case STRING: + program.bindString(index, mStringBindings[index]); + break; + case BLOB: + program.bindBlob(index, mBlobBindings[index]); + break; + } + } + } + + @Override + public void bindNull(int index) { + mBindingTypes[index] = NULL; + } + + @Override + public void bindLong(int index, long value) { + mBindingTypes[index] = LONG; + mLongBindings[index] = value; + } + + @Override + public void bindDouble(int index, double value) { + mBindingTypes[index] = DOUBLE; + mDoubleBindings[index] = value; + } + + @Override + public void bindString(int index, String value) { + mBindingTypes[index] = STRING; + mStringBindings[index] = value; + } + + @Override + public void bindBlob(int index, byte[] value) { + mBindingTypes[index] = BLOB; + mBlobBindings[index] = value; + } + + @Override + public void close() { + // no-op. not calling release because it is internal API. + } + + /** + * Copies arguments from another RoomSQLiteQuery into this query. + * + * @param other The other query, which holds the arguments to be copied. + */ + public void copyArgumentsFrom(RoomSQLiteQuery other) { + int argCount = other.getArgCount() + 1; // +1 for the binding offsets + System.arraycopy(other.mBindingTypes, 0, mBindingTypes, 0, argCount); + System.arraycopy(other.mLongBindings, 0, mLongBindings, 0, argCount); + System.arraycopy(other.mStringBindings, 0, mStringBindings, 0, argCount); + System.arraycopy(other.mBlobBindings, 0, mBlobBindings, 0, argCount); + System.arraycopy(other.mDoubleBindings, 0, mDoubleBindings, 0, argCount); + } + + @Override + public void clearBindings() { + Arrays.fill(mBindingTypes, NULL); + Arrays.fill(mStringBindings, null); + Arrays.fill(mBlobBindings, null); + mQuery = null; + // no need to clear others + } + + private static final int NULL = 1; + private static final int LONG = 2; + private static final int DOUBLE = 3; + private static final int STRING = 4; + private static final int BLOB = 5; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({NULL, LONG, DOUBLE, STRING, BLOB}) + @interface Binding { + } +} diff --git a/app/src/main/java/androidx/room/RoomTrackingLiveData.java b/app/src/main/java/androidx/room/RoomTrackingLiveData.java new file mode 100644 index 0000000000..8df1014a41 --- /dev/null +++ b/app/src/main/java/androidx/room/RoomTrackingLiveData.java @@ -0,0 +1,167 @@ +/* + * Copyright 2018 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.annotation.SuppressLint; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.arch.core.executor.ArchTaskExecutor; +import androidx.lifecycle.LiveData; + +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A LiveData implementation that closely works with {@link InvalidationTracker} to implement + * database drive {@link androidx.lifecycle.LiveData} queries that are strongly hold as long + * as they are active. + *

+ * We need this extra handling for {@link androidx.lifecycle.LiveData} because when they are + * observed forever, there is no {@link androidx.lifecycle.Lifecycle} that will keep them in + * memory but they should stay. We cannot add-remove observer in {@link LiveData#onActive()}, + * {@link LiveData#onInactive()} because that would mean missing changes in between or doing an + * extra query on every UI rotation. + *

+ * This {@link LiveData} keeps a weak observer to the {@link InvalidationTracker} but it is hold + * strongly by the {@link InvalidationTracker} as long as it is active. + */ +class RoomTrackingLiveData extends LiveData { + @SuppressWarnings("WeakerAccess") + final RoomDatabase mDatabase; + + @SuppressWarnings("WeakerAccess") + final boolean mInTransaction; + + @SuppressWarnings("WeakerAccess") + final Callable mComputeFunction; + + private final InvalidationLiveDataContainer mContainer; + + @SuppressWarnings("WeakerAccess") + final InvalidationTracker.Observer mObserver; + + @SuppressWarnings("WeakerAccess") + final AtomicBoolean mInvalid = new AtomicBoolean(true); + + @SuppressWarnings("WeakerAccess") + final AtomicBoolean mComputing = new AtomicBoolean(false); + + @SuppressWarnings("WeakerAccess") + final AtomicBoolean mRegisteredObserver = new AtomicBoolean(false); + + @SuppressWarnings("WeakerAccess") + final Runnable mRefreshRunnable = new Runnable() { + @WorkerThread + @Override + public void run() { + if (mRegisteredObserver.compareAndSet(false, true)) { + mDatabase.getInvalidationTracker().addWeakObserver(mObserver); + } + boolean computed; + do { + computed = false; + // compute can happen only in 1 thread but no reason to lock others. + if (mComputing.compareAndSet(false, true)) { + // as long as it is invalid, keep computing. + try { + T value = null; + while (mInvalid.compareAndSet(true, false)) { + computed = true; + try { + value = mComputeFunction.call(); + } catch (Exception e) { + throw new RuntimeException("Exception while computing database" + + " live data.", e); + } + } + if (computed) { + postValue(value); + } + } finally { + // release compute lock + mComputing.set(false); + } + } + // check invalid after releasing compute lock to avoid the following scenario. + // Thread A runs compute() + // Thread A checks invalid, it is false + // Main thread sets invalid to true + // Thread B runs, fails to acquire compute lock and skips + // Thread A releases compute lock + // We've left invalid in set state. The check below recovers. + } while (computed && mInvalid.get()); + } + }; + + @SuppressWarnings("WeakerAccess") + final Runnable mInvalidationRunnable = new Runnable() { + @MainThread + @Override + public void run() { + boolean isActive = hasActiveObservers(); + if (mInvalid.compareAndSet(false, true)) { + if (isActive) { + getQueryExecutor().execute(mRefreshRunnable); + } + } + } + }; + @SuppressLint("RestrictedApi") + RoomTrackingLiveData( + RoomDatabase database, + InvalidationLiveDataContainer container, + boolean inTransaction, + Callable computeFunction, + String[] tableNames) { + mDatabase = database; + mInTransaction = inTransaction; + mComputeFunction = computeFunction; + mContainer = container; + mObserver = new InvalidationTracker.Observer(tableNames) { + @Override + public void onInvalidated(@NonNull Set tables) { + ArchTaskExecutor.getInstance().executeOnMainThread(mInvalidationRunnable); + } + }; + } + + @Override + protected void onActive() { + super.onActive(); + mContainer.onActive(this); + getQueryExecutor().execute(mRefreshRunnable); + } + + @Override + protected void onInactive() { + super.onInactive(); + mContainer.onInactive(this); + } + + Executor getQueryExecutor() { + if (mInTransaction) { + return mDatabase.getTransactionExecutor(); + } else { + return mDatabase.getQueryExecutor(); + } + } +} diff --git a/app/src/main/java/androidx/room/SQLiteCopyOpenHelper.java b/app/src/main/java/androidx/room/SQLiteCopyOpenHelper.java new file mode 100644 index 0000000000..f8240ceed3 --- /dev/null +++ b/app/src/main/java/androidx/room/SQLiteCopyOpenHelper.java @@ -0,0 +1,205 @@ +/* + * 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 java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.ReadableByteChannel; + +/** + * An open helper that will copy & open a pre-populated database if it doesn't exists in internal + * storage. + */ +class SQLiteCopyOpenHelper implements SupportSQLiteOpenHelper { + + @NonNull + private final Context mContext; + @Nullable + private final String mCopyFromAssetPath; + @Nullable + private final File mCopyFromFile; + 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, + int databaseVersion, + @NonNull SupportSQLiteOpenHelper supportSQLiteOpenHelper) { + mContext = context; + mCopyFromAssetPath = copyFromAssetPath; + mCopyFromFile = copyFromFile; + 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(); + mVerified = true; + } + return mDelegate.getWritableDatabase(); + } + + @Override + public synchronized SupportSQLiteDatabase getReadableDatabase() { + if (!mVerified) { + verifyDatabaseFile(); + mVerified = true; + } + return mDelegate.getReadableDatabase(); + } + + @Override + public synchronized void close() { + mDelegate.close(); + mVerified = false; + } + + // 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() { + 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); + 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); + } 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) throws IOException { + ReadableByteChannel input; + if (mCopyFromAssetPath != null) { + input = Channels.newChannel(mContext.getAssets().open(mCopyFromAssetPath)); + } else if (mCopyFromFile != null) { + input = new FileInputStream(mCopyFromFile).getChannel(); + } else { + throw new IllegalStateException("copyFromAssetPath and copyFromFile == 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()); + 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()); + } + + if (!intermediateFile.renameTo(destinationFile)) { + throw new IOException("Failed to move intermediate file (" + + intermediateFile.getAbsolutePath() + ") to destination (" + + destinationFile.getAbsolutePath() + ")."); + } + } +} diff --git a/app/src/main/java/androidx/room/SQLiteCopyOpenHelperFactory.java b/app/src/main/java/androidx/room/SQLiteCopyOpenHelperFactory.java new file mode 100644 index 0000000000..22178e80ee --- /dev/null +++ b/app/src/main/java/androidx/room/SQLiteCopyOpenHelperFactory.java @@ -0,0 +1,56 @@ +/* + * 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 androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.sqlite.db.SupportSQLiteOpenHelper; + +import java.io.File; + +/** + * Implementation of {@link SupportSQLiteOpenHelper.Factory} that creates + * {@link SQLiteCopyOpenHelper}. + */ +class SQLiteCopyOpenHelperFactory implements SupportSQLiteOpenHelper.Factory { + + @Nullable + private final String mCopyFromAssetPath; + @Nullable + private final File mCopyFromFile; + @NonNull + private final SupportSQLiteOpenHelper.Factory mDelegate; + + SQLiteCopyOpenHelperFactory( + @Nullable String copyFromAssetPath, + @Nullable File copyFromFile, + @NonNull SupportSQLiteOpenHelper.Factory factory) { + mCopyFromAssetPath = copyFromAssetPath; + mCopyFromFile = copyFromFile; + mDelegate = factory; + } + + @Override + public SupportSQLiteOpenHelper create(SupportSQLiteOpenHelper.Configuration configuration) { + return new SQLiteCopyOpenHelper( + configuration.context, + mCopyFromAssetPath, + mCopyFromFile, + configuration.callback.version, + mDelegate.create(configuration)); + } +} diff --git a/app/src/main/java/androidx/room/SharedSQLiteStatement.java b/app/src/main/java/androidx/room/SharedSQLiteStatement.java new file mode 100644 index 0000000000..20c06c8b50 --- /dev/null +++ b/app/src/main/java/androidx/room/SharedSQLiteStatement.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2016 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 androidx.annotation.RestrictTo; +import androidx.sqlite.db.SupportSQLiteStatement; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Represents a prepared SQLite state that can be re-used multiple times. + *

+ * This class is used by generated code. After it is used, {@code release} must be called so that + * it can be used by other threads. + *

+ * To avoid re-entry even within the same thread, this class allows only 1 time access to the shared + * statement until it is released. + * + * @hide + */ +@SuppressWarnings({"WeakerAccess", "unused"}) +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +public abstract class SharedSQLiteStatement { + private final AtomicBoolean mLock = new AtomicBoolean(false); + + private final RoomDatabase mDatabase; + private volatile SupportSQLiteStatement mStmt; + + /** + * Creates an SQLite prepared statement that can be re-used across threads. If it is in use, + * it automatically creates a new one. + * + * @param database The database to create the statement in. + */ + public SharedSQLiteStatement(RoomDatabase database) { + mDatabase = database; + } + + /** + * Create the query. + * + * @return The SQL query to prepare. + */ + protected abstract String createQuery(); + + protected void assertNotMainThread() { + mDatabase.assertNotMainThread(); + } + + private SupportSQLiteStatement createNewStatement() { + String query = createQuery(); + return mDatabase.compileStatement(query); + } + + private SupportSQLiteStatement getStmt(boolean canUseCached) { + final SupportSQLiteStatement stmt; + if (canUseCached) { + if (mStmt == null) { + mStmt = createNewStatement(); + } + stmt = mStmt; + } else { + // it is in use, create a one off statement + stmt = createNewStatement(); + } + return stmt; + } + + /** + * Call this to get the statement. Must call {@link #release(SupportSQLiteStatement)} once done. + */ + public SupportSQLiteStatement acquire() { + assertNotMainThread(); + return getStmt(mLock.compareAndSet(false, true)); + } + + /** + * Must call this when statement will not be used anymore. + * + * @param statement The statement that was returned from acquire. + */ + public void release(SupportSQLiteStatement statement) { + if (statement == mStmt) { + mLock.set(false); + } + } +} diff --git a/app/src/main/java/androidx/room/TransactionExecutor.java b/app/src/main/java/androidx/room/TransactionExecutor.java new file mode 100644 index 0000000000..6a6bc1260d --- /dev/null +++ b/app/src/main/java/androidx/room/TransactionExecutor.java @@ -0,0 +1,62 @@ +/* + * 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 androidx.annotation.NonNull; + +import java.util.ArrayDeque; +import java.util.concurrent.Executor; + +/** + * Executor wrapper for performing database transactions serially. + *

+ * Since database transactions are exclusive, this executor ensures that transactions are performed + * in-order and one at a time, preventing threads from blocking each other when multiple concurrent + * transactions are attempted. + */ +class TransactionExecutor implements Executor { + + private final Executor mExecutor; + private final ArrayDeque mTasks = new ArrayDeque<>(); + private Runnable mActive; + + TransactionExecutor(@NonNull Executor executor) { + mExecutor = executor; + } + + public synchronized void execute(final Runnable command) { + mTasks.offer(new Runnable() { + public void run() { + try { + command.run(); + } finally { + scheduleNext(); + } + } + }); + if (mActive == null) { + scheduleNext(); + } + } + + @SuppressWarnings("WeakerAccess") + synchronized void scheduleNext() { + if ((mActive = mTasks.poll()) != null) { + mExecutor.execute(mActive); + } + } +} diff --git a/app/src/main/java/androidx/room/migration/Migration.java b/app/src/main/java/androidx/room/migration/Migration.java new file mode 100644 index 0000000000..4aa7a7e86a --- /dev/null +++ b/app/src/main/java/androidx/room/migration/Migration.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2017 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.migration; + +import androidx.annotation.NonNull; +import androidx.sqlite.db.SupportSQLiteDatabase; + +/** + * Base class for a database migration. + *

+ * Each migration can move between 2 versions that are defined by {@link #startVersion} and + * {@link #endVersion}. + *

+ * A migration can handle more than 1 version (e.g. if you have a faster path to choose when + * going version 3 to 5 without going to version 4). If Room opens a database at version + * 3 and latest version is >= 5, Room will use the migration object that can migrate from + * 3 to 5 instead of 3 to 4 and 4 to 5. + *

+ * If there are not enough migrations provided to move from the current version to the latest + * version, Room will clear the database and recreate so even if you have no changes between 2 + * versions, you should still provide a Migration object to the builder. + */ +public abstract class Migration { + public final int startVersion; + public final int endVersion; + + /** + * Creates a new migration between {@code startVersion} and {@code endVersion}. + * + * @param startVersion The start version of the database. + * @param endVersion The end version of the database after this migration is applied. + */ + public Migration(int startVersion, int endVersion) { + this.startVersion = startVersion; + this.endVersion = endVersion; + } + + /** + * Should run the necessary migrations. + *

+ * This class cannot access any generated Dao in this method. + *

+ * This method is already called inside a transaction and that transaction might actually be a + * composite transaction of all necessary {@code Migration}s. + * + * @param database The database instance + */ + public abstract void migrate(@NonNull SupportSQLiteDatabase database); +} diff --git a/app/src/main/java/androidx/room/package-info.java b/app/src/main/java/androidx/room/package-info.java new file mode 100644 index 0000000000..3a14e974ff --- /dev/null +++ b/app/src/main/java/androidx/room/package-info.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2017 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. + */ + +/** + * Room is a Database Object Mapping library that makes it easy to access database on Android + * applications. + *

+ * Rather than hiding the details of SQLite, Room tries to embrace them by providing convenient APIs + * to query the database and also verify such queries at compile time. This allows you to access + * the full power of SQLite while having the type safety provided by Java SQL query builders. + *

+ * There are 3 major components in Room. + *

+ * Below is a sample of a simple database. + *
+ * // File: Song.java
+ * {@literal @}Entity
+ * public class User {
+ *   {@literal @}PrimaryKey
+ *   private int id;
+ *   private String name;
+ *   {@literal @}ColumnInfo(name = "release_year")
+ *   private int releaseYear;
+ *   // getters and setters are ignored for brevity but they are required for Room to work.
+ * }
+ * // File: SongDao.java
+ * {@literal @}Dao
+ * public interface SongDao {
+ *   {@literal @}Query("SELECT * FROM song")
+ *   List<Song> loadAll();
+ *   {@literal @}Query("SELECT * FROM song WHERE id IN (:songIds)")
+ *   List<Song> loadAllBySongId(int... songIds);
+ *   {@literal @}Query("SELECT * FROM song WHERE name LIKE :name AND release_year = :year LIMIT 1")
+ *   Song loadOneByNameAndReleaseYear(String first, int year);
+ *   {@literal @}Insert
+ *   void insertAll(Song... songs);
+ *   {@literal @}Delete
+ *   void delete(Song song);
+ * }
+ * // File: MusicDatabase.java
+ * {@literal @}Database(entities = {Song.java})
+ * public abstract class MusicDatabase extends RoomDatabase {
+ *   public abstract SongDao userDao();
+ * }
+ * 
+ * You can create an instance of {@code MusicDatabase} as follows: + *
+ * MusicDatabase db = Room
+ *     .databaseBuilder(getApplicationContext(), MusicDatabase.class, "database-name")
+ *     .build();
+ * 
+ * Since Room verifies your queries at compile time, it also detects information about which tables + * are accessed by the query or what columns are present in the response. + *

+ * You can observe a particular table for changes using the + * {@link androidx.room.InvalidationTracker InvalidationTracker} class which you can acquire via + * {@link androidx.room.RoomDatabase#getInvalidationTracker() + * RoomDatabase.getInvalidationTracker}. + *

+ * For convenience, Room allows you to return {@link androidx.lifecycle.LiveData LiveData} from + * {@link androidx.room.Query Query} methods. It will automatically observe the related tables as + * long as the {@code LiveData} has active observers. + *

+ * // This live data will automatically dispatch changes as the database changes.
+ * {@literal @}Query("SELECT * FROM song ORDER BY name LIMIT 5")
+ * LiveData<Song> loadFirstFiveSongs();
+ * 
+ *

+ * You can also return arbitrary data objects from your query results as long as the fields in the + * object match the list of columns in the query response. This makes it very easy to write + * applications that drive the UI from persistent storage. + *

+ * class IdAndSongHeader {
+ *   int id;
+ *   {@literal @}ColumnInfo(name = "header")
+ *   String header;
+ * }
+ * // DAO
+ * {@literal @}Query("SELECT id, name || '-' || release_year AS header FROM user")
+ * public IdAndSongHeader[] loadSongHeaders();
+ * 
+ * If there is a mismatch between the query result and the POJO, Room will print a warning during + * compilation. + *

+ * Please see the documentation of individual classes for details. + */ +package androidx.room; diff --git a/app/src/main/java/androidx/room/paging/LimitOffsetDataSource.java b/app/src/main/java/androidx/room/paging/LimitOffsetDataSource.java new file mode 100644 index 0000000000..156ee24ac7 --- /dev/null +++ b/app/src/main/java/androidx/room/paging/LimitOffsetDataSource.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2017 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.paging; + +import android.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; +import androidx.paging.PositionalDataSource; +import androidx.room.InvalidationTracker; +import androidx.room.RoomDatabase; +import androidx.room.RoomSQLiteQuery; +import androidx.sqlite.db.SupportSQLiteQuery; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * A simple data source implementation that uses Limit & Offset to page the query. + *

+ * This is NOT the most efficient way to do paging on SQLite. It is + * recommended to use an indexed + * ORDER BY statement but that requires a more complex API. This solution is technically equal to + * receiving a {@link Cursor} from a large query but avoids the need to manually manage it, and + * never returns inconsistent data if it is invalidated. + * + * @param Data type returned by the data source. + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +public abstract class LimitOffsetDataSource extends PositionalDataSource { + private final RoomSQLiteQuery mSourceQuery; + private final String mCountQuery; + private final String mLimitOffsetQuery; + private final RoomDatabase mDb; + @SuppressWarnings("FieldCanBeLocal") + private final InvalidationTracker.Observer mObserver; + private final boolean mInTransaction; + + protected LimitOffsetDataSource(RoomDatabase db, SupportSQLiteQuery query, + boolean inTransaction, String... tables) { + this(db, RoomSQLiteQuery.copyFrom(query), inTransaction, tables); + } + + protected LimitOffsetDataSource(RoomDatabase db, RoomSQLiteQuery query, + boolean inTransaction, String... tables) { + mDb = db; + mSourceQuery = query; + mInTransaction = inTransaction; + mCountQuery = "SELECT COUNT(*) FROM ( " + mSourceQuery.getSql() + " )"; + mLimitOffsetQuery = "SELECT * FROM ( " + mSourceQuery.getSql() + " ) LIMIT ? OFFSET ?"; + mObserver = new InvalidationTracker.Observer(tables) { + @Override + public void onInvalidated(@NonNull Set tables) { + invalidate(); + } + }; + db.getInvalidationTracker().addWeakObserver(mObserver); + } + + /** + * Count number of rows query can return + * + * @hide + */ + @SuppressWarnings("WeakerAccess") + public int countItems() { + final RoomSQLiteQuery sqLiteQuery = RoomSQLiteQuery.acquire(mCountQuery, + mSourceQuery.getArgCount()); + sqLiteQuery.copyArgumentsFrom(mSourceQuery); + Cursor cursor = mDb.query(sqLiteQuery); + try { + if (cursor.moveToFirst()) { + return cursor.getInt(0); + } + return 0; + } finally { + cursor.close(); + sqLiteQuery.release(); + } + } + + @Override + public boolean isInvalid() { + mDb.getInvalidationTracker().refreshVersionsSync(); + return super.isInvalid(); + } + + @SuppressWarnings("WeakerAccess") + protected abstract List convertRows(Cursor cursor); + + @SuppressWarnings("deprecation") + @Override + public void loadInitial(@NonNull LoadInitialParams params, + @NonNull LoadInitialCallback callback) { + List list = Collections.emptyList(); + int totalCount = 0; + int firstLoadPosition = 0; + RoomSQLiteQuery sqLiteQuery = null; + Cursor cursor = null; + mDb.beginTransaction(); + try { + totalCount = countItems(); + if (totalCount != 0) { + // bound the size requested, based on known count + firstLoadPosition = computeInitialLoadPosition(params, totalCount); + int firstLoadSize = computeInitialLoadSize(params, firstLoadPosition, totalCount); + + sqLiteQuery = getSQLiteQuery(firstLoadPosition, firstLoadSize); + cursor = mDb.query(sqLiteQuery); + List rows = convertRows(cursor); + mDb.setTransactionSuccessful(); + list = rows; + } + } finally { + if (cursor != null) { + cursor.close(); + } + mDb.endTransaction(); + if (sqLiteQuery != null) { + sqLiteQuery.release(); + } + } + + callback.onResult(list, firstLoadPosition, totalCount); + } + + @Override + public void loadRange(@NonNull LoadRangeParams params, + @NonNull LoadRangeCallback callback) { + callback.onResult(loadRange(params.startPosition, params.loadSize)); + } + + /** + * Return the rows from startPos to startPos + loadCount + * + * @hide + */ + @SuppressWarnings("deprecation") + @NonNull + public List loadRange(int startPosition, int loadCount) { + final RoomSQLiteQuery sqLiteQuery = getSQLiteQuery(startPosition, loadCount); + if (mInTransaction) { + mDb.beginTransaction(); + Cursor cursor = null; + //noinspection TryFinallyCanBeTryWithResources + try { + cursor = mDb.query(sqLiteQuery); + List rows = convertRows(cursor); + mDb.setTransactionSuccessful(); + return rows; + } finally { + if (cursor != null) { + cursor.close(); + } + mDb.endTransaction(); + sqLiteQuery.release(); + } + } else { + Cursor cursor = mDb.query(sqLiteQuery); + //noinspection TryFinallyCanBeTryWithResources + try { + return convertRows(cursor); + } finally { + cursor.close(); + sqLiteQuery.release(); + } + } + } + + private RoomSQLiteQuery getSQLiteQuery(int startPosition, int loadCount) { + final RoomSQLiteQuery sqLiteQuery = RoomSQLiteQuery.acquire(mLimitOffsetQuery, + mSourceQuery.getArgCount() + 2); + sqLiteQuery.copyArgumentsFrom(mSourceQuery); + sqLiteQuery.bindLong(sqLiteQuery.getArgCount() - 1, loadCount); + sqLiteQuery.bindLong(sqLiteQuery.getArgCount(), startPosition); + return sqLiteQuery; + } +} diff --git a/app/src/main/java/androidx/room/util/CopyLock.java b/app/src/main/java/androidx/room/util/CopyLock.java new file mode 100644 index 0000000000..3750315f34 --- /dev/null +++ b/app/src/main/java/androidx/room/util/CopyLock.java @@ -0,0 +1,112 @@ +/* + * 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.util; + +import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Utility class for in-process and multi-process key-based lock mechanism for safely copying + * database files. + *

+ * Acquiring the lock will be quick if no other thread or process has a lock with the same key. + * But if the lock is already held then acquiring it will block, until the other thread or process + * releases the lock. Note that the key and lock directory must be the same to achieve + * synchronization. + *

+ * Locking is done via two levels: + *

    + *
  1. + * Thread locking within the same JVM process is done via a map of String key to ReentrantLock + * objects. + *
  2. + * Multi-process locking is done via a dummy file whose name contains the key and FileLock + * objects. + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +public class CopyLock { + + // in-process lock map + private static final Map sThreadLocks = new HashMap<>(); + + private final File mCopyLockFile; + private final Lock mThreadLock; + private final boolean mFileLevelLock; + private FileChannel mLockChannel; + + /** + * Creates a lock with {@code name} and using {@code lockDir} as the directory for the + * lock files. + * @param name the name of this lock. + * @param lockDir the directory where the lock files will be located. + * @param processLock whether to use file for process level locking or not. + */ + public CopyLock(@NonNull String name, @NonNull File lockDir, boolean processLock) { + mCopyLockFile = new File(lockDir, name + ".lck"); + mThreadLock = getThreadLock(mCopyLockFile.getAbsolutePath()); + mFileLevelLock = processLock; + } + + /** + * Attempts to grab the lock, blocking if already held by another thread or process. + */ + public void lock() { + mThreadLock.lock(); + if (mFileLevelLock) { + try { + mLockChannel = new FileOutputStream(mCopyLockFile).getChannel(); + mLockChannel.lock(); + } catch (IOException e) { + throw new IllegalStateException("Unable to grab copy lock.", e); + } + } + } + + /** + * Releases the lock. + */ + public void unlock() { + if (mLockChannel != null) { + try { + mLockChannel.close(); + } catch (IOException ignored) { } + } + mThreadLock.unlock(); + } + + private static Lock getThreadLock(String key) { + synchronized (sThreadLocks) { + Lock threadLock = sThreadLocks.get(key); + if (threadLock == null) { + threadLock = new ReentrantLock(); + sThreadLocks.put(key, threadLock); + } + return threadLock; + } + } +} diff --git a/app/src/main/java/androidx/room/util/CursorUtil.java b/app/src/main/java/androidx/room/util/CursorUtil.java new file mode 100644 index 0000000000..05b8c59170 --- /dev/null +++ b/app/src/main/java/androidx/room/util/CursorUtil.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2018 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.util; + +import android.database.Cursor; +import android.database.MatrixCursor; + +import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; + +/** + * Cursor utilities for Room + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +public class CursorUtil { + + /** + * Copies the given cursor into a in-memory cursor and then closes it. + *

    + * This is useful for iterating over a cursor multiple times without the cost of JNI while + * reading or IO while filling the window at the expense of memory consumption. + * + * @param c the cursor to copy. + * @return a new cursor containing the same data as the given cursor. + */ + @NonNull + public static Cursor copyAndClose(@NonNull Cursor c) { + final MatrixCursor matrixCursor; + try { + matrixCursor = new MatrixCursor(c.getColumnNames(), c.getCount()); + while (c.moveToNext()) { + final Object[] row = new Object[c.getColumnCount()]; + for (int i = 0; i < c.getColumnCount(); i++) { + switch (c.getType(i)) { + case Cursor.FIELD_TYPE_NULL: + row[i] = null; + break; + case Cursor.FIELD_TYPE_INTEGER: + row[i] = c.getLong(i); + break; + case Cursor.FIELD_TYPE_FLOAT: + row[i] = c.getDouble(i); + break; + case Cursor.FIELD_TYPE_STRING: + row[i] = c.getString(i); + break; + case Cursor.FIELD_TYPE_BLOB: + row[i] = c.getBlob(i); + break; + default: + throw new IllegalStateException(); + } + } + matrixCursor.addRow(row); + } + } finally { + c.close(); + } + return matrixCursor; + } + + /** + * Patches {@link Cursor#getColumnIndex(String)} to work around issues on older devices. + * If the column is not found, it retries with the specified name surrounded by backticks. + * + * @param c The cursor. + * @param name The name of the target column. + * @return The index of the column, or -1 if not found. + */ + public static int getColumnIndex(@NonNull Cursor c, @NonNull String name) { + final int index = c.getColumnIndex(name); + if (index >= 0) { + return index; + } + return c.getColumnIndex("`" + name + "`"); + } + + /** + * Patches {@link Cursor#getColumnIndexOrThrow(String)} to work around issues on older devices. + * If the column is not found, it retries with the specified name surrounded by backticks. + * + * @param c The cursor. + * @param name The name of the target column. + * @return The index of the column. + * @throws IllegalArgumentException if the column does not exist. + */ + public static int getColumnIndexOrThrow(@NonNull Cursor c, @NonNull String name) { + final int index = c.getColumnIndex(name); + if (index >= 0) { + return index; + } + return c.getColumnIndexOrThrow("`" + name + "`"); + } + + private CursorUtil() { + } +} diff --git a/app/src/main/java/androidx/room/util/DBUtil.java b/app/src/main/java/androidx/room/util/DBUtil.java new file mode 100644 index 0000000000..f7afd265a3 --- /dev/null +++ b/app/src/main/java/androidx/room/util/DBUtil.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2018 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.util; + +import android.database.AbstractWindowedCursor; +import android.database.Cursor; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; +import androidx.room.RoomDatabase; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.sqlite.db.SupportSQLiteQuery; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.util.ArrayList; +import java.util.List; + +/** + * Database utilities for Room + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +public class DBUtil { + + /** + * Performs the SQLiteQuery on the given database. + *

    + * This util method encapsulates copying the cursor if the {@code maybeCopy} parameter is + * {@code true} and either the api level is below a certain threshold or the full result of the + * query does not fit in a single window. + * + * @param db The database to perform the query on. + * @param sqLiteQuery The query to perform. + * @param maybeCopy True if the result cursor should maybe be copied, false otherwise. + * @return Result of the query. + */ + @NonNull + public static Cursor query(RoomDatabase db, SupportSQLiteQuery sqLiteQuery, boolean maybeCopy) { + final Cursor cursor = db.query(sqLiteQuery); + if (maybeCopy && cursor instanceof AbstractWindowedCursor) { + AbstractWindowedCursor windowedCursor = (AbstractWindowedCursor) cursor; + int rowsInCursor = windowedCursor.getCount(); // Should fill the window. + int rowsInWindow; + if (windowedCursor.hasWindow()) { + rowsInWindow = windowedCursor.getWindow().getNumRows(); + } else { + rowsInWindow = rowsInCursor; + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || rowsInWindow < rowsInCursor) { + return CursorUtil.copyAndClose(windowedCursor); + } + } + + return cursor; + } + + /** + * Drops all FTS content sync triggers created by Room. + *

    + * FTS content sync triggers created by Room are those that are found in the sqlite_master table + * who's names start with 'room_fts_content_sync_'. + * + * @param db The database. + */ + public static void dropFtsSyncTriggers(SupportSQLiteDatabase db) { + List existingTriggers = new ArrayList<>(); + Cursor cursor = db.query("SELECT name FROM sqlite_master WHERE type = 'trigger'"); + //noinspection TryFinallyCanBeTryWithResources + try { + while (cursor.moveToNext()) { + existingTriggers.add(cursor.getString(0)); + } + } finally { + cursor.close(); + } + + for (String triggerName : existingTriggers) { + if (triggerName.startsWith("room_fts_content_sync_")) { + db.execSQL("DROP TRIGGER IF EXISTS " + triggerName); + } + } + } + + /** + * Reads the user version number out of the database header from the given file. + * + * @param databaseFile the database file. + * @return the database version + * @throws IOException if something goes wrong reading the file, such as bad database header or + * missing permissions. + * + * @see User Version + * Number. + */ + public static int readVersion(@NonNull File databaseFile) throws IOException { + FileChannel input = null; + try { + ByteBuffer buffer = ByteBuffer.allocate(4); + input = new FileInputStream(databaseFile).getChannel(); + input.tryLock(60, 4, true); + input.position(60); + int read = input.read(buffer); + if (read != 4) { + throw new IOException("Bad database header, unable to read 4 bytes at offset 60"); + } + buffer.rewind(); + return buffer.getInt(); // ByteBuffer is big-endian by default + } finally { + if (input != null) { + input.close(); + } + } + } + + private DBUtil() { + } +} diff --git a/app/src/main/java/androidx/room/util/FileUtil.java b/app/src/main/java/androidx/room/util/FileUtil.java new file mode 100644 index 0000000000..a221ff741a --- /dev/null +++ b/app/src/main/java/androidx/room/util/FileUtil.java @@ -0,0 +1,71 @@ +/* + * 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.util; + +import android.annotation.SuppressLint; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.ReadableByteChannel; + +/** + * File utilities for Room + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +public class FileUtil { + + /** + * Copies data from the input channel to the output file channel. + * + * @param input the input channel to copy. + * @param output the output channel to copy. + * @throws IOException if there is an I/O error. + */ + @SuppressLint("LambdaLast") + public static void copy(@NonNull ReadableByteChannel input, @NonNull FileChannel output) + throws IOException { + try { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M) { + output.transferFrom(input, 0, Long.MAX_VALUE); + } else { + InputStream inputStream = Channels.newInputStream(input); + OutputStream outputStream = Channels.newOutputStream(output); + int length; + byte[] buffer = new byte[1024 * 4]; + while ((length = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, length); + } + } + output.force(false); + } finally { + input.close(); + output.close(); + } + } + + private FileUtil() { + } +} diff --git a/app/src/main/java/androidx/room/util/FtsTableInfo.java b/app/src/main/java/androidx/room/util/FtsTableInfo.java new file mode 100644 index 0000000000..c076a0238b --- /dev/null +++ b/app/src/main/java/androidx/room/util/FtsTableInfo.java @@ -0,0 +1,220 @@ +/* + * Copyright 2018 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.util; + +import android.database.Cursor; + +import androidx.annotation.RestrictTo; +import androidx.annotation.VisibleForTesting; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * A data class that holds the information about an FTS table. + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +public class FtsTableInfo { + + // A set of valid FTS Options + private static final String[] FTS_OPTIONS = new String[] { + "tokenize=", "compress=", "content=", "languageid=", "matchinfo=", "notindexed=", + "order=", "prefix=", "uncompress="}; + + /** + * The table name + */ + public final String name; + + /** + * The column names + */ + public final Set columns; + + /** + * The set of options. Each value in the set contains the option in the following format: + * <key>=<value>. + */ + public final Set options; + + public FtsTableInfo(String name, Set columns, Set options) { + this.name = name; + this.columns = columns; + this.options = options; + } + + public FtsTableInfo(String name, Set columns, String createSql) { + this.name = name; + this.columns = columns; + this.options = parseOptions(createSql); + } + + /** + * Reads the table information from the given database. + * + * @param database The database to read the information from. + * @param tableName The table name. + * @return A FtsTableInfo containing the columns and options for the provided table name. + */ + public static FtsTableInfo read(SupportSQLiteDatabase database, String tableName) { + Set columns = readColumns(database, tableName); + Set options = readOptions(database, tableName); + + return new FtsTableInfo(tableName, columns, options); + } + + @SuppressWarnings("TryFinallyCanBeTryWithResources") + private static Set readColumns(SupportSQLiteDatabase database, String tableName) { + Cursor cursor = database.query("PRAGMA table_info(`" + tableName + "`)"); + Set columns = new HashSet<>(); + try { + if (cursor.getColumnCount() > 0) { + int nameIndex = cursor.getColumnIndex("name"); + while (cursor.moveToNext()) { + columns.add(cursor.getString(nameIndex)); + } + } + } finally { + cursor.close(); + } + return columns; + } + + @SuppressWarnings("TryFinallyCanBeTryWithResources") + private static Set readOptions(SupportSQLiteDatabase database, String tableName) { + String sql = ""; + Cursor cursor = database.query( + "SELECT * FROM sqlite_master WHERE `name` = '" + tableName + "'"); + try { + if (cursor.moveToFirst()) { + sql = cursor.getString(cursor.getColumnIndexOrThrow("sql")); + } + } finally { + cursor.close(); + } + return parseOptions(sql); + } + + /** + * Parses FTS options from the create statement of an FTS table. + * + * This method assumes the given create statement is a valid well-formed SQLite statement as + * defined in the CREATE VIRTUAL TABLE + * syntax diagram. + * + * @param createStatement the "CREATE VIRTUAL TABLE" statement. + * @return the set of FTS option key and values in the create statement. + */ + @VisibleForTesting + @SuppressWarnings("WeakerAccess") /* synthetic access */ + static Set parseOptions(String createStatement) { + if (createStatement.isEmpty()) { + return new HashSet<>(); + } + + // Module arguments are within the parenthesis followed by the module name. + String argsString = createStatement.substring( + createStatement.indexOf('(') + 1, + createStatement.lastIndexOf(')')); + + // Split the module argument string by the comma delimiter, keeping track of quotation so + // so that if the delimiter is found within a string literal we don't substring at the wrong + // index. SQLite supports four ways of quoting keywords, see: + // https://www.sqlite.org/lang_keywords.html + List args = new ArrayList<>(); + ArrayDeque quoteStack = new ArrayDeque<>(); + int lastDelimiterIndex = -1; + for (int i = 0; i < argsString.length(); i++) { + char c = argsString.charAt(i); + switch (c) { + case '\'': + case '"': + case '`': + if (quoteStack.isEmpty()) { + quoteStack.push(c); + } else if (quoteStack.peek() == c) { + quoteStack.pop(); + } + break; + case '[': + if (quoteStack.isEmpty()) { + quoteStack.push(c); + } + break; + case ']': + if (!quoteStack.isEmpty() && quoteStack.peek() == '[') { + quoteStack.pop(); + } + break; + case ',': + if (quoteStack.isEmpty()) { + args.add(argsString.substring(lastDelimiterIndex + 1, i).trim()); + lastDelimiterIndex = i; + } + break; + } + } + args.add(argsString.substring(lastDelimiterIndex + 1).trim()); // Add final argument. + + // Match args against valid options, otherwise they are column definitions. + HashSet options = new HashSet<>(); + for (String arg : args) { + for (String validOption : FTS_OPTIONS) { + if (arg.startsWith(validOption)) { + options.add(arg); + } + } + } + + return options; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FtsTableInfo that = (FtsTableInfo) o; + + if (name != null ? !name.equals(that.name) : that.name != null) return false; + if (columns != null ? !columns.equals(that.columns) : that.columns != null) return false; + return options != null ? options.equals(that.options) : that.options == null; + } + + @Override + public int hashCode() { + int result = name != null ? name.hashCode() : 0; + result = 31 * result + (columns != null ? columns.hashCode() : 0); + result = 31 * result + (options != null ? options.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return "FtsTableInfo{" + + "name='" + name + '\'' + + ", columns=" + columns + + ", options=" + options + + '}'; + } +} diff --git a/app/src/main/java/androidx/room/util/SneakyThrow.java b/app/src/main/java/androidx/room/util/SneakyThrow.java new file mode 100644 index 0000000000..76968ab344 --- /dev/null +++ b/app/src/main/java/androidx/room/util/SneakyThrow.java @@ -0,0 +1,47 @@ +/* + * 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.util; + +import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; + +/** + * Java 8 Sneaky Throw technique. + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +public class SneakyThrow { + + /** + * Re-throws a checked exception as if it was a runtime exception without wrapping it. + * + * @param e the exception to re-throw. + */ + public static void reThrow(@NonNull Exception e) { + sneakyThrow(e); + } + + @SuppressWarnings("unchecked") + private static void sneakyThrow(@NonNull Throwable e) throws E { + throw (E) e; + } + + private SneakyThrow() { + + } +} diff --git a/app/src/main/java/androidx/room/util/StringUtil.java b/app/src/main/java/androidx/room/util/StringUtil.java new file mode 100644 index 0000000000..2b30eb64de --- /dev/null +++ b/app/src/main/java/androidx/room/util/StringUtil.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2016 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.util; + +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringTokenizer; + +/** + * @hide + * + * String utilities for Room + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +public class StringUtil { + + @SuppressWarnings("unused") + public static final String[] EMPTY_STRING_ARRAY = new String[0]; + /** + * Returns a new StringBuilder to be used while producing SQL queries. + * + * @return A new or recycled StringBuilder + */ + public static StringBuilder newStringBuilder() { + // TODO pool: + return new StringBuilder(); + } + + /** + * Adds bind variable placeholders (?) to the given string. Each placeholder is separated + * by a comma. + * + * @param builder The StringBuilder for the query + * @param count Number of placeholders + */ + public static void appendPlaceholders(StringBuilder builder, int count) { + for (int i = 0; i < count; i++) { + builder.append("?"); + if (i < count - 1) { + builder.append(","); + } + } + } + /** + * Splits a comma separated list of integers to integer list. + *

    + * If an input is malformed, it is omitted from the result. + * + * @param input Comma separated list of integers. + * @return A List containing the integers or null if the input is null. + */ + @Nullable + public static List splitToIntList(@Nullable String input) { + if (input == null) { + return null; + } + List result = new ArrayList<>(); + StringTokenizer tokenizer = new StringTokenizer(input, ","); + while (tokenizer.hasMoreElements()) { + final String item = tokenizer.nextToken(); + try { + result.add(Integer.parseInt(item)); + } catch (NumberFormatException ex) { + Log.e("ROOM", "Malformed integer list", ex); + } + } + return result; + } + + /** + * Joins the given list of integers into a comma separated list. + * + * @param input The list of integers. + * @return Comma separated string composed of integers in the list. If the list is null, return + * value is null. + */ + @Nullable + public static String joinIntoString(@Nullable List input) { + if (input == null) { + return null; + } + + final int size = input.size(); + if (size == 0) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < size; i++) { + sb.append(Integer.toString(input.get(i))); + if (i < size - 1) { + sb.append(","); + } + } + return sb.toString(); + } + + private StringUtil() { + } +} diff --git a/app/src/main/java/androidx/room/util/TableInfo.java b/app/src/main/java/androidx/room/util/TableInfo.java new file mode 100644 index 0000000000..a28aea49d3 --- /dev/null +++ b/app/src/main/java/androidx/room/util/TableInfo.java @@ -0,0 +1,665 @@ +/* + * Copyright (C) 2017 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.util; + +import android.database.Cursor; +import android.os.Build; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import androidx.room.ColumnInfo; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * A data class that holds the information about a table. + *

    + * It directly maps to the result of {@code PRAGMA table_info()}. Check the + * PRAGMA table_info + * documentation for more details. + *

    + * Even though SQLite column names are case insensitive, this class uses case sensitive matching. + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +@SuppressWarnings({"WeakerAccess", "unused", "TryFinallyCanBeTryWithResources", + "SimplifiableIfStatement"}) +// if you change this class, you must change TableInfoWriter.kt +public class TableInfo { + + /** + * Identifies from where the info object was created. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef(value = {CREATED_FROM_UNKNOWN, CREATED_FROM_ENTITY, CREATED_FROM_DATABASE}) + @interface CreatedFrom { + } + + /** + * Identifier for when the info is created from an unknown source. + */ + public static final int CREATED_FROM_UNKNOWN = 0; + + /** + * Identifier for when the info is created from an entity definition, such as generated code + * by the compiler or at runtime from a schema bundle, parsed from a schema JSON file. + */ + public static final int CREATED_FROM_ENTITY = 1; + + /** + * Identifier for when the info is created from the database itself, reading information from a + * PRAGMA, such as table_info. + */ + public static final int CREATED_FROM_DATABASE = 2; + + /** + * The table name. + */ + public final String name; + /** + * Unmodifiable map of columns keyed by column name. + */ + public final Map columns; + + public final Set foreignKeys; + + /** + * Sometimes, Index information is not available (older versions). If so, we skip their + * verification. + */ + @Nullable + public final Set indices; + + @SuppressWarnings("unused") + public TableInfo(String name, Map columns, Set foreignKeys, + Set indices) { + this.name = name; + this.columns = Collections.unmodifiableMap(columns); + this.foreignKeys = Collections.unmodifiableSet(foreignKeys); + this.indices = indices == null ? null : Collections.unmodifiableSet(indices); + } + + /** + * For backward compatibility with dbs created with older versions. + */ + @SuppressWarnings("unused") + public TableInfo(String name, Map columns, Set foreignKeys) { + this(name, columns, foreignKeys, Collections.emptySet()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + TableInfo tableInfo = (TableInfo) o; + + if (name != null ? !name.equals(tableInfo.name) : tableInfo.name != null) return false; + if (columns != null ? !columns.equals(tableInfo.columns) : tableInfo.columns != null) { + return false; + } + if (foreignKeys != null ? !foreignKeys.equals(tableInfo.foreignKeys) + : tableInfo.foreignKeys != null) { + return false; + } + if (indices == null || tableInfo.indices == null) { + // if one us is missing index information, seems like we couldn't acquire the + // information so we better skip. + return true; + } + return indices.equals(tableInfo.indices); + } + + @Override + public int hashCode() { + int result = name != null ? name.hashCode() : 0; + result = 31 * result + (columns != null ? columns.hashCode() : 0); + result = 31 * result + (foreignKeys != null ? foreignKeys.hashCode() : 0); + // skip index, it is not reliable for comparison. + return result; + } + + @Override + public String toString() { + return "TableInfo{" + + "name='" + name + '\'' + + ", columns=" + columns + + ", foreignKeys=" + foreignKeys + + ", indices=" + indices + + '}'; + } + + /** + * Reads the table information from the given database. + * + * @param database The database to read the information from. + * @param tableName The table name. + * @return A TableInfo containing the schema information for the provided table name. + */ + @SuppressWarnings("SameParameterValue") + public static TableInfo read(SupportSQLiteDatabase database, String tableName) { + Map columns = readColumns(database, tableName); + Set foreignKeys = readForeignKeys(database, tableName); + Set indices = readIndices(database, tableName); + return new TableInfo(tableName, columns, foreignKeys, indices); + } + + private static Set readForeignKeys(SupportSQLiteDatabase database, + String tableName) { + Set foreignKeys = new HashSet<>(); + // this seems to return everything in order but it is not documented so better be safe + Cursor cursor = database.query("PRAGMA foreign_key_list(`" + tableName + "`)"); + try { + final int idColumnIndex = cursor.getColumnIndex("id"); + final int seqColumnIndex = cursor.getColumnIndex("seq"); + final int tableColumnIndex = cursor.getColumnIndex("table"); + final int onDeleteColumnIndex = cursor.getColumnIndex("on_delete"); + final int onUpdateColumnIndex = cursor.getColumnIndex("on_update"); + + final List ordered = readForeignKeyFieldMappings(cursor); + final int count = cursor.getCount(); + for (int position = 0; position < count; position++) { + cursor.moveToPosition(position); + final int seq = cursor.getInt(seqColumnIndex); + if (seq != 0) { + continue; + } + final int id = cursor.getInt(idColumnIndex); + List myColumns = new ArrayList<>(); + List refColumns = new ArrayList<>(); + for (ForeignKeyWithSequence key : ordered) { + if (key.mId == id) { + myColumns.add(key.mFrom); + refColumns.add(key.mTo); + } + } + foreignKeys.add(new ForeignKey( + cursor.getString(tableColumnIndex), + cursor.getString(onDeleteColumnIndex), + cursor.getString(onUpdateColumnIndex), + myColumns, + refColumns + )); + } + } finally { + cursor.close(); + } + return foreignKeys; + } + + private static List readForeignKeyFieldMappings(Cursor cursor) { + final int idColumnIndex = cursor.getColumnIndex("id"); + final int seqColumnIndex = cursor.getColumnIndex("seq"); + final int fromColumnIndex = cursor.getColumnIndex("from"); + final int toColumnIndex = cursor.getColumnIndex("to"); + final int count = cursor.getCount(); + List result = new ArrayList<>(); + for (int i = 0; i < count; i++) { + cursor.moveToPosition(i); + result.add(new ForeignKeyWithSequence( + cursor.getInt(idColumnIndex), + cursor.getInt(seqColumnIndex), + cursor.getString(fromColumnIndex), + cursor.getString(toColumnIndex) + )); + } + Collections.sort(result); + return result; + } + + private static Map readColumns(SupportSQLiteDatabase database, + String tableName) { + Cursor cursor = database + .query("PRAGMA table_info(`" + tableName + "`)"); + //noinspection TryFinallyCanBeTryWithResources + Map columns = new HashMap<>(); + try { + if (cursor.getColumnCount() > 0) { + int nameIndex = cursor.getColumnIndex("name"); + int typeIndex = cursor.getColumnIndex("type"); + int notNullIndex = cursor.getColumnIndex("notnull"); + int pkIndex = cursor.getColumnIndex("pk"); + int defaultValueIndex = cursor.getColumnIndex("dflt_value"); + + while (cursor.moveToNext()) { + final String name = cursor.getString(nameIndex); + final String type = cursor.getString(typeIndex); + final boolean notNull = 0 != cursor.getInt(notNullIndex); + final int primaryKeyPosition = cursor.getInt(pkIndex); + final String defaultValue = cursor.getString(defaultValueIndex); + columns.put(name, + new Column(name, type, notNull, primaryKeyPosition, defaultValue, + CREATED_FROM_DATABASE)); + } + } + } finally { + cursor.close(); + } + return columns; + } + + /** + * @return null if we cannot read the indices due to older sqlite implementations. + */ + @Nullable + private static Set readIndices(SupportSQLiteDatabase database, String tableName) { + Cursor cursor = database.query("PRAGMA index_list(`" + tableName + "`)"); + try { + final int nameColumnIndex = cursor.getColumnIndex("name"); + final int originColumnIndex = cursor.getColumnIndex("origin"); + final int uniqueIndex = cursor.getColumnIndex("unique"); + if (nameColumnIndex == -1 || originColumnIndex == -1 || uniqueIndex == -1) { + // we cannot read them so better not validate any index. + return null; + } + HashSet indices = new HashSet<>(); + while (cursor.moveToNext()) { + String origin = cursor.getString(originColumnIndex); + if (!"c".equals(origin)) { + // Ignore auto-created indices + continue; + } + String name = cursor.getString(nameColumnIndex); + boolean unique = cursor.getInt(uniqueIndex) == 1; + Index index = readIndex(database, name, unique); + if (index == null) { + // we cannot read it properly so better not read it + return null; + } + indices.add(index); + } + return indices; + } finally { + cursor.close(); + } + } + + /** + * @return null if we cannot read the index due to older sqlite implementations. + */ + @Nullable + private static Index readIndex(SupportSQLiteDatabase database, String name, boolean unique) { + Cursor cursor = database.query("PRAGMA index_xinfo(`" + name + "`)"); + try { + final int seqnoColumnIndex = cursor.getColumnIndex("seqno"); + final int cidColumnIndex = cursor.getColumnIndex("cid"); + final int nameColumnIndex = cursor.getColumnIndex("name"); + if (seqnoColumnIndex == -1 || cidColumnIndex == -1 || nameColumnIndex == -1) { + // we cannot read them so better not validate any index. + return null; + } + final TreeMap results = new TreeMap<>(); + + while (cursor.moveToNext()) { + int cid = cursor.getInt(cidColumnIndex); + if (cid < 0) { + // Ignore SQLite row ID + continue; + } + int seq = cursor.getInt(seqnoColumnIndex); + String columnName = cursor.getString(nameColumnIndex); + results.put(seq, columnName); + } + final List columns = new ArrayList<>(results.size()); + columns.addAll(results.values()); + return new Index(name, unique, columns); + } finally { + cursor.close(); + } + } + + /** + * Holds the information about a database column. + */ + @SuppressWarnings("WeakerAccess") + public static class Column { + /** + * The column name. + */ + public final String name; + /** + * The column type affinity. + */ + public final String type; + /** + * The column type after it is normalized to one of the basic types according to + * https://www.sqlite.org/datatype3.html Section 3.1. + *

    + * This is the value Room uses for equality check. + */ + @ColumnInfo.SQLiteTypeAffinity + public final int affinity; + /** + * Whether or not the column can be NULL. + */ + public final boolean notNull; + /** + * The position of the column in the list of primary keys, 0 if the column is not part + * of the primary key. + *

    + * This information is only available in API 20+. + * (SQLite version 3.7.16.2) + * On older platforms, it will be 1 if the column is part of the primary key and 0 + * otherwise. + *

    + * The {@link #equals(Object)} implementation handles this inconsistency based on + * API levels os if you are using a custom SQLite deployment, it may return false + * positives. + */ + public final int primaryKeyPosition; + /** + * The default value of this column. + */ + public final String defaultValue; + + @CreatedFrom + private final int mCreatedFrom; + + /** + * @deprecated Use {@link Column#Column(String, String, boolean, int, String, int)} instead. + */ + @Deprecated + public Column(String name, String type, boolean notNull, int primaryKeyPosition) { + this(name, type, notNull, primaryKeyPosition, null, CREATED_FROM_UNKNOWN); + } + + // if you change this constructor, you must change TableInfoWriter.kt + public Column(String name, String type, boolean notNull, int primaryKeyPosition, + String defaultValue, @CreatedFrom int createdFrom) { + this.name = name; + this.type = type; + this.notNull = notNull; + this.primaryKeyPosition = primaryKeyPosition; + this.affinity = findAffinity(type); + this.defaultValue = defaultValue; + this.mCreatedFrom = createdFrom; + } + + /** + * Implements https://www.sqlite.org/datatype3.html section 3.1 + * + * @param type The type that was given to the sqlite + * @return The normalized type which is one of the 5 known affinities + */ + @ColumnInfo.SQLiteTypeAffinity + private static int findAffinity(@Nullable String type) { + if (type == null) { + return ColumnInfo.BLOB; + } + String uppercaseType = type.toUpperCase(Locale.US); + if (uppercaseType.contains("INT")) { + return ColumnInfo.INTEGER; + } + if (uppercaseType.contains("CHAR") + || uppercaseType.contains("CLOB") + || uppercaseType.contains("TEXT")) { + return ColumnInfo.TEXT; + } + if (uppercaseType.contains("BLOB")) { + return ColumnInfo.BLOB; + } + if (uppercaseType.contains("REAL") + || uppercaseType.contains("FLOA") + || uppercaseType.contains("DOUB")) { + return ColumnInfo.REAL; + } + // sqlite returns NUMERIC here but it is like a catch all. We already + // have UNDEFINED so it is better to use UNDEFINED for consistency. + return ColumnInfo.UNDEFINED; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Column column = (Column) o; + if (Build.VERSION.SDK_INT >= 20) { + if (primaryKeyPosition != column.primaryKeyPosition) return false; + } else { + if (isPrimaryKey() != column.isPrimaryKey()) return false; + } + + if (!name.equals(column.name)) return false; + //noinspection SimplifiableIfStatement + if (notNull != column.notNull) return false; + + // Only validate default value if it was defined in an entity, i.e. if the info + // from the compiler itself has it. b/136019383 + if (mCreatedFrom == CREATED_FROM_ENTITY + && column.mCreatedFrom == CREATED_FROM_DATABASE + && (defaultValue != null && !defaultValue.equals(column.defaultValue))) { + return false; + } else if (mCreatedFrom == CREATED_FROM_DATABASE + && column.mCreatedFrom == CREATED_FROM_ENTITY + && (column.defaultValue != null && !column.defaultValue.equals(defaultValue))) { + return false; + } else if (mCreatedFrom != CREATED_FROM_UNKNOWN + && mCreatedFrom == column.mCreatedFrom + && (defaultValue != null ? !defaultValue.equals(column.defaultValue) + : column.defaultValue != null)) { + return false; + } + + return affinity == column.affinity; + } + + /** + * Returns whether this column is part of the primary key or not. + * + * @return True if this column is part of the primary key, false otherwise. + */ + public boolean isPrimaryKey() { + return primaryKeyPosition > 0; + } + + @Override + public int hashCode() { + int result = name.hashCode(); + result = 31 * result + affinity; + result = 31 * result + (notNull ? 1231 : 1237); + result = 31 * result + primaryKeyPosition; + // Default value is not part of the hashcode since we conditionally check it for + // equality which would break the equals + hashcode contract. + // result = 31 * result + (defaultValue != null ? defaultValue.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return "Column{" + + "name='" + name + '\'' + + ", type='" + type + '\'' + + ", affinity='" + affinity + '\'' + + ", notNull=" + notNull + + ", primaryKeyPosition=" + primaryKeyPosition + + ", defaultValue='" + defaultValue + '\'' + + '}'; + } + } + + /** + * Holds the information about an SQLite foreign key + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + public static class ForeignKey { + @NonNull + public final String referenceTable; + @NonNull + public final String onDelete; + @NonNull + public final String onUpdate; + @NonNull + public final List columnNames; + @NonNull + public final List referenceColumnNames; + + public ForeignKey(@NonNull String referenceTable, @NonNull String onDelete, + @NonNull String onUpdate, + @NonNull List columnNames, @NonNull List referenceColumnNames) { + this.referenceTable = referenceTable; + this.onDelete = onDelete; + this.onUpdate = onUpdate; + this.columnNames = Collections.unmodifiableList(columnNames); + this.referenceColumnNames = Collections.unmodifiableList(referenceColumnNames); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + ForeignKey that = (ForeignKey) o; + + if (!referenceTable.equals(that.referenceTable)) return false; + if (!onDelete.equals(that.onDelete)) return false; + if (!onUpdate.equals(that.onUpdate)) return false; + //noinspection SimplifiableIfStatement + if (!columnNames.equals(that.columnNames)) return false; + return referenceColumnNames.equals(that.referenceColumnNames); + } + + @Override + public int hashCode() { + int result = referenceTable.hashCode(); + result = 31 * result + onDelete.hashCode(); + result = 31 * result + onUpdate.hashCode(); + result = 31 * result + columnNames.hashCode(); + result = 31 * result + referenceColumnNames.hashCode(); + return result; + } + + @Override + public String toString() { + return "ForeignKey{" + + "referenceTable='" + referenceTable + '\'' + + ", onDelete='" + onDelete + '\'' + + ", onUpdate='" + onUpdate + '\'' + + ", columnNames=" + columnNames + + ", referenceColumnNames=" + referenceColumnNames + + '}'; + } + } + + /** + * Temporary data holder for a foreign key row in the pragma result. We need this to ensure + * sorting in the generated foreign key object. + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + static class ForeignKeyWithSequence implements Comparable { + final int mId; + final int mSequence; + final String mFrom; + final String mTo; + + ForeignKeyWithSequence(int id, int sequence, String from, String to) { + mId = id; + mSequence = sequence; + mFrom = from; + mTo = to; + } + + @Override + public int compareTo(@NonNull ForeignKeyWithSequence o) { + final int idCmp = mId - o.mId; + if (idCmp == 0) { + return mSequence - o.mSequence; + } else { + return idCmp; + } + } + } + + /** + * Holds the information about an SQLite index + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + public static class Index { + // should match the value in Index.kt + public static final String DEFAULT_PREFIX = "index_"; + public final String name; + public final boolean unique; + public final List columns; + + public Index(String name, boolean unique, List columns) { + this.name = name; + this.unique = unique; + this.columns = columns; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Index index = (Index) o; + if (unique != index.unique) { + return false; + } + if (!columns.equals(index.columns)) { + return false; + } + if (name.startsWith(Index.DEFAULT_PREFIX)) { + return index.name.startsWith(Index.DEFAULT_PREFIX); + } else { + return name.equals(index.name); + } + } + + @Override + public int hashCode() { + int result; + if (name.startsWith(DEFAULT_PREFIX)) { + result = DEFAULT_PREFIX.hashCode(); + } else { + result = name.hashCode(); + } + result = 31 * result + (unique ? 1 : 0); + result = 31 * result + columns.hashCode(); + return result; + } + + @Override + public String toString() { + return "Index{" + + "name='" + name + '\'' + + ", unique=" + unique + + ", columns=" + columns + + '}'; + } + } +} diff --git a/app/src/main/java/androidx/room/util/ViewInfo.java b/app/src/main/java/androidx/room/util/ViewInfo.java new file mode 100644 index 0000000000..5e4b9b2bbe --- /dev/null +++ b/app/src/main/java/androidx/room/util/ViewInfo.java @@ -0,0 +1,97 @@ +/* + * Copyright 2018 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.util; + +import android.database.Cursor; + +import androidx.annotation.RestrictTo; +import androidx.sqlite.db.SupportSQLiteDatabase; + +/** + * A data class that holds the information about a view. + *

    + * This derives information from sqlite_master. + *

    + * Even though SQLite column names are case insensitive, this class uses case sensitive matching. + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +public class ViewInfo { + + /** + * The view name + */ + public final String name; + + /** + * The SQL of CREATE VIEW. + */ + public final String sql; + + public ViewInfo(String name, String sql) { + this.name = name; + this.sql = sql; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ViewInfo viewInfo = (ViewInfo) o; + return (name != null ? name.equals(viewInfo.name) : viewInfo.name == null) + && (sql != null ? sql.equals(viewInfo.sql) : viewInfo.sql == null); + } + + @Override + public int hashCode() { + int result = name != null ? name.hashCode() : 0; + result = 31 * result + (sql != null ? sql.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return "ViewInfo{" + + "name='" + name + '\'' + + ", sql='" + sql + '\'' + + '}'; + } + + /** + * Reads the view information from the given database. + * + * @param database The database to read the information from. + * @param viewName The view name. + * @return A ViewInfo containing the schema information for the provided view name. + */ + @SuppressWarnings("SameParameterValue") + public static ViewInfo read(SupportSQLiteDatabase database, String viewName) { + Cursor cursor = database.query("SELECT name, sql FROM sqlite_master " + + "WHERE type = 'view' AND name = '" + viewName + "'"); + //noinspection TryFinallyCanBeTryWithResources + try { + if (cursor.moveToFirst()) { + return new ViewInfo(cursor.getString(0), cursor.getString(1)); + } else { + return new ViewInfo(viewName, null); + } + } finally { + cursor.close(); + } + } +}