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