1 /*
2  * Copyright 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.sqlite.inspection
18 
19 import android.database.sqlite.SQLiteDatabase
20 import androidx.inspection.ArtTooling
21 import androidx.inspection.testing.DefaultTestInspectorEnvironment
22 import androidx.inspection.testing.InspectorTester
23 import androidx.inspection.testing.TestInspectorExecutors
24 import androidx.room.Database
25 import androidx.room.Entity
26 import androidx.room.InvalidationTracker
27 import androidx.room.PrimaryKey
28 import androidx.room.Room
29 import androidx.room.RoomDatabase
30 import androidx.test.core.app.ApplicationProvider
31 import androidx.test.ext.junit.runners.AndroidJUnit4
32 import com.google.common.truth.Truth.assertWithMessage
33 import java.util.concurrent.Executors
34 import java.util.concurrent.TimeUnit
35 import kotlinx.coroutines.CompletableDeferred
36 import kotlinx.coroutines.Job
37 import kotlinx.coroutines.runBlocking
38 import org.junit.After
39 import org.junit.Before
40 import org.junit.Test
41 import org.junit.runner.RunWith
42 
43 @RunWith(AndroidJUnit4::class)
44 class RoomInvalidationHookTest {
45     private lateinit var db: TestDatabase
46 
47     private val testJob = Job()
48     private val ioExecutor = Executors.newSingleThreadExecutor()
49     private val testInspectorExecutors = TestInspectorExecutors(testJob, ioExecutor)
50 
51     @Before
initDbnull52     fun initDb() {
53         db =
54             Room.inMemoryDatabaseBuilder(
55                     ApplicationProvider.getApplicationContext(),
56                     TestDatabase::class.java
57                 )
58                 .setQueryExecutor { it.run() }
59                 .setTransactionExecutor { it.run() }
60                 .build()
61     }
62 
63     @After
closeDbnull64     fun closeDb() {
65         testJob.complete()
66         ioExecutor.shutdown()
67         assertWithMessage("inspector should not have any leaking tasks")
68             .that(ioExecutor.awaitTermination(10, TimeUnit.SECONDS))
69             .isTrue()
70 
71         testInspectorExecutors.handler().looper.thread.join(10_000)
72         db.close()
73     }
74 
75     /**
76      * A full integration test where we send a query via the inspector and assert that an
77      * invalidation observer on the Room side is invoked.
78      */
79     @Test
invalidationHooknull80     fun invalidationHook() =
81         runBlocking<Unit>(testJob) {
82             val testArtTI = TestArtTooling(roomDatabase = db, sqliteDb = db.getSqliteDb())
83 
84             val testEnv =
85                 DefaultTestInspectorEnvironment(
86                     artTooling = testArtTI,
87                     testInspectorExecutors = testInspectorExecutors
88                 )
89             val tester =
90                 InspectorTester(inspectorId = "androidx.sqlite.inspection", environment = testEnv)
91             val invalidatedTables = CompletableDeferred<List<String>>()
92             db.invalidationTracker.addObserver(
93                 object : InvalidationTracker.Observer("TestEntity") {
94                     override fun onInvalidated(tables: Set<String>) {
95                         invalidatedTables.complete(tables.toList())
96                     }
97                 }
98             )
99             val startTrackingCommand =
100                 SqliteInspectorProtocol.Command.newBuilder()
101                     .setTrackDatabases(
102                         SqliteInspectorProtocol.TrackDatabasesCommand.getDefaultInstance()
103                     )
104                     .build()
105             tester.sendCommand(startTrackingCommand.toByteArray())
106             // no invalidation yet
107             assertWithMessage("test sanity. no invalidation should happen yet")
108                 .that(invalidatedTables.isActive)
109                 .isTrue()
110             // send a write query
111             val insertQuery = """INSERT INTO TestEntity VALUES(1, "foo")"""
112             val insertCommand =
113                 SqliteInspectorProtocol.Command.newBuilder()
114                     .setQuery(
115                         SqliteInspectorProtocol.QueryCommand.newBuilder()
116                             .setDatabaseId(1)
117                             .setQuery(insertQuery)
118                             .build()
119                     )
120                     .build()
121             val responseBytes = tester.sendCommand(insertCommand.toByteArray())
122             val response = SqliteInspectorProtocol.Response.parseFrom(responseBytes)
123             assertWithMessage("test sanity, insert query should succeed")
124                 .that(response.hasErrorOccurred())
125                 .isFalse()
126 
127             assertWithMessage("writing into db should trigger the table observer")
128                 .that(invalidatedTables.await())
129                 .containsExactly("TestEntity")
130         }
131 }
132 
133 /** extract the framework sqlite database instance from a room database via reflection. */
RoomDatabasenull134 private fun RoomDatabase.getSqliteDb(): SQLiteDatabase {
135     val supportDb = this.openHelper.writableDatabase
136     // this runs with defaults so we can extract db from it until inspection supports support
137     // instances directly
138     return supportDb::class.java.getDeclaredField("delegate").let {
139         it.isAccessible = true
140         it.get(supportDb)
141     } as SQLiteDatabase
142 }
143 
144 @Suppress("UNCHECKED_CAST")
145 class TestArtTooling(private val roomDatabase: RoomDatabase, private val sqliteDb: SQLiteDatabase) :
146     ArtTooling {
registerEntryHooknull147     override fun registerEntryHook(
148         originClass: Class<*>,
149         originMethod: String,
150         entryHook: ArtTooling.EntryHook
151     ) {
152         // no-op
153     }
154 
findInstancesnull155     override fun <T : Any?> findInstances(clazz: Class<T>): List<T> {
156         if (clazz.isAssignableFrom(InvalidationTracker::class.java)) {
157             return listOf(roomDatabase.invalidationTracker as T)
158         } else if (clazz.isAssignableFrom(SQLiteDatabase::class.java)) {
159             return listOf(sqliteDb as T)
160         }
161         return emptyList()
162     }
163 
registerExitHooknull164     override fun <T : Any?> registerExitHook(
165         originClass: Class<*>,
166         originMethod: String,
167         exitHook: ArtTooling.ExitHook<T>
168     ) {
169         // no-op
170     }
171 }
172 
173 @Database(exportSchema = false, entities = [TestEntity::class], version = 1)
174 abstract class TestDatabase : RoomDatabase()
175 
176 @Entity data class TestEntity(@PrimaryKey(autoGenerate = true) val id: Long, val value: String)
177