Invalidation Tracker hook for Room
This CL adds a helper class into SqliteInspector to invoke
Room's invalidation trackers so that Room can detect changes
in the database if one of the queries sent by Studio changes
data.
Bug: 153231329
Test: RoomInvalidationRegistryWithoutRoomTest, RoomInvalidationHookTest
Change-Id: Ib4b7c540d51a78dbe9fb4ff01660f5e654cc9e99
diff --git a/settings.gradle b/settings.gradle
index 39235bd..e99e4a8 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -277,6 +277,7 @@
includeProject(":sqlite:sqlite-ktx", "sqlite/sqlite-ktx")
includeProject(":sqlite:sqlite-framework", "sqlite/sqlite-framework")
includeProject(":sqlite:sqlite-inspection", "sqlite/sqlite-inspection")
+includeProject(":sqlite:integration-tests:inspection-room-testapp", "sqlite/integration-tests/inspection-room-testapp")
includeProject(":swiperefreshlayout:swiperefreshlayout", "swiperefreshlayout/swiperefreshlayout")
includeProject(":test-screenshot", "test/screenshot")
includeProject(":test-screenshot-proto", "test/screenshot/proto")
diff --git a/sqlite/integration-tests/inspection-room-testapp/build.gradle b/sqlite/integration-tests/inspection-room-testapp/build.gradle
new file mode 100644
index 0000000..356c268
--- /dev/null
+++ b/sqlite/integration-tests/inspection-room-testapp/build.gradle
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2020 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.
+ */
+
+import static androidx.build.dependencies.DependenciesKt.*
+
+plugins {
+ id("AndroidXPlugin")
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("kotlin-kapt")
+}
+
+dependencies {
+ implementation(KOTLIN_STDLIB)
+ androidTestImplementation(JUNIT)
+ androidTestImplementation(KOTLIN_COROUTINES_ANDROID)
+ androidTestImplementation(ANDROIDX_TEST_EXT_KTX)
+ androidTestImplementation(TRUTH)
+ androidTestImplementation(ANDROIDX_TEST_RUNNER)
+ androidTestImplementation(project(":room:room-runtime"))
+ androidTestImplementation(project(":sqlite:sqlite-inspection"))
+ androidTestImplementation(project(":inspection:inspection-testing"))
+ androidTestImplementation("com.google.protobuf:protobuf-javalite:3.10.0")
+ kaptAndroidTest(project(":room:room-compiler"))
+
+}
+
+android {
+ defaultConfig {
+ // studio pipeline works only starting with Android O
+ minSdkVersion 26
+ }
+}
diff --git a/sqlite/integration-tests/inspection-room-testapp/src/androidTest/java/androidx/sqlite/inspection/RoomInvalidationHookTest.kt b/sqlite/integration-tests/inspection-room-testapp/src/androidTest/java/androidx/sqlite/inspection/RoomInvalidationHookTest.kt
new file mode 100644
index 0000000..aa80fcb
--- /dev/null
+++ b/sqlite/integration-tests/inspection-room-testapp/src/androidTest/java/androidx/sqlite/inspection/RoomInvalidationHookTest.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2020 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.sqlite.inspection
+
+import android.database.sqlite.SQLiteDatabase
+import androidx.inspection.Connection
+import androidx.inspection.InspectorEnvironment
+import androidx.inspection.InspectorFactory
+import androidx.inspection.testing.InspectorTester
+import androidx.room.Database
+import androidx.room.Entity
+import androidx.room.InvalidationTracker
+import androidx.room.PrimaryKey
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+@RunWith(AndroidJUnit4::class)
+class RoomInvalidationHookTest {
+ private lateinit var db: TestDatabase
+ private val inspectionExecutor = Executors.newSingleThreadExecutor()
+ @Before
+ fun initDb() {
+ db = Room.inMemoryDatabaseBuilder(
+ ApplicationProvider.getApplicationContext(),
+ TestDatabase::class.java
+ ).setQueryExecutor {
+ it.run()
+ }.setTransactionExecutor {
+ it.run()
+ }.build()
+ }
+
+ @After
+ fun closeDb() {
+ inspectionExecutor.shutdown()
+ assertWithMessage("inspector should not have any leaking tasks")
+ .that(inspectionExecutor.awaitTermination(10, TimeUnit.SECONDS))
+ .isTrue()
+ db.close()
+ }
+
+ /**
+ * A full integration test where we send a query via the inspector and assert that an
+ * invalidation observer on the Room side is invoked.
+ */
+ @Test
+ fun invalidationHook() = runBlocking<Unit> {
+ val testEnv = TestInspectorEnvironment(
+ roomDatabase = db,
+ sqliteDb = db.getSqliteDb()
+ )
+ val tester = InspectorTester(
+ inspectorId = "test",
+ environment = testEnv,
+ factoryOverride = object : InspectorFactory<SqliteInspector>("test") {
+ override fun createInspector(
+ connection: Connection,
+ environment: InspectorEnvironment
+ ): SqliteInspector {
+ return SqliteInspector(
+ connection,
+ environment,
+ inspectionExecutor
+ )
+ }
+ }
+ )
+ val invalidatedTables = CompletableDeferred<List<String>>()
+ db.invalidationTracker.addObserver(object : InvalidationTracker.Observer("TestEntity") {
+ override fun onInvalidated(tables: MutableSet<String>) {
+ invalidatedTables.complete(tables.toList())
+ }
+ })
+ val startTrackingCommand = SqliteInspectorProtocol.Command.newBuilder().setTrackDatabases(
+ SqliteInspectorProtocol.TrackDatabasesCommand.getDefaultInstance()
+ ).build()
+ tester.sendCommand(startTrackingCommand.toByteArray())
+ // no invalidation yet
+ assertWithMessage("test sanity. no invalidation should happen yet")
+ .that(invalidatedTables.isActive)
+ .isTrue()
+ // send a write query
+ val insertQuery = """INSERT INTO TestEntity VALUES(1, "foo")"""
+ val insertCommand = SqliteInspectorProtocol.Command.newBuilder().setQuery(
+ SqliteInspectorProtocol.QueryCommand.newBuilder()
+ .setDatabaseId(1)
+ .setQuery(insertQuery)
+ .build()
+ ).build()
+ val responseBytes = tester.sendCommand(insertCommand.toByteArray())
+ val response = SqliteInspectorProtocol.Response.parseFrom(responseBytes)
+ assertWithMessage("test sanity, insert query should succeed")
+ .that(response.hasErrorOccurred())
+ .isFalse()
+
+ assertWithMessage("writing into db should trigger the table observer")
+ .that(invalidatedTables.await())
+ .containsExactly("TestEntity")
+ }
+}
+
+/**
+ * extract the framework sqlite database instance from a room database via reflection.
+ */
+private fun RoomDatabase.getSqliteDb(): SQLiteDatabase {
+ val supportDb = this.openHelper.writableDatabase
+ // this runs with defaults so we can extract db from it until inspection supports support
+ // instances directly
+ return supportDb::class.java.getDeclaredField("mDelegate").let {
+ it.isAccessible = true
+ it.get(supportDb)
+ } as SQLiteDatabase
+}
+
+@Suppress("UNCHECKED_CAST")
+class TestInspectorEnvironment(
+ private val roomDatabase: RoomDatabase,
+ private val sqliteDb: SQLiteDatabase
+) : InspectorEnvironment {
+ override fun registerEntryHook(
+ originClass: Class<*>,
+ originMethod: String,
+ entryHook: InspectorEnvironment.EntryHook
+ ) {
+ // no-op
+ }
+
+ override fun <T : Any?> findInstances(clazz: Class<T>): List<T> {
+ if (clazz.isAssignableFrom(InvalidationTracker::class.java)) {
+ return listOf(roomDatabase.invalidationTracker as T)
+ } else if (clazz.isAssignableFrom(SQLiteDatabase::class.java)) {
+ return listOf(sqliteDb as T)
+ }
+ return emptyList()
+ }
+
+ override fun <T : Any?> registerExitHook(
+ originClass: Class<*>,
+ originMethod: String,
+ exitHook: InspectorEnvironment.ExitHook<T>
+ ) {
+ // no-op
+ }
+}
+
+@Database(
+ exportSchema = false,
+ entities = [TestEntity::class],
+ version = 1
+)
+abstract class TestDatabase : RoomDatabase()
+
+@Entity
+data class TestEntity(
+ @PrimaryKey(autoGenerate = true)
+ val id: Long,
+ val value: String
+)
diff --git a/sqlite/integration-tests/inspection-room-testapp/src/main/AndroidManifest.xml b/sqlite/integration-tests/inspection-room-testapp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..6a6069c
--- /dev/null
+++ b/sqlite/integration-tests/inspection-room-testapp/src/main/AndroidManifest.xml
@@ -0,0 +1,21 @@
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.sqlite.inspection.roomtestapp">
+ <application>
+ </application>
+</manifest>
diff --git a/sqlite/sqlite-inspection/src/androidTest/java/androidx/sqlite/inspection/RoomInvalidationRegistryWithoutRoomTest.kt b/sqlite/sqlite-inspection/src/androidTest/java/androidx/sqlite/inspection/RoomInvalidationRegistryWithoutRoomTest.kt
new file mode 100644
index 0000000..22d7cbc
--- /dev/null
+++ b/sqlite/sqlite-inspection/src/androidTest/java/androidx/sqlite/inspection/RoomInvalidationRegistryWithoutRoomTest.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2020 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.sqlite.inspection
+
+import androidx.inspection.InspectorEnvironment
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * This test just checks that we have reasonable defaults (e.g. no crash) if Room is not available
+ * in the classpath.
+ */
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class RoomInvalidationRegistryWithoutRoomTest {
+ @Test
+ fun noOpTest() {
+ // this does not really assert anything, we just want to make sure it does not crash and
+ // never makes a call to the environment if Room is not available.
+ val env = object : InspectorEnvironment {
+ override fun registerEntryHook(
+ originClass: Class<*>,
+ originMethod: String,
+ entryHook: InspectorEnvironment.EntryHook
+ ) {
+ throw AssertionError("should never call environment")
+ }
+
+ override fun <T : Any?> findInstances(clazz: Class<T>): MutableList<T> {
+ throw AssertionError("should never call environment")
+ }
+
+ override fun <T : Any?> registerExitHook(
+ originClass: Class<*>,
+ originMethod: String,
+ exitHook: InspectorEnvironment.ExitHook<T>
+ ) {
+ throw AssertionError("should never call environment")
+ }
+ }
+ val tracker = RoomInvalidationRegistry(env)
+ tracker.triggerInvalidationChecks()
+ tracker.invalidateCache()
+ tracker.triggerInvalidationChecks()
+ }
+}
diff --git a/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/RoomInvalidationRegistry.java b/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/RoomInvalidationRegistry.java
new file mode 100644
index 0000000..8a861ad
--- /dev/null
+++ b/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/RoomInvalidationRegistry.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright 2020 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.sqlite.inspection;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.inspection.InspectorEnvironment;
+
+import java.lang.ref.WeakReference;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Tracks instances of Room's InvalidationTracker so that we can trigger them to re-check
+ * database for changes in case there are observed tables in the application UI.
+ * <p>
+ * The list of instances of InvalidationTrackers are cached to avoid re-finding them after each
+ * query. Make sure to call {@link #invalidateCache()} after a new database connection is detected.
+ */
+class RoomInvalidationRegistry {
+ private static final String TAG = "RoomInvalidationRegistry";
+ private static final String INVALIDATION_TRACKER_QNAME = "androidx.room.InvalidationTracker";
+
+ private final InspectorEnvironment mEnvironment;
+
+ /**
+ * Might be null if application does not ship with Room.
+ */
+ @Nullable
+ private final InvalidationTrackerInvoker mInvoker;
+
+ /**
+ * The list of InvalidationTracker instances.
+ */
+ @Nullable
+ private List<WeakReference<?>> mInvalidationInstances = null;
+
+ RoomInvalidationRegistry(InspectorEnvironment environment) {
+ mEnvironment = environment;
+ mInvoker = findInvalidationTrackerClass();
+ }
+
+ /**
+ * Calls all of the InvalidationTrackers to check their database for updated tables.
+ * <p>
+ * If the list of InvalidationTracker instances are not cached, this will do a lookup.
+ */
+ void triggerInvalidationChecks() {
+ if (mInvoker == null) {
+ return;
+ }
+ List<WeakReference<?>> instances = getInvalidationTrackerInstances();
+ for (WeakReference<?> reference : instances) {
+ Object instance = reference.get();
+ if (instance != null) {
+ mInvoker.trigger(instance);
+ }
+ }
+ }
+
+ /**
+ * Invalidates the list of InvalidationTracker instances.
+ */
+ void invalidateCache() {
+ mInvalidationInstances = null;
+ }
+
+ @NonNull
+ private List<WeakReference<?>> getInvalidationTrackerInstances() {
+ List<WeakReference<?>> cached = mInvalidationInstances;
+ if (cached != null) {
+ return cached;
+ }
+ if (mInvoker == null) {
+ cached = Collections.emptyList();
+ } else {
+ List<?> instances = mEnvironment.findInstances(mInvoker.invalidationTrackerClass);
+ cached = new ArrayList<>(instances.size());
+ for (Object instance : instances) {
+ cached.add(new WeakReference<>(instance));
+ }
+ }
+ mInvalidationInstances = cached;
+ return cached;
+ }
+
+ @Nullable
+ private InvalidationTrackerInvoker findInvalidationTrackerClass() {
+ try {
+ ClassLoader classLoader = RoomInvalidationRegistry.class.getClassLoader();
+ if (classLoader != null) {
+ Class<?> klass = classLoader.loadClass(INVALIDATION_TRACKER_QNAME);
+ return new InvalidationTrackerInvoker(klass);
+ }
+ } catch (ClassNotFoundException e) {
+ // ignore, optional functionality
+ }
+ return null;
+ }
+
+ /**
+ * Helper class to invoke methods on Room's InvalidationTracker class.
+ */
+ static class InvalidationTrackerInvoker {
+ public final Class<?> invalidationTrackerClass;
+ @Nullable
+ private final Method mRefreshMethod;
+
+ InvalidationTrackerInvoker(Class<?> invalidationTrackerClass) {
+ this.invalidationTrackerClass = invalidationTrackerClass;
+ mRefreshMethod = safeGetRefreshMethod(invalidationTrackerClass);
+ }
+
+ private Method safeGetRefreshMethod(Class<?> invalidationTrackerClass) {
+ try {
+ return invalidationTrackerClass.getMethod("refreshVersionsAsync");
+ } catch (NoSuchMethodException ex) {
+ return null;
+ }
+ }
+
+ public void trigger(Object instance) {
+ if (mRefreshMethod != null) {
+ try {
+ mRefreshMethod.invoke(instance);
+ } catch (Throwable t) {
+ Log.e(TAG, "Failed to invoke invalidation tracker", t);
+ }
+ }
+ }
+ }
+}
diff --git a/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspector.java b/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspector.java
index d27995f..de7a0b7 100644
--- a/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspector.java
+++ b/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspector.java
@@ -123,12 +123,17 @@
private final DatabaseRegistry mDatabaseRegistry = new DatabaseRegistry();
private final InspectorEnvironment mEnvironment;
private final Executor mIOExecutor;
+ /**
+ * Utility instance that handles communication with Room's InvalidationTracker instances.
+ */
+ private final RoomInvalidationRegistry mRoomInvalidationRegistry;
SqliteInspector(@NonNull Connection connection, InspectorEnvironment environment,
Executor ioExecutor) {
super(connection);
mEnvironment = environment;
mIOExecutor = ioExecutor;
+ mRoomInvalidationRegistry = new RoomInvalidationRegistry(mEnvironment);
}
@Override
@@ -222,6 +227,7 @@
.build()
.toByteArray()
);
+ mRoomInvalidationRegistry.triggerInvalidationChecks();
} catch (SQLiteException | IllegalArgumentException exception) {
callback.reply(createErrorOccurredResponse(exception, true).toByteArray());
} finally {
@@ -405,6 +411,7 @@
int id = mDatabaseRegistry.addDatabase(database);
String name = database.getPath();
response = createDatabaseOpenedEvent(id, name);
+ mRoomInvalidationRegistry.invalidateCache();
} catch (IllegalArgumentException exception) {
String message = exception.getMessage();
// TODO: clean up, e.g. replace Exception message check with a custom Exception class