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                final RowBuilder row = result.newRow();
204                for (final String key : values.keySet()) {
205                    row.add(key, values.get(key));
206                }
207            }
208
209            return result;
210        } finally {
211            deviceCursor.close();
212        }
213    }
214
215    /**
216     * Queries root documents information.
217     * @param columnNames Column names defined in
218     *     {@link android.provider.DocumentsContract.Document}.
219     * @return Database cursor.
220     */
221    @VisibleForTesting
222    Cursor queryRootDocuments(String[] columnNames) {
223        return mDatabase.query(
224                TABLE_DOCUMENTS,
225                columnNames,
226                COLUMN_ROW_STATE + " IN (?, ?) AND " + COLUMN_DOCUMENT_TYPE + " = ?",
227                strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED, DOCUMENT_TYPE_STORAGE),
228                null,
229                null,
230                null);
231    }
232
233    /**
234     * Queries documents information.
235     * @param columnNames Column names defined in
236     *     {@link android.provider.DocumentsContract.Document}.
237     * @return Database cursor.
238     */
239    Cursor queryChildDocuments(String[] columnNames, String parentDocumentId) {
240        return mDatabase.query(
241                TABLE_DOCUMENTS,
242                columnNames,
243                COLUMN_ROW_STATE + " IN (?, ?) AND " + COLUMN_PARENT_DOCUMENT_ID + " = ?",
244                strings(ROW_STATE_VALID, ROW_STATE_INVALIDATED, parentDocumentId),
245                null,
246                null,
247                null);
248    }
249
250    /**
251     * Returns document IDs of storages under the given device document.
252     *
253     * @param documentId Document ID that points a device.
254     * @return Storage document IDs.
255     * @throws FileNotFoundException The given document ID is not registered in database.
256     */
257    String[] getStorageDocumentIds(String documentId)
258            throws FileNotFoundException {
259        Preconditions.checkArgument(createIdentifier(documentId).mDocumentType ==
260                DOCUMENT_TYPE_DEVICE);
261        // Check if the parent document is device that has single storage.
262        try (final Cursor cursor = mDatabase.query(
263                TABLE_DOCUMENTS,
264                strings(Document.COLUMN_DOCUMENT_ID),
265                COLUMN_ROW_STATE + " IN (?, ?) AND " +
266                COLUMN_PARENT_DOCUMENT_ID + " = ? AND " +
267                COLUMN_DOCUMENT_TYPE + " = ?",
268                strings(ROW_STATE_VALID,
269                        ROW_STATE_INVALIDATED,
270                        documentId,
271                        DOCUMENT_TYPE_STORAGE),
272                null,
273                null,
274                null)) {
275            final String[] ids = new String[cursor.getCount()];
276            for (int i = 0; cursor.moveToNext(); i++) {
277                ids[i] = cursor.getString(0);
278            }
279            return ids;
280        }
281    }
282
283    /**
284     * Queries a single document.
285     * @param documentId
286     * @param projection
287     * @return Database cursor.
288     */
289    Cursor queryDocument(String documentId, String[] projection) {
290        return mDatabase.query(
291                TABLE_DOCUMENTS,
292                projection,
293                SELECTION_DOCUMENT_ID,
294                strings(documentId),
295                null,
296                null,
297                null,
298                "1");
299    }
300
301    @Nullable String getDocumentIdForDevice(int deviceId) {
302        final Cursor cursor = mDatabase.query(
303                TABLE_DOCUMENTS,
304                strings(Document.COLUMN_DOCUMENT_ID),
305                COLUMN_DOCUMENT_TYPE + " = ? AND " + COLUMN_DEVICE_ID + " = ?",
306                strings(DOCUMENT_TYPE_DEVICE, deviceId),
307                null,
308                null,
309                null,
310                "1");
311        try {
312            if (cursor.moveToNext()) {
313                return cursor.getString(0);
314            } else {
315                return null;
316            }
317        } finally {
318            cursor.close();
319        }
320    }
321
322    /**
323     * Obtains parent identifier.
324     * @param documentId
325     * @return parent identifier.
326     * @throws FileNotFoundException
327     */
328    Identifier getParentIdentifier(String documentId) throws FileNotFoundException {
329        final Cursor cursor = mDatabase.query(
330                TABLE_DOCUMENTS,
331                strings(COLUMN_PARENT_DOCUMENT_ID),
332                SELECTION_DOCUMENT_ID,
333                strings(documentId),
334                null,
335                null,
336                null,
337                "1");
338        try {
339            if (cursor.moveToNext()) {
340                return createIdentifier(cursor.getString(0));
341            } else {
342                throw new FileNotFoundException("Cannot find a row having ID = " + documentId);
343            }
344        } finally {
345            cursor.close();
346        }
347    }
348
349    String getDeviceDocumentId(int deviceId) throws FileNotFoundException {
350        try (final Cursor cursor = mDatabase.query(
351                TABLE_DOCUMENTS,
352                strings(Document.COLUMN_DOCUMENT_ID),
353                COLUMN_DEVICE_ID + " = ? AND " + COLUMN_DOCUMENT_TYPE + " = ? AND " +
354                COLUMN_ROW_STATE + " != ?",
355                strings(deviceId, DOCUMENT_TYPE_DEVICE, ROW_STATE_DISCONNECTED),
356                null,
357                null,
358                null,
359                "1")) {
360            if (cursor.getCount() > 0) {
361                cursor.moveToNext();
362                return cursor.getString(0);
363            } else {
364                throw new FileNotFoundException("The device ID not found: " + deviceId);
365            }
366        }
367    }
368
369    /**
370     * Adds new document under the parent.
371     * The method does not affect invalidated and pending documents because we know the document is
372     * newly added and never mapped with existing ones.
373     * @param parentDocumentId
374     * @param info
375     * @param size Object size. info#getCompressedSize() will be ignored because it does not contain
376     *     object size more than 4GB.
377     * @return Document ID of added document.
378     */
379    String putNewDocument(
380            int deviceId, String parentDocumentId, int[] operationsSupported, MtpObjectInfo info,
381            long size) {
382        final ContentValues values = new ContentValues();
383        getObjectDocumentValues(
384                values, deviceId, parentDocumentId, operationsSupported, info, size);
385        mDatabase.beginTransaction();
386        try {
387            final long id = mDatabase.insert(TABLE_DOCUMENTS, null, values);
388            mDatabase.setTransactionSuccessful();
389            return Long.toString(id);
390        } finally {
391            mDatabase.endTransaction();
392        }
393    }
394
395    /**
396     * Deletes document and its children.
397     * @param documentId
398     */
399    void deleteDocument(String documentId) {
400        deleteDocumentsAndRootsRecursively(SELECTION_DOCUMENT_ID, strings(documentId));
401    }
402
403    /**
404     * Gets identifier from document ID.
405     * @param documentId Document ID.
406     * @return Identifier.
407     * @throws FileNotFoundException
408     */
409    Identifier createIdentifier(String documentId) throws FileNotFoundException {
410        // Currently documentId is old format.
411        final Cursor cursor = mDatabase.query(
412                TABLE_DOCUMENTS,
413                strings(COLUMN_DEVICE_ID,
414                        COLUMN_STORAGE_ID,
415                        COLUMN_OBJECT_HANDLE,
416                        COLUMN_DOCUMENT_TYPE),
417                SELECTION_DOCUMENT_ID + " AND " + COLUMN_ROW_STATE + " IN (?, ?)",
418                strings(documentId, ROW_STATE_VALID, ROW_STATE_INVALIDATED),
419                null,
420                null,
421                null,
422                "1");
423        try {
424            if (cursor.getCount() == 0) {
425                throw new FileNotFoundException("ID \"" + documentId + "\" is not found.");
426            } else {
427                cursor.moveToNext();
428                return new Identifier(
429                        cursor.getInt(0),
430                        cursor.getInt(1),
431                        cursor.getInt(2),
432                        documentId,
433                        cursor.getInt(3));
434            }
435        } finally {
436            cursor.close();
437        }
438    }
439
440    /**
441     * Deletes a document, and its root information if the document is a root document.
442     * @param selection Query to select documents.
443     * @param args Arguments for selection.
444     * @return Whether the method deletes rows.
445     */
446    boolean deleteDocumentsAndRootsRecursively(String selection, String[] args) {
447        mDatabase.beginTransaction();
448        try {
449            boolean changed = false;
450            final Cursor cursor = mDatabase.query(
451                    TABLE_DOCUMENTS,
452                    strings(Document.COLUMN_DOCUMENT_ID),
453                    selection,
454                    args,
455                    null,
456                    null,
457                    null);
458            try {
459                while (cursor.moveToNext()) {
460                    if (deleteDocumentsAndRootsRecursively(
461                            COLUMN_PARENT_DOCUMENT_ID + " = ?",
462                            strings(cursor.getString(0)))) {
463                        changed = true;
464                    }
465                }
466            } finally {
467                cursor.close();
468            }
469            if (deleteDocumentsAndRoots(selection, args)) {
470                changed = true;
471            }
472            mDatabase.setTransactionSuccessful();
473            return changed;
474        } finally {
475            mDatabase.endTransaction();
476        }
477    }
478
479    /**
480     * Marks the documents and their child as disconnected documents.
481     * @param selection
482     * @param args
483     * @return True if at least one row is updated.
484     */
485    boolean disconnectDocumentsRecursively(String selection, String[] args) {
486        mDatabase.beginTransaction();
487        try {
488            boolean changed = false;
489            try (final Cursor cursor = mDatabase.query(
490                    TABLE_DOCUMENTS,
491                    strings(Document.COLUMN_DOCUMENT_ID),
492                    selection,
493                    args,
494                    null,
495                    null,
496                    null)) {
497                while (cursor.moveToNext()) {
498                    if (disconnectDocumentsRecursively(
499                            COLUMN_PARENT_DOCUMENT_ID + " = ?",
500                            strings(cursor.getString(0)))) {
501                        changed = true;
502                    }
503                }
504            }
505            if (disconnectDocuments(selection, args)) {
506                changed = true;
507            }
508            mDatabase.setTransactionSuccessful();
509            return changed;
510        } finally {
511            mDatabase.endTransaction();
512        }
513    }
514
515    boolean deleteDocumentsAndRoots(String selection, String[] args) {
516        mDatabase.beginTransaction();
517        try {
518            int deleted = 0;
519            deleted += mDatabase.delete(
520                    TABLE_ROOT_EXTRA,
521                    Root.COLUMN_ROOT_ID + " IN (" + SQLiteQueryBuilder.buildQueryString(
522                            false,
523                            TABLE_DOCUMENTS,
524                            new String[] { Document.COLUMN_DOCUMENT_ID },
525                            selection,
526                            null,
527                            null,
528                            null,
529                            null) + ")",
530                    args);
531            deleted += mDatabase.delete(TABLE_DOCUMENTS, selection, args);
532            mDatabase.setTransactionSuccessful();
533            // TODO Remove mappingState.
534            return deleted != 0;
535        } finally {
536            mDatabase.endTransaction();
537        }
538    }
539
540    boolean disconnectDocuments(String selection, String[] args) {
541        mDatabase.beginTransaction();
542        try {
543            final ContentValues values = new ContentValues();
544            values.put(COLUMN_ROW_STATE, ROW_STATE_DISCONNECTED);
545            values.putNull(COLUMN_DEVICE_ID);
546            values.putNull(COLUMN_STORAGE_ID);
547            values.putNull(COLUMN_OBJECT_HANDLE);
548            final boolean updated = mDatabase.update(TABLE_DOCUMENTS, values, selection, args) != 0;
549            mDatabase.setTransactionSuccessful();
550            return updated;
551        } finally {
552            mDatabase.endTransaction();
553        }
554    }
555
556    int getRowState(String documentId) throws FileNotFoundException {
557        try (final Cursor cursor = mDatabase.query(
558                TABLE_DOCUMENTS,
559                strings(COLUMN_ROW_STATE),
560                SELECTION_DOCUMENT_ID,
561                strings(documentId),
562                null,
563                null,
564                null)) {
565            if (cursor.getCount() == 0) {
566                throw new FileNotFoundException();
567            }
568            cursor.moveToNext();
569            return cursor.getInt(0);
570        }
571    }
572
573    void writeRowSnapshot(String documentId, ContentValues values) throws FileNotFoundException {
574        try (final Cursor cursor = mDatabase.query(
575                JOIN_ROOTS,
576                strings("*"),
577                SELECTION_DOCUMENT_ID,
578                strings(documentId),
579                null,
580                null,
581                null,
582                "1")) {
583            if (cursor.getCount() == 0) {
584                throw new FileNotFoundException();
585            }
586            cursor.moveToNext();
587            values.clear();
588            DatabaseUtils.cursorRowToContentValues(cursor, values);
589        }
590    }
591
592    void updateObject(String documentId, int deviceId, String parentId, int[] operationsSupported,
593                      MtpObjectInfo info, Long size) {
594        final ContentValues values = new ContentValues();
595        getObjectDocumentValues(values, deviceId, parentId, operationsSupported, info, size);
596
597        mDatabase.beginTransaction();
598        try {
599            mDatabase.update(
600                    TABLE_DOCUMENTS,
601                    values,
602                    Document.COLUMN_DOCUMENT_ID + " = ?",
603                    strings(documentId));
604            mDatabase.setTransactionSuccessful();
605        } finally {
606            mDatabase.endTransaction();
607        }
608    }
609
610    /**
611     * Obtains a document that has already mapped but has unmapped children.
612     * @param deviceId Device to find documents.
613     * @return Identifier of found document or null.
614     */
615    @Nullable Identifier getUnmappedDocumentsParent(int deviceId) {
616        final String fromClosure =
617                TABLE_DOCUMENTS + " AS child INNER JOIN " +
618                TABLE_DOCUMENTS + " AS parent ON " +
619                "child." + COLUMN_PARENT_DOCUMENT_ID + " = " +
620                "parent." + Document.COLUMN_DOCUMENT_ID;
621        final String whereClosure =
622                "parent." + COLUMN_DEVICE_ID + " = ? AND " +
623                "parent." + COLUMN_ROW_STATE + " IN (?, ?) AND " +
624                "parent." + COLUMN_DOCUMENT_TYPE + " != ? AND " +
625                "child." + COLUMN_ROW_STATE + " = ?";
626        try (final Cursor cursor = mDatabase.query(
627                fromClosure,
628                strings("parent." + COLUMN_DEVICE_ID,
629                        "parent." + COLUMN_STORAGE_ID,
630                        "parent." + COLUMN_OBJECT_HANDLE,
631                        "parent." + Document.COLUMN_DOCUMENT_ID,
632                        "parent." + COLUMN_DOCUMENT_TYPE),
633                whereClosure,
634                strings(deviceId, ROW_STATE_VALID, ROW_STATE_INVALIDATED, DOCUMENT_TYPE_DEVICE,
635                        ROW_STATE_DISCONNECTED),
636                null,
637                null,
638                null,
639                "1")) {
640            if (cursor.getCount() == 0) {
641                return null;
642            }
643            cursor.moveToNext();
644            return new Identifier(
645                    cursor.getInt(0),
646                    cursor.getInt(1),
647                    cursor.getInt(2),
648                    cursor.getString(3),
649                    cursor.getInt(4));
650        }
651    }
652
653    /**
654     * Removes metadata except for data used by outgoingPersistedUriPermissions.
655     */
656    void cleanDatabase(Uri[] outgoingPersistedUris) {
657        mDatabase.beginTransaction();
658        try {
659            final Set<String> ids = new HashSet<>();
660            for (final Uri uri : outgoingPersistedUris) {
661                String documentId = DocumentsContract.getDocumentId(uri);
662                while (documentId != null) {
663                    if (ids.contains(documentId)) {
664                        break;
665                    }
666                    ids.add(documentId);
667                    try (final Cursor cursor = mDatabase.query(
668                            TABLE_DOCUMENTS,
669                            strings(COLUMN_PARENT_DOCUMENT_ID),
670                            SELECTION_DOCUMENT_ID,
671                            strings(documentId),
672                            null,
673                            null,
674                            null)) {
675                        documentId = cursor.moveToNext() ? cursor.getString(0) : null;
676                    }
677                }
678            }
679            deleteDocumentsAndRoots(
680                    Document.COLUMN_DOCUMENT_ID + " NOT IN " + getIdList(ids), null);
681            mDatabase.setTransactionSuccessful();
682        } finally {
683            mDatabase.endTransaction();
684        }
685    }
686
687    int getLastBootCount() {
688        try (final Cursor cursor = mDatabase.query(
689                TABLE_LAST_BOOT_COUNT, strings(COLUMN_VALUE), null, null, null, null, null)) {
690            if (cursor.moveToNext()) {
691                return cursor.getInt(0);
692            } else {
693                return 0;
694            }
695        }
696    }
697
698    void setLastBootCount(int value) {
699        Preconditions.checkArgumentNonnegative(value, "Boot count must not be negative.");
700        mDatabase.beginTransaction();
701        try {
702            final ContentValues values = new ContentValues();
703            values.put(COLUMN_VALUE, value);
704            mDatabase.delete(TABLE_LAST_BOOT_COUNT, null, null);
705            mDatabase.insert(TABLE_LAST_BOOT_COUNT, null, values);
706            mDatabase.setTransactionSuccessful();
707        } finally {
708            mDatabase.endTransaction();
709        }
710    }
711
712    private static class OpenHelper extends SQLiteOpenHelper {
713        public OpenHelper(Context context, int flags) {
714            super(context,
715                  flags == FLAG_DATABASE_IN_MEMORY ? null : DATABASE_NAME,
716                  null,
717                  DATABASE_VERSION);
718        }
719
720        @Override
721        public void onCreate(SQLiteDatabase db) {
722            db.execSQL(QUERY_CREATE_DOCUMENTS);
723            db.execSQL(QUERY_CREATE_ROOT_EXTRA);
724            db.execSQL(QUERY_CREATE_LAST_BOOT_COUNT);
725        }
726
727        @Override
728        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
729            db.execSQL("DROP TABLE IF EXISTS " + TABLE_DOCUMENTS);
730            db.execSQL("DROP TABLE IF EXISTS " + TABLE_ROOT_EXTRA);
731            db.execSQL("DROP TABLE IF EXISTS " + TABLE_LAST_BOOT_COUNT);
732            onCreate(db);
733        }
734    }
735
736    @VisibleForTesting
737    static void deleteDatabase(Context context) {
738        context.deleteDatabase(DATABASE_NAME);
739    }
740
741    static void getDeviceDocumentValues(
742            ContentValues values,
743            ContentValues extraValues,
744            MtpDeviceRecord device) {
745        values.clear();
746        values.put(COLUMN_DEVICE_ID, device.deviceId);
747        values.putNull(COLUMN_STORAGE_ID);
748        values.putNull(COLUMN_OBJECT_HANDLE);
749        values.putNull(COLUMN_PARENT_DOCUMENT_ID);
750        values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
751        values.put(COLUMN_DOCUMENT_TYPE, DOCUMENT_TYPE_DEVICE);
752        values.put(COLUMN_MAPPING_KEY, device.deviceKey);
753        values.put(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
754        values.put(Document.COLUMN_DISPLAY_NAME, device.name);
755        values.putNull(Document.COLUMN_SUMMARY);
756        values.putNull(Document.COLUMN_LAST_MODIFIED);
757        values.put(Document.COLUMN_ICON, R.drawable.ic_root_mtp);
758        values.put(Document.COLUMN_FLAGS, getDocumentFlags(
759                device.operationsSupported,
760                Document.MIME_TYPE_DIR,
761                0,
762                MtpConstants.PROTECTION_STATUS_NONE,
763                DOCUMENT_TYPE_DEVICE));
764        values.putNull(Document.COLUMN_SIZE);
765
766        extraValues.clear();
767        extraValues.put(Root.COLUMN_FLAGS, getRootFlags(device.operationsSupported));
768        extraValues.putNull(Root.COLUMN_AVAILABLE_BYTES);
769        extraValues.putNull(Root.COLUMN_CAPACITY_BYTES);
770        extraValues.put(Root.COLUMN_MIME_TYPES, "");
771    }
772
773    /**
774     * Gets {@link ContentValues} for the given root.
775     * @param values {@link ContentValues} that receives values.
776     * @param extraValues {@link ContentValues} that receives extra values for roots.
777     * @param parentDocumentId Parent document ID.
778     * @param operationsSupported Array of Operation code supported by the device.
779     * @param root Root to be converted {@link ContentValues}.
780     */
781    static void getStorageDocumentValues(
782            ContentValues values,
783            ContentValues extraValues,
784            String parentDocumentId,
785            int[] operationsSupported,
786            MtpRoot root) {
787        values.clear();
788        values.put(COLUMN_DEVICE_ID, root.mDeviceId);
789        values.put(COLUMN_STORAGE_ID, root.mStorageId);
790        values.putNull(COLUMN_OBJECT_HANDLE);
791        values.put(COLUMN_PARENT_DOCUMENT_ID, parentDocumentId);
792        values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
793        values.put(COLUMN_DOCUMENT_TYPE, DOCUMENT_TYPE_STORAGE);
794        values.put(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
795        values.put(Document.COLUMN_DISPLAY_NAME, root.mDescription);
796        values.putNull(Document.COLUMN_SUMMARY);
797        values.putNull(Document.COLUMN_LAST_MODIFIED);
798        values.put(Document.COLUMN_ICON, R.drawable.ic_root_mtp);
799        values.put(Document.COLUMN_FLAGS, getDocumentFlags(
800                operationsSupported,
801                Document.MIME_TYPE_DIR,
802                0,
803                MtpConstants.PROTECTION_STATUS_NONE,
804                DOCUMENT_TYPE_STORAGE));
805        values.put(Document.COLUMN_SIZE, root.mMaxCapacity - root.mFreeSpace);
806
807        extraValues.put(Root.COLUMN_FLAGS, getRootFlags(operationsSupported));
808        extraValues.put(Root.COLUMN_AVAILABLE_BYTES, root.mFreeSpace);
809        extraValues.put(Root.COLUMN_CAPACITY_BYTES, root.mMaxCapacity);
810        extraValues.put(Root.COLUMN_MIME_TYPES, "");
811    }
812
813    /**
814     * Gets {@link ContentValues} for the given MTP object.
815     * @param values {@link ContentValues} that receives values.
816     * @param deviceId Device ID of the object.
817     * @param parentId Parent document ID of the object.
818     * @param info MTP object info. getCompressedSize will be ignored.
819     * @param size 64-bit size of documents. Negative value is regarded as unknown size.
820     */
821    static void getObjectDocumentValues(
822            ContentValues values, int deviceId, String parentId,
823            int[] operationsSupported, MtpObjectInfo info, long size) {
824        values.clear();
825        final String mimeType = getMimeType(info);
826        values.put(COLUMN_DEVICE_ID, deviceId);
827        values.put(COLUMN_STORAGE_ID, info.getStorageId());
828        values.put(COLUMN_OBJECT_HANDLE, info.getObjectHandle());
829        values.put(COLUMN_PARENT_DOCUMENT_ID, parentId);
830        values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
831        values.put(COLUMN_DOCUMENT_TYPE, DOCUMENT_TYPE_OBJECT);
832        values.put(Document.COLUMN_MIME_TYPE, mimeType);
833        values.put(Document.COLUMN_DISPLAY_NAME, info.getName());
834        values.putNull(Document.COLUMN_SUMMARY);
835        values.put(
836                Document.COLUMN_LAST_MODIFIED,
837                info.getDateModified() != 0 ? info.getDateModified() : null);
838        values.putNull(Document.COLUMN_ICON);
839        values.put(Document.COLUMN_FLAGS, getDocumentFlags(
840                operationsSupported, mimeType, info.getThumbCompressedSizeLong(),
841                info.getProtectionStatus(), DOCUMENT_TYPE_OBJECT));
842        if (size >= 0) {
843            values.put(Document.COLUMN_SIZE, size);
844        } else {
845            values.putNull(Document.COLUMN_SIZE);
846        }
847    }
848
849    private static String getMimeType(MtpObjectInfo info) {
850        if (info.getFormat() == MtpConstants.FORMAT_ASSOCIATION) {
851            return DocumentsContract.Document.MIME_TYPE_DIR;
852        }
853
854        final String formatCodeMimeType = MediaFile.getMimeTypeForFormatCode(info.getFormat());
855        final String mediaFileMimeType = MediaFile.getMimeTypeForFile(info.getName());
856
857        // Format code can be mapped with multiple mime types, e.g. FORMAT_MPEG is mapped with
858        // audio/mp4 and video/mp4.
859        // As file extension contains more information than format code, returns mime type obtained
860        // from file extension if it is consistent with format code.
861        if (mediaFileMimeType != null &&
862                MediaFile.getFormatCode("", mediaFileMimeType) == info.getFormat()) {
863            return mediaFileMimeType;
864        }
865        if (formatCodeMimeType != null) {
866            return formatCodeMimeType;
867        }
868        if (mediaFileMimeType != null) {
869            return mediaFileMimeType;
870        }
871        // We don't know the file type.
872        return "application/octet-stream";
873    }
874
875    private static int getRootFlags(int[] operationsSupported) {
876        int rootFlag = Root.FLAG_SUPPORTS_IS_CHILD;
877        if (MtpDeviceRecord.isWritingSupported(operationsSupported)) {
878            rootFlag |= Root.FLAG_SUPPORTS_CREATE;
879        }
880        return rootFlag;
881    }
882
883    private static int getDocumentFlags(
884            @Nullable int[] operationsSupported, String mimeType, long thumbnailSize,
885            int protectionState, @DocumentType int documentType) {
886        int flag = 0;
887        if (!mimeType.equals(Document.MIME_TYPE_DIR) &&
888                MtpDeviceRecord.isWritingSupported(operationsSupported) &&
889                protectionState == MtpConstants.PROTECTION_STATUS_NONE) {
890            flag |= Document.FLAG_SUPPORTS_WRITE;
891        }
892        if (MtpDeviceRecord.isSupported(
893                operationsSupported, MtpConstants.OPERATION_DELETE_OBJECT) &&
894                (protectionState == MtpConstants.PROTECTION_STATUS_NONE ||
895                 protectionState == MtpConstants.PROTECTION_STATUS_NON_TRANSFERABLE_DATA) &&
896                documentType == DOCUMENT_TYPE_OBJECT) {
897            flag |= Document.FLAG_SUPPORTS_DELETE;
898        }
899        if (mimeType.equals(Document.MIME_TYPE_DIR) &&
900                MtpDeviceRecord.isWritingSupported(operationsSupported) &&
901                protectionState == MtpConstants.PROTECTION_STATUS_NONE) {
902            flag |= Document.FLAG_DIR_SUPPORTS_CREATE;
903        }
904        if (thumbnailSize > 0) {
905            flag |= Document.FLAG_SUPPORTS_THUMBNAIL;
906        }
907        return flag;
908    }
909
910    static String[] strings(Object... args) {
911        final String[] results = new String[args.length];
912        for (int i = 0; i < args.length; i++) {
913            results[i] = Objects.toString(args[i]);
914        }
915        return results;
916    }
917
918    private static String getIdList(Set<String> ids) {
919        String result = "(";
920        for (final String id : ids) {
921            if (result.length() > 1) {
922                result += ",";
923            }
924            result += id;
925        }
926        result += ")";
927        return result;
928    }
929}
930