/*
* Copyright (c) 2014-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
package com.facebook.stetho.inspector.protocol.module;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import android.annotation.TargetApi;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.os.Build;
import android.util.SparseArray;
import com.facebook.stetho.common.LogUtil;
import com.facebook.stetho.common.Util;
import com.facebook.stetho.inspector.helper.ChromePeerManager;
import com.facebook.stetho.inspector.helper.ObjectIdMapper;
import com.facebook.stetho.inspector.helper.PeersRegisteredListener;
import com.facebook.stetho.inspector.jsonrpc.JsonRpcException;
import com.facebook.stetho.inspector.jsonrpc.JsonRpcPeer;
import com.facebook.stetho.inspector.jsonrpc.JsonRpcResult;
import com.facebook.stetho.inspector.jsonrpc.protocol.JsonRpcError;
import com.facebook.stetho.inspector.protocol.ChromeDevtoolsDomain;
import com.facebook.stetho.inspector.protocol.ChromeDevtoolsMethod;
import com.facebook.stetho.json.ObjectMapper;
import com.facebook.stetho.json.annotation.JsonProperty;
import org.json.JSONObject;
import javax.annotation.concurrent.GuardedBy;
import javax.annotation.concurrent.ThreadSafe;
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public class Database implements ChromeDevtoolsDomain {
/**
* The protocol doesn't offer an efficient means of pagination or anything like that so
* we'll just cap the result list to some arbitrarily large number that I think folks will
* actually need in practice.
* <p>
* Note that when this limit is exceeded, a dummy row will be introduced that indicates
* truncation occurred.
*/
private static final int MAX_EXECUTE_RESULTS = 250;
/**
* Maximum length of a BLOB field before we stop trying to interpret it and just
* return {@link #UNKNOWN_BLOB_LABEL}
*/
private static final int MAX_BLOB_LENGTH = 512;
/**
* Label to use when a BLOB column cannot be converted to a string.
*/
private static final String UNKNOWN_BLOB_LABEL = "{blob}";
private List<DatabaseDriver2> mDatabaseDrivers;
private final ChromePeerManager mChromePeerManager;
private final DatabasePeerRegistrationListener mPeerListener;
private final ObjectMapper mObjectMapper;
/**
* Constructs the object.
*/
public Database() {
mDatabaseDrivers = new ArrayList<>();
mChromePeerManager = new ChromePeerManager();
mPeerListener = new DatabasePeerRegistrationListener(mDatabaseDrivers);
mChromePeerManager.setListener(mPeerListener);
mObjectMapper = new ObjectMapper();
}
public void add(DatabaseDriver2 databaseDriver) {
mDatabaseDrivers.add(databaseDriver);
}
@ChromeDevtoolsMethod
public void enable(JsonRpcPeer peer, JSONObject params) {
mChromePeerManager.addPeer(peer);
}
@ChromeDevtoolsMethod
public void disable(JsonRpcPeer peer, JSONObject params) {
mChromePeerManager.removePeer(peer);
}
@ChromeDevtoolsMethod
public JsonRpcResult getDatabaseTableNames(JsonRpcPeer peer, JSONObject params)
throws JsonRpcException {
GetDatabaseTableNamesRequest request = mObjectMapper.convertValue(params,
GetDatabaseTableNamesRequest.class);
String databaseId = request.databaseId;
DatabaseDescriptorHolder holder =
mPeerListener.getDatabaseDescriptorHolder(databaseId);
try {
GetDatabaseTableNamesResponse response = new GetDatabaseTableNamesResponse();
response.tableNames = holder.driver.getTableNames(holder.descriptor);
return response;
} catch (SQLiteException e) {
throw new JsonRpcException(
new JsonRpcError(
JsonRpcError.ErrorCode.INVALID_REQUEST,
e.toString(),
null /* data */));
}
}
@ChromeDevtoolsMethod
public JsonRpcResult executeSQL(JsonRpcPeer peer, JSONObject params) {
ExecuteSQLRequest request = mObjectMapper.convertValue(params,
ExecuteSQLRequest.class);
DatabaseDescriptorHolder holder =
mPeerListener.getDatabaseDescriptorHolder(request.databaseId);
try {
return holder.driver.executeSQL(
holder.descriptor,
request.query,
new DatabaseDriver.ExecuteResultHandler<ExecuteSQLResponse>() {
@Override
public ExecuteSQLResponse handleRawQuery() throws SQLiteException {
ExecuteSQLResponse response = new ExecuteSQLResponse();
// This is done because the inspector UI likes to delete rows if you give them no
// name/value list
response.columnNames = Collections.singletonList("success");
response.values = Collections.singletonList("true");
return response;
}
@Override
public ExecuteSQLResponse handleSelect(Cursor result) throws SQLiteException {
ExecuteSQLResponse response = new ExecuteSQLResponse();
response.columnNames = Arrays.asList(result.getColumnNames());
response.values = flattenRows(result, MAX_EXECUTE_RESULTS);
return response;
}
@Override
public ExecuteSQLResponse handleInsert(long insertedId) throws SQLiteException {
ExecuteSQLResponse response = new ExecuteSQLResponse();
response.columnNames = Collections.singletonList("ID of last inserted row");
response.values = Collections.singletonList(String.valueOf(insertedId));
return response;
}
@Override
public ExecuteSQLResponse handleUpdateDelete(int count) throws SQLiteException {
ExecuteSQLResponse response = new ExecuteSQLResponse();
response.columnNames = Collections.singletonList("Modified rows");
response.values = Collections.singletonList(String.valueOf(count));
return response;
}
});
} catch (RuntimeException e) {
LogUtil.e(e, "Exception executing: %s", request.query);
Error error = new Error();
error.code = 0;
error.message = e.getMessage();
ExecuteSQLResponse response = new ExecuteSQLResponse();
response.sqlError = error;
return response;
}
}
/**
* Flatten all columns and all rows of a cursor to a single array. The array cannot be
* interpreted meaningfully without the number of columns.
*
* @param cursor
* @param limit Maximum number of rows to process.
* @return List of Java primitives matching the value type of each column, converted to
* strings.
*/
private static ArrayList<String> flattenRows(Cursor cursor, int limit) {
Util.throwIfNot(limit >= 0);
ArrayList<String> flatList = new ArrayList<>();
final int numColumns = cursor.getColumnCount();
for (int row = 0; row < limit && cursor.moveToNext(); row++) {
for (int column = 0; column < numColumns; column++) {
switch (cursor.getType(column)) {
case Cursor.FIELD_TYPE_NULL:
flatList.add(null);
break;
case Cursor.FIELD_TYPE_INTEGER:
flatList.add(String.valueOf(cursor.getLong(column)));
break;
case Cursor.FIELD_TYPE_FLOAT:
flatList.add(String.valueOf(cursor.getDouble(column)));
break;
case Cursor.FIELD_TYPE_BLOB:
flatList.add(blobToString(cursor.getBlob(column)));
break;
case Cursor.FIELD_TYPE_STRING:
default:
flatList.add(cursor.getString(column));
break;
}
}
}
if (!cursor.isAfterLast()) {
for (int column = 0; column < numColumns; column++) {
flatList.add("{truncated}");
}
}
return flatList;
}
private static String blobToString(byte[] blob) {
if (blob.length <= MAX_BLOB_LENGTH) {
if (fastIsAscii(blob)) {
try {
return new String(blob, "US-ASCII");
} catch (UnsupportedEncodingException e) {
// Fall through...
}
}
}
return UNKNOWN_BLOB_LABEL;
}
private static boolean fastIsAscii(byte[] blob) {
for (byte b : blob) {
if ((b & ~0x7f) != 0) {
return false;
}
}
return true;
}
@ThreadSafe
private static class DatabasePeerRegistrationListener extends PeersRegisteredListener {
private final List<DatabaseDriver2> mDatabaseDrivers;
@GuardedBy("this")
private final SparseArray<DatabaseDescriptorHolder> mDatabaseHolders = new SparseArray<>();
@GuardedBy("this")
private final ObjectIdMapper mDatabaseIdMapper = new ObjectIdMapper();
private DatabasePeerRegistrationListener(List<DatabaseDriver2> databaseDrivers) {
mDatabaseDrivers = databaseDrivers;
}
public DatabaseDescriptorHolder getDatabaseDescriptorHolder(String databaseId) {
return mDatabaseHolders.get(Integer.parseInt(databaseId));
}
@Override
protected synchronized void onFirstPeerRegistered() {
for (DatabaseDriver2<?> driver : mDatabaseDrivers) {
for (DatabaseDescriptor desc : driver.getDatabaseNames()) {
Integer databaseId = mDatabaseIdMapper.getIdForObject(desc);
if (databaseId == null) {
databaseId = mDatabaseIdMapper.putObject(desc);
mDatabaseHolders.put(
databaseId,
new DatabaseDescriptorHolder(driver, desc));
}
}
}
}
@Override
protected synchronized void onLastPeerUnregistered() {
mDatabaseIdMapper.clear();
mDatabaseHolders.clear();
}
@Override
protected synchronized void onPeerAdded(JsonRpcPeer peer) {
for (int i = 0, N = mDatabaseHolders.size(); i < N; i++) {
int id = mDatabaseHolders.keyAt(i);
DatabaseDescriptorHolder holder = mDatabaseHolders.valueAt(i);
Database.DatabaseObject databaseParams = new Database.DatabaseObject();
databaseParams.id = String.valueOf(id);
databaseParams.name = holder.descriptor.name();
databaseParams.domain = holder.driver.getContext().getPackageName();
databaseParams.version = "N/A";
Database.AddDatabaseEvent eventParams = new Database.AddDatabaseEvent();
eventParams.database = databaseParams;
peer.invokeMethod("Database.addDatabase", eventParams, null /* callback */);
}
}
@Override
protected synchronized void onPeerRemoved(JsonRpcPeer peer) {
// Nothing to do on each peer removal...
}
}
private static class DatabaseDescriptorHolder {
public final DatabaseDriver2 driver;
public final DatabaseDescriptor descriptor;
public DatabaseDescriptorHolder(DatabaseDriver2 driver, DatabaseDescriptor descriptor) {
this.driver = driver;
this.descriptor = descriptor;
}
}
private static class GetDatabaseTableNamesRequest {
@JsonProperty(required = true)
public String databaseId;
}
private static class GetDatabaseTableNamesResponse implements JsonRpcResult {
@JsonProperty(required = true)
public List<String> tableNames;
}
public static class ExecuteSQLRequest {
@JsonProperty(required = true)
public String databaseId;
@JsonProperty(required = true)
public String query;
}
public static class ExecuteSQLResponse implements JsonRpcResult {
@JsonProperty
public List<String> columnNames;
@JsonProperty
public List<String> values;
@JsonProperty
public Error sqlError;
}
public static class AddDatabaseEvent {
@JsonProperty(required = true)
public DatabaseObject database;
}
public static class DatabaseObject {
@JsonProperty(required = true)
public String id;
@JsonProperty(required = true)
public String domain;
@JsonProperty(required = true)
public String name;
@JsonProperty(required = true)
public String version;
}
public static class Error {
@JsonProperty(required = true)
public String message;
@JsonProperty(required = true)
public int code;
}
/**
* @deprecated Use {@link DatabaseDriver2} which allows for structured identifiers of database
* objects (such as a file path instead of just a string name) which also serves as a
* namespacing separation of multiple drivers.
*/
@Deprecated
public static abstract class DatabaseDriver extends BaseDatabaseDriver<String> {
public DatabaseDriver(Context context) {
super(context);
}
}
}