blob: 0e91fddd79f4501d555a875f18a7d4ac85d8631b [file] [log] [blame]
/*
* 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.content.res.Resources;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteQueryBuilder;
import android.media.MediaFile;
import android.mtp.MtpConstants;
import android.mtp.MtpObjectInfo;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Document;
import android.provider.DocumentsContract.Root;
import com.android.internal.annotations.VisibleForTesting;
import java.io.FileNotFoundException;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
/**
* Database for MTP objects.
* The object handle which is identifier for object in MTP protocol is not stable over sessions.
* When we resume the process, we need to remap our document ID with MTP's object handle.
*
* If the remote MTP device is backed by typical file system, the file name
* is unique among files in a directory. However, MTP protocol itself does
* not guarantee the uniqueness of name so we cannot use fullpath as ID.
*
* Instead of fullpath, we use artificial ID generated by MtpDatabase itself. The database object
* remembers the map of document ID and object handle, and remaps new object handle with document ID
* by comparing the directory structure and object name.
*
* To start putting documents into the database, the client needs to call
* {@link Mapper#startAddingChildDocuments(String)} with the parent document ID. Also it
* needs to call {@link Mapper#stopAddingChildDocuments(String)} after putting all child
* documents to the database. (All explanations are same for root documents)
*
* database.getMapper().startAddingChildDocuments();
* database.getMapper().putChildDocuments();
* database.getMapper().stopAddingChildDocuments();
*
* To update the existing documents, the client code can repeat to call the three methods again.
* The newly added rows update corresponding existing rows that have same MTP identifier like
* objectHandle.
*
* The client can call putChildDocuments multiple times to add documents by chunk, but it needs to
* put all documents under the parent before calling stopAddingChildDocuments. Otherwise missing
* documents are regarded as deleted, and will be removed from the database.
*
* If the client calls clearMtpIdentifier(), it clears MTP identifier in the database. In this case,
* the database tries to find corresponding rows by using document's name instead of MTP identifier
* at the next update cycle.
*
* TODO: Improve performance by SQL optimization.
*/
class MtpDatabase {
private final SQLiteDatabase mDatabase;
private final Mapper mMapper;
SQLiteDatabase getSQLiteDatabase() {
return mDatabase;
}
MtpDatabase(Context context, int flags) {
final OpenHelper helper = new OpenHelper(context, flags);
mDatabase = helper.getWritableDatabase();
mMapper = new Mapper(this);
}
void close() {
mDatabase.close();
}
/**
* Returns operations for mapping.
* @return Mapping operations.
*/
Mapper getMapper() {
return mMapper;
}
/**
* Queries roots information.
* @param columnNames Column names defined in {@link android.provider.DocumentsContract.Root}.
* @return Database cursor.
*/
Cursor queryRoots(String[] columnNames) {
return mDatabase.query(
VIEW_ROOTS,
columnNames,
COLUMN_ROW_STATE + " IN (?, ?)",
strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED),
null,
null,
null);
}
/**
* Queries root documents information.
* @param columnNames Column names defined in
* {@link android.provider.DocumentsContract.Document}.
* @return Database cursor.
*/
@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);
}
/**
* Queries documents information.
* @param columnNames Column names defined in
* {@link android.provider.DocumentsContract.Document}.
* @return Database cursor.
*/
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);
}
/**
* Queries a single document.
* @param documentId
* @param projection
* @return Database cursor.
*/
public Cursor queryDocument(String documentId, String[] projection) {
return mDatabase.query(
TABLE_DOCUMENTS,
projection,
SELECTION_DOCUMENT_ID,
strings(documentId),
null,
null,
null,
"1");
}
/**
* Remove all rows belong to a device.
* @param deviceId Device ID.
*/
void removeDeviceRows(int deviceId) {
// Call non-recursive version because it anyway deletes all rows in the devices.
deleteDocumentsAndRoots(COLUMN_DEVICE_ID + "=?", strings(deviceId));
}
/**
* Obtains parent document ID.
* @param documentId
* @return parent document ID.
* @throws FileNotFoundException
*/
String getParentId(String documentId) throws FileNotFoundException {
final Cursor cursor = mDatabase.query(
TABLE_DOCUMENTS,
strings(COLUMN_PARENT_DOCUMENT_ID),
SELECTION_DOCUMENT_ID,
strings(documentId),
null,
null,
null,
"1");
try {
if (cursor.moveToNext()) {
return cursor.getString(0);
} else {
throw new FileNotFoundException("Cannot find a row having ID=" + documentId);
}
} finally {
cursor.close();
}
}
/**
* Adds new document under the parent.
* The method does not affect invalidated and pending documents because we know the document is
* newly added and never mapped with existing ones.
* @param parentDocumentId
* @param info
* @return Document ID of added document.
*/
String putNewDocument(int deviceId, String parentDocumentId, MtpObjectInfo info) {
final ContentValues values = new ContentValues();
getChildDocumentValues(values, deviceId, parentDocumentId, info);
mDatabase.beginTransaction();
try {
final long id = mDatabase.insert(TABLE_DOCUMENTS, null, values);
mDatabase.setTransactionSuccessful();
return Long.toString(id);
} finally {
mDatabase.endTransaction();
}
}
/**
* Deletes document and its children.
* @param documentId
*/
void deleteDocument(String documentId) {
deleteDocumentsAndRootsRecursively(SELECTION_DOCUMENT_ID, strings(documentId));
}
/**
* Gets identifier from document ID.
* @param documentId Document ID.
* @return Identifier.
* @throws FileNotFoundException
*/
Identifier createIdentifier(String documentId) throws FileNotFoundException {
// Currently documentId is old format.
final Cursor cursor = mDatabase.query(
TABLE_DOCUMENTS,
strings(COLUMN_DEVICE_ID, COLUMN_STORAGE_ID, COLUMN_OBJECT_HANDLE),
SELECTION_DOCUMENT_ID,
strings(documentId),
null,
null,
null,
"1");
try {
if (cursor.getCount() == 0) {
throw new FileNotFoundException("ID is not found.");
} else {
cursor.moveToNext();
return new Identifier(
cursor.getInt(0),
cursor.getInt(1),
cursor.isNull(2) ? Identifier.DUMMY_HANDLE_FOR_ROOT : cursor.getInt(2),
documentId);
}
} finally {
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.
* @return Whether the method deletes rows.
*/
boolean deleteDocumentsAndRootsRecursively(String selection, String[] args) {
mDatabase.beginTransaction();
try {
boolean changed = false;
final Cursor cursor = mDatabase.query(
TABLE_DOCUMENTS,
strings(Document.COLUMN_DOCUMENT_ID),
selection,
args,
null,
null,
null);
try {
while (cursor.moveToNext()) {
if (deleteDocumentsAndRootsRecursively(
COLUMN_PARENT_DOCUMENT_ID + "=?",
strings(cursor.getString(0)))) {
changed = true;
}
}
} finally {
cursor.close();
}
if (deleteDocumentsAndRoots(selection, args)) {
changed = true;
}
mDatabase.setTransactionSuccessful();
return changed;
} finally {
mDatabase.endTransaction();
}
}
/**
* Returns the set of device ID stored in the database.
*/
int[] getDeviceIds() {
final Cursor cursor = mDatabase.query(
true,
TABLE_DOCUMENTS,
strings(COLUMN_DEVICE_ID),
null,
null,
null,
null,
null,
null);
try {
final int[] ids = new int[cursor.getCount()];
for (int i = 0; i < ids.length; i++) {
cursor.moveToNext();
ids[i] = cursor.getInt(0);
}
return ids;
} finally {
cursor.close();
}
}
private boolean deleteDocumentsAndRoots(String selection, String[] args) {
mDatabase.beginTransaction();
try {
int deleted = 0;
deleted += 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);
deleted += mDatabase.delete(TABLE_DOCUMENTS, selection, args);
mDatabase.setTransactionSuccessful();
// TODO Remove mappingState.
return deleted != 0;
} finally {
mDatabase.endTransaction();
}
}
private static class OpenHelper extends SQLiteOpenHelper {
public OpenHelper(Context context, int flags) {
super(context,
flags == FLAG_DATABASE_IN_MEMORY ? null : 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();
}
}
@VisibleForTesting
static void deleteDatabase(Context context) {
context.deleteDatabase(DATABASE_NAME);
}
/**
* Gets {@link ContentValues} for the given root.
* @param values {@link ContentValues} that receives values.
* @param resources Resources used to get localized root name.
* @param root Root to be converted {@link ContentValues}.
*/
static void getRootDocumentValues(ContentValues values, Resources resources, MtpRoot root) {
values.clear();
values.put(COLUMN_DEVICE_ID, root.mDeviceId);
values.put(COLUMN_STORAGE_ID, root.mStorageId);
values.putNull(COLUMN_OBJECT_HANDLE);
values.putNull(COLUMN_PARENT_DOCUMENT_ID);
values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
values.put(COLUMN_DOCUMENT_TYPE, DOCUMENT_TYPE_STORAGE);
values.put(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
values.put(Document.COLUMN_DISPLAY_NAME, root.getRootName(resources));
values.putNull(Document.COLUMN_SUMMARY);
values.putNull(Document.COLUMN_LAST_MODIFIED);
values.put(Document.COLUMN_ICON, R.drawable.ic_root_mtp);
values.put(Document.COLUMN_FLAGS, 0);
values.put(Document.COLUMN_SIZE,
(int) Math.min(root.mMaxCapacity - root.mFreeSpace, Integer.MAX_VALUE));
}
/**
* Gets {@link ContentValues} for the given MTP object.
* @param values {@link ContentValues} that receives values.
* @param deviceId Device ID of the object.
* @param parentId Parent document ID of the object.
* @param info MTP object info.
*/
static void getChildDocumentValues(
ContentValues values, int deviceId, String parentId, MtpObjectInfo info) {
values.clear();
final String mimeType = info.getFormat() == MtpConstants.FORMAT_ASSOCIATION ?
DocumentsContract.Document.MIME_TYPE_DIR :
MediaFile.getMimeTypeForFormatCode(info.getFormat());
int flag = 0;
if (info.getProtectionStatus() == 0) {
flag |= Document.FLAG_SUPPORTS_DELETE |
Document.FLAG_SUPPORTS_WRITE;
if (mimeType == Document.MIME_TYPE_DIR) {
flag |= Document.FLAG_DIR_SUPPORTS_CREATE;
}
}
if (info.getThumbCompressedSize() > 0) {
flag |= Document.FLAG_SUPPORTS_THUMBNAIL;
}
values.put(COLUMN_DEVICE_ID, deviceId);
values.put(COLUMN_STORAGE_ID, info.getStorageId());
values.put(COLUMN_OBJECT_HANDLE, info.getObjectHandle());
values.put(COLUMN_PARENT_DOCUMENT_ID, parentId);
values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
values.put(COLUMN_DOCUMENT_TYPE, DOCUMENT_TYPE_OBJECT);
values.put(Document.COLUMN_MIME_TYPE, mimeType);
values.put(Document.COLUMN_DISPLAY_NAME, info.getName());
values.putNull(Document.COLUMN_SUMMARY);
values.put(
Document.COLUMN_LAST_MODIFIED,
info.getDateModified() != 0 ? info.getDateModified() : null);
values.putNull(Document.COLUMN_ICON);
values.put(Document.COLUMN_FLAGS, flag);
values.put(Document.COLUMN_SIZE, info.getCompressedSize());
}
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;
}
}