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 
getSQLiteDatabase()89     SQLiteDatabase getSQLiteDatabase() {
90         return mDatabase;
91     }
92 
MtpDatabase(Context context, int flags)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 
close()99     void close() {
100         mDatabase.close();
101     }
102 
103     /**
104      * Returns operations for mapping.
105      * @return Mapping operations.
106      */
getMapper()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      */
queryRoots(Resources resources, String[] columnNames)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
queryRootDocuments(String[] columnNames)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      */
queryChildDocuments(String[] columnNames, String parentDocumentId)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      */
getStorageDocumentIds(String documentId)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      */
queryDocument(String documentId, String[] projection)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 
getDocumentIdForDevice(int deviceId)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      */
getParentIdentifier(String documentId)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 
getDeviceDocumentId(int deviceId)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      */
putNewDocument( int deviceId, String parentDocumentId, int[] operationsSupported, MtpObjectInfo info, long size)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      */
deleteDocument(String documentId)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      */
createIdentifier(String documentId)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      */
deleteDocumentsAndRootsRecursively(String selection, String[] args)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      */
disconnectDocumentsRecursively(String selection, String[] args)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 
deleteDocumentsAndRoots(String selection, String[] args)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 
disconnectDocuments(String selection, String[] args)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 
getRowState(String documentId)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 
writeRowSnapshot(String documentId, ContentValues values)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 
updateObject(String documentId, int deviceId, String parentId, int[] operationsSupported, MtpObjectInfo info, Long size)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      */
getUnmappedDocumentsParent(int deviceId)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      */
cleanDatabase(Uri[] outgoingPersistedUris)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 
getLastBootCount()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 
setLastBootCount(int value)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 {
OpenHelper(Context context, int flags)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
onCreate(SQLiteDatabase db)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
onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)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
deleteDatabase(Context context)735     static void deleteDatabase(Context context) {
736         context.deleteDatabase(DATABASE_NAME);
737     }
738 
getDeviceDocumentValues( ContentValues values, ContentValues extraValues, MtpDeviceRecord device)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      */
getStorageDocumentValues( ContentValues values, ContentValues extraValues, String parentDocumentId, int[] operationsSupported, MtpRoot root)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      */
getObjectDocumentValues( ContentValues values, int deviceId, String parentId, int[] operationsSupported, MtpObjectInfo info, long size)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 
getMimeType(MtpObjectInfo info)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 
getRootFlags(int[] operationsSupported)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 
getDocumentFlags( @ullable int[] operationsSupported, String mimeType, long thumbnailSize, int protectionState, @DocumentType int documentType)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 
strings(Object... args)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 
putValuesToCursor(ContentValues values, MatrixCursor cursor)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 
getIdList(Set<String> ids)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