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