blob: 3af620558b327b0be6d2037f57ccf17d30cd8b48 [file] [log] [blame]
/*
* 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 com.android.tools.appinspection.database
import android.annotation.SuppressLint
import android.app.Application
import android.database.Cursor
import android.database.CursorWrapper
import android.database.DatabaseUtils
import android.database.sqlite.SQLiteClosable
import android.database.sqlite.SQLiteCursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteException
import android.database.sqlite.SQLiteStatement
import android.os.Build
import android.os.CancellationSignal
import android.util.Log
import androidx.annotation.VisibleForTesting
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.Command.OneOfCase.ACQUIRE_DATABASE_LOCK
import androidx.sqlite.inspection.SqliteInspectorProtocol.Command.OneOfCase.GET_SCHEMA
import androidx.sqlite.inspection.SqliteInspectorProtocol.Command.OneOfCase.KEEP_DATABASES_OPEN
import androidx.sqlite.inspection.SqliteInspectorProtocol.Command.OneOfCase.QUERY
import androidx.sqlite.inspection.SqliteInspectorProtocol.Command.OneOfCase.RELEASE_DATABASE_LOCK
import androidx.sqlite.inspection.SqliteInspectorProtocol.Command.OneOfCase.TRACK_DATABASES
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.ErrorContent.ErrorCode.ERROR_UNKNOWN
import androidx.sqlite.inspection.SqliteInspectorProtocol.ErrorContent.ErrorCode.ERROR_UNRECOGNISED_COMMAND
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.TrackDatabasesCommand
import androidx.sqlite.inspection.SqliteInspectorProtocol.TrackDatabasesResponse
import com.android.tools.appinspection.database.RequestCollapsingThrottler.DeferredExecutor
import com.android.tools.appinspection.database.SqliteInspectionExecutors.submit
import com.android.tools.idea.protobuf.ByteString
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.util.Collections
import java.util.WeakHashMap
import java.util.concurrent.Executor
import java.util.concurrent.Future
private const val OPEN_DATABASE_COMMAND_SIG_API_11 =
"openDatabase" +
"(" +
"Ljava/lang/String;" +
"Landroid/database/sqlite/SQLiteDatabase\$CursorFactory;" +
"I" +
"Landroid/database/DatabaseErrorHandler;" +
")" +
"Landroid/database/sqlite/SQLiteDatabase;"
private const val OPEN_DATABASE_COMMAND_SIG_API_27 =
"openDatabase" +
"(" +
"Ljava/io/File;" +
"Landroid/database/sqlite/SQLiteDatabase\$OpenParams;" +
")" +
"Landroid/database/sqlite/SQLiteDatabase;"
private const val CREATE_IN_MEMORY_DATABASE_COMMAND_SIG_API_27 =
"createInMemory" +
"(" +
"Landroid/database/sqlite/SQLiteDatabase\$OpenParams;" +
")" +
"Landroid/database/sqlite/SQLiteDatabase;"
private const val ALL_REFERENCES_RELEASE_COMMAND_SIGNATURE = "onAllReferencesReleased()V"
// SQLiteStatement methods
private val SQLITE_STATEMENT_EXECUTE_METHODS_SIGNATURES: List<String> =
mutableListOf("execute()V", "executeInsert()J", "executeUpdateDelete()I")
private const val INVALIDATION_MIN_INTERVAL_MS = 1000
// Note: this only works on API26+ because of pragma_* functions
// TODO: replace with a resource file
// language=SQLite
private const val QUERY_TABLE_INFO =
"""
select
m.type as type,
m.name as tableName,
ti.name as columnName,
ti.type as columnType,
[notnull],
pk,
ifnull([unique], 0) as [unique]
from sqlite_master AS m, pragma_table_info(m.name) as ti
left outer join
(
select tableName, name as columnName, ti.[unique]
from
(
select m.name as tableName, il.name as indexName, il.[unique]
from
sqlite_master AS m,
pragma_index_list(m.name) AS il,
pragma_index_info(il.name) as ii
where il.[unique] = 1
group by il.name
having count(*) = 1 -- countOfColumnsInIndex=1
)
as ti, -- tableName|indexName|unique : unique=1 and countOfColumnsInIndex=1
pragma_index_info(ti.indexName)
)
as tci -- tableName|columnName|unique : unique=1 and countOfColumnsInIndex=1
on tci.tableName = m.name and tci.columnName = ti.name
where m.type in ('table', 'view')
order by type, tableName, ti.cid -- cid = columnId
"""
private val HIDDEN_TABLES = setOf("android_metadata", "sqlite_sequence")
/**
* Inspector to work with SQLite databases
*
* TODO(aalbert): Propagate CancellationException where appropriate
*/
internal class SqliteInspector(
connection: Connection,
private val environment: InspectorEnvironment,
testMode: Boolean = false,
) : Inspector(connection) {
@VisibleForTesting
internal val databaseRegistry =
DatabaseRegistry(::dispatchDatabaseOpenedEvent, ::dispatchDatabaseClosedEvent, testMode)
private val databaseLockRegistry = DatabaseLockRegistry(databaseRegistry)
private val ioExecutor = environment.executors().io()
/** Utility instance that handles communication with Room's InvalidationTracker instances. */
private val roomInvalidationRegistry = RoomInvalidationRegistry(environment)
private val invalidations =
listOf(
roomInvalidationRegistry,
SqlDelightInvalidation.create(environment.artTooling()),
SqlDelight2Invalidation.create(environment.artTooling()),
)
override fun onReceiveCommand(data: ByteArray, callback: CommandCallback) {
try {
val command = Command.parseFrom(data)
when (command.oneOfCase) {
TRACK_DATABASES -> handleTrackDatabases(command.trackDatabases, callback)
GET_SCHEMA -> handleGetSchema(command.getSchema, callback)
QUERY -> handleQuery(command.query, callback)
KEEP_DATABASES_OPEN -> handleKeepDatabasesOpen(command.keepDatabasesOpen, callback)
ACQUIRE_DATABASE_LOCK -> handleAcquireDatabaseLock(command.acquireDatabaseLock, callback)
RELEASE_DATABASE_LOCK -> handleReleaseDatabaseLock(command.releaseDatabaseLock, callback)
else ->
callback.reply(
createErrorOccurredResponse(
"Unrecognised command type: " + command.oneOfCase.name,
null,
true,
ERROR_UNRECOGNISED_COMMAND,
)
.toByteArray()
)
}
} catch (exception: Throwable) {
callback.reply(
createErrorOccurredResponse(
"Unhandled Exception while processing the command: " + exception.message,
stackTraceFromException(exception),
null,
ERROR_UNKNOWN,
)
.toByteArray()
)
}
}
override fun onDispose() {
super.onDispose()
databaseRegistry.dispose()
databaseLockRegistry.dispose()
}
private fun handleTrackDatabases(command: TrackDatabasesCommand, callback: CommandCallback) {
callback.reply(
Response.newBuilder()
.setTrackDatabases(TrackDatabasesResponse.getDefaultInstance())
.build()
.toByteArray()
)
registerReleaseReferenceHooks()
registerDatabaseOpenedHooks()
val hookRegistry = EntryExitMatchingHookRegistry(environment)
registerInvalidationHooks(hookRegistry)
registerDatabaseClosedHooks(hookRegistry)
// Check for database instances in memory
for (instance in environment.artTooling().findInstances(SQLiteDatabase::class.java)) {
/* the race condition here will be handled by mDatabaseRegistry */
if (instance.isOpen) {
onDatabaseOpened(instance)
} else {
onDatabaseClosed(instance)
}
}
if (command.forceOpen) {
databaseRegistry.enableForceOpen()
}
// Check for database instances on disk
for (instance in environment.artTooling().findInstances(Application::class.java)) {
for (name in instance.databaseList()) {
val path = instance.getDatabasePath(name)
if (path.exists() && !isHelperSqliteFile(path)) {
databaseRegistry.notifyOnDiskDatabase(path.absolutePath)
}
}
}
}
/**
* 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).
*/
// code inside the future is exception-proofed
private fun handleAcquireDatabaseLock(
command: AcquireDatabaseLockCommand,
callback: CommandCallback,
) {
val databaseId = command.databaseId
val connection = acquireConnection(databaseId, callback) ?: return
// Timeout is covered by mDatabaseLockRegistry
submit(
ioExecutor,
Runnable {
val lockId: Int
try {
lockId = databaseLockRegistry.acquireLock(databaseId, connection.database)
} catch (e: Throwable) {
processLockingException(callback, e, true)
return@Runnable
}
callback.reply(
Response.newBuilder()
.setAcquireDatabaseLock(AcquireDatabaseLockResponse.newBuilder().setLockId(lockId))
.build()
.toByteArray()
)
},
)
}
// code inside the future is exception-proofed
private fun handleReleaseDatabaseLock(
command: ReleaseDatabaseLockCommand,
callback: CommandCallback,
) {
// Timeout is covered by mDatabaseLockRegistry
submit(
ioExecutor,
Runnable {
try {
databaseLockRegistry.releaseLock(command.lockId)
} catch (e: Throwable) {
processLockingException(callback, e, false)
return@Runnable
}
callback.reply(
Response.newBuilder()
.setReleaseDatabaseLock(ReleaseDatabaseLockResponse.getDefaultInstance())
.build()
.toByteArray()
)
},
)
}
/** @param isLockingStage provide true for acquiring a lock; false for releasing a lock */
private fun processLockingException(
callback: CommandCallback,
exception: Throwable,
isLockingStage: Boolean,
) {
val errorCode =
if (((exception is IllegalStateException) && exception.isAttemptAtUsingClosedDatabase()))
ErrorCode.ERROR_DB_CLOSED_DURING_OPERATION
else ErrorCode.ERROR_ISSUE_WITH_LOCKING_DATABASE
val message =
if (isLockingStage) "Issue while trying to lock the database for the export operation: "
else "Issue while trying to unlock the database after the export operation: "
val isRecoverable =
if (isLockingStage) true // failure to lock the db should be recoverable
else 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 [ ][.ALL_REFERENCES_RELEASE_COMMAND_SIGNATURE]
*/
private fun registerDatabaseClosedHooks(hookRegistry: EntryExitMatchingHookRegistry) {
hookRegistry.registerHook(
SQLiteDatabase::class.java,
ALL_REFERENCES_RELEASE_COMMAND_SIGNATURE,
) { exitFrame ->
val thisObject = exitFrame.thisObject
if (thisObject is SQLiteDatabase) {
onDatabaseClosed(thisObject as SQLiteDatabase?)
}
}
}
private fun registerDatabaseOpenedHooks() {
val entryHook = EntryHook { _, args ->
// args[0] is either a `String` or a `File`. Either way, `toString()` works
databaseLockRegistry.waitForUnlockedDatabase(args[0].toString())
}
val exitHook =
ExitHook<SQLiteDatabase> { database ->
try {
onDatabaseOpened(database)
} catch (exception: Throwable) {
connection.sendEvent(
createErrorOccurredEvent(
"Unhandled Exception while processing an onDatabaseAdded " +
"event: " +
exception.message,
stackTraceFromException(exception),
null,
ErrorCode.ERROR_ISSUE_WITH_PROCESSING_NEW_DATABASE_CONNECTION,
)
.toByteArray()
)
}
database
}
val artTooling = environment.artTooling()
val clazz = SQLiteDatabase::class.java
artTooling.registerEntryHook(clazz, OPEN_DATABASE_COMMAND_SIG_API_11, entryHook)
artTooling.registerExitHook(clazz, OPEN_DATABASE_COMMAND_SIG_API_11, exitHook)
if (Build.VERSION.SDK_INT >= 27) {
artTooling.registerEntryHook(clazz, OPEN_DATABASE_COMMAND_SIG_API_27, entryHook)
artTooling.registerExitHook(clazz, OPEN_DATABASE_COMMAND_SIG_API_27, exitHook)
artTooling.registerExitHook(clazz, CREATE_IN_MEMORY_DATABASE_COMMAND_SIG_API_27, exitHook)
}
}
private fun registerReleaseReferenceHooks() {
environment.artTooling().registerEntryHook(SQLiteClosable::class.java, "releaseReference()V") {
thisObject,
_ ->
if (thisObject is SQLiteDatabase) {
databaseRegistry.notifyReleaseReference((thisObject as SQLiteDatabase?)!!)
}
}
}
private fun registerInvalidationHooks(hookRegistry: EntryExitMatchingHookRegistry) {
/*
* Schedules a task using {@link mScheduledExecutor} and executes it on {@link mIOExecutor}.
*/
val deferredExecutor = DeferredExecutor { command, delayMs ->
// TODO: handle errors from Future
environment.executors().handler().postDelayed({ ioExecutor.execute(command) }, delayMs)
}
val throttler =
RequestCollapsingThrottler(
INVALIDATION_MIN_INTERVAL_MS.toLong(),
{ dispatchDatabasePossiblyChangedEvent() },
deferredExecutor,
)
registerInvalidationHooksSqliteStatement(throttler)
registerInvalidationHooksTransaction(throttler)
registerInvalidationHooksSQLiteCursor(throttler, hookRegistry)
}
/**
* Triggering invalidation on [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
* [ ][SQLiteDatabase.setTransactionSuccessful] was called
*/
private fun registerInvalidationHooksTransaction(throttler: RequestCollapsingThrottler) {
environment.artTooling().registerExitHook<Any>(
SQLiteDatabase::class.java,
"endTransaction()V",
) { result ->
throttler.submitRequest()
result
}
}
/**
* Invalidation hooks triggered by:
* * [SQLiteStatement.execute]
* * [SQLiteStatement.executeInsert]
* * [SQLiteStatement.executeUpdateDelete]
*/
private fun registerInvalidationHooksSqliteStatement(throttler: RequestCollapsingThrottler) {
for (method in SQLITE_STATEMENT_EXECUTE_METHODS_SIGNATURES) {
environment.artTooling().registerExitHook<Any>(SQLiteStatement::class.java, method) { result
->
throttler.submitRequest()
result
}
}
}
/**
* Invalidation hooks triggered by [SQLiteCursor.close] which means that the cursor's query was
* executed.
*
* In order to access cursor's query, we also use [SQLiteDatabase.rawQueryWithFactory] which takes
* a query String and constructs a cursor based on it.
*/
private fun registerInvalidationHooksSQLiteCursor(
throttler: RequestCollapsingThrottler,
hookRegistry: EntryExitMatchingHookRegistry,
) {
// TODO: add active pruning via Cursor#close listener
val trackedCursors = Collections.synchronizedMap(WeakHashMap<SQLiteCursor, Void?>())
val rawQueryMethodSignature =
("rawQueryWithFactory(" +
"Landroid/database/sqlite/SQLiteDatabase\$CursorFactory;" +
"Ljava/lang/String;" +
"[Ljava/lang/String;" +
"Ljava/lang/String;" +
"Landroid/os/CancellationSignal;" +
")Landroid/database/Cursor;")
hookRegistry.registerHook(SQLiteDatabase::class.java, rawQueryMethodSignature) { exitFrame ->
val cursor = cursorParam(exitFrame.result)
val query = stringParam(exitFrame.args[1]!!)
// Only track cursors that might modify the database.
// TODO: handle PRAGMA select queries, e.g. PRAGMA_TABLE_INFO
if (
cursor != null &&
query != null &&
DatabaseUtils.getSqlStatementType(query) != DatabaseUtils.STATEMENT_SELECT
) {
trackedCursors[cursor] = null
}
}
environment.artTooling().registerEntryHook(SQLiteCursor::class.java, "close()V") { thisObject, _
->
if (trackedCursors.containsKey(thisObject)) {
throttler.submitRequest()
}
}
}
// Gets a SQLiteCursor from a passed-in Object (if possible)
private fun cursorParam(cursor: Any?): SQLiteCursor? {
if (cursor is SQLiteCursor) {
return cursor
}
if (cursor is CursorWrapper) {
return cursorParam(cursor.wrappedCursor)
}
// TODO: add support for more cursor types
Log.w(
SqliteInspector::class.java.name,
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 fun stringParam(string: Any): String? {
return if (string is String) string else null
}
private fun dispatchDatabaseOpenedEvent(
databaseId: Int,
path: String,
isForced: Boolean,
isReadOnly: Boolean,
) {
Log.v(HIDDEN_TAG, "dispatchDatabaseOpenedEvent: ${path.substringAfterLast("/")}")
connection.sendEvent(
Event.newBuilder()
.setDatabaseOpened(
DatabaseOpenedEvent.newBuilder()
.setDatabaseId(databaseId)
.setPath(path)
.setIsForcedConnection(isForced)
.setIsReadOnly(isReadOnly)
)
.build()
.toByteArray()
)
}
private fun dispatchDatabaseClosedEvent(databaseId: Int, path: String) {
Log.v(HIDDEN_TAG, "dispatchDatabaseClosedEvent: ${path.substringAfterLast("/")}")
connection.sendEvent(
Event.newBuilder()
.setDatabaseClosed(DatabaseClosedEvent.newBuilder().setDatabaseId(databaseId).setPath(path))
.build()
.toByteArray()
)
}
private fun dispatchDatabasePossiblyChangedEvent() {
connection.sendEvent(
Event.newBuilder()
.setDatabasePossiblyChanged(DatabasePossiblyChangedEvent.getDefaultInstance())
.build()
.toByteArray()
)
}
// code inside the future is exception-proofed
private fun handleGetSchema(command: GetSchemaCommand, callback: CommandCallback) {
val connection = acquireConnection(command.databaseId, callback) ?: return
// TODO: consider a timeout
submit(connection.executor) { callback.reply(querySchema(connection.database).toByteArray()) }
}
private fun handleQuery(command: QueryCommand, callback: CommandCallback) {
val connection = acquireConnection(command.databaseId, callback) ?: return
val cancellationSignal = CancellationSignal()
val executor = connection.executor
// TODO: consider a timeout
val future: Future<*> =
submit(executor) {
val params = parseQueryParameterValues(command)
var cursor: Cursor? = null
try {
cursor = rawQuery(connection.database, command.query, params, cancellationSignal)
var responseSizeLimitHint = command.responseSizeLimitHint
// treating unset field as unbounded
if (responseSizeLimitHint <= 0) responseSizeLimitHint = Long.MAX_VALUE
val columnNames = listOf(*cursor.columnNames)
callback.reply(
Response.newBuilder()
.setQuery(
QueryResponse.newBuilder()
.setIsForcedConnection(databaseRegistry.isForcedConnection(connection.database))
.addAllRows(convert(cursor, responseSizeLimitHint))
.addAllColumnNames(columnNames)
.build()
)
.build()
.toByteArray()
)
triggerInvalidation(command.query)
} catch (e: SQLiteException) {
callback.reply(
createErrorOccurredResponse(e, true, ErrorCode.ERROR_ISSUE_WITH_PROCESSING_QUERY)
.toByteArray()
)
} catch (e: IllegalArgumentException) {
callback.reply(
createErrorOccurredResponse(e, true, ErrorCode.ERROR_ISSUE_WITH_PROCESSING_QUERY)
.toByteArray()
)
} catch (e: IllegalStateException) {
if (e.isAttemptAtUsingClosedDatabase()) {
callback.reply(
createErrorOccurredResponse(e, true, ErrorCode.ERROR_DB_CLOSED_DURING_OPERATION)
.toByteArray()
)
} else {
callback.reply(createErrorOccurredResponse(e, null, ERROR_UNKNOWN).toByteArray())
}
} catch (e: Throwable) {
callback.reply(createErrorOccurredResponse(e, null, ERROR_UNKNOWN).toByteArray())
} finally {
cursor?.close()
}
}
callback.addCancellationListener(environment.executors().primary()) {
cancellationSignal.cancel()
future.cancel(true)
}
}
private fun triggerInvalidation(query: String) {
if (DatabaseUtils.getSqlStatementType(query) != DatabaseUtils.STATEMENT_SELECT) {
for (invalidation in invalidations) {
invalidation.triggerInvalidations()
}
}
}
private fun handleKeepDatabasesOpen(
keepDatabasesOpen: KeepDatabasesOpenCommand,
callback: CommandCallback,
) {
// Acknowledge the command
callback.reply(
Response.newBuilder()
.setKeepDatabasesOpen(KeepDatabasesOpenResponse.getDefaultInstance())
.build()
.toByteArray()
)
databaseRegistry.notifyKeepOpenToggle(keepDatabasesOpen.setEnabled)
}
/**
* Tries to find a database for an id. If no such database is found, it replies with an [ ] via
* the `callback` provided.
*
* 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.
*
* 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
*/
private fun acquireConnection(databaseId: Int, callback: CommandCallback): DatabaseConnection? {
val connection = databaseLockRegistry.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 if (connection.database.isWriteAheadLoggingEnabled)
DatabaseConnection(connection.database, ioExecutor)
else connection
}
val database = databaseRegistry.getConnection(databaseId)
if (database == null) {
replyNoDatabaseWithId(callback, databaseId)
return null
}
// Given no lock, IO executor is appropriate.
return DatabaseConnection(database, ioExecutor)
}
private fun replyNoDatabaseWithId(callback: CommandCallback, databaseId: Int) {
val 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,
ErrorCode.ERROR_NO_OPEN_DATABASE_WITH_REQUESTED_ID,
)
.toByteArray()
)
}
private fun querySchema(database: SQLiteDatabase): Response {
var cursor: Cursor? = null
try {
cursor = rawQuery(database, QUERY_TABLE_INFO, arrayOfNulls(0), null)
val schemaBuilder =
GetSchemaResponse.newBuilder()
.setIsForcedConnection(databaseRegistry.isForcedConnection(database))
val objectTypeIx = cursor.getColumnIndex("type") // view or table
val tableNameIx = cursor.getColumnIndex("tableName")
val columnNameIx = cursor.getColumnIndex("columnName")
val typeIx = cursor.getColumnIndex("columnType")
val pkIx = cursor.getColumnIndex("pk")
val notNullIx = cursor.getColumnIndex("notnull")
val uniqueIx = cursor.getColumnIndex("unique")
var tableBuilder: Table.Builder? = null
while (cursor.moveToNext()) {
val tableName = cursor.getString(tableNameIx)
// ignore certain tables
if (HIDDEN_TABLES.contains(tableName)) {
continue
}
// check if getting data for a new table or appending columns to the current one
if (tableBuilder == null || tableBuilder.name != tableName) {
if (tableBuilder != null) {
schemaBuilder.addTables(tableBuilder.build())
}
tableBuilder = Table.newBuilder()
tableBuilder.setName(tableName)
tableBuilder.setIsView("view".equals(cursor.getString(objectTypeIx), ignoreCase = true))
}
// 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 (e: IllegalStateException) {
return if (e.isAttemptAtUsingClosedDatabase()) {
createErrorOccurredResponse(e, true, ErrorCode.ERROR_DB_CLOSED_DURING_OPERATION)
} else {
createErrorOccurredResponse(e, null, ERROR_UNKNOWN)
}
} catch (e: Throwable) {
return createErrorOccurredResponse(e, null, ERROR_UNKNOWN)
} finally {
cursor?.close()
}
}
private fun onDatabaseOpened(database: SQLiteDatabase?) {
roomInvalidationRegistry.invalidateCache()
databaseRegistry.notifyDatabaseOpened(database!!)
}
private fun onDatabaseClosed(database: SQLiteDatabase?) {
databaseRegistry.notifyAllDatabaseReferencesReleased(database!!)
}
@Suppress("SameParameterValue")
private fun createErrorOccurredEvent(
message: String?,
stackTrace: String?,
isRecoverable: Boolean?,
errorCode: ErrorCode,
): Event {
return Event.newBuilder()
.setErrorOccurred(
ErrorOccurredEvent.newBuilder()
.setContent(createErrorContentMessage(message, stackTrace, isRecoverable, errorCode))
.build()
)
.build()
}
/**
* 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.
*/
internal class DatabaseConnection(val database: SQLiteDatabase, val executor: Executor)
companion object {
@SuppressLint("Recycle") // For: "The cursor should be freed up after use with #close"
private fun rawQuery(
database: SQLiteDatabase,
queryText: String,
params: Array<String?>,
cancellationSignal: CancellationSignal?,
): Cursor {
val cursorFactory =
SQLiteDatabase.CursorFactory { _, driver, editTable, query ->
for (i in params.indices) {
val value = params[i]
val index = i + 1
if (value == null) {
query.bindNull(index)
} else {
query.bindString(index, value)
}
}
SQLiteCursor(driver, editTable, query)
}
return database.rawQueryWithFactory(cursorFactory, queryText, null, null, cancellationSignal)
}
private fun parseQueryParameterValues(command: QueryCommand): Array<String?> {
val params = arrayOfNulls<String>(command.queryParameterValuesCount)
for (i in 0 until command.queryParameterValuesCount) {
val param = command.getQueryParameterValues(i)
when (param.oneOfCase) {
QueryParameterValue.OneOfCase.STRING_VALUE -> params[i] = param.stringValue
QueryParameterValue.OneOfCase.ONEOF_NOT_SET -> params[i] = null
else ->
throw IllegalArgumentException(
"Unsupported parameter type. OneOfCase=" + param.oneOfCase
)
}
}
return params
}
/** @param responseSizeLimitHint expressed in bytes */
private fun convert(cursor: Cursor?, responseSizeLimitHint: Long): List<Row> {
var responseSize: Long = 0
val result: MutableList<Row> = ArrayList()
val columnCount = cursor!!.columnCount
while (cursor.moveToNext() && responseSize < responseSizeLimitHint) {
val rowBuilder = Row.newBuilder()
for (i in 0 until columnCount) {
val value = readValue(cursor, i)
rowBuilder.addValues(value)
}
val 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.serializedSize.toLong()
}
return result
}
private fun readValue(cursor: Cursor?, index: Int): CellValue {
val builder = CellValue.newBuilder()
when (cursor!!.getType(index)) {
Cursor.FIELD_TYPE_NULL -> {}
Cursor.FIELD_TYPE_BLOB -> builder.setBlobValue(ByteString.copyFrom(cursor.getBlob(index)))
Cursor.FIELD_TYPE_STRING -> builder.setStringValue(cursor.getString(index))
Cursor.FIELD_TYPE_INTEGER -> builder.setLongValue(cursor.getLong(index))
Cursor.FIELD_TYPE_FLOAT -> builder.setDoubleValue(cursor.getDouble(index))
}
return builder.build()
}
private fun createErrorContentMessage(
message: String?,
stackTrace: String?,
isRecoverable: Boolean?,
errorCode: ErrorCode,
): ErrorContent {
val builder = ErrorContent.newBuilder()
if (message != null) {
builder.setMessage(message)
}
if (stackTrace != null) {
builder.setStackTrace(stackTrace)
}
val 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 fun createErrorOccurredResponse(
exception: Throwable,
isRecoverable: Boolean?,
errorCode: ErrorCode,
): Response {
return createErrorOccurredResponse("", isRecoverable, exception, errorCode)
}
private fun createErrorOccurredResponse(
messagePrefix: String,
isRecoverable: Boolean?,
exception: Throwable,
errorCode: ErrorCode,
): Response {
var message = exception.message
if (message == null) message = exception.toString()
return createErrorOccurredResponse(
messagePrefix + message,
stackTraceFromException(exception),
isRecoverable,
errorCode,
)
}
private fun createErrorOccurredResponse(
message: String?,
stackTrace: String?,
isRecoverable: Boolean?,
errorCode: ErrorCode,
): Response {
return Response.newBuilder()
.setErrorOccurred(
ErrorOccurredResponse.newBuilder()
.setContent(createErrorContentMessage(message, stackTrace, isRecoverable, errorCode))
)
.build()
}
private fun stackTraceFromException(exception: Throwable): String {
val writer = StringWriter()
exception.printStackTrace(PrintWriter(writer))
return writer.toString()
}
private fun isHelperSqliteFile(file: File): Boolean {
val path = file.path
return path.endsWith("-journal") || path.endsWith("-shm") || path.endsWith("-wal")
}
}
}