package com.android.mtp;
+import static com.android.mtp.MtpDatabaseConstants.*;
+
import android.content.ContentValues;
import android.content.Context;
import android.content.res.Resources;
import android.database.Cursor;
-import android.database.DatabaseUtils;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteOpenHelper;
-import android.database.sqlite.SQLiteQueryBuilder;
import android.mtp.MtpObjectInfo;
import android.provider.DocumentsContract.Document;
import android.provider.DocumentsContract.Root;
import java.util.HashMap;
import java.util.Map;
-import java.util.Objects;
/**
* Database for MTP objects.
*/
@VisibleForTesting
class MtpDatabase {
- private static final int VERSION = 1;
- private static final String NAME = "mtp";
-
- /**
- * Table representing documents including root documents.
- */
- private static final String TABLE_DOCUMENTS = "Documents";
-
- /**
- * Table containing additional information only available for root documents.
- * The table uses same primary keys with corresponding documents.
- */
- private static final String TABLE_ROOT_EXTRA = "RootExtra";
-
- /**
- * View to join Documents and RootExtra tables to provide roots information.
- */
- private static final String VIEW_ROOTS = "Roots";
-
- static final String COLUMN_DEVICE_ID = "device_id";
- static final String COLUMN_STORAGE_ID = "storage_id";
- static final String COLUMN_OBJECT_HANDLE = "object_handle";
- static final String COLUMN_PARENT_DOCUMENT_ID = "parent_document_id";
- static final String COLUMN_ROW_STATE = "row_state";
-
- /**
- * The state represents that the row has a valid object handle.
- */
- static final int ROW_STATE_VALID = 0;
-
- /**
- * The state represents that the rows added at the previous cycle and need to be updated with
- * fresh values.
- * The row may not have valid object handle. External application can still fetch the documents.
- * If the external application tries to fetch object handle, the provider resolves pending
- * documents with invalidated documents ahead.
- */
- static final int ROW_STATE_INVALIDATED = 1;
-
- /**
- * The state represents the raw has a valid object handle but it may be going to be mapped with
- * another rows invalidated. After fetching all documents under the parent, the database tries
- * to map the pending documents and the invalidated documents in order to keep old document ID
- * alive.
- */
- static final int ROW_STATE_PENDING = 2;
-
- /**
- * Mapping mode that uses MTP identifier to find corresponding rows.
- */
- static final int MAP_BY_MTP_IDENTIFIER = 0;
-
- /**
- * Mapping mode that uses name to find corresponding rows.
- */
- static final int MAP_BY_NAME = 1;
-
- private static final String SELECTION_DOCUMENT_ID = Document.COLUMN_DOCUMENT_ID + " = ?";
- private static final String SELECTION_ROOT_ID = Root.COLUMN_ROOT_ID + " = ?";
- private static final String SELECTION_ROOT_DOCUMENTS =
- COLUMN_DEVICE_ID + " = ? AND " + COLUMN_PARENT_DOCUMENT_ID + " IS NULL";
- private static final String SELECTION_CHILD_DOCUMENTS = COLUMN_PARENT_DOCUMENT_ID + " = ?";
-
- static class ParentNotFoundException extends Exception {}
-
- private static class OpenHelper extends SQLiteOpenHelper {
- private static final String QUERY_CREATE_DOCUMENTS =
- "CREATE TABLE " + TABLE_DOCUMENTS + " (" +
- Document.COLUMN_DOCUMENT_ID +
- " INTEGER PRIMARY KEY AUTOINCREMENT," +
- COLUMN_DEVICE_ID + " INTEGER NOT NULL," +
- COLUMN_STORAGE_ID + " INTEGER," +
- COLUMN_OBJECT_HANDLE + " INTEGER," +
- COLUMN_PARENT_DOCUMENT_ID + " INTEGER," +
- COLUMN_ROW_STATE + " INTEGER NOT NULL," +
- Document.COLUMN_MIME_TYPE + " TEXT," +
- Document.COLUMN_DISPLAY_NAME + " TEXT NOT NULL," +
- Document.COLUMN_SUMMARY + " TEXT," +
- Document.COLUMN_LAST_MODIFIED + " INTEGER," +
- Document.COLUMN_ICON + " INTEGER," +
- Document.COLUMN_FLAGS + " INTEGER NOT NULL," +
- Document.COLUMN_SIZE + " INTEGER NOT NULL);";
-
- private static final String QUERY_CREATE_ROOT_EXTRA =
- "CREATE TABLE " + TABLE_ROOT_EXTRA + " (" +
- Root.COLUMN_ROOT_ID + " INTEGER PRIMARY KEY," +
- Root.COLUMN_FLAGS + " INTEGER NOT NULL," +
- Root.COLUMN_AVAILABLE_BYTES + " INTEGER NOT NULL," +
- Root.COLUMN_CAPACITY_BYTES + " INTEGER NOT NULL," +
- Root.COLUMN_MIME_TYPES + " TEXT NOT NULL);";
-
- /**
- * Creates a view to join Documents table and RootExtra table on their primary keys to
- * provide DocumentContract.Root equivalent information.
- */
- private static final String QUERY_CREATE_VIEW_ROOTS =
- "CREATE VIEW " + VIEW_ROOTS + " AS SELECT " +
- TABLE_DOCUMENTS + "." + Document.COLUMN_DOCUMENT_ID + " AS " +
- Root.COLUMN_ROOT_ID + "," +
- TABLE_ROOT_EXTRA + "." + Root.COLUMN_FLAGS + "," +
- TABLE_DOCUMENTS + "." + Document.COLUMN_ICON + " AS " +
- Root.COLUMN_ICON + "," +
- TABLE_DOCUMENTS + "." + Document.COLUMN_DISPLAY_NAME + " AS " +
- Root.COLUMN_TITLE + "," +
- TABLE_DOCUMENTS + "." + Document.COLUMN_SUMMARY + " AS " +
- Root.COLUMN_SUMMARY + "," +
- TABLE_DOCUMENTS + "." + Document.COLUMN_DOCUMENT_ID + " AS " +
- Root.COLUMN_DOCUMENT_ID + "," +
- TABLE_ROOT_EXTRA + "." + Root.COLUMN_AVAILABLE_BYTES + "," +
- TABLE_ROOT_EXTRA + "." + Root.COLUMN_CAPACITY_BYTES + "," +
- TABLE_ROOT_EXTRA + "." + Root.COLUMN_MIME_TYPES + "," +
- TABLE_DOCUMENTS + "." + COLUMN_ROW_STATE +
- " FROM " + TABLE_DOCUMENTS + " INNER JOIN " + TABLE_ROOT_EXTRA +
- " ON " +
- COLUMN_PARENT_DOCUMENT_ID + " IS NULL AND " +
- TABLE_DOCUMENTS + "." + Document.COLUMN_DOCUMENT_ID +
- "=" +
- TABLE_ROOT_EXTRA + "." + Root.COLUMN_ROOT_ID;
-
- public OpenHelper(Context context) {
- super(context, NAME, null, VERSION);
- }
-
- @Override
- public void onCreate(SQLiteDatabase db) {
- db.execSQL(QUERY_CREATE_DOCUMENTS);
- db.execSQL(QUERY_CREATE_ROOT_EXTRA);
- db.execSQL(QUERY_CREATE_VIEW_ROOTS);
- }
-
- @Override
- public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
- throw new UnsupportedOperationException();
- }
- }
-
+ private final MtpDatabaseInternal mDatabase;
private final Map<String, Integer> mMappingMode = new HashMap<>();
- private final SQLiteDatabase mDatabase;
@VisibleForTesting
MtpDatabase(Context context) {
- final OpenHelper helper = new OpenHelper(context);
- mDatabase = helper.getWritableDatabase();
- }
-
- @VisibleForTesting
- static void deleteDatabase(Context context) {
- SQLiteDatabase.deleteDatabase(context.getDatabasePath(NAME));
+ mDatabase = new MtpDatabaseInternal(context);
}
@VisibleForTesting
Cursor queryRoots(String[] columnNames) {
- return mDatabase.query(
- VIEW_ROOTS,
- columnNames,
- COLUMN_ROW_STATE + " IN (?, ?)",
- strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED),
- null,
- null,
- null);
+ return mDatabase.queryRoots(columnNames);
}
@VisibleForTesting
Cursor queryRootDocuments(String[] columnNames) {
- return mDatabase.query(
- TABLE_DOCUMENTS,
- columnNames,
- COLUMN_ROW_STATE + " IN (?, ?)",
- strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED),
- null,
- null,
- null);
+ return mDatabase.queryRootDocuments(columnNames);
}
@VisibleForTesting
Cursor queryChildDocuments(String[] columnNames, String parentDocumentId) {
- return mDatabase.query(
- TABLE_DOCUMENTS,
- columnNames,
- COLUMN_ROW_STATE + " IN (?, ?) AND " + COLUMN_PARENT_DOCUMENT_ID + " = ?",
- strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED, parentDocumentId),
- null,
- null,
- null);
+ return mDatabase.queryChildDocuments(columnNames, parentDocumentId);
}
@VisibleForTesting
}
mMappingMode.put(
mappingStateKey,
- startAddingDocuments(SELECTION_ROOT_DOCUMENTS, Integer.toString(deviceId)));
+ mDatabase.startAddingDocuments(
+ SELECTION_ROOT_DOCUMENTS, Integer.toString(deviceId)));
}
@VisibleForTesting
}
mMappingMode.put(
mappingStateKey,
- startAddingDocuments(SELECTION_CHILD_DOCUMENTS, parentDocumentId));
- }
-
- /**
- * Starts adding new documents.
- * The methods decides mapping mode depends on if all documents under the given parent have MTP
- * identifier or not. If all the documents have MTP identifier, it uses the identifier to find
- * a corresponding existing row. Otherwise it does heuristic.
- *
- * @param selection Query matches valid documents.
- * @param arg Argument for selection.
- * @return Mapping mode.
- */
- @VisibleForTesting
- private int startAddingDocuments(String selection, String arg) {
- mDatabase.beginTransaction();
- try {
- // Delete all pending rows.
- deleteDocumentsAndRoots(
- selection + " AND " + COLUMN_ROW_STATE + "=?", strings(arg, ROW_STATE_PENDING));
-
- // Set all documents as invalidated.
- final ContentValues values = new ContentValues();
- values.put(COLUMN_ROW_STATE, ROW_STATE_INVALIDATED);
- mDatabase.update(TABLE_DOCUMENTS, values, selection, new String[] { arg });
-
- // If we have rows that does not have MTP identifier, do heuristic mapping by name.
- final boolean useNameForResolving = DatabaseUtils.queryNumEntries(
- mDatabase,
- TABLE_DOCUMENTS,
- selection + " AND " + COLUMN_STORAGE_ID + " IS NULL",
- new String[] { arg }) > 0;
- mDatabase.setTransactionSuccessful();
- return useNameForResolving ? MAP_BY_NAME : MAP_BY_MTP_IDENTIFIER;
- } finally {
- mDatabase.endTransaction();
- }
+ mDatabase.startAddingDocuments(SELECTION_CHILD_DOCUMENTS, parentDocumentId));
}
@VisibleForTesting
default:
throw new Error("Unexpected map mode.");
}
- final long[] documentIds = putDocuments(
+ final long[] documentIds = mDatabase.putDocuments(
valuesList,
SELECTION_ROOT_DOCUMENTS,
Integer.toString(deviceId),
values.put(Root.COLUMN_AVAILABLE_BYTES, root.mFreeSpace);
values.put(Root.COLUMN_CAPACITY_BYTES, root.mMaxCapacity);
values.put(Root.COLUMN_MIME_TYPES, "");
- mDatabase.replace(TABLE_ROOT_EXTRA, null, values);
+ mDatabase.putRootExtra(values);
}
mDatabase.setTransactionSuccessful();
} finally {
default:
throw new Error("Unexpected map mode.");
}
- putDocuments(valuesList, SELECTION_CHILD_DOCUMENTS, parentId, heuristic, mapColumn);
+ mDatabase.putDocuments(
+ valuesList, SELECTION_CHILD_DOCUMENTS, parentId, heuristic, mapColumn);
}
- /**
- * Clears MTP related identifier.
- * It clears MTP's object handle and storage ID that are not stable over MTP sessions and mark
- * the all documents as 'invalidated'. It also remove 'pending' rows as adding is cancelled
- * now.
- */
@VisibleForTesting
void clearMapping() {
- mDatabase.beginTransaction();
- try {
- deleteDocumentsAndRoots(COLUMN_ROW_STATE + " = ?", strings(ROW_STATE_PENDING));
- final ContentValues values = new ContentValues();
- values.putNull(COLUMN_OBJECT_HANDLE);
- values.putNull(COLUMN_STORAGE_ID);
- values.put(COLUMN_ROW_STATE, ROW_STATE_INVALIDATED);
- mDatabase.update(TABLE_DOCUMENTS, values, null, null);
- mDatabase.setTransactionSuccessful();
- mMappingMode.clear();
- } finally {
- mDatabase.endTransaction();
- }
+ mDatabase.clearMapping();
+ mMappingMode.clear();
}
@VisibleForTesting
final String mappingModeKey = getRootDocumentsMappingStateKey(deviceId);
switch (mMappingMode.get(mappingModeKey)) {
case MAP_BY_MTP_IDENTIFIER:
- stopAddingDocuments(
+ mDatabase.stopAddingDocuments(
SELECTION_ROOT_DOCUMENTS,
Integer.toString(deviceId),
COLUMN_STORAGE_ID);
break;
case MAP_BY_NAME:
- stopAddingDocuments(
+ mDatabase.stopAddingDocuments(
SELECTION_ROOT_DOCUMENTS,
Integer.toString(deviceId),
Document.COLUMN_DISPLAY_NAME);
final String mappingModeKey = getChildDocumentsMappingStateKey(parentId);
switch (mMappingMode.get(mappingModeKey)) {
case MAP_BY_MTP_IDENTIFIER:
- stopAddingDocuments(
+ mDatabase.stopAddingDocuments(
SELECTION_CHILD_DOCUMENTS,
parentId,
COLUMN_OBJECT_HANDLE);
break;
case MAP_BY_NAME:
- stopAddingDocuments(
+ mDatabase.stopAddingDocuments(
SELECTION_CHILD_DOCUMENTS,
parentId,
Document.COLUMN_DISPLAY_NAME);
}
/**
- * Puts the documents into the database.
- * If the mapping mode is not heuristic, it just adds the rows to the database or updates the
- * existing rows with the new values. If the mapping mode is heuristic, it adds some new rows as
- * 'pending' state when that rows may be corresponding to existing 'invalidated' rows. Then
- * {@link #stopAddingDocuments(String, String, String)} turns the pending rows into 'valid'
- * rows.
- *
- * @param valuesList Values that are stored in the database.
- * @param selection SQL where closure to select rows that shares the same parent.
- * @param arg Argument for selection SQL.
- * @param heuristic Whether the mapping mode is heuristic.
- * @return List of Document ID inserted to the table.
- */
- private long[] putDocuments(
- ContentValues[] valuesList,
- String selection,
- String arg,
- boolean heuristic,
- String mappingKey) {
- mDatabase.beginTransaction();
- try {
- final long[] documentIds = new long[valuesList.length];
- int i = 0;
- for (final ContentValues values : valuesList) {
- final Cursor candidateCursor = mDatabase.query(
- TABLE_DOCUMENTS,
- strings(Document.COLUMN_DOCUMENT_ID),
- selection + " AND " +
- COLUMN_ROW_STATE + "=? AND " +
- mappingKey + "=?",
- strings(arg, ROW_STATE_INVALIDATED, values.getAsString(mappingKey)),
- null,
- null,
- null,
- "1");
- final long rowId;
- if (candidateCursor.getCount() == 0) {
- rowId = mDatabase.insert(TABLE_DOCUMENTS, null, values);
- } else if (!heuristic) {
- candidateCursor.moveToNext();
- final String documentId = candidateCursor.getString(0);
- rowId = mDatabase.update(
- TABLE_DOCUMENTS, values, SELECTION_DOCUMENT_ID, strings(documentId));
- } else {
- values.put(COLUMN_ROW_STATE, ROW_STATE_PENDING);
- rowId = mDatabase.insert(TABLE_DOCUMENTS, null, values);
- }
- // Document ID is a primary integer key of the table. So the returned row
- // IDs should be same with the document ID.
- documentIds[i++] = rowId;
- candidateCursor.close();
- }
-
- mDatabase.setTransactionSuccessful();
- return documentIds;
- } finally {
- mDatabase.endTransaction();
- }
- }
-
- /**
- * Maps 'pending' document and 'invalidated' document that shares the same column of groupKey.
- * If the database does not find corresponding 'invalidated' document, it just removes
- * 'invalidated' document from the database.
- * @param selection Query to select rows for resolving.
- * @param arg Argument for selection SQL.
- * @param groupKey Column name used to find corresponding rows.
- */
- private void stopAddingDocuments(String selection, String arg, String groupKey) {
- mDatabase.beginTransaction();
- try {
- // Get 1-to-1 mapping of invalidated document and pending document.
- final String invalidatedIdQuery = createStateFilter(
- ROW_STATE_INVALIDATED, Document.COLUMN_DOCUMENT_ID);
- final String pendingIdQuery = createStateFilter(
- ROW_STATE_PENDING, Document.COLUMN_DOCUMENT_ID);
- // SQL should be like:
- // SELECT group_concat(CASE WHEN raw_state = 1 THEN document_id ELSE NULL END),
- // group_concat(CASE WHEN raw_state = 2 THEN document_id ELSE NULL END)
- // WHERE device_id = ? AND parent_document_id IS NULL
- // GROUP BY display_name
- // HAVING count(CASE WHEN raw_state = 1 THEN document_id ELSE NULL END) = 1 AND
- // count(CASE WHEN raw_state = 2 THEN document_id ELSE NULL END) = 1
- final Cursor mergingCursor = mDatabase.query(
- TABLE_DOCUMENTS,
- new String[] {
- "group_concat(" + invalidatedIdQuery + ")",
- "group_concat(" + pendingIdQuery + ")"
- },
- selection,
- strings(arg),
- groupKey,
- "count(" + invalidatedIdQuery + ") = 1 AND count(" + pendingIdQuery + ") = 1",
- null);
-
- final ContentValues values = new ContentValues();
- while (mergingCursor.moveToNext()) {
- final String invalidatedId = mergingCursor.getString(0);
- final String pendingId = mergingCursor.getString(1);
-
- // Obtain the new values including the latest object handle from mapping row.
- getFirstRow(
- TABLE_DOCUMENTS,
- SELECTION_DOCUMENT_ID,
- new String[] { pendingId },
- values);
- values.remove(Document.COLUMN_DOCUMENT_ID);
- values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
- mDatabase.update(
- TABLE_DOCUMENTS,
- values,
- SELECTION_DOCUMENT_ID,
- new String[] { invalidatedId });
-
- getFirstRow(
- TABLE_ROOT_EXTRA,
- SELECTION_ROOT_ID,
- new String[] { pendingId },
- values);
- if (values.size() > 0) {
- values.remove(Root.COLUMN_ROOT_ID);
- mDatabase.update(
- TABLE_ROOT_EXTRA,
- values,
- SELECTION_ROOT_ID,
- new String[] { invalidatedId });
- }
-
- // Delete 'pending' row.
- deleteDocumentsAndRoots(SELECTION_DOCUMENT_ID, new String[] { pendingId });
- }
- mergingCursor.close();
-
- // Delete all invalidated rows that cannot be mapped.
- deleteDocumentsAndRoots(
- COLUMN_ROW_STATE + " = ? AND " + selection,
- strings(ROW_STATE_INVALIDATED, arg));
-
- // The database cannot find old document ID for the pending rows.
- // Turn the all pending rows into valid state, which means the rows become to be
- // valid with new document ID.
- values.clear();
- values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
- mDatabase.update(
- TABLE_DOCUMENTS,
- values,
- COLUMN_ROW_STATE + " = ? AND " + selection,
- strings(ROW_STATE_PENDING, arg));
- mDatabase.setTransactionSuccessful();
- } finally {
- mDatabase.endTransaction();
- }
- }
-
- /**
* Gets {@link ContentValues} for the given root.
* @param values {@link ContentValues} that receives values.
* @param resources Resources used to get localized root name.
values.put(Document.COLUMN_SIZE, info.getCompressedSize());
}
- /**
- * Obtains values of the first row for the query.
- * @param values ContentValues that the values are stored to.
- * @param table Target table.
- * @param selection Query to select rows.
- * @param args Argument for query.
- */
- private void getFirstRow(String table, String selection, String[] args, ContentValues values) {
- values.clear();
- final Cursor cursor = mDatabase.query(table, null, selection, args, null, null, null, "1");
- if (cursor.getCount() == 0) {
- return;
- }
- cursor.moveToNext();
- DatabaseUtils.cursorRowToContentValues(cursor, values);
- cursor.close();
- }
-
- /**
- * Deletes a document, and its root information if the document is a root document.
- * @param selection Query to select documents.
- * @param args Arguments for selection.
- */
- private void deleteDocumentsAndRoots(String selection, String[] args) {
- mDatabase.beginTransaction();
- try {
- mDatabase.delete(
- TABLE_ROOT_EXTRA,
- Root.COLUMN_ROOT_ID + " IN (" + SQLiteQueryBuilder.buildQueryString(
- false,
- TABLE_DOCUMENTS,
- new String[] { Document.COLUMN_DOCUMENT_ID },
- selection,
- null,
- null,
- null,
- null) + ")",
- args);
- mDatabase.delete(TABLE_DOCUMENTS, selection, args);
- mDatabase.setTransactionSuccessful();
- } finally {
- mDatabase.endTransaction();
- }
- }
-
private String getRootDocumentsMappingStateKey(int deviceId) {
return "RootDocuments/" + deviceId;
}
private String getChildDocumentsMappingStateKey(String parentDocumentId) {
return "ChildDocuments/" + parentDocumentId;
}
-
- /**
- * Converts values into string array.
- * @param args Values converted into string array.
- * @return String array.
- */
- private static String[] strings(Object... args) {
- final String[] results = new String[args.length];
- for (int i = 0; i < args.length; i++) {
- results[i] = Objects.toString(args[i]);
- }
- return results;
- }
-
- /**
- * Gets SQL expression that represents the given value or NULL depends on the row state.
- * @param state Expected row state.
- * @param a SQL value.
- * @return Expression that represents a if the row state is expected one, and represents NULL
- * otherwise.
- */
- private static String createStateFilter(int state, String a) {
- return "CASE WHEN " + COLUMN_ROW_STATE + " = " + Integer.toString(state) +
- " THEN " + a + " ELSE NULL END";
- }
}
--- /dev/null
+/*
+ * Copyright (C) 2015 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.mtp;
+
+import android.provider.DocumentsContract.Document;
+import android.provider.DocumentsContract.Root;
+
+/**
+ * Class containing MtpDatabase constants.
+ */
+class MtpDatabaseConstants {
+ static final int DATABASE_VERSION = 1;
+ static final String DATABASE_NAME = null;
+
+ /**
+ * Table representing documents including root documents.
+ */
+ static final String TABLE_DOCUMENTS = "Documents";
+
+ /**
+ * Table containing additional information only available for root documents.
+ * The table uses same primary keys with corresponding documents.
+ */
+ static final String TABLE_ROOT_EXTRA = "RootExtra";
+
+ /**
+ * View to join Documents and RootExtra tables to provide roots information.
+ */
+ static final String VIEW_ROOTS = "Roots";
+
+ static final String COLUMN_DEVICE_ID = "device_id";
+ static final String COLUMN_STORAGE_ID = "storage_id";
+ static final String COLUMN_OBJECT_HANDLE = "object_handle";
+ static final String COLUMN_PARENT_DOCUMENT_ID = "parent_document_id";
+ static final String COLUMN_ROW_STATE = "row_state";
+
+ /**
+ * The state represents that the row has a valid object handle.
+ */
+ static final int ROW_STATE_VALID = 0;
+
+ /**
+ * The state represents that the rows added at the previous cycle and need to be updated with
+ * fresh values.
+ * The row may not have valid object handle. External application can still fetch the documents.
+ * If the external application tries to fetch object handle, the provider resolves pending
+ * documents with invalidated documents ahead.
+ */
+ static final int ROW_STATE_INVALIDATED = 1;
+
+ /**
+ * The state represents the raw has a valid object handle but it may be going to be mapped with
+ * another rows invalidated. After fetching all documents under the parent, the database tries
+ * to map the pending documents and the invalidated documents in order to keep old document ID
+ * alive.
+ */
+ static final int ROW_STATE_PENDING = 2;
+
+ /**
+ * Mapping mode that uses MTP identifier to find corresponding rows.
+ */
+ static final int MAP_BY_MTP_IDENTIFIER = 0;
+
+ /**
+ * Mapping mode that uses name to find corresponding rows.
+ */
+ static final int MAP_BY_NAME = 1;
+
+ static final String SELECTION_DOCUMENT_ID = Document.COLUMN_DOCUMENT_ID + " = ?";
+ static final String SELECTION_ROOT_ID = Root.COLUMN_ROOT_ID + " = ?";
+ static final String SELECTION_ROOT_DOCUMENTS =
+ COLUMN_DEVICE_ID + " = ? AND " + COLUMN_PARENT_DOCUMENT_ID + " IS NULL";
+ static final String SELECTION_CHILD_DOCUMENTS = COLUMN_PARENT_DOCUMENT_ID + " = ?";
+
+ static final String QUERY_CREATE_DOCUMENTS =
+ "CREATE TABLE " + TABLE_DOCUMENTS + " (" +
+ Document.COLUMN_DOCUMENT_ID +
+ " INTEGER PRIMARY KEY AUTOINCREMENT," +
+ COLUMN_DEVICE_ID + " INTEGER NOT NULL," +
+ COLUMN_STORAGE_ID + " INTEGER," +
+ COLUMN_OBJECT_HANDLE + " INTEGER," +
+ COLUMN_PARENT_DOCUMENT_ID + " INTEGER," +
+ COLUMN_ROW_STATE + " INTEGER NOT NULL," +
+ Document.COLUMN_MIME_TYPE + " TEXT," +
+ Document.COLUMN_DISPLAY_NAME + " TEXT NOT NULL," +
+ Document.COLUMN_SUMMARY + " TEXT," +
+ Document.COLUMN_LAST_MODIFIED + " INTEGER," +
+ Document.COLUMN_ICON + " INTEGER," +
+ Document.COLUMN_FLAGS + " INTEGER NOT NULL," +
+ Document.COLUMN_SIZE + " INTEGER NOT NULL);";
+
+ static final String QUERY_CREATE_ROOT_EXTRA =
+ "CREATE TABLE " + TABLE_ROOT_EXTRA + " (" +
+ Root.COLUMN_ROOT_ID + " INTEGER PRIMARY KEY," +
+ Root.COLUMN_FLAGS + " INTEGER NOT NULL," +
+ Root.COLUMN_AVAILABLE_BYTES + " INTEGER NOT NULL," +
+ Root.COLUMN_CAPACITY_BYTES + " INTEGER NOT NULL," +
+ Root.COLUMN_MIME_TYPES + " TEXT NOT NULL);";
+
+ /**
+ * Creates a view to join Documents table and RootExtra table on their primary keys to
+ * provide DocumentContract.Root equivalent information.
+ */
+ static final String QUERY_CREATE_VIEW_ROOTS =
+ "CREATE VIEW " + VIEW_ROOTS + " AS SELECT " +
+ TABLE_DOCUMENTS + "." + Document.COLUMN_DOCUMENT_ID + " AS " +
+ Root.COLUMN_ROOT_ID + "," +
+ TABLE_ROOT_EXTRA + "." + Root.COLUMN_FLAGS + "," +
+ TABLE_DOCUMENTS + "." + Document.COLUMN_ICON + " AS " +
+ Root.COLUMN_ICON + "," +
+ TABLE_DOCUMENTS + "." + Document.COLUMN_DISPLAY_NAME + " AS " +
+ Root.COLUMN_TITLE + "," +
+ TABLE_DOCUMENTS + "." + Document.COLUMN_SUMMARY + " AS " +
+ Root.COLUMN_SUMMARY + "," +
+ TABLE_DOCUMENTS + "." + Document.COLUMN_DOCUMENT_ID + " AS " +
+ Root.COLUMN_DOCUMENT_ID + "," +
+ TABLE_ROOT_EXTRA + "." + Root.COLUMN_AVAILABLE_BYTES + "," +
+ TABLE_ROOT_EXTRA + "." + Root.COLUMN_CAPACITY_BYTES + "," +
+ TABLE_ROOT_EXTRA + "." + Root.COLUMN_MIME_TYPES + "," +
+ TABLE_DOCUMENTS + "." + COLUMN_ROW_STATE +
+ " FROM " + TABLE_DOCUMENTS + " INNER JOIN " + TABLE_ROOT_EXTRA +
+ " ON " +
+ COLUMN_PARENT_DOCUMENT_ID + " IS NULL AND " +
+ TABLE_DOCUMENTS + "." + Document.COLUMN_DOCUMENT_ID +
+ "=" +
+ TABLE_ROOT_EXTRA + "." + Root.COLUMN_ROOT_ID;
+}
--- /dev/null
+/*
+ * Copyright (C) 2015 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.mtp;
+
+import static com.android.mtp.MtpDatabaseConstants.*;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.provider.DocumentsContract.Document;
+import android.provider.DocumentsContract.Root;
+
+import java.util.Objects;
+
+/**
+ * Class that provides operations processing SQLite database directly.
+ */
+class MtpDatabaseInternal {
+ private static class OpenHelper extends SQLiteOpenHelper {
+ public OpenHelper(Context context) {
+ super(context, DATABASE_NAME, null, DATABASE_VERSION);
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ db.execSQL(QUERY_CREATE_DOCUMENTS);
+ db.execSQL(QUERY_CREATE_ROOT_EXTRA);
+ db.execSQL(QUERY_CREATE_VIEW_ROOTS);
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ private final SQLiteDatabase mDatabase;
+
+ MtpDatabaseInternal(Context context) {
+ final OpenHelper helper = new OpenHelper(context);
+ mDatabase = helper.getWritableDatabase();
+ }
+
+ Cursor queryRoots(String[] columnNames) {
+ return mDatabase.query(
+ VIEW_ROOTS,
+ columnNames,
+ COLUMN_ROW_STATE + " IN (?, ?)",
+ strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED),
+ null,
+ null,
+ null);
+ }
+
+ Cursor queryRootDocuments(String[] columnNames) {
+ return mDatabase.query(
+ TABLE_DOCUMENTS,
+ columnNames,
+ COLUMN_ROW_STATE + " IN (?, ?)",
+ strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED),
+ null,
+ null,
+ null);
+ }
+
+ Cursor queryChildDocuments(String[] columnNames, String parentDocumentId) {
+ return mDatabase.query(
+ TABLE_DOCUMENTS,
+ columnNames,
+ COLUMN_ROW_STATE + " IN (?, ?) AND " + COLUMN_PARENT_DOCUMENT_ID + " = ?",
+ strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED, parentDocumentId),
+ null,
+ null,
+ null);
+ }
+
+ /**
+ * Starts adding new documents.
+ * The methods decides mapping mode depends on if all documents under the given parent have MTP
+ * identifier or not. If all the documents have MTP identifier, it uses the identifier to find
+ * a corresponding existing row. Otherwise it does heuristic.
+ *
+ * @param selection Query matches valid documents.
+ * @param arg Argument for selection.
+ * @return Mapping mode.
+ */
+ int startAddingDocuments(String selection, String arg) {
+ mDatabase.beginTransaction();
+ try {
+ // Delete all pending rows.
+ deleteDocumentsAndRoots(
+ selection + " AND " + COLUMN_ROW_STATE + "=?", strings(arg, ROW_STATE_PENDING));
+
+ // Set all documents as invalidated.
+ final ContentValues values = new ContentValues();
+ values.put(COLUMN_ROW_STATE, ROW_STATE_INVALIDATED);
+ mDatabase.update(TABLE_DOCUMENTS, values, selection, new String[] { arg });
+
+ // If we have rows that does not have MTP identifier, do heuristic mapping by name.
+ final boolean useNameForResolving = DatabaseUtils.queryNumEntries(
+ mDatabase,
+ TABLE_DOCUMENTS,
+ selection + " AND " + COLUMN_STORAGE_ID + " IS NULL",
+ new String[] { arg }) > 0;
+ mDatabase.setTransactionSuccessful();
+ return useNameForResolving ? MAP_BY_NAME : MAP_BY_MTP_IDENTIFIER;
+ } finally {
+ mDatabase.endTransaction();
+ }
+ }
+
+ /**
+ * Puts the documents into the database.
+ * If the mapping mode is not heuristic, it just adds the rows to the database or updates the
+ * existing rows with the new values. If the mapping mode is heuristic, it adds some new rows as
+ * 'pending' state when that rows may be corresponding to existing 'invalidated' rows. Then
+ * {@link #stopAddingDocuments(String, String, String)} turns the pending rows into 'valid'
+ * rows.
+ *
+ * @param valuesList Values that are stored in the database.
+ * @param selection SQL where closure to select rows that shares the same parent.
+ * @param arg Argument for selection SQL.
+ * @param heuristic Whether the mapping mode is heuristic.
+ * @return List of Document ID inserted to the table.
+ */
+ long[] putDocuments(
+ ContentValues[] valuesList,
+ String selection,
+ String arg,
+ boolean heuristic,
+ String mappingKey) {
+ mDatabase.beginTransaction();
+ try {
+ final long[] documentIds = new long[valuesList.length];
+ int i = 0;
+ for (final ContentValues values : valuesList) {
+ final Cursor candidateCursor = mDatabase.query(
+ TABLE_DOCUMENTS,
+ strings(Document.COLUMN_DOCUMENT_ID),
+ selection + " AND " +
+ COLUMN_ROW_STATE + "=? AND " +
+ mappingKey + "=?",
+ strings(arg, ROW_STATE_INVALIDATED, values.getAsString(mappingKey)),
+ null,
+ null,
+ null,
+ "1");
+ final long rowId;
+ if (candidateCursor.getCount() == 0) {
+ rowId = mDatabase.insert(TABLE_DOCUMENTS, null, values);
+ } else if (!heuristic) {
+ candidateCursor.moveToNext();
+ final String documentId = candidateCursor.getString(0);
+ rowId = mDatabase.update(
+ TABLE_DOCUMENTS, values, SELECTION_DOCUMENT_ID, strings(documentId));
+ } else {
+ values.put(COLUMN_ROW_STATE, ROW_STATE_PENDING);
+ rowId = mDatabase.insert(TABLE_DOCUMENTS, null, values);
+ }
+ // Document ID is a primary integer key of the table. So the returned row
+ // IDs should be same with the document ID.
+ documentIds[i++] = rowId;
+ candidateCursor.close();
+ }
+
+ mDatabase.setTransactionSuccessful();
+ return documentIds;
+ } finally {
+ mDatabase.endTransaction();
+ }
+ }
+
+ void putRootExtra(ContentValues values) {
+ mDatabase.replace(TABLE_ROOT_EXTRA, null, values);
+ }
+
+ /**
+ * Maps 'pending' document and 'invalidated' document that shares the same column of groupKey.
+ * If the database does not find corresponding 'invalidated' document, it just removes
+ * 'invalidated' document from the database.
+ * @param selection Query to select rows for resolving.
+ * @param arg Argument for selection SQL.
+ * @param groupKey Column name used to find corresponding rows.
+ */
+ void stopAddingDocuments(String selection, String arg, String groupKey) {
+ mDatabase.beginTransaction();
+ try {
+ // Get 1-to-1 mapping of invalidated document and pending document.
+ final String invalidatedIdQuery = createStateFilter(
+ ROW_STATE_INVALIDATED, Document.COLUMN_DOCUMENT_ID);
+ final String pendingIdQuery = createStateFilter(
+ ROW_STATE_PENDING, Document.COLUMN_DOCUMENT_ID);
+ // SQL should be like:
+ // SELECT group_concat(CASE WHEN raw_state = 1 THEN document_id ELSE NULL END),
+ // group_concat(CASE WHEN raw_state = 2 THEN document_id ELSE NULL END)
+ // WHERE device_id = ? AND parent_document_id IS NULL
+ // GROUP BY display_name
+ // HAVING count(CASE WHEN raw_state = 1 THEN document_id ELSE NULL END) = 1 AND
+ // count(CASE WHEN raw_state = 2 THEN document_id ELSE NULL END) = 1
+ final Cursor mergingCursor = mDatabase.query(
+ TABLE_DOCUMENTS,
+ new String[] {
+ "group_concat(" + invalidatedIdQuery + ")",
+ "group_concat(" + pendingIdQuery + ")"
+ },
+ selection,
+ strings(arg),
+ groupKey,
+ "count(" + invalidatedIdQuery + ") = 1 AND count(" + pendingIdQuery + ") = 1",
+ null);
+
+ final ContentValues values = new ContentValues();
+ while (mergingCursor.moveToNext()) {
+ final String invalidatedId = mergingCursor.getString(0);
+ final String pendingId = mergingCursor.getString(1);
+
+ // Obtain the new values including the latest object handle from mapping row.
+ getFirstRow(
+ TABLE_DOCUMENTS,
+ SELECTION_DOCUMENT_ID,
+ new String[] { pendingId },
+ values);
+ values.remove(Document.COLUMN_DOCUMENT_ID);
+ values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
+ mDatabase.update(
+ TABLE_DOCUMENTS,
+ values,
+ SELECTION_DOCUMENT_ID,
+ new String[] { invalidatedId });
+
+ getFirstRow(
+ TABLE_ROOT_EXTRA,
+ SELECTION_ROOT_ID,
+ new String[] { pendingId },
+ values);
+ if (values.size() > 0) {
+ values.remove(Root.COLUMN_ROOT_ID);
+ mDatabase.update(
+ TABLE_ROOT_EXTRA,
+ values,
+ SELECTION_ROOT_ID,
+ new String[] { invalidatedId });
+ }
+
+ // Delete 'pending' row.
+ deleteDocumentsAndRoots(SELECTION_DOCUMENT_ID, new String[] { pendingId });
+ }
+ mergingCursor.close();
+
+ // Delete all invalidated rows that cannot be mapped.
+ deleteDocumentsAndRoots(
+ COLUMN_ROW_STATE + " = ? AND " + selection,
+ strings(ROW_STATE_INVALIDATED, arg));
+
+ // The database cannot find old document ID for the pending rows.
+ // Turn the all pending rows into valid state, which means the rows become to be
+ // valid with new document ID.
+ values.clear();
+ values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
+ mDatabase.update(
+ TABLE_DOCUMENTS,
+ values,
+ COLUMN_ROW_STATE + " = ? AND " + selection,
+ strings(ROW_STATE_PENDING, arg));
+ mDatabase.setTransactionSuccessful();
+ } finally {
+ mDatabase.endTransaction();
+ }
+ }
+
+ /**
+ * Clears MTP related identifier.
+ * It clears MTP's object handle and storage ID that are not stable over MTP sessions and mark
+ * the all documents as 'invalidated'. It also remove 'pending' rows as adding is cancelled
+ * now.
+ */
+ void clearMapping() {
+ mDatabase.beginTransaction();
+ try {
+ deleteDocumentsAndRoots(COLUMN_ROW_STATE + " = ?", strings(ROW_STATE_PENDING));
+ final ContentValues values = new ContentValues();
+ values.putNull(COLUMN_OBJECT_HANDLE);
+ values.putNull(COLUMN_STORAGE_ID);
+ values.put(COLUMN_ROW_STATE, ROW_STATE_INVALIDATED);
+ mDatabase.update(TABLE_DOCUMENTS, values, null, null);
+ mDatabase.setTransactionSuccessful();
+ } finally {
+ mDatabase.endTransaction();
+ }
+ }
+
+ void beginTransaction() {
+ mDatabase.beginTransaction();
+ }
+
+ void setTransactionSuccessful() {
+ mDatabase.setTransactionSuccessful();
+ }
+
+ void endTransaction() {
+ mDatabase.endTransaction();
+ }
+
+ /**
+ * Deletes a document, and its root information if the document is a root document.
+ * @param selection Query to select documents.
+ * @param args Arguments for selection.
+ */
+ private void deleteDocumentsAndRoots(String selection, String[] args) {
+ mDatabase.beginTransaction();
+ try {
+ mDatabase.delete(
+ TABLE_ROOT_EXTRA,
+ Root.COLUMN_ROOT_ID + " IN (" + SQLiteQueryBuilder.buildQueryString(
+ false,
+ TABLE_DOCUMENTS,
+ new String[] { Document.COLUMN_DOCUMENT_ID },
+ selection,
+ null,
+ null,
+ null,
+ null) + ")",
+ args);
+ mDatabase.delete(TABLE_DOCUMENTS, selection, args);
+ mDatabase.setTransactionSuccessful();
+ } finally {
+ mDatabase.endTransaction();
+ }
+ }
+
+ /**
+ * Obtains values of the first row for the query.
+ * @param values ContentValues that the values are stored to.
+ * @param table Target table.
+ * @param selection Query to select rows.
+ * @param args Argument for query.
+ */
+ private void getFirstRow(String table, String selection, String[] args, ContentValues values) {
+ values.clear();
+ final Cursor cursor = mDatabase.query(table, null, selection, args, null, null, null, "1");
+ if (cursor.getCount() == 0) {
+ return;
+ }
+ cursor.moveToNext();
+ DatabaseUtils.cursorRowToContentValues(cursor, values);
+ cursor.close();
+ }
+
+ /**
+ * Gets SQL expression that represents the given value or NULL depends on the row state.
+ * @param state Expected row state.
+ * @param a SQL value.
+ * @return Expression that represents a if the row state is expected one, and represents NULL
+ * otherwise.
+ */
+ private static String createStateFilter(int state, String a) {
+ return "CASE WHEN " + COLUMN_ROW_STATE + " = " + Integer.toString(state) +
+ " THEN " + a + " ELSE NULL END";
+ }
+
+ /**
+ * Converts values into string array.
+ * @param args Values converted into string array.
+ * @return String array.
+ */
+ private static String[] strings(Object... args) {
+ final String[] results = new String[args.length];
+ for (int i = 0; i < args.length; i++) {
+ results[i] = Objects.toString(args[i]);
+ }
+ return results;
+ }
+}
import android.provider.DocumentsContract.Root;
import android.test.AndroidTestCase;
import android.test.suitebuilder.annotation.SmallTest;
-import android.util.Log;
@SmallTest
public class MtpDatabaseTest extends AndroidTestCase {
private final String[] COLUMN_NAMES = new String[] {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
- MtpDatabase.COLUMN_DEVICE_ID,
- MtpDatabase.COLUMN_STORAGE_ID,
- MtpDatabase.COLUMN_OBJECT_HANDLE,
+ MtpDatabaseConstants.COLUMN_DEVICE_ID,
+ MtpDatabaseConstants.COLUMN_STORAGE_ID,
+ MtpDatabaseConstants.COLUMN_OBJECT_HANDLE,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_SUMMARY,
private final TestResources resources = new TestResources();
- @Override
- public void tearDown() {
- MtpDatabase.deleteDatabase(getContext());
- }
-
public void testPutRootDocuments() throws Exception {
final MtpDatabase database = new MtpDatabase(getContext());
database.startAddingRootDocuments(0);
final MtpDatabase database = new MtpDatabase(getContext());
final String[] columns = new String[] {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
- MtpDatabase.COLUMN_STORAGE_ID,
+ MtpDatabaseConstants.COLUMN_STORAGE_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME
};
final String[] rootColumns = new String[] {
final MtpDatabase database = new MtpDatabase(getContext());
final String[] columns = new String[] {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
- MtpDatabase.COLUMN_OBJECT_HANDLE,
+ MtpDatabaseConstants.COLUMN_OBJECT_HANDLE,
DocumentsContract.Document.COLUMN_DISPLAY_NAME
};
database.startAddingChildDocuments("parentId");
final MtpDatabase database = new MtpDatabase(getContext());
final String[] columns = new String[] {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
- MtpDatabase.COLUMN_STORAGE_ID,
+ MtpDatabaseConstants.COLUMN_STORAGE_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME
};
final String[] rootColumns = new String[] {
final MtpDatabase database = new MtpDatabase(getContext());
final String[] columns = new String[] {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
- MtpDatabase.COLUMN_OBJECT_HANDLE
+ MtpDatabaseConstants.COLUMN_OBJECT_HANDLE
};
database.startAddingChildDocuments("parentId1");
final MtpDatabase database = new MtpDatabase(getContext());
final String[] columns = new String[] {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
- MtpDatabase.COLUMN_STORAGE_ID,
+ MtpDatabaseConstants.COLUMN_STORAGE_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME
};
final String[] rootColumns = new String[] {
final MtpDatabase database = new MtpDatabase(getContext());
final String[] columns = new String[] {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
- MtpDatabase.COLUMN_STORAGE_ID,
+ MtpDatabaseConstants.COLUMN_STORAGE_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME
};
final String[] rootColumns = new String[] {
{
final String[] columns = new String[] {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
- MtpDatabase.COLUMN_STORAGE_ID,
+ MtpDatabaseConstants.COLUMN_STORAGE_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME
};
final Cursor cursor = database.queryRootDocuments(columns);