Home | History | Annotate | Download | only in mtp
      1 /*
      2  * Copyright (C) 2015 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.mtp;
     18 
     19 import static com.android.mtp.MtpDatabaseConstants.*;
     20 
     21 import android.annotation.Nullable;
     22 import android.content.ContentValues;
     23 import android.content.Context;
     24 import android.content.res.Resources;
     25 import android.database.Cursor;
     26 import android.database.DatabaseUtils;
     27 import android.database.MatrixCursor;
     28 import android.database.MatrixCursor.RowBuilder;
     29 import android.database.sqlite.SQLiteDatabase;
     30 import android.database.sqlite.SQLiteOpenHelper;
     31 import android.database.sqlite.SQLiteQueryBuilder;
     32 import android.media.MediaFile;
     33 import android.mtp.MtpConstants;
     34 import android.mtp.MtpObjectInfo;
     35 import android.net.Uri;
     36 import android.provider.DocumentsContract;
     37 import android.provider.MetadataReader;
     38 import android.provider.DocumentsContract.Document;
     39 import android.provider.DocumentsContract.Root;
     40 
     41 import com.android.internal.annotations.VisibleForTesting;
     42 import com.android.internal.util.Preconditions;
     43 
     44 import java.io.FileNotFoundException;
     45 import java.util.HashSet;
     46 import java.util.Objects;
     47 import java.util.Set;
     48 
     49 /**
     50  * Database for MTP objects.
     51  * The object handle which is identifier for object in MTP protocol is not stable over sessions.
     52  * When we resume the process, we need to remap our document ID with MTP's object handle.
     53  *
     54  * If the remote MTP device is backed by typical file system, the file name
     55  * is unique among files in a directory. However, MTP protocol itself does
     56  * not guarantee the uniqueness of name so we cannot use fullpath as ID.
     57  *
     58  * Instead of fullpath, we use artificial ID generated by MtpDatabase itself. The database object
     59  * remembers the map of document ID and object handle, and remaps new object handle with document ID
     60  * by comparing the directory structure and object name.
     61  *
     62  * To start putting documents into the database, the client needs to call
     63  * {@link Mapper#startAddingDocuments(String)} with the parent document ID. Also it
     64  * needs to call {@link Mapper#stopAddingDocuments(String)} after putting all child
     65  * documents to the database. (All explanations are same for root documents)
     66  *
     67  * database.getMapper().startAddingDocuments();
     68  * database.getMapper().putChildDocuments();
     69  * database.getMapper().stopAddingDocuments();
     70  *
     71  * To update the existing documents, the client code can repeat to call the three methods again.
     72  * The newly added rows update corresponding existing rows that have same MTP identifier like
     73  * objectHandle.
     74  *
     75  * The client can call putChildDocuments multiple times to add documents by chunk, but it needs to
     76  * put all documents under the parent before calling stopAddingChildDocuments. Otherwise missing
     77  * documents are regarded as deleted, and will be removed from the database.
     78  *
     79  * If the client calls clearMtpIdentifier(), it clears MTP identifier in the database. In this case,
     80  * the database tries to find corresponding rows by using document's name instead of MTP identifier
     81  * at the next update cycle.
     82  *
     83  * TODO: Improve performance by SQL optimization.
     84  */
     85 class MtpDatabase {
     86     private final SQLiteDatabase mDatabase;
     87     private final Mapper mMapper;
     88 
     89     SQLiteDatabase getSQLiteDatabase() {
     90         return mDatabase;
     91     }
     92 
     93     MtpDatabase(Context context, int flags) {
     94         final OpenHelper helper = new OpenHelper(context, flags);
     95         mDatabase = helper.getWritableDatabase();
     96         mMapper = new Mapper(this);
     97     }
     98 
     99     void close() {
    100         mDatabase.close();
    101     }
    102 
    103     /**
    104      * Returns operations for mapping.
    105      * @return Mapping operations.
    106      */
    107     Mapper getMapper() {
    108         return mMapper;
    109     }
    110 
    111     /**
    112      * Queries roots information.
    113      * @param columnNames Column names defined in {@link android.provider.DocumentsContract.Root}.
    114      * @return Database cursor.
    115      */
    116     Cursor queryRoots(Resources resources, String[] columnNames) {
    117         final String selection =
    118                 COLUMN_ROW_STATE + " IN (?, ?) AND " + COLUMN_DOCUMENT_TYPE + " = ?";
    119         final Cursor deviceCursor = mDatabase.query(
    120                 TABLE_DOCUMENTS,
    121                 strings(COLUMN_DEVICE_ID),
    122                 selection,
    123                 strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED, DOCUMENT_TYPE_DEVICE),
    124                 COLUMN_DEVICE_ID,
    125                 null,
    126                 null,
    127                 null);
    128 
    129         try {
    130             final SQLiteQueryBuilder builder = new SQLiteQueryBuilder();
    131             builder.setTables(JOIN_ROOTS);
    132             builder.setProjectionMap(COLUMN_MAP_ROOTS);
    133             final MatrixCursor result = new MatrixCursor(columnNames);
    134             final ContentValues values = new ContentValues();
    135 
    136             while (deviceCursor.moveToNext()) {
    137                 final int deviceId = deviceCursor.getInt(0);
    138                 final Cursor storageCursor = builder.query(
    139                         mDatabase,
    140                         columnNames,
    141                         selection + " AND " + COLUMN_DEVICE_ID + " = ?",
    142                         strings(ROW_STATE_VALID,
    143                                 ROW_STATE_INVALIDATED,
    144                                 DOCUMENT_TYPE_STORAGE,
    145                                 deviceId),
    146                         null,
    147                         null,
    148                         null);
    149                 try {
    150                     values.clear();
    151                     try (final Cursor deviceRoot = builder.query(
    152                             mDatabase,
    153                             columnNames,
    154                             selection + " AND " + COLUMN_DEVICE_ID + " = ?",
    155                             strings(ROW_STATE_VALID,
    156                                     ROW_STATE_INVALIDATED,
    157                                     DOCUMENT_TYPE_DEVICE,
    158                                     deviceId),
    159                             null,
    160                             null,
    161                             null)) {
    162                         deviceRoot.moveToNext();
    163                         DatabaseUtils.cursorRowToContentValues(deviceRoot, values);
    164                     }
    165 
    166                     if (storageCursor.getCount() != 0) {
    167                         long capacityBytes = 0;
    168                         long availableBytes = 0;
    169                         final int capacityIndex =
    170                                 storageCursor.getColumnIndex(Root.COLUMN_CAPACITY_BYTES);
    171                         final int availableIndex =
    172                                 storageCursor.getColumnIndex(Root.COLUMN_AVAILABLE_BYTES);
    173                         while (storageCursor.moveToNext()) {
    174                             // If requested columnNames does not include COLUMN_XXX_BYTES, we
    175                             // don't calculate corresponding values.
    176                             if (capacityIndex != -1) {
    177                                 capacityBytes += storageCursor.getLong(capacityIndex);
    178                             }
    179                             if (availableIndex != -1) {
    180                                 availableBytes += storageCursor.getLong(availableIndex);
    181                             }
    182                         }
    183                         values.put(Root.COLUMN_CAPACITY_BYTES, capacityBytes);
    184                         values.put(Root.COLUMN_AVAILABLE_BYTES, availableBytes);
    185                     } else {
    186                         values.putNull(Root.COLUMN_CAPACITY_BYTES);
    187                         values.putNull(Root.COLUMN_AVAILABLE_BYTES);
    188                     }
    189                     if (storageCursor.getCount() == 1 && values.containsKey(Root.COLUMN_TITLE)) {
    190                         storageCursor.moveToFirst();
    191                         // Add storage name to device name if we have only 1 storage.
    192                         values.put(
    193                                 Root.COLUMN_TITLE,
    194                                 resources.getString(
    195                                         R.string.root_name,
    196                                         values.getAsString(Root.COLUMN_TITLE),
    197                                         storageCursor.getString(
    198                                                 storageCursor.getColumnIndex(Root.COLUMN_TITLE))));
    199                     }
    200                 } finally {
    201                     storageCursor.close();
    202                 }
    203 
    204                 putValuesToCursor(values, result);
    205             }
    206 
    207             return result;
    208         } finally {
    209             deviceCursor.close();
    210         }
    211     }
    212 
    213     /**
    214      * Queries root documents information.
    215      * @param columnNames Column names defined in
    216      *     {@link android.provider.DocumentsContract.Document}.
    217      * @return Database cursor.
    218      */
    219     @VisibleForTesting
    220     Cursor queryRootDocuments(String[] columnNames) {
    221         return mDatabase.query(
    222                 TABLE_DOCUMENTS,
    223                 columnNames,
    224                 COLUMN_ROW_STATE + " IN (?, ?) AND " + COLUMN_DOCUMENT_TYPE + " = ?",
    225                 strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED, DOCUMENT_TYPE_STORAGE),
    226                 null,
    227                 null,
    228                 null);
    229     }
    230 
    231     /**
    232      * Queries documents information.
    233      * @param columnNames Column names defined in
    234      *     {@link android.provider.DocumentsContract.Document}.
    235      * @return Database cursor.
    236      */
    237     Cursor queryChildDocuments(String[] columnNames, String parentDocumentId) {
    238         return mDatabase.query(
    239                 TABLE_DOCUMENTS,
    240                 columnNames,
    241                 COLUMN_ROW_STATE + " IN (?, ?) AND " + COLUMN_PARENT_DOCUMENT_ID + " = ?",
    242                 strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED, parentDocumentId),
    243                 null,
    244                 null,
    245                 null);
    246     }
    247 
    248     /**
    249      * Returns document IDs of storages under the given device document.
    250      *
    251      * @param documentId Document ID that points a device.
    252      * @return Storage document IDs.
    253      * @throws FileNotFoundException The given document ID is not registered in database.
    254      */
    255     String[] getStorageDocumentIds(String documentId)
    256             throws FileNotFoundException {
    257         Preconditions.checkArgument(createIdentifier(documentId).mDocumentType ==
    258                 DOCUMENT_TYPE_DEVICE);
    259         // Check if the parent document is device that has single storage.
    260         try (final Cursor cursor = mDatabase.query(
    261                 TABLE_DOCUMENTS,
    262                 strings(Document.COLUMN_DOCUMENT_ID),
    263                 COLUMN_ROW_STATE + " IN (?, ?) AND " +
    264                 COLUMN_PARENT_DOCUMENT_ID + " = ? AND " +
    265                 COLUMN_DOCUMENT_TYPE + " = ?",
    266                 strings(ROW_STATE_VALID,
    267                         ROW_STATE_INVALIDATED,
    268                         documentId,
    269                         DOCUMENT_TYPE_STORAGE),
    270                 null,
    271                 null,
    272                 null)) {
    273             final String[] ids = new String[cursor.getCount()];
    274             for (int i = 0; cursor.moveToNext(); i++) {
    275                 ids[i] = cursor.getString(0);
    276             }
    277             return ids;
    278         }
    279     }
    280 
    281     /**
    282      * Queries a single document.
    283      * @param documentId
    284      * @param projection
    285      * @return Database cursor.
    286      */
    287     Cursor queryDocument(String documentId, String[] projection) {
    288         return mDatabase.query(
    289                 TABLE_DOCUMENTS,
    290                 projection,
    291                 SELECTION_DOCUMENT_ID,
    292                 strings(documentId),
    293                 null,
    294                 null,
    295                 null,
    296                 "1");
    297     }
    298 
    299     @Nullable String getDocumentIdForDevice(int deviceId) {
    300         final Cursor cursor = mDatabase.query(
    301                 TABLE_DOCUMENTS,
    302                 strings(Document.COLUMN_DOCUMENT_ID),
    303                 COLUMN_DOCUMENT_TYPE + " = ? AND " + COLUMN_DEVICE_ID + " = ?",
    304                 strings(DOCUMENT_TYPE_DEVICE, deviceId),
    305                 null,
    306                 null,
    307                 null,
    308                 "1");
    309         try {
    310             if (cursor.moveToNext()) {
    311                 return cursor.getString(0);
    312             } else {
    313                 return null;
    314             }
    315         } finally {
    316             cursor.close();
    317         }
    318     }
    319 
    320     /**
    321      * Obtains parent identifier.
    322      * @param documentId
    323      * @return parent identifier.
    324      * @throws FileNotFoundException
    325      */
    326     Identifier getParentIdentifier(String documentId) throws FileNotFoundException {
    327         final Cursor cursor = mDatabase.query(
    328                 TABLE_DOCUMENTS,
    329                 strings(COLUMN_PARENT_DOCUMENT_ID),
    330                 SELECTION_DOCUMENT_ID,
    331                 strings(documentId),
    332                 null,
    333                 null,
    334                 null,
    335                 "1");
    336         try {
    337             if (cursor.moveToNext()) {
    338                 return createIdentifier(cursor.getString(0));
    339             } else {
    340                 throw new FileNotFoundException("Cannot find a row having ID = " + documentId);
    341             }
    342         } finally {
    343             cursor.close();
    344         }
    345     }
    346 
    347     String getDeviceDocumentId(int deviceId) throws FileNotFoundException {
    348         try (final Cursor cursor = mDatabase.query(
    349                 TABLE_DOCUMENTS,
    350                 strings(Document.COLUMN_DOCUMENT_ID),
    351                 COLUMN_DEVICE_ID + " = ? AND " + COLUMN_DOCUMENT_TYPE + " = ? AND " +
    352                 COLUMN_ROW_STATE + " != ?",
    353                 strings(deviceId, DOCUMENT_TYPE_DEVICE, ROW_STATE_DISCONNECTED),
    354                 null,
    355                 null,
    356                 null,
    357                 "1")) {
    358             if (cursor.getCount() > 0) {
    359                 cursor.moveToNext();
    360                 return cursor.getString(0);
    361             } else {
    362                 throw new FileNotFoundException("The device ID not found: " + deviceId);
    363             }
    364         }
    365     }
    366 
    367     /**
    368      * Adds new document under the parent.
    369      * The method does not affect invalidated and pending documents because we know the document is
    370      * newly added and never mapped with existing ones.
    371      * @param parentDocumentId
    372      * @param info
    373      * @param size Object size. info#getCompressedSize() will be ignored because it does not contain
    374      *     object size more than 4GB.
    375      * @return Document ID of added document.
    376      */
    377     String putNewDocument(
    378             int deviceId, String parentDocumentId, int[] operationsSupported, MtpObjectInfo info,
    379             long size) {
    380         final ContentValues values = new ContentValues();
    381         getObjectDocumentValues(
    382                 values, deviceId, parentDocumentId, operationsSupported, info, size);
    383         mDatabase.beginTransaction();
    384         try {
    385             final long id = mDatabase.insert(TABLE_DOCUMENTS, null, values);
    386             mDatabase.setTransactionSuccessful();
    387             return Long.toString(id);
    388         } finally {
    389             mDatabase.endTransaction();
    390         }
    391     }
    392 
    393     /**
    394      * Deletes document and its children.
    395      * @param documentId
    396      */
    397     void deleteDocument(String documentId) {
    398         deleteDocumentsAndRootsRecursively(SELECTION_DOCUMENT_ID, strings(documentId));
    399     }
    400 
    401     /**
    402      * Gets identifier from document ID.
    403      * @param documentId Document ID.
    404      * @return Identifier.
    405      * @throws FileNotFoundException
    406      */
    407     Identifier createIdentifier(String documentId) throws FileNotFoundException {
    408         // Currently documentId is old format.
    409         final Cursor cursor = mDatabase.query(
    410                 TABLE_DOCUMENTS,
    411                 strings(COLUMN_DEVICE_ID,
    412                         COLUMN_STORAGE_ID,
    413                         COLUMN_OBJECT_HANDLE,
    414                         COLUMN_DOCUMENT_TYPE),
    415                 SELECTION_DOCUMENT_ID + " AND " + COLUMN_ROW_STATE + " IN (?, ?)",
    416                 strings(documentId, ROW_STATE_VALID, ROW_STATE_INVALIDATED),
    417                 null,
    418                 null,
    419                 null,
    420                 "1");
    421         try {
    422             if (cursor.getCount() == 0) {
    423                 throw new FileNotFoundException("ID \"" + documentId + "\" is not found.");
    424             } else {
    425                 cursor.moveToNext();
    426                 return new Identifier(
    427                         cursor.getInt(0),
    428                         cursor.getInt(1),
    429                         cursor.getInt(2),
    430                         documentId,
    431                         cursor.getInt(3));
    432             }
    433         } finally {
    434             cursor.close();
    435         }
    436     }
    437 
    438     /**
    439      * Deletes a document, and its root information if the document is a root document.
    440      * @param selection Query to select documents.
    441      * @param args Arguments for selection.
    442      * @return Whether the method deletes rows.
    443      */
    444     boolean deleteDocumentsAndRootsRecursively(String selection, String[] args) {
    445         mDatabase.beginTransaction();
    446         try {
    447             boolean changed = false;
    448             final Cursor cursor = mDatabase.query(
    449                     TABLE_DOCUMENTS,
    450                     strings(Document.COLUMN_DOCUMENT_ID),
    451                     selection,
    452                     args,
    453                     null,
    454                     null,
    455                     null);
    456             try {
    457                 while (cursor.moveToNext()) {
    458                     if (deleteDocumentsAndRootsRecursively(
    459                             COLUMN_PARENT_DOCUMENT_ID + " = ?",
    460                             strings(cursor.getString(0)))) {
    461                         changed = true;
    462                     }
    463                 }
    464             } finally {
    465                 cursor.close();
    466             }
    467             if (deleteDocumentsAndRoots(selection, args)) {
    468                 changed = true;
    469             }
    470             mDatabase.setTransactionSuccessful();
    471             return changed;
    472         } finally {
    473             mDatabase.endTransaction();
    474         }
    475     }
    476 
    477     /**
    478      * Marks the documents and their child as disconnected documents.
    479      * @param selection
    480      * @param args
    481      * @return True if at least one row is updated.
    482      */
    483     boolean disconnectDocumentsRecursively(String selection, String[] args) {
    484         mDatabase.beginTransaction();
    485         try {
    486             boolean changed = false;
    487             try (final Cursor cursor = mDatabase.query(
    488                     TABLE_DOCUMENTS,
    489                     strings(Document.COLUMN_DOCUMENT_ID),
    490                     selection,
    491                     args,
    492                     null,
    493                     null,
    494                     null)) {
    495                 while (cursor.moveToNext()) {
    496                     if (disconnectDocumentsRecursively(
    497                             COLUMN_PARENT_DOCUMENT_ID + " = ?",
    498                             strings(cursor.getString(0)))) {
    499                         changed = true;
    500                     }
    501                 }
    502             }
    503             if (disconnectDocuments(selection, args)) {
    504                 changed = true;
    505             }
    506             mDatabase.setTransactionSuccessful();
    507             return changed;
    508         } finally {
    509             mDatabase.endTransaction();
    510         }
    511     }
    512 
    513     boolean deleteDocumentsAndRoots(String selection, String[] args) {
    514         mDatabase.beginTransaction();
    515         try {
    516             int deleted = 0;
    517             deleted += mDatabase.delete(
    518                     TABLE_ROOT_EXTRA,
    519                     Root.COLUMN_ROOT_ID + " IN (" + SQLiteQueryBuilder.buildQueryString(
    520                             false,
    521                             TABLE_DOCUMENTS,
    522                             new String[] { Document.COLUMN_DOCUMENT_ID },
    523                             selection,
    524                             null,
    525                             null,
    526                             null,
    527                             null) + ")",
    528                     args);
    529             deleted += mDatabase.delete(TABLE_DOCUMENTS, selection, args);
    530             mDatabase.setTransactionSuccessful();
    531             // TODO Remove mappingState.
    532             return deleted != 0;
    533         } finally {
    534             mDatabase.endTransaction();
    535         }
    536     }
    537 
    538     boolean disconnectDocuments(String selection, String[] args) {
    539         mDatabase.beginTransaction();
    540         try {
    541             final ContentValues values = new ContentValues();
    542             values.put(COLUMN_ROW_STATE, ROW_STATE_DISCONNECTED);
    543             values.putNull(COLUMN_DEVICE_ID);
    544             values.putNull(COLUMN_STORAGE_ID);
    545             values.putNull(COLUMN_OBJECT_HANDLE);
    546             final boolean updated = mDatabase.update(TABLE_DOCUMENTS, values, selection, args) != 0;
    547             mDatabase.setTransactionSuccessful();
    548             return updated;
    549         } finally {
    550             mDatabase.endTransaction();
    551         }
    552     }
    553 
    554     int getRowState(String documentId) throws FileNotFoundException {
    555         try (final Cursor cursor = mDatabase.query(
    556                 TABLE_DOCUMENTS,
    557                 strings(COLUMN_ROW_STATE),
    558                 SELECTION_DOCUMENT_ID,
    559                 strings(documentId),
    560                 null,
    561                 null,
    562                 null)) {
    563             if (cursor.getCount() == 0) {
    564                 throw new FileNotFoundException();
    565             }
    566             cursor.moveToNext();
    567             return cursor.getInt(0);
    568         }
    569     }
    570 
    571     void writeRowSnapshot(String documentId, ContentValues values) throws FileNotFoundException {
    572         try (final Cursor cursor = mDatabase.query(
    573                 JOIN_ROOTS,
    574                 strings("*"),
    575                 SELECTION_DOCUMENT_ID,
    576                 strings(documentId),
    577                 null,
    578                 null,
    579                 null,
    580                 "1")) {
    581             if (cursor.getCount() == 0) {
    582                 throw new FileNotFoundException();
    583             }
    584             cursor.moveToNext();
    585             values.clear();
    586             DatabaseUtils.cursorRowToContentValues(cursor, values);
    587         }
    588     }
    589 
    590     void updateObject(String documentId, int deviceId, String parentId, int[] operationsSupported,
    591                       MtpObjectInfo info, Long size) {
    592         final ContentValues values = new ContentValues();
    593         getObjectDocumentValues(values, deviceId, parentId, operationsSupported, info, size);
    594 
    595         mDatabase.beginTransaction();
    596         try {
    597             mDatabase.update(
    598                     TABLE_DOCUMENTS,
    599                     values,
    600                     Document.COLUMN_DOCUMENT_ID + " = ?",
    601                     strings(documentId));
    602             mDatabase.setTransactionSuccessful();
    603         } finally {
    604             mDatabase.endTransaction();
    605         }
    606     }
    607 
    608     /**
    609      * Obtains a document that has already mapped but has unmapped children.
    610      * @param deviceId Device to find documents.
    611      * @return Identifier of found document or null.
    612      */
    613     @Nullable Identifier getUnmappedDocumentsParent(int deviceId) {
    614         final String fromClosure =
    615                 TABLE_DOCUMENTS + " AS child INNER JOIN " +
    616                 TABLE_DOCUMENTS + " AS parent ON " +
    617                 "child." + COLUMN_PARENT_DOCUMENT_ID + " = " +
    618                 "parent." + Document.COLUMN_DOCUMENT_ID;
    619         final String whereClosure =
    620                 "parent." + COLUMN_DEVICE_ID + " = ? AND " +
    621                 "parent." + COLUMN_ROW_STATE + " IN (?, ?) AND " +
    622                 "parent." + COLUMN_DOCUMENT_TYPE + " != ? AND " +
    623                 "child." + COLUMN_ROW_STATE + " = ?";
    624         try (final Cursor cursor = mDatabase.query(
    625                 fromClosure,
    626                 strings("parent." + COLUMN_DEVICE_ID,
    627                         "parent." + COLUMN_STORAGE_ID,
    628                         "parent." + COLUMN_OBJECT_HANDLE,
    629                         "parent." + Document.COLUMN_DOCUMENT_ID,
    630                         "parent." + COLUMN_DOCUMENT_TYPE),
    631                 whereClosure,
    632                 strings(deviceId, ROW_STATE_VALID, ROW_STATE_INVALIDATED, DOCUMENT_TYPE_DEVICE,
    633                         ROW_STATE_DISCONNECTED),
    634                 null,
    635                 null,
    636                 null,
    637                 "1")) {
    638             if (cursor.getCount() == 0) {
    639                 return null;
    640             }
    641             cursor.moveToNext();
    642             return new Identifier(
    643                     cursor.getInt(0),
    644                     cursor.getInt(1),
    645                     cursor.getInt(2),
    646                     cursor.getString(3),
    647                     cursor.getInt(4));
    648         }
    649     }
    650 
    651     /**
    652      * Removes metadata except for data used by outgoingPersistedUriPermissions.
    653      */
    654     void cleanDatabase(Uri[] outgoingPersistedUris) {
    655         mDatabase.beginTransaction();
    656         try {
    657             final Set<String> ids = new HashSet<>();
    658             for (final Uri uri : outgoingPersistedUris) {
    659                 String documentId = DocumentsContract.getDocumentId(uri);
    660                 while (documentId != null) {
    661                     if (ids.contains(documentId)) {
    662                         break;
    663                     }
    664                     ids.add(documentId);
    665                     try (final Cursor cursor = mDatabase.query(
    666                             TABLE_DOCUMENTS,
    667                             strings(COLUMN_PARENT_DOCUMENT_ID),
    668                             SELECTION_DOCUMENT_ID,
    669                             strings(documentId),
    670                             null,
    671                             null,
    672                             null)) {
    673                         documentId = cursor.moveToNext() ? cursor.getString(0) : null;
    674                     }
    675                 }
    676             }
    677             deleteDocumentsAndRoots(
    678                     Document.COLUMN_DOCUMENT_ID + " NOT IN " + getIdList(ids), null);
    679             mDatabase.setTransactionSuccessful();
    680         } finally {
    681             mDatabase.endTransaction();
    682         }
    683     }
    684 
    685     int getLastBootCount() {
    686         try (final Cursor cursor = mDatabase.query(
    687                 TABLE_LAST_BOOT_COUNT, strings(COLUMN_VALUE), null, null, null, null, null)) {
    688             if (cursor.moveToNext()) {
    689                 return cursor.getInt(0);
    690             } else {
    691                 return 0;
    692             }
    693         }
    694     }
    695 
    696     void setLastBootCount(int value) {
    697         Preconditions.checkArgumentNonnegative(value, "Boot count must not be negative.");
    698         mDatabase.beginTransaction();
    699         try {
    700             final ContentValues values = new ContentValues();
    701             values.put(COLUMN_VALUE, value);
    702             mDatabase.delete(TABLE_LAST_BOOT_COUNT, null, null);
    703             mDatabase.insert(TABLE_LAST_BOOT_COUNT, null, values);
    704             mDatabase.setTransactionSuccessful();
    705         } finally {
    706             mDatabase.endTransaction();
    707         }
    708     }
    709 
    710     private static class OpenHelper extends SQLiteOpenHelper {
    711         public OpenHelper(Context context, int flags) {
    712             super(context,
    713                   flags == FLAG_DATABASE_IN_MEMORY ? null : DATABASE_NAME,
    714                   null,
    715                   DATABASE_VERSION);
    716         }
    717 
    718         @Override
    719         public void onCreate(SQLiteDatabase db) {
    720             db.execSQL(QUERY_CREATE_DOCUMENTS);
    721             db.execSQL(QUERY_CREATE_ROOT_EXTRA);
    722             db.execSQL(QUERY_CREATE_LAST_BOOT_COUNT);
    723         }
    724 
    725         @Override
    726         public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    727             db.execSQL("DROP TABLE IF EXISTS " + TABLE_DOCUMENTS);
    728             db.execSQL("DROP TABLE IF EXISTS " + TABLE_ROOT_EXTRA);
    729             db.execSQL("DROP TABLE IF EXISTS " + TABLE_LAST_BOOT_COUNT);
    730             onCreate(db);
    731         }
    732     }
    733 
    734     @VisibleForTesting
    735     static void deleteDatabase(Context context) {
    736         context.deleteDatabase(DATABASE_NAME);
    737     }
    738 
    739     static void getDeviceDocumentValues(
    740             ContentValues values,
    741             ContentValues extraValues,
    742             MtpDeviceRecord device) {
    743         values.clear();
    744         values.put(COLUMN_DEVICE_ID, device.deviceId);
    745         values.putNull(COLUMN_STORAGE_ID);
    746         values.putNull(COLUMN_OBJECT_HANDLE);
    747         values.putNull(COLUMN_PARENT_DOCUMENT_ID);
    748         values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
    749         values.put(COLUMN_DOCUMENT_TYPE, DOCUMENT_TYPE_DEVICE);
    750         values.put(COLUMN_MAPPING_KEY, device.deviceKey);
    751         values.put(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
    752         values.put(Document.COLUMN_DISPLAY_NAME, device.name);
    753         values.putNull(Document.COLUMN_SUMMARY);
    754         values.putNull(Document.COLUMN_LAST_MODIFIED);
    755         values.put(Document.COLUMN_ICON, R.drawable.ic_root_mtp);
    756         values.put(Document.COLUMN_FLAGS, getDocumentFlags(
    757                 device.operationsSupported,
    758                 Document.MIME_TYPE_DIR,
    759                 0,
    760                 MtpConstants.PROTECTION_STATUS_NONE,
    761                 // Storages are placed under device so we cannot create a document just under
    762                 // device.
    763                 DOCUMENT_TYPE_DEVICE) & ~Document.FLAG_DIR_SUPPORTS_CREATE);
    764         values.putNull(Document.COLUMN_SIZE);
    765 
    766         extraValues.clear();
    767         extraValues.put(Root.COLUMN_FLAGS, getRootFlags(device.operationsSupported));
    768         extraValues.putNull(Root.COLUMN_AVAILABLE_BYTES);
    769         extraValues.putNull(Root.COLUMN_CAPACITY_BYTES);
    770         extraValues.put(Root.COLUMN_MIME_TYPES, "");
    771     }
    772 
    773     /**
    774      * Gets {@link ContentValues} for the given root.
    775      * @param values {@link ContentValues} that receives values.
    776      * @param extraValues {@link ContentValues} that receives extra values for roots.
    777      * @param parentDocumentId Parent document ID.
    778      * @param operationsSupported Array of Operation code supported by the device.
    779      * @param root Root to be converted {@link ContentValues}.
    780      */
    781     static void getStorageDocumentValues(
    782             ContentValues values,
    783             ContentValues extraValues,
    784             String parentDocumentId,
    785             int[] operationsSupported,
    786             MtpRoot root) {
    787         values.clear();
    788         values.put(COLUMN_DEVICE_ID, root.mDeviceId);
    789         values.put(COLUMN_STORAGE_ID, root.mStorageId);
    790         values.putNull(COLUMN_OBJECT_HANDLE);
    791         values.put(COLUMN_PARENT_DOCUMENT_ID, parentDocumentId);
    792         values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
    793         values.put(COLUMN_DOCUMENT_TYPE, DOCUMENT_TYPE_STORAGE);
    794         values.put(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
    795         values.put(Document.COLUMN_DISPLAY_NAME, root.mDescription);
    796         values.putNull(Document.COLUMN_SUMMARY);
    797         values.putNull(Document.COLUMN_LAST_MODIFIED);
    798         values.put(Document.COLUMN_ICON, R.drawable.ic_root_mtp);
    799         values.put(Document.COLUMN_FLAGS, getDocumentFlags(
    800                 operationsSupported,
    801                 Document.MIME_TYPE_DIR,
    802                 0,
    803                 MtpConstants.PROTECTION_STATUS_NONE,
    804                 DOCUMENT_TYPE_STORAGE));
    805         values.put(Document.COLUMN_SIZE, root.mMaxCapacity - root.mFreeSpace);
    806 
    807         extraValues.put(Root.COLUMN_FLAGS, getRootFlags(operationsSupported));
    808         extraValues.put(Root.COLUMN_AVAILABLE_BYTES, root.mFreeSpace);
    809         extraValues.put(Root.COLUMN_CAPACITY_BYTES, root.mMaxCapacity);
    810         extraValues.put(Root.COLUMN_MIME_TYPES, "");
    811     }
    812 
    813     /**
    814      * Gets {@link ContentValues} for the given MTP object.
    815      * @param values {@link ContentValues} that receives values.
    816      * @param deviceId Device ID of the object.
    817      * @param parentId Parent document ID of the object.
    818      * @param info MTP object info. getCompressedSize will be ignored.
    819      * @param size 64-bit size of documents. Negative value is regarded as unknown size.
    820      */
    821     static void getObjectDocumentValues(
    822             ContentValues values, int deviceId, String parentId,
    823             int[] operationsSupported, MtpObjectInfo info, long size) {
    824         values.clear();
    825         final String mimeType = getMimeType(info);
    826         values.put(COLUMN_DEVICE_ID, deviceId);
    827         values.put(COLUMN_STORAGE_ID, info.getStorageId());
    828         values.put(COLUMN_OBJECT_HANDLE, info.getObjectHandle());
    829         values.put(COLUMN_PARENT_DOCUMENT_ID, parentId);
    830         values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
    831         values.put(COLUMN_DOCUMENT_TYPE, DOCUMENT_TYPE_OBJECT);
    832         values.put(Document.COLUMN_MIME_TYPE, mimeType);
    833         values.put(Document.COLUMN_DISPLAY_NAME, info.getName());
    834         values.putNull(Document.COLUMN_SUMMARY);
    835         values.put(
    836                 Document.COLUMN_LAST_MODIFIED,
    837                 info.getDateModified() != 0 ? info.getDateModified() : null);
    838         values.putNull(Document.COLUMN_ICON);
    839         values.put(Document.COLUMN_FLAGS, getDocumentFlags(
    840                 operationsSupported, mimeType, info.getThumbCompressedSizeLong(),
    841                 info.getProtectionStatus(), DOCUMENT_TYPE_OBJECT));
    842         if (size >= 0) {
    843             values.put(Document.COLUMN_SIZE, size);
    844         } else {
    845             values.putNull(Document.COLUMN_SIZE);
    846         }
    847     }
    848 
    849     private static String getMimeType(MtpObjectInfo info) {
    850         if (info.getFormat() == MtpConstants.FORMAT_ASSOCIATION) {
    851             return DocumentsContract.Document.MIME_TYPE_DIR;
    852         }
    853 
    854         final String formatCodeMimeType = MediaFile.getMimeTypeForFormatCode(info.getFormat());
    855         final String mediaFileMimeType = MediaFile.getMimeTypeForFile(info.getName());
    856 
    857         // Format code can be mapped with multiple mime types, e.g. FORMAT_MPEG is mapped with
    858         // audio/mp4 and video/mp4.
    859         // As file extension contains more information than format code, returns mime type obtained
    860         // from file extension if it is consistent with format code.
    861         if (mediaFileMimeType != null &&
    862                 MediaFile.getFormatCode("", mediaFileMimeType) == info.getFormat()) {
    863             return mediaFileMimeType;
    864         }
    865         if (formatCodeMimeType != null) {
    866             return formatCodeMimeType;
    867         }
    868         if (mediaFileMimeType != null) {
    869             return mediaFileMimeType;
    870         }
    871         // We don't know the file type.
    872         return "application/octet-stream";
    873     }
    874 
    875     private static int getRootFlags(int[] operationsSupported) {
    876         int rootFlag = Root.FLAG_SUPPORTS_IS_CHILD | Root.FLAG_LOCAL_ONLY;
    877         if (MtpDeviceRecord.isWritingSupported(operationsSupported)) {
    878             rootFlag |= Root.FLAG_SUPPORTS_CREATE;
    879         }
    880         return rootFlag;
    881     }
    882 
    883     private static int getDocumentFlags(
    884             @Nullable int[] operationsSupported, String mimeType, long thumbnailSize,
    885             int protectionState, @DocumentType int documentType) {
    886         int flag = 0;
    887         if (!mimeType.equals(Document.MIME_TYPE_DIR) &&
    888                 MtpDeviceRecord.isWritingSupported(operationsSupported) &&
    889                 protectionState == MtpConstants.PROTECTION_STATUS_NONE) {
    890             flag |= Document.FLAG_SUPPORTS_WRITE;
    891         }
    892         if (MtpDeviceRecord.isSupported(
    893                 operationsSupported, MtpConstants.OPERATION_DELETE_OBJECT) &&
    894                 (protectionState == MtpConstants.PROTECTION_STATUS_NONE ||
    895                  protectionState == MtpConstants.PROTECTION_STATUS_NON_TRANSFERABLE_DATA) &&
    896                 documentType == DOCUMENT_TYPE_OBJECT) {
    897             flag |= Document.FLAG_SUPPORTS_DELETE;
    898         }
    899         if (mimeType.equals(Document.MIME_TYPE_DIR) &&
    900                 MtpDeviceRecord.isWritingSupported(operationsSupported) &&
    901                 protectionState == MtpConstants.PROTECTION_STATUS_NONE) {
    902             flag |= Document.FLAG_DIR_SUPPORTS_CREATE;
    903         }
    904         if (MetadataReader.isSupportedMimeType(mimeType)) {
    905             flag |= Document.FLAG_SUPPORTS_METADATA;
    906         }
    907         if (thumbnailSize > 0) {
    908             flag |= Document.FLAG_SUPPORTS_THUMBNAIL;
    909         }
    910         return flag;
    911     }
    912 
    913     static String[] strings(Object... args) {
    914         final String[] results = new String[args.length];
    915         for (int i = 0; i < args.length; i++) {
    916             results[i] = Objects.toString(args[i]);
    917         }
    918         return results;
    919     }
    920 
    921     static void putValuesToCursor(ContentValues values, MatrixCursor cursor) {
    922         final RowBuilder row = cursor.newRow();
    923         for (final String name : cursor.getColumnNames()) {
    924             row.add(values.get(name));
    925         }
    926     }
    927 
    928     private static String getIdList(Set<String> ids) {
    929         String result = "(";
    930         for (final String id : ids) {
    931             if (result.length() > 1) {
    932                 result += ",";
    933             }
    934             result += id;
    935         }
    936         result += ")";
    937         return result;
    938     }
    939 }
    940