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 android.annotation.Nullable;
     20 import android.content.ContentValues;
     21 import android.database.Cursor;
     22 import android.database.DatabaseUtils;
     23 import android.database.sqlite.SQLiteDatabase;
     24 import android.mtp.MtpObjectInfo;
     25 import android.provider.DocumentsContract.Document;
     26 import android.provider.DocumentsContract.Root;
     27 import android.util.ArraySet;
     28 import android.util.Log;
     29 
     30 import com.android.internal.util.Preconditions;
     31 
     32 import java.io.FileNotFoundException;
     33 import java.util.Set;
     34 
     35 import static com.android.mtp.MtpDatabaseConstants.*;
     36 import static com.android.mtp.MtpDatabase.strings;
     37 
     38 /**
     39  * Mapping operations for MtpDatabase.
     40  * Also see the comments of {@link MtpDatabase}.
     41  */
     42 class Mapper {
     43     private static final String[] EMPTY_ARGS = new String[0];
     44     private final MtpDatabase mDatabase;
     45 
     46     /**
     47      * IDs which currently Mapper operates mapping for.
     48      */
     49     private final Set<String> mInMappingIds = new ArraySet<>();
     50 
     51     Mapper(MtpDatabase database) {
     52         mDatabase = database;
     53     }
     54 
     55     /**
     56      * Puts device information to database.
     57      *
     58      * @return If device is added to the database.
     59      * @throws FileNotFoundException
     60      */
     61     synchronized boolean putDeviceDocument(MtpDeviceRecord device) throws FileNotFoundException {
     62         final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
     63         database.beginTransaction();
     64         try {
     65             final ContentValues[] valuesList = new ContentValues[1];
     66             final ContentValues[] extraValuesList = new ContentValues[1];
     67             valuesList[0] = new ContentValues();
     68             extraValuesList[0] = new ContentValues();
     69             MtpDatabase.getDeviceDocumentValues(valuesList[0], extraValuesList[0], device);
     70             final boolean changed = putDocuments(
     71                     null,
     72                     valuesList,
     73                     extraValuesList,
     74                     COLUMN_PARENT_DOCUMENT_ID + " IS NULL",
     75                     EMPTY_ARGS,
     76                     strings(COLUMN_DEVICE_ID, COLUMN_MAPPING_KEY));
     77             database.setTransactionSuccessful();
     78             return changed;
     79         } finally {
     80             database.endTransaction();
     81         }
     82     }
     83 
     84     /**
     85      * Puts root information to database.
     86      *
     87      * @param parentDocumentId Document ID of device document.
     88      * @param roots List of root information.
     89      * @return If roots are added or removed from the database.
     90      * @throws FileNotFoundException
     91      */
     92     synchronized boolean putStorageDocuments(
     93             String parentDocumentId, int[] operationsSupported, MtpRoot[] roots)
     94             throws FileNotFoundException {
     95         final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
     96         database.beginTransaction();
     97         try {
     98             final ContentValues[] valuesList = new ContentValues[roots.length];
     99             final ContentValues[] extraValuesList = new ContentValues[roots.length];
    100             for (int i = 0; i < roots.length; i++) {
    101                 valuesList[i] = new ContentValues();
    102                 extraValuesList[i] = new ContentValues();
    103                 MtpDatabase.getStorageDocumentValues(
    104                         valuesList[i],
    105                         extraValuesList[i],
    106                         parentDocumentId,
    107                         operationsSupported,
    108                         roots[i]);
    109             }
    110             final boolean changed = putDocuments(
    111                     parentDocumentId,
    112                     valuesList,
    113                     extraValuesList,
    114                     COLUMN_PARENT_DOCUMENT_ID + " = ?",
    115                     strings(parentDocumentId),
    116                     strings(COLUMN_STORAGE_ID, Document.COLUMN_DISPLAY_NAME));
    117 
    118             database.setTransactionSuccessful();
    119             return changed;
    120         } finally {
    121             database.endTransaction();
    122         }
    123     }
    124 
    125     /**
    126      * Puts document information to database.
    127      *
    128      * @param deviceId Device ID
    129      * @param parentId Parent document ID.
    130      * @param documents List of document information.
    131      * @param documentSizes 64-bit size of documents. MtpObjectInfo#getComporessedSize will be
    132      *     ignored because it does not contain 4GB> object size. Can be -1 if the size is unknown.
    133      * @throws FileNotFoundException
    134      */
    135     synchronized void putChildDocuments(
    136             int deviceId, String parentId,
    137             int[] operationsSupported,
    138             MtpObjectInfo[] documents,
    139             long[] documentSizes)
    140             throws FileNotFoundException {
    141         assert documents.length == documentSizes.length;
    142         final ContentValues[] valuesList = new ContentValues[documents.length];
    143         for (int i = 0; i < documents.length; i++) {
    144             valuesList[i] = new ContentValues();
    145             MtpDatabase.getObjectDocumentValues(
    146                     valuesList[i],
    147                     deviceId,
    148                     parentId,
    149                     operationsSupported,
    150                     documents[i],
    151                     documentSizes[i]);
    152         }
    153         putDocuments(
    154                 parentId,
    155                 valuesList,
    156                 null,
    157                 COLUMN_PARENT_DOCUMENT_ID + " = ?",
    158                 strings(parentId),
    159                 strings(COLUMN_OBJECT_HANDLE, Document.COLUMN_DISPLAY_NAME));
    160     }
    161 
    162     void clearMapping() {
    163         final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
    164         database.beginTransaction();
    165         try {
    166             mInMappingIds.clear();
    167             // Disconnect all device rows.
    168             try {
    169                 startAddingDocuments(null);
    170                 stopAddingDocuments(null);
    171             } catch (FileNotFoundException exception) {
    172                 Log.e(MtpDocumentsProvider.TAG, "Unexpected FileNotFoundException.", exception);
    173                 throw new RuntimeException(exception);
    174             }
    175             database.setTransactionSuccessful();
    176         } finally {
    177             database.endTransaction();
    178         }
    179     }
    180 
    181     /**
    182      * Starts adding new documents.
    183      * It changes the direct child documents of the given document from VALID to INVALIDATED.
    184      * Note that it keeps DISCONNECTED documents as they are.
    185      *
    186      * @param parentDocumentId Parent document ID or NULL for root documents.
    187      * @throws FileNotFoundException
    188      */
    189     void startAddingDocuments(@Nullable String parentDocumentId) throws FileNotFoundException {
    190         final String selection;
    191         final String[] args;
    192         if (parentDocumentId != null) {
    193             selection = COLUMN_PARENT_DOCUMENT_ID + " = ?";
    194             args = strings(parentDocumentId);
    195         } else {
    196             selection = COLUMN_PARENT_DOCUMENT_ID + " IS NULL";
    197             args = EMPTY_ARGS;
    198         }
    199 
    200         final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
    201         database.beginTransaction();
    202         try {
    203             getParentOrHaltMapping(parentDocumentId);
    204             Preconditions.checkState(!mInMappingIds.contains(parentDocumentId));
    205 
    206             // Set all valid documents as invalidated.
    207             final ContentValues values = new ContentValues();
    208             values.put(COLUMN_ROW_STATE, ROW_STATE_INVALIDATED);
    209             database.update(
    210                     TABLE_DOCUMENTS,
    211                     values,
    212                     selection + " AND " + COLUMN_ROW_STATE + " = ?",
    213                     DatabaseUtils.appendSelectionArgs(args, strings(ROW_STATE_VALID)));
    214 
    215             database.setTransactionSuccessful();
    216             mInMappingIds.add(parentDocumentId);
    217         } finally {
    218             database.endTransaction();
    219         }
    220     }
    221 
    222     /**
    223      * Puts the documents into the database.
    224      * If the mapping mode is not heuristic, it just adds the rows to the database or updates the
    225      * existing rows with the new values. If the mapping mode is heuristic, it adds some new rows as
    226      * 'pending' state when that rows may be corresponding to existing 'invalidated' rows. Then
    227      * {@link #stopAddingDocuments(String)} turns the pending rows into 'valid'
    228      * rows. If the methods adds rows to database, it updates valueList with correct document ID.
    229      *
    230      * @param parentId Parent document ID.
    231      * @param valuesList Values for documents to be stored in the database.
    232      * @param rootExtraValuesList Values for root extra to be stored in the database.
    233      * @param selection SQL where closure to select rows that shares the same parent.
    234      * @param args Argument for selection SQL.
    235      * @return Whether the database content is changed.
    236      * @throws FileNotFoundException When parentId is not registered in the database.
    237      */
    238     private boolean putDocuments(
    239             String parentId,
    240             ContentValues[] valuesList,
    241             @Nullable ContentValues[] rootExtraValuesList,
    242             String selection,
    243             String[] args,
    244             String[] mappingKeys) throws FileNotFoundException {
    245         final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
    246         boolean changed = false;
    247         database.beginTransaction();
    248         try {
    249             getParentOrHaltMapping(parentId);
    250             Preconditions.checkState(mInMappingIds.contains(parentId));
    251             final ContentValues oldRowSnapshot = new ContentValues();
    252             final ContentValues newRowSnapshot = new ContentValues();
    253             for (int i = 0; i < valuesList.length; i++) {
    254                 final ContentValues values = valuesList[i];
    255                 final ContentValues rootExtraValues;
    256                 if (rootExtraValuesList != null) {
    257                     rootExtraValues = rootExtraValuesList[i];
    258                 } else {
    259                     rootExtraValues = null;
    260                 }
    261                 try (final Cursor candidateCursor =
    262                         queryCandidate(selection, args, mappingKeys, values)) {
    263                     final long rowId;
    264                     if (candidateCursor == null) {
    265                         rowId = database.insert(TABLE_DOCUMENTS, null, values);
    266                         changed = true;
    267                     } else {
    268                         candidateCursor.moveToNext();
    269                         rowId = candidateCursor.getLong(0);
    270                         if (!changed) {
    271                             mDatabase.writeRowSnapshot(String.valueOf(rowId), oldRowSnapshot);
    272                         }
    273                         database.update(
    274                                 TABLE_DOCUMENTS,
    275                                 values,
    276                                 SELECTION_DOCUMENT_ID,
    277                                 strings(rowId));
    278                     }
    279                     // Document ID is a primary integer key of the table. So the returned row
    280                     // IDs should be same with the document ID.
    281                     values.put(Document.COLUMN_DOCUMENT_ID, rowId);
    282                     if (rootExtraValues != null) {
    283                         rootExtraValues.put(Root.COLUMN_ROOT_ID, rowId);
    284                         database.replace(TABLE_ROOT_EXTRA, null, rootExtraValues);
    285                     }
    286 
    287                     if (!changed) {
    288                         mDatabase.writeRowSnapshot(String.valueOf(rowId), newRowSnapshot);
    289                         // Put row state as string because SQLite returns snapshot values as string.
    290                         oldRowSnapshot.put(COLUMN_ROW_STATE, String.valueOf(ROW_STATE_VALID));
    291                         if (!oldRowSnapshot.equals(newRowSnapshot)) {
    292                             changed = true;
    293                         }
    294                     }
    295                 }
    296             }
    297 
    298             database.setTransactionSuccessful();
    299             return changed;
    300         } finally {
    301             database.endTransaction();
    302         }
    303     }
    304 
    305     /**
    306      * Stops adding documents.
    307      * It handles 'invalidated' and 'disconnected' documents which we don't put corresponding
    308      * documents so far.
    309      * If the type adding document is 'device' or 'storage', the document may appear again
    310      * afterward. The method marks such documents as 'disconnected'. If the type of adding document
    311      * is 'object', it seems the documents are really removed from the remote MTP device. So the
    312      * method deletes the metadata from the database.
    313      *
    314      * @param parentId Parent document ID or null for root documents.
    315      * @return Whether the methods changes file metadata in database.
    316      * @throws FileNotFoundException
    317      */
    318     boolean stopAddingDocuments(@Nullable String parentId) throws FileNotFoundException {
    319         final String selection;
    320         final String[] args;
    321         if (parentId != null) {
    322             selection = COLUMN_PARENT_DOCUMENT_ID + " = ?";
    323             args = strings(parentId);
    324         } else {
    325             selection = COLUMN_PARENT_DOCUMENT_ID + " IS NULL";
    326             args = EMPTY_ARGS;
    327         }
    328 
    329         final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
    330         database.beginTransaction();
    331         try {
    332             final Identifier parentIdentifier = getParentOrHaltMapping(parentId);
    333             Preconditions.checkState(mInMappingIds.contains(parentId));
    334             mInMappingIds.remove(parentId);
    335 
    336             boolean changed = false;
    337             // Delete/disconnect all invalidated/disconnected rows that cannot be mapped.
    338             // If parentIdentifier is null, added documents are devices.
    339             // if parentIdentifier is DOCUMENT_TYPE_DEVICE, added documents are storages.
    340             final boolean keepUnmatchedDocument =
    341                     parentIdentifier == null ||
    342                     parentIdentifier.mDocumentType == DOCUMENT_TYPE_DEVICE;
    343             if (keepUnmatchedDocument) {
    344                 if (mDatabase.disconnectDocumentsRecursively(
    345                         COLUMN_ROW_STATE + " = ? AND " + selection,
    346                         DatabaseUtils.appendSelectionArgs(strings(ROW_STATE_INVALIDATED), args))) {
    347                     changed = true;
    348                 }
    349             } else {
    350                 if (mDatabase.deleteDocumentsAndRootsRecursively(
    351                         COLUMN_ROW_STATE + " IN (?, ?) AND " + selection,
    352                         DatabaseUtils.appendSelectionArgs(
    353                                 strings(ROW_STATE_INVALIDATED, ROW_STATE_DISCONNECTED), args))) {
    354                     changed = true;
    355                 }
    356             }
    357 
    358             database.setTransactionSuccessful();
    359             return changed;
    360         } finally {
    361             database.endTransaction();
    362         }
    363     }
    364 
    365     /**
    366      * Cancels adding documents.
    367      * @param parentId
    368      */
    369     void cancelAddingDocuments(@Nullable String parentId) {
    370         final String selection;
    371         final String[] args;
    372         if (parentId != null) {
    373             selection = COLUMN_PARENT_DOCUMENT_ID + " = ?";
    374             args = strings(parentId);
    375         } else {
    376             selection = COLUMN_PARENT_DOCUMENT_ID + " IS NULL";
    377             args = EMPTY_ARGS;
    378         }
    379 
    380         final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
    381         database.beginTransaction();
    382         try {
    383             if (!mInMappingIds.contains(parentId)) {
    384                 return;
    385             }
    386             mInMappingIds.remove(parentId);
    387             final ContentValues values = new ContentValues();
    388             values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
    389             mDatabase.getSQLiteDatabase().update(
    390                     TABLE_DOCUMENTS,
    391                     values,
    392                     selection + " AND " + COLUMN_ROW_STATE + " = ?",
    393                     DatabaseUtils.appendSelectionArgs(args, strings(ROW_STATE_INVALIDATED)));
    394             database.setTransactionSuccessful();
    395         } finally {
    396             database.endTransaction();
    397         }
    398     }
    399 
    400     /**
    401      * Queries candidate for each mappingKey, and returns the first cursor that includes a
    402      * candidate.
    403      *
    404      * @param selection Pre-selection for candidate.
    405      * @param args Arguments for selection.
    406      * @param mappingKeys List of mapping key columns.
    407      * @param values Values of document that Mapper tries to map.
    408      * @return Cursor for mapping candidate or null when Mapper does not find any candidate.
    409      */
    410     private @Nullable Cursor queryCandidate(
    411             String selection, String[] args, String[] mappingKeys, ContentValues values) {
    412         for (final String mappingKey : mappingKeys) {
    413             final Cursor candidateCursor = queryCandidate(selection, args, mappingKey, values);
    414             if (candidateCursor.getCount() == 0) {
    415                 candidateCursor.close();
    416                 continue;
    417             }
    418             return candidateCursor;
    419         }
    420         return null;
    421     }
    422 
    423     /**
    424      * Looks for mapping candidate with given mappingKey.
    425      *
    426      * @param selection Pre-selection for candidate.
    427      * @param args Arguments for selection.
    428      * @param mappingKey Column name of mapping key.
    429      * @param values Values of document that Mapper tries to map.
    430      * @return Cursor for mapping candidate.
    431      */
    432     private Cursor queryCandidate(
    433             String selection, String[] args, String mappingKey, ContentValues values) {
    434         final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
    435         return database.query(
    436                 TABLE_DOCUMENTS,
    437                 strings(Document.COLUMN_DOCUMENT_ID),
    438                 selection + " AND " +
    439                 COLUMN_ROW_STATE + " IN (?, ?) AND " +
    440                 mappingKey + " = ?",
    441                 DatabaseUtils.appendSelectionArgs(
    442                         args,
    443                         strings(ROW_STATE_INVALIDATED,
    444                                 ROW_STATE_DISCONNECTED,
    445                                 values.getAsString(mappingKey))),
    446                 null,
    447                 null,
    448                 null,
    449                 "1");
    450     }
    451 
    452     /**
    453      * Returns the parent identifier from parent document ID if the parent ID is found in the
    454      * database. Otherwise it halts mapping and throws FileNotFoundException.
    455      *
    456      * @param parentId Parent document ID
    457      * @return Parent identifier
    458      * @throws FileNotFoundException
    459      */
    460     private @Nullable Identifier getParentOrHaltMapping(
    461             @Nullable String parentId) throws FileNotFoundException {
    462         if (parentId == null) {
    463             return null;
    464         }
    465         try {
    466             return mDatabase.createIdentifier(parentId);
    467         } catch (FileNotFoundException error) {
    468             mInMappingIds.remove(parentId);
    469             throw error;
    470         }
    471     }
    472 }
    473