| /* |
| * 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.annotation.Nullable; |
| 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.Objects; |
| |
| /** |
| * 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#startAddingDocuments(String)} with the parent document ID. Also it |
| * needs to call {@link Mapper#stopAddingDocuments(String)} after putting all child |
| * documents to the database. (All explanations are same for root documents) |
| * |
| * database.getMapper().startAddingDocuments(); |
| * database.getMapper().putChildDocuments(); |
| * database.getMapper().stopAddingDocuments(); |
| * |
| * 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) { |
| final SQLiteQueryBuilder builder = new SQLiteQueryBuilder(); |
| builder.setTables(JOIN_ROOTS); |
| builder.setProjectionMap(COLUMN_MAP_ROOTS); |
| return builder.query( |
| mDatabase, |
| columnNames, |
| COLUMN_ROW_STATE + " IN (?, ?) AND " + COLUMN_DOCUMENT_TYPE + " = ?", |
| strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED, DOCUMENT_TYPE_STORAGE), |
| 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 (?, ?) AND " + COLUMN_DOCUMENT_TYPE + "=?", |
| strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED, DOCUMENT_TYPE_STORAGE), |
| 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)); |
| } |
| |
| @Nullable String getDocumentIdForDevice(int deviceId) { |
| final Cursor cursor = mDatabase.query( |
| TABLE_DOCUMENTS, |
| strings(Document.COLUMN_DOCUMENT_ID), |
| COLUMN_DOCUMENT_TYPE + " = ? AND " + COLUMN_DEVICE_ID + " = ?", |
| strings(DOCUMENT_TYPE_DEVICE, deviceId), |
| null, |
| null, |
| null, |
| "1"); |
| try { |
| if (cursor.moveToNext()) { |
| return cursor.getString(0); |
| } else { |
| return null; |
| } |
| } finally { |
| cursor.close(); |
| } |
| } |
| |
| /** |
| * 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(); |
| getObjectDocumentValues(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(); |
| } |
| } |
| |
| 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); |
| } |
| |
| @Override |
| public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { |
| throw new UnsupportedOperationException(); |
| } |
| } |
| |
| @VisibleForTesting |
| static void deleteDatabase(Context context) { |
| context.deleteDatabase(DATABASE_NAME); |
| } |
| |
| static void getDeviceDocumentValues(ContentValues values, MtpDeviceRecord device) { |
| values.clear(); |
| values.put(COLUMN_DEVICE_ID, device.deviceId); |
| values.putNull(COLUMN_STORAGE_ID); |
| 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_DEVICE); |
| values.put(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR); |
| values.put(Document.COLUMN_DISPLAY_NAME, device.name); |
| 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); |
| long size = 0; |
| for (final MtpRoot root : device.roots) { |
| size += root.mMaxCapacity - root.mFreeSpace; |
| } |
| values.put(Document.COLUMN_SIZE, size); |
| } |
| |
| /** |
| * 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 getStorageDocumentValues( |
| ContentValues values, Resources resources, String parentDocumentId, MtpRoot root) { |
| values.clear(); |
| values.put(COLUMN_DEVICE_ID, root.mDeviceId); |
| values.put(COLUMN_STORAGE_ID, root.mStorageId); |
| values.putNull(COLUMN_OBJECT_HANDLE); |
| values.put(COLUMN_PARENT_DOCUMENT_ID, parentDocumentId); |
| 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 getObjectDocumentValues( |
| 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; |
| } |
| } |