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 
Mapper(MtpDatabase database)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      */
putDeviceDocument(MtpDeviceRecord device)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      */
putStorageDocuments( String parentDocumentId, int[] operationsSupported, MtpRoot[] roots)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      */
putChildDocuments( int deviceId, String parentId, int[] operationsSupported, MtpObjectInfo[] documents, long[] documentSizes)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 
clearMapping()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      */
startAddingDocuments(@ullable String parentDocumentId)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      */
putDocuments( String parentId, ContentValues[] valuesList, @Nullable ContentValues[] rootExtraValuesList, String selection, String[] args, String[] mappingKeys)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      */
stopAddingDocuments(@ullable String parentId)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      */
cancelAddingDocuments(@ullable String parentId)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      */
queryCandidate( String selection, String[] args, String[] mappingKeys, ContentValues values)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      */
queryCandidate( String selection, String[] args, String mappingKey, ContentValues values)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      */
getParentOrHaltMapping( @ullable String parentId)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