/*
 * Copyright (C) 2015 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.mtp;

import android.annotation.Nullable;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.mtp.MtpObjectInfo;
import android.provider.DocumentsContract.Document;
import android.provider.DocumentsContract.Root;
import android.util.ArraySet;
import android.util.Log;

import com.android.internal.util.Preconditions;

import java.io.FileNotFoundException;
import java.util.Set;

import static com.android.mtp.MtpDatabaseConstants.*;
import static com.android.mtp.MtpDatabase.strings;

Mapping operations for MtpDatabase. Also see the comments of MtpDatabase.
/** * Mapping operations for MtpDatabase. * Also see the comments of {@link MtpDatabase}. */
class Mapper { private static final String[] EMPTY_ARGS = new String[0]; private final MtpDatabase mDatabase;
IDs which currently Mapper operates mapping for.
/** * IDs which currently Mapper operates mapping for. */
private final Set<String> mInMappingIds = new ArraySet<>(); Mapper(MtpDatabase database) { mDatabase = database; }
Puts device information to database.
Throws:
Returns:If device is added to the database.
/** * Puts device information to database. * * @return If device is added to the database. * @throws FileNotFoundException */
synchronized boolean putDeviceDocument(MtpDeviceRecord device) throws FileNotFoundException { final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); database.beginTransaction(); try { final ContentValues[] valuesList = new ContentValues[1]; final ContentValues[] extraValuesList = new ContentValues[1]; valuesList[0] = new ContentValues(); extraValuesList[0] = new ContentValues(); MtpDatabase.getDeviceDocumentValues(valuesList[0], extraValuesList[0], device); final boolean changed = putDocuments( null, valuesList, extraValuesList, COLUMN_PARENT_DOCUMENT_ID + " IS NULL", EMPTY_ARGS, strings(COLUMN_DEVICE_ID, COLUMN_MAPPING_KEY)); database.setTransactionSuccessful(); return changed; } finally { database.endTransaction(); } }
Puts root information to database.
Params:
  • parentDocumentId – Document ID of device document.
  • roots – List of root information.
Throws:
Returns:If roots are added or removed from the database.
/** * Puts root information to database. * * @param parentDocumentId Document ID of device document. * @param roots List of root information. * @return If roots are added or removed from the database. * @throws FileNotFoundException */
synchronized boolean putStorageDocuments( String parentDocumentId, int[] operationsSupported, MtpRoot[] roots) throws FileNotFoundException { final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); database.beginTransaction(); try { final ContentValues[] valuesList = new ContentValues[roots.length]; final ContentValues[] extraValuesList = new ContentValues[roots.length]; for (int i = 0; i < roots.length; i++) { valuesList[i] = new ContentValues(); extraValuesList[i] = new ContentValues(); MtpDatabase.getStorageDocumentValues( valuesList[i], extraValuesList[i], parentDocumentId, operationsSupported, roots[i]); } final boolean changed = putDocuments( parentDocumentId, valuesList, extraValuesList, COLUMN_PARENT_DOCUMENT_ID + " = ?", strings(parentDocumentId), strings(COLUMN_STORAGE_ID, Document.COLUMN_DISPLAY_NAME)); database.setTransactionSuccessful(); return changed; } finally { database.endTransaction(); } }
Puts document information to database.
Params:
  • deviceId – Device ID
  • parentId – Parent document ID.
  • documents – List of document information.
  • documentSizes – 64-bit size of documents. MtpObjectInfo#getComporessedSize will be ignored because it does not contain 4GB> object size. Can be -1 if the size is unknown.
Throws:
/** * Puts document information to database. * * @param deviceId Device ID * @param parentId Parent document ID. * @param documents List of document information. * @param documentSizes 64-bit size of documents. MtpObjectInfo#getComporessedSize will be * ignored because it does not contain 4GB> object size. Can be -1 if the size is unknown. * @throws FileNotFoundException */
synchronized void putChildDocuments( int deviceId, String parentId, int[] operationsSupported, MtpObjectInfo[] documents, long[] documentSizes) throws FileNotFoundException { assert documents.length == documentSizes.length; final ContentValues[] valuesList = new ContentValues[documents.length]; for (int i = 0; i < documents.length; i++) { valuesList[i] = new ContentValues(); MtpDatabase.getObjectDocumentValues( valuesList[i], deviceId, parentId, operationsSupported, documents[i], documentSizes[i]); } putDocuments( parentId, valuesList, null, COLUMN_PARENT_DOCUMENT_ID + " = ?", strings(parentId), strings(COLUMN_OBJECT_HANDLE, Document.COLUMN_DISPLAY_NAME)); } void clearMapping() { final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); database.beginTransaction(); try { mInMappingIds.clear(); // Disconnect all device rows. try { startAddingDocuments(null); stopAddingDocuments(null); } catch (FileNotFoundException exception) { Log.e(MtpDocumentsProvider.TAG, "Unexpected FileNotFoundException.", exception); throw new RuntimeException(exception); } database.setTransactionSuccessful(); } finally { database.endTransaction(); } }
Starts adding new documents. It changes the direct child documents of the given document from VALID to INVALIDATED. Note that it keeps DISCONNECTED documents as they are.
Params:
  • parentDocumentId – Parent document ID or NULL for root documents.
Throws:
/** * Starts adding new documents. * It changes the direct child documents of the given document from VALID to INVALIDATED. * Note that it keeps DISCONNECTED documents as they are. * * @param parentDocumentId Parent document ID or NULL for root documents. * @throws FileNotFoundException */
void startAddingDocuments(@Nullable String parentDocumentId) throws FileNotFoundException { final String selection; final String[] args; if (parentDocumentId != null) { selection = COLUMN_PARENT_DOCUMENT_ID + " = ?"; args = strings(parentDocumentId); } else { selection = COLUMN_PARENT_DOCUMENT_ID + " IS NULL"; args = EMPTY_ARGS; } final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); database.beginTransaction(); try { getParentOrHaltMapping(parentDocumentId); Preconditions.checkState(!mInMappingIds.contains(parentDocumentId)); // Set all valid documents as invalidated. final ContentValues values = new ContentValues(); values.put(COLUMN_ROW_STATE, ROW_STATE_INVALIDATED); database.update( TABLE_DOCUMENTS, values, selection + " AND " + COLUMN_ROW_STATE + " = ?", DatabaseUtils.appendSelectionArgs(args, strings(ROW_STATE_VALID))); database.setTransactionSuccessful(); mInMappingIds.add(parentDocumentId); } finally { database.endTransaction(); } }
Puts the documents into the database. If the mapping mode is not heuristic, it just adds the rows to the database or updates the existing rows with the new values. If the mapping mode is heuristic, it adds some new rows as 'pending' state when that rows may be corresponding to existing 'invalidated' rows. Then stopAddingDocuments(String) turns the pending rows into 'valid' rows. If the methods adds rows to database, it updates valueList with correct document ID.
Params:
  • parentId – Parent document ID.
  • valuesList – Values for documents to be stored in the database.
  • rootExtraValuesList – Values for root extra to be stored in the database.
  • selection – SQL where closure to select rows that shares the same parent.
  • args – Argument for selection SQL.
Throws:
Returns:Whether the database content is changed.
/** * Puts the documents into the database. * If the mapping mode is not heuristic, it just adds the rows to the database or updates the * existing rows with the new values. If the mapping mode is heuristic, it adds some new rows as * 'pending' state when that rows may be corresponding to existing 'invalidated' rows. Then * {@link #stopAddingDocuments(String)} turns the pending rows into 'valid' * rows. If the methods adds rows to database, it updates valueList with correct document ID. * * @param parentId Parent document ID. * @param valuesList Values for documents to be stored in the database. * @param rootExtraValuesList Values for root extra to be stored in the database. * @param selection SQL where closure to select rows that shares the same parent. * @param args Argument for selection SQL. * @return Whether the database content is changed. * @throws FileNotFoundException When parentId is not registered in the database. */
private boolean putDocuments( String parentId, ContentValues[] valuesList, @Nullable ContentValues[] rootExtraValuesList, String selection, String[] args, String[] mappingKeys) throws FileNotFoundException { final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); boolean changed = false; database.beginTransaction(); try { getParentOrHaltMapping(parentId); Preconditions.checkState(mInMappingIds.contains(parentId)); final ContentValues oldRowSnapshot = new ContentValues(); final ContentValues newRowSnapshot = new ContentValues(); for (int i = 0; i < valuesList.length; i++) { final ContentValues values = valuesList[i]; final ContentValues rootExtraValues; if (rootExtraValuesList != null) { rootExtraValues = rootExtraValuesList[i]; } else { rootExtraValues = null; } try (final Cursor candidateCursor = queryCandidate(selection, args, mappingKeys, values)) { final long rowId; if (candidateCursor == null) { rowId = database.insert(TABLE_DOCUMENTS, null, values); changed = true; } else { candidateCursor.moveToNext(); rowId = candidateCursor.getLong(0); if (!changed) { mDatabase.writeRowSnapshot(String.valueOf(rowId), oldRowSnapshot); } database.update( TABLE_DOCUMENTS, values, SELECTION_DOCUMENT_ID, strings(rowId)); } // Document ID is a primary integer key of the table. So the returned row // IDs should be same with the document ID. values.put(Document.COLUMN_DOCUMENT_ID, rowId); if (rootExtraValues != null) { rootExtraValues.put(Root.COLUMN_ROOT_ID, rowId); database.replace(TABLE_ROOT_EXTRA, null, rootExtraValues); } if (!changed) { mDatabase.writeRowSnapshot(String.valueOf(rowId), newRowSnapshot); // Put row state as string because SQLite returns snapshot values as string. oldRowSnapshot.put(COLUMN_ROW_STATE, String.valueOf(ROW_STATE_VALID)); if (!oldRowSnapshot.equals(newRowSnapshot)) { changed = true; } } } } database.setTransactionSuccessful(); return changed; } finally { database.endTransaction(); } }
Stops adding documents. It handles 'invalidated' and 'disconnected' documents which we don't put corresponding documents so far. If the type adding document is 'device' or 'storage', the document may appear again afterward. The method marks such documents as 'disconnected'. If the type of adding document is 'object', it seems the documents are really removed from the remote MTP device. So the method deletes the metadata from the database.
Params:
  • parentId – Parent document ID or null for root documents.
Throws:
Returns:Whether the methods changes file metadata in database.
/** * Stops adding documents. * It handles 'invalidated' and 'disconnected' documents which we don't put corresponding * documents so far. * If the type adding document is 'device' or 'storage', the document may appear again * afterward. The method marks such documents as 'disconnected'. If the type of adding document * is 'object', it seems the documents are really removed from the remote MTP device. So the * method deletes the metadata from the database. * * @param parentId Parent document ID or null for root documents. * @return Whether the methods changes file metadata in database. * @throws FileNotFoundException */
boolean stopAddingDocuments(@Nullable String parentId) throws FileNotFoundException { final String selection; final String[] args; if (parentId != null) { selection = COLUMN_PARENT_DOCUMENT_ID + " = ?"; args = strings(parentId); } else { selection = COLUMN_PARENT_DOCUMENT_ID + " IS NULL"; args = EMPTY_ARGS; } final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); database.beginTransaction(); try { final Identifier parentIdentifier = getParentOrHaltMapping(parentId); Preconditions.checkState(mInMappingIds.contains(parentId)); mInMappingIds.remove(parentId); boolean changed = false; // Delete/disconnect all invalidated/disconnected rows that cannot be mapped. // If parentIdentifier is null, added documents are devices. // if parentIdentifier is DOCUMENT_TYPE_DEVICE, added documents are storages. final boolean keepUnmatchedDocument = parentIdentifier == null || parentIdentifier.mDocumentType == DOCUMENT_TYPE_DEVICE; if (keepUnmatchedDocument) { if (mDatabase.disconnectDocumentsRecursively( COLUMN_ROW_STATE + " = ? AND " + selection, DatabaseUtils.appendSelectionArgs(strings(ROW_STATE_INVALIDATED), args))) { changed = true; } } else { if (mDatabase.deleteDocumentsAndRootsRecursively( COLUMN_ROW_STATE + " IN (?, ?) AND " + selection, DatabaseUtils.appendSelectionArgs( strings(ROW_STATE_INVALIDATED, ROW_STATE_DISCONNECTED), args))) { changed = true; } } database.setTransactionSuccessful(); return changed; } finally { database.endTransaction(); } }
Cancels adding documents.
Params:
  • parentId –
/** * Cancels adding documents. * @param parentId */
void cancelAddingDocuments(@Nullable String parentId) { final String selection; final String[] args; if (parentId != null) { selection = COLUMN_PARENT_DOCUMENT_ID + " = ?"; args = strings(parentId); } else { selection = COLUMN_PARENT_DOCUMENT_ID + " IS NULL"; args = EMPTY_ARGS; } final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); database.beginTransaction(); try { if (!mInMappingIds.contains(parentId)) { return; } mInMappingIds.remove(parentId); final ContentValues values = new ContentValues(); values.put(COLUMN_ROW_STATE, ROW_STATE_VALID); mDatabase.getSQLiteDatabase().update( TABLE_DOCUMENTS, values, selection + " AND " + COLUMN_ROW_STATE + " = ?", DatabaseUtils.appendSelectionArgs(args, strings(ROW_STATE_INVALIDATED))); database.setTransactionSuccessful(); } finally { database.endTransaction(); } }
Queries candidate for each mappingKey, and returns the first cursor that includes a candidate.
Params:
  • selection – Pre-selection for candidate.
  • args – Arguments for selection.
  • mappingKeys – List of mapping key columns.
  • values – Values of document that Mapper tries to map.
Returns:Cursor for mapping candidate or null when Mapper does not find any candidate.
/** * Queries candidate for each mappingKey, and returns the first cursor that includes a * candidate. * * @param selection Pre-selection for candidate. * @param args Arguments for selection. * @param mappingKeys List of mapping key columns. * @param values Values of document that Mapper tries to map. * @return Cursor for mapping candidate or null when Mapper does not find any candidate. */
private @Nullable Cursor queryCandidate( String selection, String[] args, String[] mappingKeys, ContentValues values) { for (final String mappingKey : mappingKeys) { final Cursor candidateCursor = queryCandidate(selection, args, mappingKey, values); if (candidateCursor.getCount() == 0) { candidateCursor.close(); continue; } return candidateCursor; } return null; }
Looks for mapping candidate with given mappingKey.
Params:
  • selection – Pre-selection for candidate.
  • args – Arguments for selection.
  • mappingKey – Column name of mapping key.
  • values – Values of document that Mapper tries to map.
Returns:Cursor for mapping candidate.
/** * Looks for mapping candidate with given mappingKey. * * @param selection Pre-selection for candidate. * @param args Arguments for selection. * @param mappingKey Column name of mapping key. * @param values Values of document that Mapper tries to map. * @return Cursor for mapping candidate. */
private Cursor queryCandidate( String selection, String[] args, String mappingKey, ContentValues values) { final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); return database.query( TABLE_DOCUMENTS, strings(Document.COLUMN_DOCUMENT_ID), selection + " AND " + COLUMN_ROW_STATE + " IN (?, ?) AND " + mappingKey + " = ?", DatabaseUtils.appendSelectionArgs( args, strings(ROW_STATE_INVALIDATED, ROW_STATE_DISCONNECTED, values.getAsString(mappingKey))), null, null, null, "1"); }
Returns the parent identifier from parent document ID if the parent ID is found in the database. Otherwise it halts mapping and throws FileNotFoundException.
Params:
  • parentId – Parent document ID
Throws:
Returns:Parent identifier
/** * Returns the parent identifier from parent document ID if the parent ID is found in the * database. Otherwise it halts mapping and throws FileNotFoundException. * * @param parentId Parent document ID * @return Parent identifier * @throws FileNotFoundException */
private @Nullable Identifier getParentOrHaltMapping( @Nullable String parentId) throws FileNotFoundException { if (parentId == null) { return null; } try { return mDatabase.createIdentifier(parentId); } catch (FileNotFoundException error) { mInMappingIds.remove(parentId); throw error; } } }