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