diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b060268..712bdb4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,9 @@ jobs: java-version: "17" distribution: "adopt" + - name: Verify sqlite-android-inspection + run: ./scripts/verify-sqlite-android-inspection.sh + - name: Run Android tests uses: reactivecircus/android-emulator-runner@v2 with: diff --git a/.gitignore b/.gitignore index b0a2d860..0a4a5337 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ /.idea/libraries .DS_Store /build +sqlite-android-inspection/build/ /captures diff --git a/README.md b/README.md index 6ba02eba..05ba55e4 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,34 @@ on a `SupportSQLiteOpenHelper.Configuration` and `SupportSQLiteOpenHelper.Callba This also allows you to use sqlite-android with libraries like Room by passing an instance of `RequerySQLiteOpenHelperFactory` to them. +### Android Studio Database Inspector (API 26+) + +App Inspection’s Database Inspector expects AndroidX’s sqlite inspector and the official +`androidx.inspection` runtime (including `libart_tooling.so`). Add the optional +**`sqlite-android-inspection`** artifact **for debug builds only**: + +```gradle +// Root settings.gradle: androidx.dev snapshot (see gradle.properties in this repo for build id). +dependencyResolutionManagement { + repositories { + // ... google(), mavenCentral(), etc. + maven { + url "https://androidx.dev/snapshots/builds//artifacts/repository" + content { includeGroup("androidx.inspection") } + } + } +} + +dependencies { + implementation "com.github.requery:sqlite-android:" + // JitPack multi-module artifact (see https://docs.jitpack.io/#building-a-multi-module-project) + debugImplementation "com.github.requery.sqlite-android:sqlite-android-inspection:" +} +``` + +When consuming the library from Maven, use the same snapshot repository so the transitive +`androidx.inspection:inspection` dependency resolves. See `sqlite-android-inspection/VENDOR.txt`. + CPU Architectures ----------------- diff --git a/build.gradle b/build.gradle index 09606df6..35f7bea8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,5 @@ plugins { id "com.android.library" version "8.13.0" apply false id "de.undercouch.download" version "5.6.0" apply false + id "com.google.protobuf" version "0.9.4" apply false } diff --git a/gradle.properties b/gradle.properties index 0d327a04..24f7e8bc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,6 +15,10 @@ org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" android.useAndroidX=true # +# androidx.inspection snapshot (see https://androidx.dev/snapshots/builds) +androidx.inspection.snapshot.buildId=15127136 +androidx.inspection.version=1.0.0-SNAPSHOT +# GROUP=io.requery VERSION_NAME=3.50.4-SNAPSHOT # diff --git a/scripts/verify-sqlite-android-inspection.sh b/scripts/verify-sqlite-android-inspection.sh new file mode 100755 index 00000000..8b198f3a --- /dev/null +++ b/scripts/verify-sqlite-android-inspection.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# +# Verifies sqlite-android-inspection: assemble, debug classpath, AAR SPI/classes. +# Usage (from repo root): ./scripts/verify-sqlite-android-inspection.sh +# Exit non-zero on any failure. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +echo "==> sqlite-android-inspection verification (repo: $ROOT)" +echo + +echo "==> [1/5] Gradle assembleDebug" +./gradlew :sqlite-android-inspection:assembleDebug --no-daemon -q + +echo "==> [2/5] Gradle assembleRelease" +./gradlew :sqlite-android-inspection:assembleRelease --no-daemon -q + +echo "==> [3/5] debugCompileClasspath includes androidx.inspection:inspection" +if ! ./gradlew :sqlite-android-inspection:dependencies --configuration debugCompileClasspath --no-daemon -q 2>/dev/null | grep -q 'androidx.inspection:inspection'; then + echo "ERROR: androidx.inspection:inspection not found on debugCompileClasspath (check snapshot repo / androidx.inspection.snapshot.buildId)" >&2 + exit 1 +fi + +AAR_DIR="$ROOT/sqlite-android-inspection/build/outputs/aar" +AAR="" +if [[ -d "$AAR_DIR" ]]; then + AAR=$(ls -1 "$AAR_DIR"/*-debug.aar 2>/dev/null | head -1 || true) +fi + +if [[ -z "${AAR:-}" || ! -f "$AAR" ]]; then + echo "ERROR: expected debug AAR under $AAR_DIR" >&2 + exit 1 +fi +echo "==> [4/5] AAR: $AAR" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +unzip -q -o "$AAR" -d "$TMP" + +JAR="$TMP/classes.jar" +if [[ ! -f "$JAR" ]]; then + echo "ERROR: classes.jar missing in AAR" >&2 + exit 1 +fi + +SPI_REL="META-INF/services/androidx.inspection.InspectorFactory" +if ! jar tf "$JAR" | grep -q "^${SPI_REL}\$"; then + echo "ERROR: missing $SPI_REL in classes.jar" >&2 + exit 1 +fi +SPI_CONTENT="$(unzip -p "$JAR" "$SPI_REL")" +if ! grep -q 'androidx\.sqlite\.inspection\.SqliteInspectorFactory' <<<"$SPI_CONTENT"; then + echo "ERROR: SPI factory line not found in $SPI_REL" >&2 + printf '%s\n' "$SPI_CONTENT" >&2 + exit 1 +fi +echo " SPI OK: $(echo "$SPI_CONTENT" | tr -d '\n')" + +for class in \ + 'androidx/sqlite/inspection/SqliteInspector.class' \ + 'androidx/sqlite/inspection/SqliteInspectorFactory.class'; do + if ! jar tf "$JAR" | grep -q "^$class\$"; then + echo "ERROR: missing $class in classes.jar" >&2 + exit 1 + fi + echo " class OK: $class" +done + +echo +echo "==> [5/5] All checks passed. E2E: :sqlite-android-inspection:connectedDebugAndroidTest (see VENDOR.txt)." diff --git a/settings.gradle b/settings.gradle index 6b2026b9..623bc56b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,9 +10,19 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + def props = new Properties() + file("${rootDir}/gradle.properties").withInputStream { props.load(it) } + def buildId = props.getProperty("androidx.inspection.snapshot.buildId", "15127136") + maven { + url = uri("https://androidx.dev/snapshots/builds/${buildId}/artifacts/repository") + content { + includeGroup("androidx.inspection") + } + } } } rootProject.name = 'requery-sqlite' include ':sqlite-android' +include ':sqlite-android-inspection' diff --git a/sqlite-android-inspection/VENDOR.txt b/sqlite-android-inspection/VENDOR.txt new file mode 100644 index 00000000..d2df6213 --- /dev/null +++ b/sqlite-android-inspection/VENDOR.txt @@ -0,0 +1,16 @@ +androidx.sqlite.inspection fork for io.requery (OpenParams-free), plus proto and SPI +(META-INF/services/androidx.inspection.InspectorFactory → SqliteInspectorFactory). + +Runtime androidx.inspection.* and libart_tooling.so come from Maven artifact +androidx.inspection:inspection (not vendored here). Snapshot repo and build id: +gradle.properties (androidx.inspection.version, androidx.inspection.snapshot.buildId); +settings.gradle adds https://androidx.dev/snapshots/builds//artifacts/repository + +debugApi(inspection): transitive for debug consumers. releaseCompileOnly: release AAR does not bundle JVMTI. + +App: debugImplementation(project(":sqlite-android-inspection")) or published coordinates. +If the app resolves this module from Maven (not project()), add the same androidx.dev snapshot repo +(or a mirror) so transitive androidx.inspection:inspection resolves. + +Checks: ./scripts/verify-sqlite-android-inspection.sh +E2E (device): ./gradlew :sqlite-android-inspection:connectedDebugAndroidTest (InspectionJvmtiRuntimeTest) diff --git a/sqlite-android-inspection/build.gradle b/sqlite-android-inspection/build.gradle new file mode 100644 index 00000000..08a7dc5e --- /dev/null +++ b/sqlite-android-inspection/build.gradle @@ -0,0 +1,59 @@ +plugins { + id "com.android.library" + id "com.google.protobuf" + id "com.vanniktech.maven.publish" version "0.34.0" +} + +android { + buildToolsVersion = "36.1.0" + compileSdk { + version = release(36) + } + namespace "androidx.sqlite.inspection" + defaultConfig { + minSdkVersion 26 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + lint { + abortOnError false + } +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:3.25.3" + } + generateProtoTasks { + all().each { task -> + task.builtins { + java { + option "lite" + } + } + } + } +} + +dependencies { + def inspVersion = findProperty("androidx.inspection.version") ?: "1.0.0-SNAPSHOT" + debugApi("androidx.inspection:inspection:${inspVersion}") + releaseCompileOnly("androidx.inspection:inspection:${inspVersion}") + + compileOnly("androidx.annotation:annotation:1.8.1") + api("org.jspecify:jspecify:1.0.0") + api("com.google.protobuf:protobuf-javalite:3.25.3") + api(project(":sqlite-android")) + + androidTestImplementation("androidx.test:runner:1.7.0") + androidTestImplementation("androidx.test.ext:junit:1.3.0") +} + +mavenPublishing { + publishToMavenCentral(/* automaticRelease */ true) + signAllPublications() +} diff --git a/sqlite-android-inspection/consumer-rules.pro b/sqlite-android-inspection/consumer-rules.pro new file mode 100644 index 00000000..b07001aa --- /dev/null +++ b/sqlite-android-inspection/consumer-rules.pro @@ -0,0 +1,11 @@ +# App Inspection / Database Inspector (vendored androidx sqlite-inspection + io.requery hooks) +-keep class androidx.sqlite.inspection.** { *; } +-keep class androidx.inspection.** { *; } +-keepclassmembers class io.requery.android.database.sqlite.SQLiteDatabase { + public static *** openDatabase(...); + public *** rawQueryWithFactory(...); + void endTransaction(); +} +-keep class io.requery.android.database.sqlite.SQLiteClosable { *; } +-keep class io.requery.android.database.sqlite.SQLiteCursor { *; } +-keep class io.requery.android.database.sqlite.SQLiteStatement { *; } diff --git a/sqlite-android-inspection/src/androidTest/java/androidx/sqlite/inspection/InspectionJvmtiRuntimeTest.java b/sqlite-android-inspection/src/androidTest/java/androidx/sqlite/inspection/InspectionJvmtiRuntimeTest.java new file mode 100644 index 00000000..02a8d8b3 --- /dev/null +++ b/sqlite-android-inspection/src/androidTest/java/androidx/sqlite/inspection/InspectionJvmtiRuntimeTest.java @@ -0,0 +1,42 @@ +package androidx.sqlite.inspection; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.os.Build; +import androidx.inspection.ArtToolingImpl; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Runtime check: inspection AAR supplies libart_tooling.so in APK, loads, {@link ArtToolingImpl} initializes. */ +@RunWith(AndroidJUnit4.class) +public class InspectionJvmtiRuntimeTest { + + @Test + public void libArtTooling_so_inBaseApk_forPrimaryAbi() throws Exception { + String apkPath = + InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageCodePath(); + String abi = Build.SUPPORTED_ABIS[0]; + String entryName = "lib/" + abi + "/libart_tooling.so"; + try (ZipFile zf = new ZipFile(apkPath)) { + ZipEntry entry = zf.getEntry(entryName); + assertTrue( + "Missing " + entryName + " in " + apkPath + " (add androidx.inspection:inspection)", + entry != null); + } + } + + @Test + public void art_tooling_nativeLibrary_loads() { + System.loadLibrary("art_tooling"); + } + + @Test + public void artToolingImpl_instance_initializes() { + assertNotNull(ArtToolingImpl.instance()); + } +} diff --git a/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/DatabaseExtensions.java b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/DatabaseExtensions.java new file mode 100644 index 00000000..ee9ae9c7 --- /dev/null +++ b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/DatabaseExtensions.java @@ -0,0 +1,83 @@ +/* + * 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 io.requery.android.database.sqlite.SQLiteDatabase; + +import org.jspecify.annotations.NonNull; + +import java.io.File; +import java.util.Objects; + +final class DatabaseExtensions { + private static final String sInMemoryDatabasePath = ":memory:"; + + /** Placeholder {@code %x} is for database's hashcode */ + private static final String sInMemoryDatabaseNameFormat = + sInMemoryDatabasePath + " {hashcode=0x%x}"; + + private DatabaseExtensions() { } + + /** Thread-safe as {@link SQLiteDatabase#getPath} and {@link Object#hashCode) are thread-safe.*/ + static String pathForDatabase(@NonNull SQLiteDatabase database) { + return isInMemoryDatabase(database) + ? String.format(sInMemoryDatabaseNameFormat, database.hashCode()) + : new File(database.getPath()).getAbsolutePath(); + } + + /** Thread-safe as {@link SQLiteDatabase#getPath} is thread-safe. */ + static boolean isInMemoryDatabase(@NonNull SQLiteDatabase database) { + return Objects.equals(sInMemoryDatabasePath, database.getPath()); + } + + /** + * Attempts to call {@link SQLiteDatabase#acquireReference} on the provided object. + * + * @return true if the operation was successful; false if unsuccessful because the database + * was already closed; otherwise re-throws the exception thrown by + * {@link SQLiteDatabase#acquireReference}. + */ + static boolean tryAcquireReference(@NonNull SQLiteDatabase database) { + if (!database.isOpen()) { + return false; + } + + try { + database.acquireReference(); + return true; // success + } catch (IllegalStateException e) { + if (isAttemptAtUsingClosedDatabase(e)) { + return false; + } + throw e; + } + } + + /** + * Note that this is best-effort as relies on Exception message parsing, which could break in + * the future. + * Use in the context where false negatives (more likely) and false positives (less likely + * due to the specificity of the message) are tolerable, e.g. to assign error codes where if + * it fails we will just send an 'unknown' error. + */ + static boolean isAttemptAtUsingClosedDatabase(IllegalStateException exception) { + String message = exception.getMessage(); + return message != null && (message.contains("attempt to re-open an already-closed object") + || message.contains( + "Cannot perform this operation because the connection pool has been closed")); + } +} diff --git a/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/DatabaseLockRegistry.java b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/DatabaseLockRegistry.java new file mode 100644 index 00000000..5a9ec61b --- /dev/null +++ b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/DatabaseLockRegistry.java @@ -0,0 +1,187 @@ +/* + * Copyright 2021 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 io.requery.android.database.sqlite.SQLiteDatabase; +import androidx.core.os.CancellationSignal; + +import androidx.annotation.GuardedBy; +import androidx.annotation.VisibleForTesting; +import androidx.sqlite.inspection.SqliteInspector.DatabaseConnection; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +/** + * Handles database locking and associated bookkeeping. + * Thread-safe. + */ +public class DatabaseLockRegistry { + @VisibleForTesting public static int sTimeoutMs = 5000; + + private final Object mLock = new Object(); // used for synchronization within the class + @GuardedBy("mLock") private final Map mLockIdToLockMap = new HashMap<>(); + @GuardedBy("mLock") private final Map mDatabaseIdToLockMap = new HashMap<>(); + @GuardedBy("mLock") private int mNextLockId = 1; + + // A dedicated thread required as database transactions are tied to a thread. In order to + // release a lock, we need to use the same thread as the one we used to establish the lock. + // Thread names need to start with 'Studio:' as per some framework limitations. + private final @NonNull Executor mExecutor = + Executors.newSingleThreadExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "Studio:Sql:Lock"); // limit = 15 characters + thread.setDaemon(true); + return thread; + } + }); + + /** + * Locks a database identified by the provided database id. If a lock on the database is + * already in place, an existing lock will be issued. Locks keep count of simultaneous + * requests, so that the database is only unlocked once all callers release their issued locks. + */ + public int acquireLock(int databaseId, @NonNull SQLiteDatabase database) throws Exception { + synchronized (mLock) { + Lock lock = mDatabaseIdToLockMap.get(databaseId); + if (lock == null) { + lock = new Lock(mNextLockId++, databaseId, database); + lockDatabase(lock.mDatabase); + mLockIdToLockMap.put(lock.mLockId, lock); + mDatabaseIdToLockMap.put(lock.mDatabaseId, lock); + } + lock.mCount++; + return lock.mLockId; + } + } + + /** + * Releases a lock on a database identified by the provided lock id. If the same lock has been + * provided multiple times (for lock requests on an already locked database), the lock + * needs to be released by all previous requestors for the database to get unlocked. + */ + public void releaseLock(int lockId) throws Exception { + synchronized (mLock) { + Lock lock = mLockIdToLockMap.get(lockId); + if (lock == null) throw new IllegalArgumentException("No lock with id: " + lockId); + + if (--lock.mCount == 0) { + try { + unlockDatabase(lock.mDatabase); + } catch (Exception e) { + lock.mCount++; // correct the count + throw e; + } + mLockIdToLockMap.remove(lock.mLockId); + mDatabaseIdToLockMap.remove(lock.mDatabaseId); + } + } + } + + /** + * @return `null` if the database is not locked; the database and the executor that locked the + * database otherwise + */ + @Nullable DatabaseConnection getConnection(int databaseId) { + synchronized (mLock) { + Lock lock = mDatabaseIdToLockMap.get(databaseId); + return (lock == null) + ? null + : new DatabaseConnection(lock.mDatabase, mExecutor); + } + } + + /** + * Starts a database transaction and acquires an extra database reference to keep the database + * open while the lock is in place. + */ + private void lockDatabase(final SQLiteDatabase database) throws Exception { + // keeps the database open while a lock is in place; released when the lock is released + boolean keepOpenReferenceAcquired = false; + + final CancellationSignal cancellationSignal = new CancellationSignal(); + Future future = null; + try { + database.acquireReference(); + keepOpenReferenceAcquired = true; + + // Submitting a Runnable, so we can set a timeout. + future = SqliteInspectionExecutors.submit(mExecutor, new Runnable() { + @Override + public void run() { + // starts a transaction + database.rawQuery("BEGIN IMMEDIATE;", new String[0], cancellationSignal) + .getCount(); // forces the cursor to execute the query + } + }); + future.get(sTimeoutMs, TimeUnit.MILLISECONDS); + } catch (Exception e) { + if (keepOpenReferenceAcquired) database.releaseReference(); + cancellationSignal.cancel(); + if (future != null) future.cancel(true); + throw e; + } + } + + /** + * Ends the database transaction and releases the extra database reference that kept the + * database open while the lock was in place. + */ + private void unlockDatabase(final SQLiteDatabase database) throws Exception { + final CancellationSignal cancellationSignal = new CancellationSignal(); + Future future = null; + try { + // Submitting a Runnable, so we can set a timeout. + future = SqliteInspectionExecutors.submit(mExecutor, new Runnable() { + @Override + public void run() { + // ends the transaction + database.rawQuery("ROLLBACK;", new String[0], cancellationSignal) + .getCount(); // forces the cursor to execute the query + database.releaseReference(); + } + }); + future.get(sTimeoutMs, TimeUnit.MILLISECONDS); + } catch (Exception e) { + cancellationSignal.cancel(); + if (future != null) future.cancel(true); + throw e; + } + } + + private static final class Lock { + final int mLockId; + final int mDatabaseId; + final SQLiteDatabase mDatabase; + int mCount = 0; // number of simultaneous locks secured on the database + + Lock(int lockId, int databaseId, SQLiteDatabase database) { + this.mLockId = lockId; + this.mDatabaseId = databaseId; + this.mDatabase = database; + } + } +} diff --git a/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/DatabaseRegistry.java b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/DatabaseRegistry.java new file mode 100644 index 00000000..7f1922ea --- /dev/null +++ b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/DatabaseRegistry.java @@ -0,0 +1,383 @@ +/* + * 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 static androidx.sqlite.inspection.DatabaseExtensions.isInMemoryDatabase; +import static androidx.sqlite.inspection.DatabaseExtensions.pathForDatabase; + +import io.requery.android.database.sqlite.SQLiteDatabase; +import android.util.ArraySet; + +import androidx.annotation.GuardedBy; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +/** + * The class keeps track of databases under inspection, and can keep database connections open if + * such option is enabled. + *

Signals expected to be provided to the class: + *

    + *
  • {@link #notifyDatabaseOpened} - should be called when the inspection code detects a + * database open operation. + *
  • {@link #notifyAllDatabaseReferencesReleased} - should be called when the inspection code + * detects that the last database connection reference has been released (effectively a connection + * closed event). + *
  • {@link #notifyKeepOpenToggle} - should be called when the inspection code detects a + * request to change the keep-database-connection-open setting (enabled|disabled). + *

+ *

Callbacks exposed by the class: + *

    + *
  • Detected a database that is now open, and previously was either closed or not tracked. + *
  • Detected a database that is now closed, and previously was reported as open. + *

+ */ +@SuppressWarnings({"DanglingJavadoc", "SyntheticAccessor"}) +class DatabaseRegistry { + private static final int NOT_TRACKED = -1; + + // Called when tracking state changes (notTracked|closed)->open + private final Callback mOnOpenedCallback; + // Called when tracking state changes open->closed + private final Callback mOnClosedCallback; + + // True if keep-database-connection-open functionality is enabled. + private boolean mKeepDatabasesOpen = false; + + private final Object mLock = new Object(); + + // Starting from '1' to distinguish from '0' which could stand for an unset parameter. + @GuardedBy("mLock") private int mNextId = 1; + + // TODO: decide if use weak-references to database objects + + /** + * Database connection id -> a list of database references pointing to the same database. The + * collection is meant to only contain open connections (eventually consistent after all + * callbacks queued behind {@link #mLock} are processed). + */ + @GuardedBy("mLock") private final Map> mDatabases = + new HashMap<>(); + + // Database connection id -> extra database reference used to facilitate the + // keep-database-connection-open functionality. + @GuardedBy("mLock") private final Map mKeepOpenReferences = + new HashMap<>(); + + // Database path -> database connection id - allowing to report a consistent id for all + // references pointing to the same path. + @GuardedBy("mLock") private final Map mPathToId = new HashMap<>(); + + /** + * @param onOpenedCallback called when tracking state changes (notTracked|closed)->open + * @param onClosedCallback called when tracking state changes open->closed + */ + DatabaseRegistry(Callback onOpenedCallback, Callback onClosedCallback) { + mOnOpenedCallback = onOpenedCallback; + mOnClosedCallback = onClosedCallback; + } + + /** + * Should be called when the inspection code detects a database being open operation. + *

Note that the method should be called before any code has a chance to close the + * database, so e.g. in an {@link androidx.inspection.ArtTooling.ExitHook#onExit} + * before the return value is released. + * Thread-safe. + */ + void notifyDatabaseOpened(@NonNull SQLiteDatabase database) { + handleDatabaseSignal(database); + } + + void notifyReleaseReference(SQLiteDatabase database) { + synchronized (mLock) { + /* Prevent all other methods from releasing a reference if a + {@link KeepOpenReference} is present */ + for (KeepOpenReference reference : mKeepOpenReferences.values()) { + if (reference.mDatabase == database) { + /* The below will always succeed as {@link mKeepOpenReferences} only + * contains active references: + * - we only insert active references into {@link mKeepOpenReferences} + * - {@link KeepOpenReference#releaseAllReferences} is the only place where we + * allow references to be released + * - {@link KeepOpenReference#releaseAllReferences} is private an can only be + * called from this class; and before it is called, it must be removed from + * from {@link mKeepOpenReferences} + */ + reference.acquireReference(); + } + } + } + } + + /** + * Should be called when the inspection code detects that the last database connection + * reference has been released (effectively a connection closed event). + * Thread-safe. + */ + void notifyAllDatabaseReferencesReleased(@NonNull SQLiteDatabase database) { + handleDatabaseSignal(database); + } + + /** + * Should be called when the inspection code detects a request to change the + * keep-database-connection-open setting (enabled|disabled). + * Thread-safe. + */ + void notifyKeepOpenToggle(boolean setEnabled) { + synchronized (mLock) { + if (mKeepDatabasesOpen == setEnabled) { + return; // no change + } + + if (setEnabled) { // allowClose -> keepOpen + mKeepDatabasesOpen = true; + + for (int id : mDatabases.keySet()) { + secureKeepOpenReference(id); + } + } else { // keepOpen -> allowClose + mKeepDatabasesOpen = false; + + Iterator> iterator = + mKeepOpenReferences.entrySet().iterator(); + while (iterator.hasNext()) { + KeepOpenReference reference = iterator.next().getValue(); + iterator.remove(); // first remove so it doesn't get in its own way + reference.releaseAllReferences(); // then release its references + } + } + } + } + + /** + * Should be called at the start of inspection to pre-populate the list of databases with + * ones on disk. + */ + void notifyOnDiskDatabase(@NonNull String path) { + synchronized (mLock) { + Integer currentId = mPathToId.get(path); + if (currentId == null) { + int id = mNextId++; + mPathToId.put(path, id); + mOnClosedCallback.onPostEvent(id, path); + } + } + } + + /** Thread-safe */ + private void handleDatabaseSignal(@NonNull SQLiteDatabase database) { + Integer notifyOpenedId = null; + Integer notifyClosedId = null; + + synchronized (mLock) { + int id = getIdForDatabase(database); + + // TODO: revisit the text below since now we're synchronized on the same lock (mLock) + // as releaseReference() calls -- which most likely allows for simplifying invariants + // Guaranteed up to date: + // - either called in a secure context (e.g. before the newly created connection is + // returned from the creation; or with an already acquiredReference on it), + // - or called after the last reference was released which cannot be undone. + final boolean isOpen = database.isOpen(); + + if (id == NOT_TRACKED) { // handling a transition: not tracked -> tracked + id = mNextId++; + registerReference(id, database); + if (isOpen) { + notifyOpenedId = id; + } else { + notifyClosedId = id; + } + } else if (isOpen) { // handling a transition: tracked(closed) -> tracked(open) + // There are two scenarios here: + // - hasReferences is up to date and there is an open reference already, so we + // don't need to announce a new one + // - hasReferences is stale, and references in it are queued up to be + // announced as closing, in this case the outside world thinks that the + // connection is open (close ones not processed yet), so we don't need to + // announce anything; later, when processing the queued up closed events nothing + // will be announced as the currently processed database will keep at least one open + // connection. + if (!hasReferences(id)) { + notifyOpenedId = id; + } + registerReference(id, database); + } else { // handling a transition: tracked(open) -> tracked(closed) + // There are two scenarios here: + // - hasReferences is up to date and we can use it + // - hasReferences is stale, and references in it are queued up to be + // announced as closed; in this case there is no harm not announcing a closed + // event now as the subsequent calls will do it if appropriate + final boolean hasReferencesPre = hasReferences(id); + unregisterReference(id, database); + final boolean hasReferencesPost = hasReferences(id); + if (hasReferencesPre && !hasReferencesPost) { + notifyClosedId = id; + } + } + + secureKeepOpenReference(id); + + // notify of changes if any + if (notifyOpenedId != null) { + mOnOpenedCallback.onPostEvent(notifyOpenedId, pathForDatabase(database)); + } else if (notifyClosedId != null) { + mOnClosedCallback.onPostEvent(notifyClosedId, pathForDatabase(database)); + } + } + } + + /** + * Returns a currently active database reference if one is available. Null otherwise. + * Consumer of this method must release the reference when done using it. + * Thread-safe + */ + @Nullable SQLiteDatabase getConnection(int databaseId) { + synchronized (mLock) { + return getConnectionImpl(databaseId); + } + } + + @GuardedBy("mLock") + private SQLiteDatabase getConnectionImpl(int databaseId) { + KeepOpenReference keepOpenReference = mKeepOpenReferences.get(databaseId); + if (keepOpenReference != null) { + return keepOpenReference.mDatabase; + } + + final Set references = mDatabases.get(databaseId); + if (references == null) return null; + + // tries to find an open reference preferring write-enabled over read-only + SQLiteDatabase readOnlyReference = null; + for (SQLiteDatabase reference : references) { + if (reference.isOpen()) { + if (!reference.isReadOnly()) return reference; // write-enabled was found: return it + readOnlyReference = reference; // remember the read-only reference but keep looking + } + } + return readOnlyReference; // or null if we did not find an open reference + } + + @GuardedBy("mLock") + private void registerReference(int id, @NonNull SQLiteDatabase database) { + Set references = mDatabases.get(id); + if (references == null) { + references = new ArraySet<>(1); + mDatabases.put(id, references); + if (!isInMemoryDatabase(database)) { + mPathToId.put(pathForDatabase(database), id); + } + } + // mDatabases only tracks open instances + if (database.isOpen()) { + references.add(database); + } + } + + @GuardedBy("mLock") + private void unregisterReference(int id, @NonNull SQLiteDatabase database) { + Set references = mDatabases.get(id); + if (references == null) { + return; + } + references.remove(database); + } + + @GuardedBy("mLock") + private void secureKeepOpenReference(int id) { + if (!mKeepDatabasesOpen || mKeepOpenReferences.containsKey(id)) { + // Keep-open is disabled or we already have a keep-open-reference for that id. + return; + } + + // Try secure a keep-open reference + SQLiteDatabase reference = getConnectionImpl(id); + if (reference != null) { + mKeepOpenReferences.put(id, new KeepOpenReference(reference)); + } + } + + @GuardedBy("mLock") + private int getIdForDatabase(SQLiteDatabase database) { + String databasePath = pathForDatabase(database); + + Integer previousId = mPathToId.get(databasePath); + if (previousId != null) { + return previousId; + } + + if (isInMemoryDatabase(database)) { + for (Map.Entry> entry : mDatabases.entrySet()) { + for (SQLiteDatabase entryDb : entry.getValue()) { + if (entryDb == database) { + return entry.getKey(); + } + } + } + } + + return NOT_TRACKED; + } + + @GuardedBy("mLock") + private boolean hasReferences(int databaseId) { + final Set references = mDatabases.get(databaseId); + return references != null && !references.isEmpty(); + } + + interface Callback { + void onPostEvent(int databaseId, String path); + } + + private static final class KeepOpenReference { + private final SQLiteDatabase mDatabase; + + private final Object mLock = new Object(); + @GuardedBy("mLock") private int mAcquiredReferenceCount = 0; + + private KeepOpenReference(SQLiteDatabase database) { + mDatabase = database; + } + + private void acquireReference() { + synchronized (mLock) { + if (DatabaseExtensions.tryAcquireReference(mDatabase)) { + mAcquiredReferenceCount++; + } + } + } + + /** + * This should only be called after removing the object from + * {@link DatabaseRegistry#mKeepOpenReferences}. Otherwise, the object will get in its + * own way or releasing its references. + */ + private void releaseAllReferences() { + synchronized (mLock) { + for (; mAcquiredReferenceCount > 0; mAcquiredReferenceCount--) { + mDatabase.releaseReference(); + } + } + } + } +} diff --git a/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/EntryExitMatchingHookRegistry.java b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/EntryExitMatchingHookRegistry.java new file mode 100644 index 00000000..473c4c18 --- /dev/null +++ b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/EntryExitMatchingHookRegistry.java @@ -0,0 +1,106 @@ +/* + * 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.ArtTooling.EntryHook; +import androidx.inspection.ArtTooling.ExitHook; +import androidx.inspection.InspectorEnvironment; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; + +/** + * The class allows for observing method's EntryHook parameters in ExitHook. + *

+ * It works by registering both (entry and exit) hooks and keeping its own method frame stack. + * On exit, it calls {@link OnExitCallback} provided by the user. + *

+ * TODO: handle cases when frames could be dropped (e.g. because of an Exception) causing internal + * state to be corrupted. + *

+ * Thread safe. + */ +final class EntryExitMatchingHookRegistry { + private final InspectorEnvironment mEnvironment; + private final ThreadLocal> mFrameStack; + + EntryExitMatchingHookRegistry(InspectorEnvironment environment) { + mEnvironment = environment; + mFrameStack = new ThreadLocal>() { + @Override + protected @NonNull Deque initialValue() { + return new ArrayDeque<>(); + } + }; + } + + void registerHook(Class originClass, final String originMethod, + final OnExitCallback onExitCallback) { + mEnvironment.artTooling().registerEntryHook(originClass, originMethod, + new EntryHook() { + @Override + public void onEntry(@Nullable Object thisObject, + @NonNull List args) { + getFrameStack().addLast(new Frame(originMethod, thisObject, args, null)); + } + }); + + mEnvironment.artTooling().registerExitHook(originClass, originMethod, + new ExitHook() { + @Override + public Object onExit(Object result) { + Frame entryFrame = getFrameStack().pollLast(); + if (entryFrame == null || !originMethod.equals(entryFrame.mMethod)) { + // TODO: make more specific and handle + throw new IllegalStateException(); + } + + onExitCallback.onExit(new Frame(entryFrame.mMethod, entryFrame.mThisObject, + entryFrame.mArgs, result)); + return result; + } + }); + } + + private @NonNull Deque getFrameStack() { + /* It won't be null because of overridden {@link ThreadLocal#initialValue} */ + //noinspection ConstantConditions + return mFrameStack.get(); + } + + static final class Frame { + final String mMethod; + final Object mThisObject; + final List mArgs; + final Object mResult; + + private Frame(String method, Object thisObject, List args, Object result) { + mMethod = method; + mThisObject = thisObject; + mArgs = args; + mResult = result; + } + } + + interface OnExitCallback { + void onExit(Frame exitFrame); + } +} diff --git a/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/Invalidation.java b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/Invalidation.java new file mode 100644 index 00000000..62ae5092 --- /dev/null +++ b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/Invalidation.java @@ -0,0 +1,24 @@ +/* + * Copyright 2024 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; + +/** + * Triggers invalidation after SQL mutations are executed + */ +interface Invalidation { + void triggerInvalidations(); +} diff --git a/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/RequestCollapsingThrottler.java b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/RequestCollapsingThrottler.java new file mode 100644 index 00000000..7cdff67c --- /dev/null +++ b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/RequestCollapsingThrottler.java @@ -0,0 +1,91 @@ +/* + * 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.annotation.GuardedBy; + +/** + * Throttler implementation ensuring that events are run not more frequently that specified + * interval. Events submitted during the interval period are collapsed into one (i.e. only one is + * executed). + * + * Thread safe. + */ +final class RequestCollapsingThrottler { + private static final long NEVER = -1; + + private final Runnable mAction; + private final long mMinIntervalMs; + private final DeferredExecutor mExecutor; + private final Object mLock = new Object(); + + @GuardedBy("mLock") private boolean mPendingDispatch = false; + @GuardedBy("mLock") private long mLastSubmitted = NEVER; + + RequestCollapsingThrottler(long minIntervalMs, Runnable action, DeferredExecutor executor) { + mExecutor = executor; + mAction = action; + mMinIntervalMs = minIntervalMs; + } + + public void submitRequest() { + synchronized (mLock) { + if (mPendingDispatch) { + return; + } else { + mPendingDispatch = true; // about to schedule + } + } + long delayMs = mMinIntervalMs - sinceLast(); // delayMs < 0 is OK + scheduleDispatch(delayMs); + } + + // TODO: switch to ListenableFuture to react on failures + @SuppressWarnings("FutureReturnValueIgnored") + private void scheduleDispatch(long delayMs) { + mExecutor.schedule(new Runnable() { + @Override + public void run() { + try { + mAction.run(); + } finally { + synchronized (mLock) { + mLastSubmitted = now(); + mPendingDispatch = false; + } + } + } + }, delayMs); + } + + private static long now() { + return System.currentTimeMillis(); + } + + private long sinceLast() { + synchronized (mLock) { + final long lastSubmitted = mLastSubmitted; + return lastSubmitted == NEVER + ? (mMinIntervalMs + 1) // more than mMinIntervalMs + : (now() - lastSubmitted); + } + } + + interface DeferredExecutor { + void schedule(Runnable command, long delayMs); + } +} diff --git a/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/RoomInvalidationRegistry.java b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/RoomInvalidationRegistry.java new file mode 100644 index 00000000..d987c3ff --- /dev/null +++ b/sqlite-android-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.annotation.SuppressLint; +import android.util.Log; + +import androidx.inspection.InspectorEnvironment; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +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. + *

+ * 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 implements Invalidation { + 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. + */ + private final @Nullable InvalidationTrackerInvoker mInvoker; + + /** + * The list of InvalidationTracker instances. + */ + private @Nullable List> mInvalidationInstances = null; + + RoomInvalidationRegistry(InspectorEnvironment environment) { + mEnvironment = environment; + mInvoker = findInvalidationTrackerClass(); + } + + /** + * Calls all of the InvalidationTrackers to check their database for updated tables. + *

+ * If the list of InvalidationTracker instances are not cached, this will do a lookup. + */ + @Override + public void triggerInvalidations() { + if (mInvoker == null) { + return; + } + List> 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; + } + + private @NonNull List> getInvalidationTrackerInstances() { + List> cached = mInvalidationInstances; + if (cached != null) { + return cached; + } + if (mInvoker == null) { + cached = Collections.emptyList(); + } else { + List instances = + mEnvironment.artTooling().findInstances(mInvoker.invalidationTrackerClass); + cached = new ArrayList<>(instances.size()); + for (Object instance : instances) { + cached.add(new WeakReference<>(instance)); + } + } + mInvalidationInstances = cached; + return cached; + } + + private @Nullable 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; + private final @Nullable 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; + } + } + + @SuppressLint("BanUncheckedReflection") // Not a platform method. + 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-android-inspection/src/main/java/androidx/sqlite/inspection/SqlDelight2Invalidation.java b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqlDelight2Invalidation.java new file mode 100644 index 00000000..19e6c4e2 --- /dev/null +++ b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqlDelight2Invalidation.java @@ -0,0 +1,118 @@ +/* + * Copyright 2024 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.annotation.SuppressLint; +import android.util.Log; + +import androidx.inspection.ArtTooling; + +import org.jspecify.annotations.NonNull; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.Objects; + +/** + * An [Invalidation] for the SqlDelight 2 library. + *

+ * SqlDelight 2 invalidation API uses an internal "queryKey" to associate queries with listeners. + * The key is created by the generated code and is typically just the affected table name but can + * in theory be anything. In fact, a user can register a listener directly using + * SqlDriver#addListener() and provide their own queryKeys. This will work as long as the user + * also manages notification using SqlDriver#notifyListeners(). + *

+ * The public API that notifies listeners requires this queryKey: + *

+ *   override fun notifyListeners(vararg queryKeys: String)
+ * 
+ * There is no public API that works without it and there is no public API that lists the current + * listeners or queryKey's. + *

+ * Because of this, we need to access the private field AndroidSqliteDriver#listeners and extract + * the registered queryKeys. + */ +class SqlDelight2Invalidation implements Invalidation { + public static final String TAG = "StudioInspectors"; + public static final String HIDDEN_TAG = "studio.inspectors"; + + public static final String DRIVER_CLASSNAME = + "app.cash.sqldelight.driver.android.AndroidSqliteDriver"; + public static final String NOTIFY_METHOD = "notifyListeners"; + public static final String LISTENERS_FIELD = "listeners"; + + private final @NonNull ArtTooling mArtTooling; + private final @NonNull Class mDriverClass; + private final @NonNull Method mNotifyListenersMethod; + private final @NonNull Field mListenersField; + + static Invalidation create(@NonNull ArtTooling artTooling) { + try { + ClassLoader classLoader = Objects.requireNonNull( + SqlDelight2Invalidation.class.getClassLoader()); + Class driverClass = classLoader.loadClass(DRIVER_CLASSNAME); + Method notifyListenersMethod = + driverClass.getDeclaredMethod(NOTIFY_METHOD, String[].class); + Field listenersField = driverClass.getDeclaredField(LISTENERS_FIELD); + listenersField.setAccessible(true); + return new SqlDelight2Invalidation( + artTooling, + driverClass, + notifyListenersMethod, + listenersField); + } catch (ClassNotFoundException e) { + Log.v(HIDDEN_TAG, "SqlDelight 2 not found", e); + return () -> { + }; + } catch (Exception e) { + Log.w(TAG, "Error setting up SqlDelight 2 invalidation", e); + return () -> { + }; + } + } + + private SqlDelight2Invalidation( + @NonNull ArtTooling artTooling, + @NonNull Class driverClass, + @NonNull Method notifyListenersMethod, + @NonNull Field listenersField) { + mArtTooling = artTooling; + mDriverClass = driverClass; + mNotifyListenersMethod = notifyListenersMethod; + mListenersField = listenersField; + } + + @SuppressLint("BanUncheckedReflection") + @Override + public void triggerInvalidations() { + for (Object driver : mArtTooling.findInstances(mDriverClass)) { + try { + @SuppressWarnings("unchecked") + Map listeners = + Objects.requireNonNull((Map) mListenersField.get(driver)); + synchronized (listeners) { + mNotifyListenersMethod.invoke( + driver, + (Object) listeners.keySet().toArray(new String[0])); + } + } catch (Exception e) { + Log.w(TAG, "Error invalidating SqlDriver", e); + } + } + } +} diff --git a/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqlDelightInvalidation.java b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqlDelightInvalidation.java new file mode 100644 index 00000000..a0126217 --- /dev/null +++ b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqlDelightInvalidation.java @@ -0,0 +1,78 @@ +/* + * 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.annotation.SuppressLint; +import android.util.Log; + +import androidx.inspection.ArtTooling; + +import org.jspecify.annotations.NonNull; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Objects; + +class SqlDelightInvalidation implements Invalidation { + public static final String TAG = "StudioInspectors"; + public static final String HIDDEN_TAG = "studio.inspectors"; + + private static final String SQLDELIGHT_QUERY_CLASS_NAME = "com.squareup.sqldelight.Query"; + private static final String SQLDELIGHT_NOTIFY_METHOD_NAME = "notifyDataChanged"; + + private final @NonNull ArtTooling mArtTooling; + private final @NonNull Class mQueryClass; + private final @NonNull Method mNotifyDataChangeMethod; + + static @NonNull Invalidation create(@NonNull ArtTooling artTooling) { + ClassLoader classLoader = SqlDelightInvalidation.class.getClassLoader(); + Objects.requireNonNull(classLoader); + try { + Class queryClass = classLoader.loadClass(SQLDELIGHT_QUERY_CLASS_NAME); + Method notifyMethod = queryClass.getMethod(SQLDELIGHT_NOTIFY_METHOD_NAME); + return new SqlDelightInvalidation(artTooling, queryClass, notifyMethod); + } catch (ClassNotFoundException e) { + Log.v(HIDDEN_TAG, "SqlDelight not found", e); + return () -> { + }; + } catch (Exception e) { + Log.w(TAG, "Error setting up SqlDelight invalidation", e); + return () -> { + }; + } + } + + private SqlDelightInvalidation(@NonNull ArtTooling artTooling, @NonNull Class queryClass, + @NonNull Method notifyDataChangeMethod) { + mArtTooling = artTooling; + mQueryClass = queryClass; + mNotifyDataChangeMethod = notifyDataChangeMethod; + } + + @SuppressLint("BanUncheckedReflection") + @Override + public void triggerInvalidations() { + // invalidating all queries because we can't say which ones were actually affected. + for (Object query: mArtTooling.findInstances(mQueryClass)) { + try { + mNotifyDataChangeMethod.invoke(query); + } catch (IllegalAccessException | InvocationTargetException e) { + Log.w(TAG, "Error calling notifyDataChanged", e); + } + } + } +} diff --git a/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspectionExecutors.java b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspectionExecutors.java new file mode 100644 index 00000000..2f661516 --- /dev/null +++ b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspectionExecutors.java @@ -0,0 +1,32 @@ +/* + * 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 java.util.concurrent.Executor; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; + +class SqliteInspectionExecutors { + private SqliteInspectionExecutors() { + } + + static Future submit(Executor executor, Runnable runnable) { + FutureTask task = new FutureTask<>(runnable, null); + executor.execute(task); + return task; + } +} diff --git a/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspector.java b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspector.java new file mode 100644 index 00000000..38a1bc07 --- /dev/null +++ b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspector.java @@ -0,0 +1,964 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.sqlite.inspection; + +import static android.database.DatabaseUtils.getSqlStatementType; + +import static androidx.sqlite.inspection.DatabaseExtensions.isAttemptAtUsingClosedDatabase; +import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_DB_CLOSED_DURING_OPERATION; +import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_ISSUE_WITH_LOCKING_DATABASE; +import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_ISSUE_WITH_PROCESSING_NEW_DATABASE_CONNECTION; +import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_ISSUE_WITH_PROCESSING_QUERY; +import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_NO_OPEN_DATABASE_WITH_REQUESTED_ID; +import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_UNKNOWN; +import static androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_UNRECOGNISED_COMMAND; + +import android.annotation.SuppressLint; +import android.app.Application; +import android.database.Cursor; +import android.database.CursorWrapper; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteException; + +import io.requery.android.database.sqlite.SQLiteClosable; +import io.requery.android.database.sqlite.SQLiteCursor; +import io.requery.android.database.sqlite.SQLiteCursorDriver; +import io.requery.android.database.sqlite.SQLiteDatabase; +import io.requery.android.database.sqlite.SQLiteQuery; +import io.requery.android.database.sqlite.SQLiteStatement; +import androidx.core.os.CancellationSignal; +import android.util.Log; + +import androidx.inspection.ArtTooling; +import androidx.inspection.ArtTooling.EntryHook; +import androidx.inspection.ArtTooling.ExitHook; +import androidx.inspection.Connection; +import androidx.inspection.Inspector; +import androidx.inspection.InspectorEnvironment; +import androidx.sqlite.inspection.SqliteInspectorProtocol.AcquireDatabaseLockCommand; +import androidx.sqlite.inspection.SqliteInspectorProtocol.AcquireDatabaseLockResponse; +import androidx.sqlite.inspection.SqliteInspectorProtocol.CellValue; +import androidx.sqlite.inspection.SqliteInspectorProtocol.Column; +import androidx.sqlite.inspection.SqliteInspectorProtocol.Command; +import androidx.sqlite.inspection.SqliteInspectorProtocol.DatabaseClosedEvent; +import androidx.sqlite.inspection.SqliteInspectorProtocol.DatabaseOpenedEvent; +import androidx.sqlite.inspection.SqliteInspectorProtocol.DatabasePossiblyChangedEvent; +import androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent; +import androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode; +import androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorOccurredEvent; +import androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorOccurredResponse; +import androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorRecoverability; +import androidx.sqlite.inspection.SqliteInspectorProtocol.Event; +import androidx.sqlite.inspection.SqliteInspectorProtocol.GetSchemaCommand; +import androidx.sqlite.inspection.SqliteInspectorProtocol.GetSchemaResponse; +import androidx.sqlite.inspection.SqliteInspectorProtocol.KeepDatabasesOpenCommand; +import androidx.sqlite.inspection.SqliteInspectorProtocol.KeepDatabasesOpenResponse; +import androidx.sqlite.inspection.SqliteInspectorProtocol.QueryCommand; +import androidx.sqlite.inspection.SqliteInspectorProtocol.QueryParameterValue; +import androidx.sqlite.inspection.SqliteInspectorProtocol.QueryResponse; +import androidx.sqlite.inspection.SqliteInspectorProtocol.ReleaseDatabaseLockCommand; +import androidx.sqlite.inspection.SqliteInspectorProtocol.ReleaseDatabaseLockResponse; +import androidx.sqlite.inspection.SqliteInspectorProtocol.Response; +import androidx.sqlite.inspection.SqliteInspectorProtocol.Row; +import androidx.sqlite.inspection.SqliteInspectorProtocol.Table; +import androidx.sqlite.inspection.SqliteInspectorProtocol.TrackDatabasesResponse; + +import com.google.protobuf.ByteString; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.io.File; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; + +/** + * Inspector to work with SQLite databases + */ +@SuppressWarnings({"TryFinallyCanBeTryWithResources", "SameParameterValue"}) +final class SqliteInspector extends Inspector { + private static final String OPEN_DATABASE_COMMAND_SIGNATURE_API_11 = "openDatabase" + + "(" + + "Ljava/lang/String;" + + "Lio/requery/android/database/sqlite/SQLiteDatabase$CursorFactory;" + + "I" + + "Lio/requery/android/database/DatabaseErrorHandler;" + + ")" + + "Lio/requery/android/database/sqlite/SQLiteDatabase;"; + + private static final String ALL_REFERENCES_RELEASE_COMMAND_SIGNATURE = + "onAllReferencesReleased()V"; + + // SQLiteStatement methods + private static final List SQLITE_STATEMENT_EXECUTE_METHODS_SIGNATURES = Arrays.asList( + "execute()V", + "executeInsert()J", + "executeUpdateDelete()I"); + + private static final int INVALIDATION_MIN_INTERVAL_MS = 1000; + + // Note: this only works on API26+ because of pragma_* functions + // TODO: replace with a resource file + // language=SQLite + private static final String sQueryTableInfo = "select\n" + + " m.type as type,\n" + + " m.name as tableName,\n" + + " ti.name as columnName,\n" + + " ti.type as columnType,\n" + + " [notnull],\n" + + " pk,\n" + + " ifnull([unique], 0) as [unique]\n" + + "from sqlite_master AS m, pragma_table_info(m.name) as ti\n" + + "left outer join\n" + + " (\n" + + " select tableName, name as columnName, ti.[unique]\n" + + " from\n" + + " (\n" + + " select m.name as tableName, il.name as indexName, il.[unique]\n" + + " from\n" + + " sqlite_master AS m,\n" + + " pragma_index_list(m.name) AS il,\n" + + " pragma_index_info(il.name) as ii\n" + + " where il.[unique] = 1\n" + + " group by il.name\n" + + " having count(*) = 1 -- countOfColumnsInIndex=1\n" + + " )\n" + + " as ti, -- tableName|indexName|unique : unique=1 and " + + "countOfColumnsInIndex=1\n" + + " pragma_index_info(ti.indexName)\n" + + " )\n" + + " as tci -- tableName|columnName|unique : unique=1 and countOfColumnsInIndex=1\n" + + " on tci.tableName = m.name and tci.columnName = ti.name\n" + + "where m.type in ('table', 'view')\n" + + "order by type, tableName, ti.cid -- cid = columnId"; + + private static final Set sHiddenTables = new HashSet<>(Arrays.asList( + "android_metadata", "sqlite_sequence")); + + private final DatabaseRegistry mDatabaseRegistry; + private final DatabaseLockRegistry mDatabaseLockRegistry; + private final InspectorEnvironment mEnvironment; + private final Executor mIOExecutor; + + /** + * Utility instance that handles communication with Room's InvalidationTracker instances. + */ + private final RoomInvalidationRegistry mRoomInvalidationRegistry; + + private final List mInvalidations = new ArrayList<>(); + + SqliteInspector(@NonNull Connection connection, @NonNull InspectorEnvironment environment) { + super(connection); + mEnvironment = environment; + mIOExecutor = environment.executors().io(); + mRoomInvalidationRegistry = new RoomInvalidationRegistry(mEnvironment); + mInvalidations.add(mRoomInvalidationRegistry); + mInvalidations.add(SqlDelightInvalidation.create(mEnvironment.artTooling())); + mInvalidations.add(SqlDelight2Invalidation.create(mEnvironment.artTooling())); + + mDatabaseRegistry = new DatabaseRegistry( + new DatabaseRegistry.Callback() { + @Override + public void onPostEvent(int databaseId, String path) { + dispatchDatabaseOpenedEvent(databaseId, path); + } + }, + new DatabaseRegistry.Callback() { + @Override + public void onPostEvent(int databaseId, String path) { + dispatchDatabaseClosedEvent(databaseId, path); + } + }); + + mDatabaseLockRegistry = new DatabaseLockRegistry(); + } + + @Override + public void onReceiveCommand(byte @NonNull [] data, @NonNull CommandCallback callback) { + try { + Command command = Command.parseFrom(data); + switch (command.getOneOfCase()) { + case TRACK_DATABASES: + handleTrackDatabases(callback); + break; + case GET_SCHEMA: + handleGetSchema(command.getGetSchema(), callback); + break; + case QUERY: + handleQuery(command.getQuery(), callback); + break; + case KEEP_DATABASES_OPEN: + handleKeepDatabasesOpen(command.getKeepDatabasesOpen(), callback); + break; + case ACQUIRE_DATABASE_LOCK: + handleAcquireDatabaseLock(command.getAcquireDatabaseLock(), callback); + break; + case RELEASE_DATABASE_LOCK: + handleReleaseDatabaseLock(command.getReleaseDatabaseLock(), callback); + break; + default: + callback.reply( + createErrorOccurredResponse( + "Unrecognised command type: " + command.getOneOfCase().name(), + null, + true, + ERROR_UNRECOGNISED_COMMAND).toByteArray()); + } + } catch (Exception exception) { + callback.reply( + createErrorOccurredResponse( + "Unhandled Exception while processing the command: " + + exception.getMessage(), + stackTraceFromException(exception), + null, + ERROR_UNKNOWN).toByteArray() + ); + } + } + + @Override + public void onDispose() { + super.onDispose(); + // TODO(161081452): release database locks and keep-open references + } + + private void handleTrackDatabases(CommandCallback callback) { + callback.reply(Response.newBuilder() + .setTrackDatabases(TrackDatabasesResponse.getDefaultInstance()) + .build().toByteArray() + ); + + registerReleaseReferenceHooks(); + registerDatabaseOpenedHooks(); + + EntryExitMatchingHookRegistry hookRegistry = new EntryExitMatchingHookRegistry( + mEnvironment); + + registerInvalidationHooks(hookRegistry); + registerDatabaseClosedHooks(hookRegistry); + + // Check for database instances in memory + for (SQLiteDatabase instance : + mEnvironment.artTooling().findInstances(SQLiteDatabase.class)) { + /* the race condition here will be handled by mDatabaseRegistry */ + if (instance.isOpen()) { + onDatabaseOpened(instance); + } else { + onDatabaseClosed(instance); + } + } + + // Check for database instances on disk + for (Application instance : mEnvironment.artTooling().findInstances(Application.class)) { + for (String name : instance.databaseList()) { + File path = instance.getDatabasePath(name); + if (path.exists() && !isHelperSqliteFile(path)) { + mDatabaseRegistry.notifyOnDiskDatabase(path.getAbsolutePath()); + } + } + } + } + + /** + * Secures a lock (transaction) on the database. Note that while the lock is in place, no + * changes to the database are possible: + * - the lock prevents other threads from modifying the database, + * - lock thread, on releasing the lock, rolls-back all changes (transaction is rolled-back). + */ + @SuppressWarnings("FutureReturnValueIgnored") // code inside the future is exception-proofed + private void handleAcquireDatabaseLock( + AcquireDatabaseLockCommand command, + final CommandCallback callback) { + final int databaseId = command.getDatabaseId(); + final DatabaseConnection connection = acquireConnection(databaseId, callback); + if (connection == null) return; + + // Timeout is covered by mDatabaseLockRegistry + SqliteInspectionExecutors.submit(mIOExecutor, new Runnable() { + @Override + public void run() { + int lockId; + try { + lockId = mDatabaseLockRegistry.acquireLock(databaseId, connection.mDatabase); + } catch (Exception e) { + processLockingException(callback, e, true); + return; + } + + callback.reply(Response.newBuilder().setAcquireDatabaseLock( + AcquireDatabaseLockResponse.newBuilder().setLockId(lockId) + ).build().toByteArray()); + } + }); + } + + @SuppressWarnings("FutureReturnValueIgnored") // code inside the future is exception-proofed + private void handleReleaseDatabaseLock( + final ReleaseDatabaseLockCommand command, + final CommandCallback callback) { + // Timeout is covered by mDatabaseLockRegistry + SqliteInspectionExecutors.submit(mIOExecutor, new Runnable() { + @Override + public void run() { + try { + mDatabaseLockRegistry.releaseLock(command.getLockId()); + } catch (Exception e) { + processLockingException(callback, e, false); + return; + } + callback.reply(Response.newBuilder().setReleaseDatabaseLock( + ReleaseDatabaseLockResponse.getDefaultInstance() + ).build().toByteArray()); + } + }); + } + + /** + * @param isLockingStage provide true for acquiring a lock; false for releasing a lock + */ + private void processLockingException(CommandCallback callback, Exception exception, + boolean isLockingStage) { + ErrorCode errorCode = ((exception instanceof IllegalStateException) + && isAttemptAtUsingClosedDatabase((IllegalStateException) exception)) + ? ERROR_DB_CLOSED_DURING_OPERATION + : ERROR_ISSUE_WITH_LOCKING_DATABASE; + + String message = isLockingStage + ? "Issue while trying to lock the database for the export operation: " + : "Issue while trying to unlock the database after the export operation: "; + + Boolean isRecoverable = isLockingStage + ? true // failure to lock the db should be recoverable + : null; // not sure if we can recover from a failure to unlock the db, so UNKNOWN + + callback.reply(createErrorOccurredResponse(message, isRecoverable, exception, + errorCode).toByteArray()); + } + + /** + * Tracking potential database closed events via + * {@link #ALL_REFERENCES_RELEASE_COMMAND_SIGNATURE} + */ + private void registerDatabaseClosedHooks(EntryExitMatchingHookRegistry hookRegistry) { + hookRegistry.registerHook(SQLiteDatabase.class, ALL_REFERENCES_RELEASE_COMMAND_SIGNATURE, + new EntryExitMatchingHookRegistry.OnExitCallback() { + @Override + public void onExit(EntryExitMatchingHookRegistry.Frame exitFrame) { + final Object thisObject = exitFrame.mThisObject; + if (thisObject instanceof SQLiteDatabase) { + onDatabaseClosed((SQLiteDatabase) thisObject); + } + } + }); + } + + private void registerDatabaseOpenedHooks() { + // sqlite-android does not expose API 27 OpenParams/createInMemory(OpenParams); hook the + // 4-arg openDatabase used by io.requery.android.database.sqlite.SQLiteDatabase. + List methods = Arrays.asList(OPEN_DATABASE_COMMAND_SIGNATURE_API_11); + + ExitHook hook = + new ExitHook() { + @Override + public SQLiteDatabase onExit(SQLiteDatabase database) { + try { + onDatabaseOpened(database); + } catch (Exception exception) { + getConnection().sendEvent(createErrorOccurredEvent( + "Unhandled Exception while processing an onDatabaseAdded " + + "event: " + + exception.getMessage(), + stackTraceFromException(exception), null, + ERROR_ISSUE_WITH_PROCESSING_NEW_DATABASE_CONNECTION) + .toByteArray()); + } + return database; + } + }; + for (String method : methods) { + mEnvironment.artTooling().registerExitHook(SQLiteDatabase.class, method, hook); + } + } + + private void registerReleaseReferenceHooks() { + mEnvironment.artTooling().registerEntryHook( + SQLiteClosable.class, + "releaseReference()V", + new EntryHook() { + @Override + public void onEntry(@Nullable Object thisObject, + @NonNull List args) { + if (thisObject instanceof SQLiteDatabase) { + mDatabaseRegistry.notifyReleaseReference((SQLiteDatabase) thisObject); + } + } + }); + } + + private void registerInvalidationHooks(EntryExitMatchingHookRegistry hookRegistry) { + /* + * Schedules a task using {@link mScheduledExecutor} and executes it on {@link mIOExecutor}. + */ + final RequestCollapsingThrottler.DeferredExecutor deferredExecutor = + new RequestCollapsingThrottler.DeferredExecutor() { + @Override + @SuppressWarnings("FutureReturnValueIgnored") // TODO: handle errors from Future + public void schedule(final Runnable command, final long delayMs) { + mEnvironment.executors().handler().postDelayed(new Runnable() { + @Override + public void run() { + mIOExecutor.execute(command); + } + }, delayMs); + } + }; + final RequestCollapsingThrottler throttler = new RequestCollapsingThrottler( + INVALIDATION_MIN_INTERVAL_MS, + new Runnable() { + @Override + public void run() { + dispatchDatabasePossiblyChangedEvent(); + } + }, deferredExecutor); + + registerInvalidationHooksSqliteStatement(throttler); + registerInvalidationHooksTransaction(throttler); + registerInvalidationHooksSQLiteCursor(throttler, hookRegistry); + } + + /** + * Triggering invalidation on {@link SQLiteDatabase#endTransaction} allows us to avoid + * showing incorrect stale values that could originate from a mid-transaction query. + * + * TODO: track if transaction committed or rolled back by observing if + * {@link SQLiteDatabase#setTransactionSuccessful} was called + */ + private void registerInvalidationHooksTransaction(final RequestCollapsingThrottler throttler) { + mEnvironment.artTooling().registerExitHook(SQLiteDatabase.class, "endTransaction()V", + new ExitHook() { + @Override + public Object onExit(Object result) { + throttler.submitRequest(); + return result; + } + }); + } + + /** + * Invalidation hooks triggered by: + *
    + *
  • {@link SQLiteStatement#execute}
  • + *
  • {@link SQLiteStatement#executeInsert}
  • + *
  • {@link SQLiteStatement#executeUpdateDelete}
  • + *
+ */ + private void registerInvalidationHooksSqliteStatement( + final RequestCollapsingThrottler throttler) { + for (String method : SQLITE_STATEMENT_EXECUTE_METHODS_SIGNATURES) { + mEnvironment.artTooling().registerExitHook(SQLiteStatement.class, method, + new ExitHook() { + @Override + public Object onExit(Object result) { + throttler.submitRequest(); + return result; + } + }); + } + } + + /** + * Invalidation hooks triggered by {@link SQLiteCursor#close()} + * which means that the cursor's query was executed. + *

+ * In order to access cursor's query, we also use {@link SQLiteDatabase#rawQueryWithFactory} + * which takes a query String and constructs a cursor based on it. + */ + private void registerInvalidationHooksSQLiteCursor(final RequestCollapsingThrottler throttler, + EntryExitMatchingHookRegistry hookRegistry) { + + // TODO: add active pruning via Cursor#close listener + final Map trackedCursors = Collections.synchronizedMap( + new WeakHashMap()); + + final String rawQueryMethodSignature = "rawQueryWithFactory(" + + "Lio/requery/android/database/sqlite/SQLiteDatabase$CursorFactory;" + + "Ljava/lang/String;" + + "[Ljava/lang/String;" + + "Ljava/lang/String;" + + "Landroidx/core/os/CancellationSignal;" + + ")Landroid/database/Cursor;"; + hookRegistry.registerHook(SQLiteDatabase.class, + rawQueryMethodSignature, new EntryExitMatchingHookRegistry.OnExitCallback() { + @Override + public void onExit(EntryExitMatchingHookRegistry.Frame exitFrame) { + SQLiteCursor cursor = cursorParam(exitFrame.mResult); + String query = stringParam(exitFrame.mArgs.get(1)); + + // Only track cursors that might modify the database. + // TODO: handle PRAGMA select queries, e.g. PRAGMA_TABLE_INFO + if (cursor != null && query != null && getSqlStatementType(query) + != DatabaseUtils.STATEMENT_SELECT) { + trackedCursors.put(cursor, null); + } + } + }); + + + mEnvironment.artTooling().registerEntryHook(SQLiteCursor.class, "close()V", + new ArtTooling.EntryHook() { + @Override + public void onEntry(@Nullable Object thisObject, @NonNull List args) { + if (trackedCursors.containsKey(thisObject)) { + throttler.submitRequest(); + } + } + }); + } + + // Gets a SQLiteCursor from a passed-in Object (if possible) + private @Nullable SQLiteCursor cursorParam(Object cursor) { + if (cursor instanceof SQLiteCursor) { + return (SQLiteCursor) cursor; + } + + if (cursor instanceof CursorWrapper) { + CursorWrapper wrapper = (CursorWrapper) cursor; + return cursorParam(wrapper.getWrappedCursor()); + } + + // TODO: add support for more cursor types + Log.w(SqliteInspector.class.getName(), String.format( + "Unsupported Cursor type: %s. Invalidation might not work correctly.", cursor)); + return null; + } + + // Gets a String from a passed-in Object (if possible) + private @Nullable String stringParam(Object string) { + return string instanceof String ? (String) string : null; + } + + private void dispatchDatabaseOpenedEvent(int databaseId, String path) { + getConnection().sendEvent(Event.newBuilder().setDatabaseOpened( + DatabaseOpenedEvent.newBuilder().setDatabaseId(databaseId).setPath(path) + ).build().toByteArray()); + } + + private void dispatchDatabaseClosedEvent(int databaseId, String path) { + getConnection().sendEvent(Event.newBuilder().setDatabaseClosed( + DatabaseClosedEvent.newBuilder().setDatabaseId(databaseId).setPath(path) + ).build().toByteArray()); + } + + private void dispatchDatabasePossiblyChangedEvent() { + getConnection().sendEvent(Event.newBuilder().setDatabasePossiblyChanged( + DatabasePossiblyChangedEvent.getDefaultInstance() + ).build().toByteArray()); + } + + @SuppressWarnings("FutureReturnValueIgnored") // code inside the future is exception-proofed + private void handleGetSchema(GetSchemaCommand command, final CommandCallback callback) { + final DatabaseConnection connection = acquireConnection(command.getDatabaseId(), callback); + if (connection == null) return; + + // TODO: consider a timeout + SqliteInspectionExecutors.submit(connection.mExecutor, new Runnable() { + @Override + public void run() { + callback.reply(querySchema(connection.mDatabase).toByteArray()); + } + }); + } + + private void handleQuery(final QueryCommand command, final CommandCallback callback) { + final DatabaseConnection connection = acquireConnection(command.getDatabaseId(), callback); + if (connection == null) return; + + final CancellationSignal cancellationSignal = new CancellationSignal(); + final Executor executor = connection.mExecutor; + // TODO: consider a timeout + final Future future = SqliteInspectionExecutors.submit(executor, new Runnable() { + @Override + public void run() { + String[] params = parseQueryParameterValues(command); + Cursor cursor = null; + try { + cursor = rawQuery(connection.mDatabase, command.getQuery(), params, + cancellationSignal); + + long responseSizeLimitHint = command.getResponseSizeLimitHint(); + // treating unset field as unbounded + if (responseSizeLimitHint <= 0) responseSizeLimitHint = Long.MAX_VALUE; + + List columnNames = Arrays.asList(cursor.getColumnNames()); + callback.reply(Response.newBuilder() + .setQuery(QueryResponse.newBuilder() + .addAllRows(convert(cursor, responseSizeLimitHint)) + .addAllColumnNames(columnNames) + .build()) + .build() + .toByteArray() + ); + triggerInvalidation(command.getQuery()); + } catch (SQLiteException | IllegalArgumentException e) { + callback.reply(createErrorOccurredResponse(e, true, + ERROR_ISSUE_WITH_PROCESSING_QUERY).toByteArray()); + } catch (IllegalStateException e) { + if (isAttemptAtUsingClosedDatabase(e)) { + callback.reply(createErrorOccurredResponse(e, true, + ERROR_DB_CLOSED_DURING_OPERATION).toByteArray()); + } else { + callback.reply(createErrorOccurredResponse(e, null, + ERROR_UNKNOWN).toByteArray()); + } + } catch (Exception e) { + callback.reply(createErrorOccurredResponse(e, null, + ERROR_UNKNOWN).toByteArray()); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + }); + callback.addCancellationListener(mEnvironment.executors().primary(), new Runnable() { + @Override + public void run() { + cancellationSignal.cancel(); + future.cancel(true); + } + }); + } + + private void triggerInvalidation(String query) { + if (getSqlStatementType(query) != DatabaseUtils.STATEMENT_SELECT) { + for (Invalidation invalidation : mInvalidations) { + invalidation.triggerInvalidations(); + } + } + } + + private void handleKeepDatabasesOpen(KeepDatabasesOpenCommand keepDatabasesOpen, + CommandCallback callback) { + // Acknowledge the command + callback.reply(Response.newBuilder().setKeepDatabasesOpen( + KeepDatabasesOpenResponse.getDefaultInstance() + ).build().toByteArray()); + + mDatabaseRegistry.notifyKeepOpenToggle(keepDatabasesOpen.getSetEnabled()); + } + + @SuppressLint("Recycle") // For: "The cursor should be freed up after use with #close" + private static Cursor rawQuery(@NonNull SQLiteDatabase database, @NonNull String queryText, + final String @NonNull [] params, @Nullable CancellationSignal cancellationSignal) { + SQLiteDatabase.CursorFactory cursorFactory = new SQLiteDatabase.CursorFactory() { + @Override + public Cursor newCursor(SQLiteDatabase db, SQLiteCursorDriver driver, + String editTable, SQLiteQuery query) { + for (int i = 0; i < params.length; i++) { + String value = params[i]; + int index = i + 1; + if (value == null) { + query.bindNull(index); + } else { + query.bindString(index, value); + } + } + return new SQLiteCursor(driver, editTable, query); + } + }; + + return database.rawQueryWithFactory(cursorFactory, queryText, null, null, + cancellationSignal); + } + + private static String @NonNull [] parseQueryParameterValues(QueryCommand command) { + String[] params = new String[command.getQueryParameterValuesCount()]; + for (int i = 0; i < command.getQueryParameterValuesCount(); i++) { + QueryParameterValue param = command.getQueryParameterValues(i); + switch (param.getOneOfCase()) { + case STRING_VALUE: + params[i] = param.getStringValue(); + break; + case ONEOF_NOT_SET: + params[i] = null; + break; + default: + throw new IllegalArgumentException( + "Unsupported parameter type. OneOfCase=" + param.getOneOfCase()); + } + } + return params; + } + + /** + * Tries to find a database for an id. If no such database is found, it replies with an + * {@link ErrorOccurredResponse} via the {@code callback} provided. + * + * TODO: remove race condition (affects WAL=off) + * - lock request is received and in the process of being secured + * - query request is received and since no lock in place, receives an IO Executor + * - lock request completes and holds a lock on the database + * - query cannot run because there is a lock in place + * + * The race condition can be mitigated by clients by securing a lock synchronously with no + * other queries in place. + * + * @return null if no database found for the provided id. A database reference otherwise. + */ + private @Nullable DatabaseConnection acquireConnection(int databaseId, + CommandCallback callback) { + DatabaseConnection connection = mDatabaseLockRegistry.getConnection(databaseId); + if (connection != null) { + // With WAL enabled, we prefer to use the IO executor. With WAL off we don't have a + // choice and must use the executor that has a lock (transaction) on the database. + return connection.mDatabase.isWriteAheadLoggingEnabled() + ? new DatabaseConnection(connection.mDatabase, mIOExecutor) + : connection; + } + + SQLiteDatabase database = mDatabaseRegistry.getConnection(databaseId); + if (database == null) { + replyNoDatabaseWithId(callback, databaseId); + return null; + } + + // Given no lock, IO executor is appropriate. + return new DatabaseConnection(database, mIOExecutor); + } + + /** + * @param responseSizeLimitHint expressed in bytes + */ + private static List convert(Cursor cursor, long responseSizeLimitHint) { + long responseSize = 0; + List result = new ArrayList<>(); + int columnCount = cursor.getColumnCount(); + while (cursor.moveToNext() && responseSize < responseSizeLimitHint) { + Row.Builder rowBuilder = Row.newBuilder(); + for (int i = 0; i < columnCount; i++) { + CellValue value = readValue(cursor, i); + rowBuilder.addValues(value); + } + Row row = rowBuilder.build(); + // Optimistically adding a row before checking the limit. Eliminates the case when a + // misconfigured client (limit too low) is unable to fetch any results. Row size in + // SQLite Android is limited to (~2MB), so the worst case scenario is very manageable. + result.add(row); + responseSize += row.getSerializedSize(); + } + return result; + } + + private static CellValue readValue(Cursor cursor, int index) { + CellValue.Builder builder = CellValue.newBuilder(); + + switch (cursor.getType(index)) { + case Cursor.FIELD_TYPE_NULL: + // no field to set + break; + case Cursor.FIELD_TYPE_BLOB: + builder.setBlobValue(ByteString.copyFrom(cursor.getBlob(index))); + break; + case Cursor.FIELD_TYPE_STRING: + builder.setStringValue(cursor.getString(index)); + break; + case Cursor.FIELD_TYPE_INTEGER: + builder.setLongValue(cursor.getLong(index)); + break; + case Cursor.FIELD_TYPE_FLOAT: + builder.setDoubleValue(cursor.getDouble(index)); + break; + } + + return builder.build(); + } + + private void replyNoDatabaseWithId(CommandCallback callback, int databaseId) { + String message = String.format("Unable to perform an operation on database (id=%s)." + + " The database may have already been closed.", databaseId); + callback.reply(createErrorOccurredResponse(message, null, true, + ERROR_NO_OPEN_DATABASE_WITH_REQUESTED_ID).toByteArray()); + } + + private @NonNull Response querySchema(SQLiteDatabase database) { + Cursor cursor = null; + try { + cursor = rawQuery(database, sQueryTableInfo, new String[0], null); + GetSchemaResponse.Builder schemaBuilder = GetSchemaResponse.newBuilder(); + + int objectTypeIx = cursor.getColumnIndex("type"); // view or table + int tableNameIx = cursor.getColumnIndex("tableName"); + int columnNameIx = cursor.getColumnIndex("columnName"); + int typeIx = cursor.getColumnIndex("columnType"); + int pkIx = cursor.getColumnIndex("pk"); + int notNullIx = cursor.getColumnIndex("notnull"); + int uniqueIx = cursor.getColumnIndex("unique"); + + Table.Builder tableBuilder = null; + while (cursor.moveToNext()) { + String tableName = cursor.getString(tableNameIx); + + // ignore certain tables + if (sHiddenTables.contains(tableName)) { + continue; + } + + // check if getting data for a new table or appending columns to the current one + if (tableBuilder == null || !tableBuilder.getName().equals(tableName)) { + if (tableBuilder != null) { + schemaBuilder.addTables(tableBuilder.build()); + } + tableBuilder = Table.newBuilder(); + tableBuilder.setName(tableName); + tableBuilder.setIsView("view".equalsIgnoreCase(cursor.getString(objectTypeIx))); + } + + // append column information to the current table info + tableBuilder.addColumns(Column.newBuilder() + .setName(cursor.getString(columnNameIx)) + .setType(cursor.getString(typeIx)) + .setPrimaryKey(cursor.getInt(pkIx)) + .setIsNotNull(cursor.getInt(notNullIx) > 0) + .setIsUnique(cursor.getInt(uniqueIx) > 0) + .build() + ); + } + if (tableBuilder != null) { + schemaBuilder.addTables(tableBuilder.build()); + } + + return Response.newBuilder().setGetSchema(schemaBuilder.build()).build(); + } catch (IllegalStateException e) { + if (isAttemptAtUsingClosedDatabase(e)) { + return createErrorOccurredResponse(e, true, + ERROR_DB_CLOSED_DURING_OPERATION); + } else { + return createErrorOccurredResponse(e, null, + ERROR_UNKNOWN); + } + } catch (Exception e) { + return createErrorOccurredResponse(e, null, + ERROR_UNKNOWN); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + @SuppressWarnings("WeakerAccess") // avoiding a synthetic accessor + void onDatabaseOpened(SQLiteDatabase database) { + mRoomInvalidationRegistry.invalidateCache(); + mDatabaseRegistry.notifyDatabaseOpened(database); + } + + @SuppressWarnings("WeakerAccess") // avoiding a synthetic accessor + void onDatabaseClosed(SQLiteDatabase database) { + mDatabaseRegistry.notifyAllDatabaseReferencesReleased(database); + } + + private Event createErrorOccurredEvent(@Nullable String message, @Nullable String stackTrace, + Boolean isRecoverable, ErrorCode errorCode) { + return Event.newBuilder().setErrorOccurred( + ErrorOccurredEvent.newBuilder() + .setContent( + createErrorContentMessage(message, + stackTrace, + isRecoverable, + errorCode)) + .build()) + .build(); + } + + private static ErrorContent createErrorContentMessage(@Nullable String message, + @Nullable String stackTrace, Boolean isRecoverable, ErrorCode errorCode) { + ErrorContent.Builder builder = ErrorContent.newBuilder(); + if (message != null) { + builder.setMessage(message); + } + if (stackTrace != null) { + builder.setStackTrace(stackTrace); + } + ErrorRecoverability.Builder recoverability = ErrorRecoverability.newBuilder(); + if (isRecoverable != null) { // leave unset otherwise, which translates to 'unknown' + recoverability.setIsRecoverable(isRecoverable); + } + builder.setRecoverability(recoverability.build()); + builder.setErrorCode(errorCode); + return builder.build(); + } + + private static Response createErrorOccurredResponse(@NonNull Exception exception, + Boolean isRecoverable, ErrorCode errorCode) { + return createErrorOccurredResponse("", isRecoverable, exception, errorCode); + } + + private static Response createErrorOccurredResponse(@NonNull String messagePrefix, + Boolean isRecoverable, @NonNull Exception exception, ErrorCode errorCode) { + String message = exception.getMessage(); + if (message == null) message = exception.toString(); + return createErrorOccurredResponse(messagePrefix + message, + stackTraceFromException(exception), isRecoverable, errorCode); + } + + private static Response createErrorOccurredResponse(@Nullable String message, + @Nullable String stackTrace, Boolean isRecoverable, ErrorCode errorCode) { + return Response.newBuilder() + .setErrorOccurred( + ErrorOccurredResponse.newBuilder() + .setContent(createErrorContentMessage(message, stackTrace, + isRecoverable, errorCode))) + .build(); + } + + private static @NonNull String stackTraceFromException(Exception exception) { + StringWriter writer = new StringWriter(); + exception.printStackTrace(new PrintWriter(writer)); + return writer.toString(); + } + + private static boolean isHelperSqliteFile(File file) { + String path = file.getPath(); + return path.endsWith("-journal") || path.endsWith("-shm") || path.endsWith("-wal"); + } + + /** + * Provides a reference to the database and an executor to access the database. + * + * Executor is relevant in the context of locking, where a locked database with WAL disabled + * needs to run queries on the thread that locked it. + */ + static final class DatabaseConnection { + final @NonNull SQLiteDatabase mDatabase; + final @NonNull Executor mExecutor; + + DatabaseConnection(@NonNull SQLiteDatabase database, @NonNull Executor executor) { + mDatabase = database; + mExecutor = executor; + } + } +} diff --git a/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspectorFactory.java b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspectorFactory.java new file mode 100644 index 00000000..e3d2f01f --- /dev/null +++ b/sqlite-android-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspectorFactory.java @@ -0,0 +1,41 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.sqlite.inspection; + +import androidx.inspection.Connection; +import androidx.inspection.InspectorEnvironment; +import androidx.inspection.InspectorFactory; + +import org.jspecify.annotations.NonNull; + +/** + * Factory for SqliteInspector + */ +public final class SqliteInspectorFactory extends InspectorFactory { + private static final String SQLITE_INSPECTOR_ID = "androidx.sqlite.inspection"; + + @SuppressWarnings("unused") // called by ServiceLoader + public SqliteInspectorFactory() { + super(SQLITE_INSPECTOR_ID); + } + + @Override + public @NonNull SqliteInspector createInspector(@NonNull Connection connection, + @NonNull InspectorEnvironment environment) { + return new SqliteInspector(connection, environment); + } +} diff --git a/sqlite-android-inspection/src/main/proto/live_sql_protocol.proto b/sqlite-android-inspection/src/main/proto/live_sql_protocol.proto new file mode 100644 index 00000000..8c6b4ed0 --- /dev/null +++ b/sqlite-android-inspection/src/main/proto/live_sql_protocol.proto @@ -0,0 +1,266 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +syntax = "proto3"; + +package androidx.sqlite.inspection; + +option java_package = "androidx.sqlite.inspection"; +option java_outer_classname = "SqliteInspectorProtocol"; + +// --- Commands --- + +// Generic Command object grouping all Inspector Command types. Expected in +// Inspector's onReceiveCommand. +message Command { + // Wrapped specialised Command. + oneof OneOf { + TrackDatabasesCommand track_databases = 1; + GetSchemaCommand get_schema = 2; + QueryCommand query = 3; + KeepDatabasesOpenCommand keep_databases_open = 4; + AcquireDatabaseLockCommand acquire_database_lock = 5; + ReleaseDatabaseLockCommand release_database_lock = 6; + } +} + +// Request for the Inspector to track database connections (existing and new) +// and notify of those via DatabaseOpenedEvent objects. +message TrackDatabasesCommand {} + +// Request for the Inspector to return schema information for a given database. +message GetSchemaCommand { + // Id uniquely identifying a connection to the database. + int32 database_id = 1; +} + +// Request for the Inspector to execute a query and return its results. +// TODO: add support for parameterised queries +message QueryCommand { + // Id uniquely identifying a connection to the database. + int32 database_id = 1; + // Query to execute. + string query = 2; + // The query may include ?s, which will be replaced by values from + // query_parameters, in the order that they appear in the query. Values will + // be bound as Strings. + repeated QueryParameterValue query_parameter_values = 3; + // Approximate response size limit in bytes. + // Best effort: + // - can deviate from the value up to 40% due to encoding overheads + // - in some cases can deviate by an extra ~2MB (max size of one row in Android SQLite) + // When unset, or set to <= `0`, it is considered unbounded. + int64 response_size_limit_hint = 4; +} + +// Value of a parameter in QueryCommand. Currently only string and null values +// are supported. +message QueryParameterValue { + oneof OneOf { + string string_value = 1; + } +} + +// Request to prevent databases from being closed by the app +// (allowing for a larger time-window for inspection) +message KeepDatabasesOpenCommand { + // True to enable the functionality; false to disable + bool set_enabled = 1; +} + +// Request to lock a database to prevent modifications on it +// (allowing for e.g. creating a consistent snapshot) +message AcquireDatabaseLockCommand { + // Database to secure a lock on + int32 database_id = 1; +} + +// Request to release a previously acquired database lock (see AcquireDatabaseLockCommand) +message ReleaseDatabaseLockCommand { + // Id of the lock to release (see AcquireDatabaseLockResponse) + int32 lock_id = 1; +} + +// --- Responses --- + +// Generic Response object grouping all Inspector Response types to Command +// objects. +message Response { + // Wrapped specialised Response. + oneof OneOf { + TrackDatabasesResponse track_databases = 1; + GetSchemaResponse get_schema = 2; + QueryResponse query = 3; + KeepDatabasesOpenResponse keep_databases_open = 4; + AcquireDatabaseLockResponse acquire_database_lock = 5; + ReleaseDatabaseLockResponse release_database_lock = 6; + ErrorOccurredResponse error_occurred = 400; + } +} + +// Object expected as a response to TrackDatabasesCommand. +message TrackDatabasesResponse {} + +// Object expected as a response to GetSchemaCommand. +message GetSchemaResponse { + repeated Table tables = 1; +} + +// Schema information for a table or a view. +message Table { + string name = 1; + // True for a view; false for a regular table. + bool is_view = 2; + repeated Column columns = 3; +} + +// Schema information for a table column. +message Column { + string name = 1; + // Column type affinity. + string type = 2; + // PRIMARY KEY constraint: zero for columns that are not part of the primary + // key, or the index of the column in the primary key for columns that are + // part of the primary key. + int32 primary_key = 3; + // NOT NULL constraint. + bool is_not_null = 4; + // UNIQUE constraint on its own (i.e. not as a part of + // compound-unique-constraint-index). + bool is_unique = 5; +} + +// Object expected as a response to QueryCommand. +message QueryResponse { + repeated Row rows = 1; + // Names of columns in the result set + repeated string column_names = 2; +} + +// Query result row. +message Row { + repeated CellValue values = 1; +} + +// Query result cell. +message CellValue { + // Resulting cell value depending on type affinity rules. + oneof OneOf { + double double_value = 1; + int64 long_value = 2; + string string_value = 3; + bytes blob_value = 4; + } +} + +message KeepDatabasesOpenResponse {} + +// Object expected as a response to AcquireDatabaseLockCommand. +message AcquireDatabaseLockResponse { + // Id of the lock (allowing for releasing the lock later using ReleaseDatabaseLockRequest) + int32 lock_id = 1; +} + +// Object expected as a response to ReleaseDatabaseLockCommand. +message ReleaseDatabaseLockResponse {} + +// General Error message. +// TODO: decide on a more fine-grained approach +message ErrorOccurredResponse { + ErrorContent content = 1; +} + +message ErrorContent { + // Main error message. + string message = 1; + // Optional stack trace. + string stack_trace = 2; + // Recoverability information + ErrorRecoverability recoverability = 3; + // Error code + enum ErrorCode { + NOT_SET = 0; + ERROR_UNKNOWN = 10; + ERROR_UNRECOGNISED_COMMAND = 20; + ERROR_DATABASE_VERSION_TOO_OLD = 30; + ERROR_ISSUE_WITH_PROCESSING_QUERY = 40; + ERROR_NO_OPEN_DATABASE_WITH_REQUESTED_ID = 50; + ERROR_ISSUE_WITH_PROCESSING_NEW_DATABASE_CONNECTION = 60; + ERROR_DB_CLOSED_DURING_OPERATION = 70; + ERROR_ISSUE_WITH_LOCKING_DATABASE = 80; + } + ErrorCode error_code = 4; +} + +// Recoverability of an error: +// - is_recoverable = true -> an error is recoverable (e.g. query syntax error) +// - is_recoverable = false -> an error is not recoverable (e.g. corrupt internal inspector state) +// - unset -> not enough information to determine recoverability (e.g. an issue that may or may not +// prevent a user from continuing the session - and is left for the user to decide how to act on) +message ErrorRecoverability { + oneof OneOf { + bool is_recoverable = 1; + } +} + +// --- Events --- + +// Generic Event object grouping all Inspector Event types. Expected in +// Connection's sendEvent method. +message Event { + // Wrapped specialised Event. + oneof OneOf { + DatabaseOpenedEvent database_opened = 1; + DatabaseClosedEvent database_closed = 2; + DatabasePossiblyChangedEvent database_possibly_changed = 3; + ErrorOccurredEvent error_occurred = 400; + } +} + +// Notification of a database connection established (new) / discovered +// (existing). +message DatabaseOpenedEvent { + // Id uniquely identifying a connection to a database. Required to perform + // requests on the database. + int32 database_id = 1; + // Path to db file or ":memory" if it is in-memory database. + // Note: there is no guarantee that it is necessarily unique between databases. + string path = 2; +} + +// Notification of a database connection closed (database no longer query-able) +// TODO: consider consolidating with DatabaseOpenedEvent +message DatabaseClosedEvent { + // Id uniquely identifying a connection to a database. Required to perform + // requests on the database. + int32 database_id = 1; + // Path to db file or ":memory" if it is in-memory database. + // Note: there is no guarantee that it is necessarily unique between databases. + string path = 2; +} + +// An event sent when an operation that could potentially change the contents of a database has +// been detected. The event might be used in triggering a refresh of currently displayed query +// results to keep the results current. +message DatabasePossiblyChangedEvent { + // TODO: add database id +} + +// General Error message. +// TODO: decide on a more fine-grained approach +message ErrorOccurredEvent { + ErrorContent content = 1; +} diff --git a/sqlite-android-inspection/src/main/resources/META-INF/services/androidx.inspection.InspectorFactory b/sqlite-android-inspection/src/main/resources/META-INF/services/androidx.inspection.InspectorFactory new file mode 100644 index 00000000..3c8f9c4b --- /dev/null +++ b/sqlite-android-inspection/src/main/resources/META-INF/services/androidx.inspection.InspectorFactory @@ -0,0 +1 @@ +androidx.sqlite.inspection.SqliteInspectorFactory