1 /*
2  * Copyright 2019 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 static android.database.DatabaseUtils.getSqlStatementType;
20 
21 import static androidx.sqlite.inspection.DatabaseExtensions.isAttemptAtUsingClosedDatabase;
22 import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_DB_CLOSED_DURING_OPERATION;
23 import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_ISSUE_WITH_LOCKING_DATABASE;
24 import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_ISSUE_WITH_PROCESSING_NEW_DATABASE_CONNECTION;
25 import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_ISSUE_WITH_PROCESSING_QUERY;
26 import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_NO_OPEN_DATABASE_WITH_REQUESTED_ID;
27 import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_UNKNOWN;
28 import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_UNRECOGNISED_COMMAND;
29 
30 import android.annotation.SuppressLint;
31 import android.app.Application;
32 import android.database.Cursor;
33 import android.database.CursorWrapper;
34 import android.database.DatabaseUtils;
35 import android.database.sqlite.SQLiteClosable;
36 import android.database.sqlite.SQLiteCursor;
37 import android.database.sqlite.SQLiteCursorDriver;
38 import android.database.sqlite.SQLiteDatabase;
39 import android.database.sqlite.SQLiteException;
40 import android.database.sqlite.SQLiteQuery;
41 import android.database.sqlite.SQLiteStatement;
42 import android.os.Build;
43 import android.os.CancellationSignal;
44 import android.util.Log;
45 
46 import androidx.inspection.ArtTooling;
47 import androidx.inspection.ArtTooling.EntryHook;
48 import androidx.inspection.ArtTooling.ExitHook;
49 import androidx.inspection.Connection;
50 import androidx.inspection.Inspector;
51 import androidx.inspection.InspectorEnvironment;
52 import androidx.sqlite.inspection.SqliteInspectorProtocol.AcquireDatabaseLockCommand;
53 import androidx.sqlite.inspection.SqliteInspectorProtocol.AcquireDatabaseLockResponse;
54 import androidx.sqlite.inspection.SqliteInspectorProtocol.CellValue;
55 import androidx.sqlite.inspection.SqliteInspectorProtocol.Column;
56 import androidx.sqlite.inspection.SqliteInspectorProtocol.Command;
57 import androidx.sqlite.inspection.SqliteInspectorProtocol.DatabaseClosedEvent;
58 import androidx.sqlite.inspection.SqliteInspectorProtocol.DatabaseOpenedEvent;
59 import androidx.sqlite.inspection.SqliteInspectorProtocol.DatabasePossiblyChangedEvent;
60 import androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent;
61 import androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode;
62 import androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorOccurredEvent;
63 import androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorOccurredResponse;
64 import androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorRecoverability;
65 import androidx.sqlite.inspection.SqliteInspectorProtocol.Event;
66 import androidx.sqlite.inspection.SqliteInspectorProtocol.GetSchemaCommand;
67 import androidx.sqlite.inspection.SqliteInspectorProtocol.GetSchemaResponse;
68 import androidx.sqlite.inspection.SqliteInspectorProtocol.KeepDatabasesOpenCommand;
69 import androidx.sqlite.inspection.SqliteInspectorProtocol.KeepDatabasesOpenResponse;
70 import androidx.sqlite.inspection.SqliteInspectorProtocol.QueryCommand;
71 import androidx.sqlite.inspection.SqliteInspectorProtocol.QueryParameterValue;
72 import androidx.sqlite.inspection.SqliteInspectorProtocol.QueryResponse;
73 import androidx.sqlite.inspection.SqliteInspectorProtocol.ReleaseDatabaseLockCommand;
74 import androidx.sqlite.inspection.SqliteInspectorProtocol.ReleaseDatabaseLockResponse;
75 import androidx.sqlite.inspection.SqliteInspectorProtocol.Response;
76 import androidx.sqlite.inspection.SqliteInspectorProtocol.Row;
77 import androidx.sqlite.inspection.SqliteInspectorProtocol.Table;
78 import androidx.sqlite.inspection.SqliteInspectorProtocol.TrackDatabasesResponse;
79 
80 import com.google.protobuf.ByteString;
81 
82 import org.jspecify.annotations.NonNull;
83 import org.jspecify.annotations.Nullable;
84 
85 import java.io.File;
86 import java.io.PrintWriter;
87 import java.io.StringWriter;
88 import java.util.ArrayList;
89 import java.util.Arrays;
90 import java.util.Collections;
91 import java.util.HashSet;
92 import java.util.List;
93 import java.util.Map;
94 import java.util.Set;
95 import java.util.WeakHashMap;
96 import java.util.concurrent.Executor;
97 import java.util.concurrent.Future;
98 
99 /**
100  * Inspector to work with SQLite databases
101  */
102 @SuppressWarnings({"TryFinallyCanBeTryWithResources", "SameParameterValue"})
103 final class SqliteInspector extends Inspector {
104     private static final String OPEN_DATABASE_COMMAND_SIGNATURE_API_11 = "openDatabase"
105             + "("
106             + "Ljava/lang/String;"
107             + "Landroid/database/sqlite/SQLiteDatabase$CursorFactory;"
108             + "I"
109             + "Landroid/database/DatabaseErrorHandler;"
110             + ")"
111             + "Landroid/database/sqlite/SQLiteDatabase;";
112 
113     private static final String OPEN_DATABASE_COMMAND_SIGNATURE_API_27 = "openDatabase"
114             + "("
115             + "Ljava/io/File;"
116             + "Landroid/database/sqlite/SQLiteDatabase$OpenParams;"
117             + ")"
118             + "Landroid/database/sqlite/SQLiteDatabase;";
119 
120     private static final String CREATE_IN_MEMORY_DATABASE_COMMAND_SIGNATURE_API_27 =
121             "createInMemory"
122             + "("
123             + "Landroid/database/sqlite/SQLiteDatabase$OpenParams;"
124             + ")"
125             + "Landroid/database/sqlite/SQLiteDatabase;";
126 
127     private static final String ALL_REFERENCES_RELEASE_COMMAND_SIGNATURE =
128             "onAllReferencesReleased()V";
129 
130     // SQLiteStatement methods
131     private static final List<String> SQLITE_STATEMENT_EXECUTE_METHODS_SIGNATURES = Arrays.asList(
132             "execute()V",
133             "executeInsert()J",
134             "executeUpdateDelete()I");
135 
136     private static final int INVALIDATION_MIN_INTERVAL_MS = 1000;
137 
138     // Note: this only works on API26+ because of pragma_* functions
139     // TODO: replace with a resource file
140     // language=SQLite
141     private static final String sQueryTableInfo = "select\n"
142             + "  m.type as type,\n"
143             + "  m.name as tableName,\n"
144             + "  ti.name as columnName,\n"
145             + "  ti.type as columnType,\n"
146             + "  [notnull],\n"
147             + "  pk,\n"
148             + "  ifnull([unique], 0) as [unique]\n"
149             + "from sqlite_master AS m, pragma_table_info(m.name) as ti\n"
150             + "left outer join\n"
151             + "  (\n"
152             + "    select tableName, name as columnName, ti.[unique]\n"
153             + "    from\n"
154             + "      (\n"
155             + "        select m.name as tableName, il.name as indexName, il.[unique]\n"
156             + "        from\n"
157             + "          sqlite_master AS m,\n"
158             + "          pragma_index_list(m.name) AS il,\n"
159             + "          pragma_index_info(il.name) as ii\n"
160             + "        where il.[unique] = 1\n"
161             + "        group by il.name\n"
162             + "        having count(*) = 1  -- countOfColumnsInIndex=1\n"
163             + "      )\n"
164             + "        as ti,  -- tableName|indexName|unique : unique=1 and "
165             + "countOfColumnsInIndex=1\n"
166             + "      pragma_index_info(ti.indexName)\n"
167             + "  )\n"
168             + "    as tci  -- tableName|columnName|unique : unique=1 and countOfColumnsInIndex=1\n"
169             + "  on tci.tableName = m.name and tci.columnName = ti.name\n"
170             + "where m.type in ('table', 'view')\n"
171             + "order by type, tableName, ti.cid  -- cid = columnId";
172 
173     private static final Set<String> sHiddenTables = new HashSet<>(Arrays.asList(
174             "android_metadata", "sqlite_sequence"));
175 
176     private final DatabaseRegistry mDatabaseRegistry;
177     private final DatabaseLockRegistry mDatabaseLockRegistry;
178     private final InspectorEnvironment mEnvironment;
179     private final Executor mIOExecutor;
180 
181     /**
182      * Utility instance that handles communication with Room's InvalidationTracker instances.
183      */
184     private final RoomInvalidationRegistry mRoomInvalidationRegistry;
185 
186     private final List<Invalidation> mInvalidations = new ArrayList<>();
187 
SqliteInspector(@onNull Connection connection, @NonNull InspectorEnvironment environment)188     SqliteInspector(@NonNull Connection connection, @NonNull InspectorEnvironment environment) {
189         super(connection);
190         mEnvironment = environment;
191         mIOExecutor = environment.executors().io();
192         mRoomInvalidationRegistry = new RoomInvalidationRegistry(mEnvironment);
193         mInvalidations.add(mRoomInvalidationRegistry);
194         mInvalidations.add(SqlDelightInvalidation.create(mEnvironment.artTooling()));
195         mInvalidations.add(SqlDelight2Invalidation.create(mEnvironment.artTooling()));
196 
197         mDatabaseRegistry = new DatabaseRegistry(
198                 new DatabaseRegistry.Callback() {
199                     @Override
200                     public void onPostEvent(int databaseId, String path) {
201                         dispatchDatabaseOpenedEvent(databaseId, path);
202                     }
203                 },
204                 new DatabaseRegistry.Callback() {
205                     @Override
206                     public void onPostEvent(int databaseId, String path) {
207                         dispatchDatabaseClosedEvent(databaseId, path);
208                     }
209                 });
210 
211         mDatabaseLockRegistry = new DatabaseLockRegistry();
212     }
213 
214     @Override
onReceiveCommand(byte @NonNull [] data, @NonNull CommandCallback callback)215     public void onReceiveCommand(byte @NonNull [] data, @NonNull CommandCallback callback) {
216         try {
217             Command command = Command.parseFrom(data);
218             switch (command.getOneOfCase()) {
219                 case TRACK_DATABASES:
220                 handleTrackDatabases(callback);
221                     break;
222                 case GET_SCHEMA:
223                 handleGetSchema(command.getGetSchema(), callback);
224                     break;
225                 case QUERY:
226                 handleQuery(command.getQuery(), callback);
227                     break;
228                 case KEEP_DATABASES_OPEN:
229                     handleKeepDatabasesOpen(command.getKeepDatabasesOpen(), callback);
230                     break;
231                 case ACQUIRE_DATABASE_LOCK:
232                     handleAcquireDatabaseLock(command.getAcquireDatabaseLock(), callback);
233                     break;
234                 case RELEASE_DATABASE_LOCK:
235                     handleReleaseDatabaseLock(command.getReleaseDatabaseLock(), callback);
236                     break;
237                 default:
238                     callback.reply(
239                         createErrorOccurredResponse(
240                                 "Unrecognised command type: " + command.getOneOfCase().name(),
241                                 null,
242                                 true,
243                                 ERROR_UNRECOGNISED_COMMAND).toByteArray());
244             }
245         } catch (Exception exception) {
246             callback.reply(
247                     createErrorOccurredResponse(
248                             "Unhandled Exception while processing the command: "
249                                     + exception.getMessage(),
250                             stackTraceFromException(exception),
251                             null,
252                             ERROR_UNKNOWN).toByteArray()
253             );
254         }
255     }
256 
257     @Override
onDispose()258     public void onDispose() {
259         super.onDispose();
260         // TODO(161081452): release database locks and keep-open references
261     }
262 
handleTrackDatabases(CommandCallback callback)263     private void handleTrackDatabases(CommandCallback callback) {
264         callback.reply(Response.newBuilder()
265                 .setTrackDatabases(TrackDatabasesResponse.getDefaultInstance())
266                 .build().toByteArray()
267         );
268 
269         registerReleaseReferenceHooks();
270         registerDatabaseOpenedHooks();
271 
272         EntryExitMatchingHookRegistry hookRegistry = new EntryExitMatchingHookRegistry(
273                 mEnvironment);
274 
275         registerInvalidationHooks(hookRegistry);
276         registerDatabaseClosedHooks(hookRegistry);
277 
278         // Check for database instances in memory
279         for (SQLiteDatabase instance :
280                 mEnvironment.artTooling().findInstances(SQLiteDatabase.class)) {
281             /* the race condition here will be handled by mDatabaseRegistry */
282             if (instance.isOpen()) {
283                 onDatabaseOpened(instance);
284             } else {
285                 onDatabaseClosed(instance);
286             }
287         }
288 
289         // Check for database instances on disk
290         for (Application instance : mEnvironment.artTooling().findInstances(Application.class)) {
291             for (String name : instance.databaseList()) {
292                 File path = instance.getDatabasePath(name);
293                 if (path.exists() && !isHelperSqliteFile(path)) {
294                     mDatabaseRegistry.notifyOnDiskDatabase(path.getAbsolutePath());
295                 }
296             }
297         }
298     }
299 
300     /**
301      * Secures a lock (transaction) on the database. Note that while the lock is in place, no
302      * changes to the database are possible:
303      * - the lock prevents other threads from modifying the database,
304      * - lock thread, on releasing the lock, rolls-back all changes (transaction is rolled-back).
305      */
306     @SuppressWarnings("FutureReturnValueIgnored") // code inside the future is exception-proofed
handleAcquireDatabaseLock( AcquireDatabaseLockCommand command, final CommandCallback callback)307     private void handleAcquireDatabaseLock(
308             AcquireDatabaseLockCommand command,
309             final CommandCallback callback) {
310         final int databaseId = command.getDatabaseId();
311         final DatabaseConnection connection = acquireConnection(databaseId, callback);
312         if (connection == null) return;
313 
314         // Timeout is covered by mDatabaseLockRegistry
315         SqliteInspectionExecutors.submit(mIOExecutor, new Runnable() {
316             @Override
317             public void run() {
318                 int lockId;
319                 try {
320                     lockId = mDatabaseLockRegistry.acquireLock(databaseId, connection.mDatabase);
321                 } catch (Exception e) {
322                     processLockingException(callback, e, true);
323                     return;
324                 }
325 
326                 callback.reply(Response.newBuilder().setAcquireDatabaseLock(
327                         AcquireDatabaseLockResponse.newBuilder().setLockId(lockId)
328                 ).build().toByteArray());
329             }
330         });
331     }
332 
333     @SuppressWarnings("FutureReturnValueIgnored") // code inside the future is exception-proofed
handleReleaseDatabaseLock( final ReleaseDatabaseLockCommand command, final CommandCallback callback)334     private void handleReleaseDatabaseLock(
335             final ReleaseDatabaseLockCommand command,
336             final CommandCallback callback) {
337         // Timeout is covered by mDatabaseLockRegistry
338         SqliteInspectionExecutors.submit(mIOExecutor, new Runnable() {
339             @Override
340             public void run() {
341                 try {
342                     mDatabaseLockRegistry.releaseLock(command.getLockId());
343                 } catch (Exception e) {
344                     processLockingException(callback, e, false);
345                     return;
346                 }
347                 callback.reply(Response.newBuilder().setReleaseDatabaseLock(
348                         ReleaseDatabaseLockResponse.getDefaultInstance()
349                 ).build().toByteArray());
350             }
351         });
352     }
353 
354     /**
355      * @param isLockingStage provide true for acquiring a lock; false for releasing a lock
356      */
processLockingException(CommandCallback callback, Exception exception, boolean isLockingStage)357     private void processLockingException(CommandCallback callback, Exception exception,
358             boolean isLockingStage) {
359         ErrorCode errorCode = ((exception instanceof IllegalStateException)
360                 && isAttemptAtUsingClosedDatabase((IllegalStateException) exception))
361                 ? ERROR_DB_CLOSED_DURING_OPERATION
362                 : ERROR_ISSUE_WITH_LOCKING_DATABASE;
363 
364         String message = isLockingStage
365                 ? "Issue while trying to lock the database for the export operation: "
366                 : "Issue while trying to unlock the database after the export operation: ";
367 
368         Boolean isRecoverable = isLockingStage
369                 ? true // failure to lock the db should be recoverable
370                 : null; // not sure if we can recover from a failure to unlock the db, so UNKNOWN
371 
372         callback.reply(createErrorOccurredResponse(message, isRecoverable, exception,
373                 errorCode).toByteArray());
374     }
375 
376     /**
377      * Tracking potential database closed events via
378      * {@link #ALL_REFERENCES_RELEASE_COMMAND_SIGNATURE}
379      */
registerDatabaseClosedHooks(EntryExitMatchingHookRegistry hookRegistry)380     private void registerDatabaseClosedHooks(EntryExitMatchingHookRegistry hookRegistry) {
381         hookRegistry.registerHook(SQLiteDatabase.class, ALL_REFERENCES_RELEASE_COMMAND_SIGNATURE,
382                 new EntryExitMatchingHookRegistry.OnExitCallback() {
383                     @Override
384                     public void onExit(EntryExitMatchingHookRegistry.Frame exitFrame) {
385                         final Object thisObject = exitFrame.mThisObject;
386                         if (thisObject instanceof SQLiteDatabase) {
387                             onDatabaseClosed((SQLiteDatabase) thisObject);
388                         }
389                     }
390                 });
391     }
392 
registerDatabaseOpenedHooks()393     private void registerDatabaseOpenedHooks() {
394         List<String> methods = (Build.VERSION.SDK_INT < 27)
395                 ? Arrays.asList(OPEN_DATABASE_COMMAND_SIGNATURE_API_11)
396                 : Arrays.asList(OPEN_DATABASE_COMMAND_SIGNATURE_API_27,
397                         OPEN_DATABASE_COMMAND_SIGNATURE_API_11,
398                         CREATE_IN_MEMORY_DATABASE_COMMAND_SIGNATURE_API_27);
399 
400         ExitHook<SQLiteDatabase> hook =
401                 new ExitHook<SQLiteDatabase>() {
402                     @Override
403                     public SQLiteDatabase onExit(SQLiteDatabase database) {
404                         try {
405                             onDatabaseOpened(database);
406                         } catch (Exception exception) {
407                             getConnection().sendEvent(createErrorOccurredEvent(
408                                     "Unhandled Exception while processing an onDatabaseAdded "
409                                             + "event: "
410                                             + exception.getMessage(),
411                                     stackTraceFromException(exception), null,
412                                     ERROR_ISSUE_WITH_PROCESSING_NEW_DATABASE_CONNECTION)
413                                     .toByteArray());
414                         }
415                         return database;
416                     }
417                 };
418         for (String method : methods) {
419             mEnvironment.artTooling().registerExitHook(SQLiteDatabase.class, method, hook);
420         }
421     }
422 
registerReleaseReferenceHooks()423     private void registerReleaseReferenceHooks() {
424         mEnvironment.artTooling().registerEntryHook(
425                 SQLiteClosable.class,
426                 "releaseReference()V",
427                 new EntryHook() {
428                     @Override
429                     public void onEntry(@Nullable Object thisObject,
430                             @NonNull List<Object> args) {
431                         if (thisObject instanceof SQLiteDatabase) {
432                             mDatabaseRegistry.notifyReleaseReference((SQLiteDatabase) thisObject);
433                         }
434                     }
435                 });
436     }
437 
registerInvalidationHooks(EntryExitMatchingHookRegistry hookRegistry)438     private void registerInvalidationHooks(EntryExitMatchingHookRegistry hookRegistry) {
439         /*
440          * Schedules a task using {@link mScheduledExecutor} and executes it on {@link mIOExecutor}.
441          */
442         final RequestCollapsingThrottler.DeferredExecutor deferredExecutor =
443                 new RequestCollapsingThrottler.DeferredExecutor() {
444                     @Override
445                     @SuppressWarnings("FutureReturnValueIgnored") // TODO: handle errors from Future
446                     public void schedule(final Runnable command, final long delayMs) {
447                         mEnvironment.executors().handler().postDelayed(new Runnable() {
448                             @Override
449                             public void run() {
450                                 mIOExecutor.execute(command);
451                             }
452                         }, delayMs);
453                     }
454                 };
455         final RequestCollapsingThrottler throttler = new RequestCollapsingThrottler(
456                 INVALIDATION_MIN_INTERVAL_MS,
457                 new Runnable() {
458                     @Override
459                     public void run() {
460                         dispatchDatabasePossiblyChangedEvent();
461                     }
462                 }, deferredExecutor);
463 
464         registerInvalidationHooksSqliteStatement(throttler);
465         registerInvalidationHooksTransaction(throttler);
466         registerInvalidationHooksSQLiteCursor(throttler, hookRegistry);
467     }
468 
469     /**
470      * Triggering invalidation on {@link SQLiteDatabase#endTransaction} allows us to avoid
471      * showing incorrect stale values that could originate from a mid-transaction query.
472      *
473      * TODO: track if transaction committed or rolled back by observing if
474      * {@link SQLiteDatabase#setTransactionSuccessful} was called
475      */
registerInvalidationHooksTransaction(final RequestCollapsingThrottler throttler)476     private void registerInvalidationHooksTransaction(final RequestCollapsingThrottler throttler) {
477         mEnvironment.artTooling().registerExitHook(SQLiteDatabase.class, "endTransaction()V",
478                 new ExitHook<Object>() {
479                     @Override
480                     public Object onExit(Object result) {
481                         throttler.submitRequest();
482                         return result;
483                     }
484                 });
485     }
486 
487     /**
488      * Invalidation hooks triggered by:
489      * <ul>
490      *     <li>{@link SQLiteStatement#execute}</li>
491      *     <li>{@link SQLiteStatement#executeInsert}</li>
492      *     <li>{@link SQLiteStatement#executeUpdateDelete}</li>
493      * </ul>
494      */
registerInvalidationHooksSqliteStatement( final RequestCollapsingThrottler throttler)495     private void registerInvalidationHooksSqliteStatement(
496             final RequestCollapsingThrottler throttler) {
497         for (String method : SQLITE_STATEMENT_EXECUTE_METHODS_SIGNATURES) {
498             mEnvironment.artTooling().registerExitHook(SQLiteStatement.class, method,
499                     new ExitHook<Object>() {
500                         @Override
501                         public Object onExit(Object result) {
502                             throttler.submitRequest();
503                             return result;
504                         }
505                     });
506         }
507     }
508 
509     /**
510      * Invalidation hooks triggered by {@link SQLiteCursor#close()}
511      * which means that the cursor's query was executed.
512      * <p>
513      * In order to access cursor's query, we also use {@link SQLiteDatabase#rawQueryWithFactory}
514      * which takes a query String and constructs a cursor based on it.
515      */
registerInvalidationHooksSQLiteCursor(final RequestCollapsingThrottler throttler, EntryExitMatchingHookRegistry hookRegistry)516     private void registerInvalidationHooksSQLiteCursor(final RequestCollapsingThrottler throttler,
517             EntryExitMatchingHookRegistry hookRegistry) {
518 
519         // TODO: add active pruning via Cursor#close listener
520         final Map<SQLiteCursor, Void> trackedCursors = Collections.synchronizedMap(
521                 new WeakHashMap<SQLiteCursor, Void>());
522 
523         final String rawQueryMethodSignature = "rawQueryWithFactory("
524                 + "Landroid/database/sqlite/SQLiteDatabase$CursorFactory;"
525                 + "Ljava/lang/String;"
526                 + "[Ljava/lang/String;"
527                 + "Ljava/lang/String;"
528                 + "Landroid/os/CancellationSignal;"
529                 + ")Landroid/database/Cursor;";
530         hookRegistry.registerHook(SQLiteDatabase.class,
531                 rawQueryMethodSignature, new EntryExitMatchingHookRegistry.OnExitCallback() {
532                     @Override
533                     public void onExit(EntryExitMatchingHookRegistry.Frame exitFrame) {
534                         SQLiteCursor cursor = cursorParam(exitFrame.mResult);
535                         String query = stringParam(exitFrame.mArgs.get(1));
536 
537                         // Only track cursors that might modify the database.
538                         // TODO: handle PRAGMA select queries, e.g. PRAGMA_TABLE_INFO
539                         if (cursor != null && query != null && getSqlStatementType(query)
540                                 != DatabaseUtils.STATEMENT_SELECT) {
541                             trackedCursors.put(cursor, null);
542                         }
543                     }
544                 });
545 
546 
547         mEnvironment.artTooling().registerEntryHook(SQLiteCursor.class, "close()V",
548                 new ArtTooling.EntryHook() {
549                     @Override
550                     public void onEntry(@Nullable Object thisObject, @NonNull List<Object> args) {
551                         if (trackedCursors.containsKey(thisObject)) {
552                             throttler.submitRequest();
553                         }
554                     }
555                 });
556     }
557 
558     // Gets a SQLiteCursor from a passed-in Object (if possible)
cursorParam(Object cursor)559     private @Nullable SQLiteCursor cursorParam(Object cursor) {
560         if (cursor instanceof SQLiteCursor) {
561             return (SQLiteCursor) cursor;
562         }
563 
564         if (cursor instanceof CursorWrapper) {
565             CursorWrapper wrapper = (CursorWrapper) cursor;
566             return cursorParam(wrapper.getWrappedCursor());
567         }
568 
569         // TODO: add support for more cursor types
570         Log.w(SqliteInspector.class.getName(), String.format(
571                 "Unsupported Cursor type: %s. Invalidation might not work correctly.", cursor));
572         return null;
573     }
574 
575     // Gets a String from a passed-in Object (if possible)
stringParam(Object string)576     private @Nullable String stringParam(Object string) {
577         return string instanceof String ? (String) string : null;
578     }
579 
dispatchDatabaseOpenedEvent(int databaseId, String path)580     private void dispatchDatabaseOpenedEvent(int databaseId, String path) {
581         getConnection().sendEvent(Event.newBuilder().setDatabaseOpened(
582                 DatabaseOpenedEvent.newBuilder().setDatabaseId(databaseId).setPath(path)
583         ).build().toByteArray());
584     }
585 
dispatchDatabaseClosedEvent(int databaseId, String path)586     private void dispatchDatabaseClosedEvent(int databaseId, String path) {
587         getConnection().sendEvent(Event.newBuilder().setDatabaseClosed(
588                 DatabaseClosedEvent.newBuilder().setDatabaseId(databaseId).setPath(path)
589         ).build().toByteArray());
590     }
591 
dispatchDatabasePossiblyChangedEvent()592     private void dispatchDatabasePossiblyChangedEvent() {
593         getConnection().sendEvent(Event.newBuilder().setDatabasePossiblyChanged(
594                 DatabasePossiblyChangedEvent.getDefaultInstance()
595         ).build().toByteArray());
596     }
597 
598     @SuppressWarnings("FutureReturnValueIgnored") // code inside the future is exception-proofed
handleGetSchema(GetSchemaCommand command, final CommandCallback callback)599     private void handleGetSchema(GetSchemaCommand command, final CommandCallback callback) {
600         final DatabaseConnection connection = acquireConnection(command.getDatabaseId(), callback);
601         if (connection == null) return;
602 
603         // TODO: consider a timeout
604         SqliteInspectionExecutors.submit(connection.mExecutor, new Runnable() {
605             @Override
606             public void run() {
607                 callback.reply(querySchema(connection.mDatabase).toByteArray());
608             }
609         });
610     }
611 
handleQuery(final QueryCommand command, final CommandCallback callback)612     private void handleQuery(final QueryCommand command, final CommandCallback callback) {
613         final DatabaseConnection connection = acquireConnection(command.getDatabaseId(), callback);
614         if (connection == null) return;
615 
616         final CancellationSignal cancellationSignal = new CancellationSignal();
617         final Executor executor = connection.mExecutor;
618         // TODO: consider a timeout
619         final Future<?> future = SqliteInspectionExecutors.submit(executor, new Runnable() {
620             @Override
621             public void run() {
622                 String[] params = parseQueryParameterValues(command);
623                 Cursor cursor = null;
624                 try {
625                     cursor = rawQuery(connection.mDatabase, command.getQuery(), params,
626                             cancellationSignal);
627 
628                     long responseSizeLimitHint = command.getResponseSizeLimitHint();
629                     // treating unset field as unbounded
630                     if (responseSizeLimitHint <= 0) responseSizeLimitHint = Long.MAX_VALUE;
631 
632                     List<String> columnNames = Arrays.asList(cursor.getColumnNames());
633                     callback.reply(Response.newBuilder()
634                             .setQuery(QueryResponse.newBuilder()
635                                     .addAllRows(convert(cursor, responseSizeLimitHint))
636                                     .addAllColumnNames(columnNames)
637                                     .build())
638                             .build()
639                             .toByteArray()
640                     );
641                     triggerInvalidation(command.getQuery());
642                 } catch (SQLiteException | IllegalArgumentException e) {
643                     callback.reply(createErrorOccurredResponse(e, true,
644                             ERROR_ISSUE_WITH_PROCESSING_QUERY).toByteArray());
645                 } catch (IllegalStateException e) {
646                     if (isAttemptAtUsingClosedDatabase(e)) {
647                         callback.reply(createErrorOccurredResponse(e, true,
648                                 ERROR_DB_CLOSED_DURING_OPERATION).toByteArray());
649                     } else {
650                         callback.reply(createErrorOccurredResponse(e, null,
651                                 ERROR_UNKNOWN).toByteArray());
652                     }
653                 } catch (Exception e) {
654                     callback.reply(createErrorOccurredResponse(e, null,
655                             ERROR_UNKNOWN).toByteArray());
656                 } finally {
657                     if (cursor != null) {
658                         cursor.close();
659                     }
660                 }
661             }
662         });
663         callback.addCancellationListener(mEnvironment.executors().primary(), new Runnable() {
664             @Override
665             public void run() {
666                 cancellationSignal.cancel();
667                 future.cancel(true);
668             }
669         });
670     }
671 
triggerInvalidation(String query)672     private void triggerInvalidation(String query) {
673         if (getSqlStatementType(query) != DatabaseUtils.STATEMENT_SELECT) {
674             for (Invalidation invalidation : mInvalidations) {
675                 invalidation.triggerInvalidations();
676             }
677         }
678     }
679 
handleKeepDatabasesOpen(KeepDatabasesOpenCommand keepDatabasesOpen, CommandCallback callback)680     private void handleKeepDatabasesOpen(KeepDatabasesOpenCommand keepDatabasesOpen,
681             CommandCallback callback) {
682         // Acknowledge the command
683         callback.reply(Response.newBuilder().setKeepDatabasesOpen(
684                 KeepDatabasesOpenResponse.getDefaultInstance()
685         ).build().toByteArray());
686 
687         mDatabaseRegistry.notifyKeepOpenToggle(keepDatabasesOpen.getSetEnabled());
688     }
689 
690     @SuppressLint("Recycle") // For: "The cursor should be freed up after use with #close"
rawQuery(@onNull SQLiteDatabase database, @NonNull String queryText, final String @NonNull [] params, @Nullable CancellationSignal cancellationSignal)691     private static Cursor rawQuery(@NonNull SQLiteDatabase database, @NonNull String queryText,
692             final String @NonNull [] params, @Nullable CancellationSignal cancellationSignal) {
693         SQLiteDatabase.CursorFactory cursorFactory = new SQLiteDatabase.CursorFactory() {
694             @Override
695             public Cursor newCursor(SQLiteDatabase db, SQLiteCursorDriver driver,
696                     String editTable, SQLiteQuery query) {
697                 for (int i = 0; i < params.length; i++) {
698                     String value = params[i];
699                     int index = i + 1;
700                     if (value == null) {
701                         query.bindNull(index);
702                     } else {
703                         query.bindString(index, value);
704                     }
705                 }
706                 return new SQLiteCursor(driver, editTable, query);
707             }
708         };
709 
710         return database.rawQueryWithFactory(cursorFactory, queryText, null, null,
711                 cancellationSignal);
712     }
713 
parseQueryParameterValues(QueryCommand command)714     private static String @NonNull [] parseQueryParameterValues(QueryCommand command) {
715         String[] params = new String[command.getQueryParameterValuesCount()];
716         for (int i = 0; i < command.getQueryParameterValuesCount(); i++) {
717             QueryParameterValue param = command.getQueryParameterValues(i);
718             switch (param.getOneOfCase()) {
719                 case STRING_VALUE:
720                     params[i] = param.getStringValue();
721                     break;
722                 case ONEOF_NOT_SET:
723                     params[i] = null;
724                     break;
725                 default:
726                     throw new IllegalArgumentException(
727                             "Unsupported parameter type. OneOfCase=" + param.getOneOfCase());
728             }
729         }
730         return params;
731     }
732 
733     /**
734      * Tries to find a database for an id. If no such database is found, it replies with an
735      * {@link ErrorOccurredResponse} via the {@code callback} provided.
736      *
737      * TODO: remove race condition (affects WAL=off)
738      * - lock request is received and in the process of being secured
739      * - query request is received and since no lock in place, receives an IO Executor
740      * - lock request completes and holds a lock on the database
741      * - query cannot run because there is a lock in place
742      *
743      * The race condition can be mitigated by clients by securing a lock synchronously with no
744      * other queries in place.
745      *
746      * @return null if no database found for the provided id. A database reference otherwise.
747      */
acquireConnection(int databaseId, CommandCallback callback)748     private @Nullable DatabaseConnection acquireConnection(int databaseId,
749             CommandCallback callback) {
750         DatabaseConnection connection = mDatabaseLockRegistry.getConnection(databaseId);
751         if (connection != null) {
752             // With WAL enabled, we prefer to use the IO executor. With WAL off we don't have a
753             // choice and must use the executor that has a lock (transaction) on the database.
754             return connection.mDatabase.isWriteAheadLoggingEnabled()
755                     ? new DatabaseConnection(connection.mDatabase, mIOExecutor)
756                     : connection;
757         }
758 
759         SQLiteDatabase database = mDatabaseRegistry.getConnection(databaseId);
760         if (database == null) {
761             replyNoDatabaseWithId(callback, databaseId);
762             return null;
763         }
764 
765         // Given no lock, IO executor is appropriate.
766         return new DatabaseConnection(database, mIOExecutor);
767     }
768 
769     /**
770      * @param responseSizeLimitHint expressed in bytes
771      */
convert(Cursor cursor, long responseSizeLimitHint)772     private static List<Row> convert(Cursor cursor, long responseSizeLimitHint) {
773         long responseSize = 0;
774         List<Row> result = new ArrayList<>();
775         int columnCount = cursor.getColumnCount();
776         while (cursor.moveToNext() && responseSize < responseSizeLimitHint) {
777             Row.Builder rowBuilder = Row.newBuilder();
778             for (int i = 0; i < columnCount; i++) {
779                 CellValue value = readValue(cursor, i);
780                 rowBuilder.addValues(value);
781             }
782             Row row = rowBuilder.build();
783             // Optimistically adding a row before checking the limit. Eliminates the case when a
784             // misconfigured client (limit too low) is unable to fetch any results. Row size in
785             // SQLite Android is limited to (~2MB), so the worst case scenario is very manageable.
786             result.add(row);
787             responseSize += row.getSerializedSize();
788         }
789         return result;
790     }
791 
readValue(Cursor cursor, int index)792     private static CellValue readValue(Cursor cursor, int index) {
793         CellValue.Builder builder = CellValue.newBuilder();
794 
795         switch (cursor.getType(index)) {
796             case Cursor.FIELD_TYPE_NULL:
797                 // no field to set
798                 break;
799             case Cursor.FIELD_TYPE_BLOB:
800                 builder.setBlobValue(ByteString.copyFrom(cursor.getBlob(index)));
801                 break;
802             case Cursor.FIELD_TYPE_STRING:
803                 builder.setStringValue(cursor.getString(index));
804                 break;
805             case Cursor.FIELD_TYPE_INTEGER:
806                 builder.setLongValue(cursor.getLong(index));
807                 break;
808             case Cursor.FIELD_TYPE_FLOAT:
809                 builder.setDoubleValue(cursor.getDouble(index));
810                 break;
811         }
812 
813         return builder.build();
814     }
815 
replyNoDatabaseWithId(CommandCallback callback, int databaseId)816     private void replyNoDatabaseWithId(CommandCallback callback, int databaseId) {
817         String message = String.format("Unable to perform an operation on database (id=%s)."
818                 + " The database may have already been closed.", databaseId);
819         callback.reply(createErrorOccurredResponse(message, null, true,
820                 ERROR_NO_OPEN_DATABASE_WITH_REQUESTED_ID).toByteArray());
821     }
822 
querySchema(SQLiteDatabase database)823     private @NonNull Response querySchema(SQLiteDatabase database) {
824         Cursor cursor = null;
825         try {
826             cursor = rawQuery(database, sQueryTableInfo, new String[0], null);
827             GetSchemaResponse.Builder schemaBuilder = GetSchemaResponse.newBuilder();
828 
829             int objectTypeIx = cursor.getColumnIndex("type"); // view or table
830             int tableNameIx = cursor.getColumnIndex("tableName");
831             int columnNameIx = cursor.getColumnIndex("columnName");
832             int typeIx = cursor.getColumnIndex("columnType");
833             int pkIx = cursor.getColumnIndex("pk");
834             int notNullIx = cursor.getColumnIndex("notnull");
835             int uniqueIx = cursor.getColumnIndex("unique");
836 
837             Table.Builder tableBuilder = null;
838             while (cursor.moveToNext()) {
839                 String tableName = cursor.getString(tableNameIx);
840 
841                 // ignore certain tables
842                 if (sHiddenTables.contains(tableName)) {
843                     continue;
844                 }
845 
846                 // check if getting data for a new table or appending columns to the current one
847                 if (tableBuilder == null || !tableBuilder.getName().equals(tableName)) {
848                     if (tableBuilder != null) {
849                         schemaBuilder.addTables(tableBuilder.build());
850                     }
851                     tableBuilder = Table.newBuilder();
852                     tableBuilder.setName(tableName);
853                     tableBuilder.setIsView("view".equalsIgnoreCase(cursor.getString(objectTypeIx)));
854                 }
855 
856                 // append column information to the current table info
857                 tableBuilder.addColumns(Column.newBuilder()
858                         .setName(cursor.getString(columnNameIx))
859                         .setType(cursor.getString(typeIx))
860                         .setPrimaryKey(cursor.getInt(pkIx))
861                         .setIsNotNull(cursor.getInt(notNullIx) > 0)
862                         .setIsUnique(cursor.getInt(uniqueIx) > 0)
863                         .build()
864                 );
865             }
866             if (tableBuilder != null) {
867                 schemaBuilder.addTables(tableBuilder.build());
868             }
869 
870             return Response.newBuilder().setGetSchema(schemaBuilder.build()).build();
871         } catch (IllegalStateException e) {
872             if (isAttemptAtUsingClosedDatabase(e)) {
873                 return createErrorOccurredResponse(e, true,
874                         ERROR_DB_CLOSED_DURING_OPERATION);
875             } else {
876                 return createErrorOccurredResponse(e, null,
877                         ERROR_UNKNOWN);
878             }
879         } catch (Exception e) {
880             return createErrorOccurredResponse(e, null,
881                     ERROR_UNKNOWN);
882         } finally {
883             if (cursor != null) {
884                 cursor.close();
885             }
886         }
887     }
888 
889     @SuppressWarnings("WeakerAccess") // avoiding a synthetic accessor
onDatabaseOpened(SQLiteDatabase database)890     void onDatabaseOpened(SQLiteDatabase database) {
891         mRoomInvalidationRegistry.invalidateCache();
892         mDatabaseRegistry.notifyDatabaseOpened(database);
893     }
894 
895     @SuppressWarnings("WeakerAccess") // avoiding a synthetic accessor
onDatabaseClosed(SQLiteDatabase database)896     void onDatabaseClosed(SQLiteDatabase database) {
897         mDatabaseRegistry.notifyAllDatabaseReferencesReleased(database);
898     }
899 
createErrorOccurredEvent(@ullable String message, @Nullable String stackTrace, Boolean isRecoverable, ErrorCode errorCode)900     private Event createErrorOccurredEvent(@Nullable String message, @Nullable String stackTrace,
901             Boolean isRecoverable, ErrorCode errorCode) {
902         return Event.newBuilder().setErrorOccurred(
903                 ErrorOccurredEvent.newBuilder()
904                         .setContent(
905                                 createErrorContentMessage(message,
906                                         stackTrace,
907                                         isRecoverable,
908                                         errorCode))
909                         .build())
910                 .build();
911     }
912 
createErrorContentMessage(@ullable String message, @Nullable String stackTrace, Boolean isRecoverable, ErrorCode errorCode)913     private static ErrorContent createErrorContentMessage(@Nullable String message,
914             @Nullable String stackTrace, Boolean isRecoverable, ErrorCode errorCode) {
915         ErrorContent.Builder builder = ErrorContent.newBuilder();
916         if (message != null) {
917             builder.setMessage(message);
918         }
919         if (stackTrace != null) {
920             builder.setStackTrace(stackTrace);
921         }
922         ErrorRecoverability.Builder recoverability = ErrorRecoverability.newBuilder();
923         if (isRecoverable != null) { // leave unset otherwise, which translates to 'unknown'
924             recoverability.setIsRecoverable(isRecoverable);
925         }
926         builder.setRecoverability(recoverability.build());
927         builder.setErrorCode(errorCode);
928         return builder.build();
929     }
930 
createErrorOccurredResponse(@onNull Exception exception, Boolean isRecoverable, ErrorCode errorCode)931     private static Response createErrorOccurredResponse(@NonNull Exception exception,
932             Boolean isRecoverable, ErrorCode errorCode) {
933         return createErrorOccurredResponse("", isRecoverable, exception, errorCode);
934     }
935 
createErrorOccurredResponse(@onNull String messagePrefix, Boolean isRecoverable, @NonNull Exception exception, ErrorCode errorCode)936     private static Response createErrorOccurredResponse(@NonNull String messagePrefix,
937             Boolean isRecoverable, @NonNull Exception exception, ErrorCode errorCode) {
938         String message = exception.getMessage();
939         if (message == null) message = exception.toString();
940         return createErrorOccurredResponse(messagePrefix + message,
941                 stackTraceFromException(exception), isRecoverable, errorCode);
942     }
943 
createErrorOccurredResponse(@ullable String message, @Nullable String stackTrace, Boolean isRecoverable, ErrorCode errorCode)944     private static Response createErrorOccurredResponse(@Nullable String message,
945             @Nullable String stackTrace, Boolean isRecoverable, ErrorCode errorCode) {
946         return Response.newBuilder()
947                 .setErrorOccurred(
948                         ErrorOccurredResponse.newBuilder()
949                                 .setContent(createErrorContentMessage(message, stackTrace,
950                                         isRecoverable, errorCode)))
951                 .build();
952     }
953 
stackTraceFromException(Exception exception)954     private static @NonNull String stackTraceFromException(Exception exception) {
955         StringWriter writer = new StringWriter();
956         exception.printStackTrace(new PrintWriter(writer));
957         return writer.toString();
958     }
959 
isHelperSqliteFile(File file)960     private static boolean isHelperSqliteFile(File file) {
961         String path = file.getPath();
962         return path.endsWith("-journal") || path.endsWith("-shm") || path.endsWith("-wal");
963     }
964 
965     /**
966      * Provides a reference to the database and an executor to access the database.
967      *
968      * Executor is relevant in the context of locking, where a locked database with WAL disabled
969      * needs to run queries on the thread that locked it.
970      */
971     static final class DatabaseConnection {
972         final @NonNull SQLiteDatabase mDatabase;
973         final @NonNull Executor mExecutor;
974 
DatabaseConnection(@onNull SQLiteDatabase database, @NonNull Executor executor)975         DatabaseConnection(@NonNull SQLiteDatabase database, @NonNull Executor executor) {
976             mDatabase = database;
977             mExecutor = executor;
978         }
979     }
980 }
981