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