blob: 63f18f324d693d3193937db0193c0547ecee21c4 [file] [log] [blame]
/*
* 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 {@link MtpDatabase}.
*/
class Mapper {
private static final String[] EMPTY_ARGS = new String[0];
private final MtpDatabase mDatabase;
/**
* IDs which currently Mapper operates mapping for.
*/
private final Set<String> mInMappingIds = new ArraySet<>();
Mapper(MtpDatabase database) {
mDatabase = 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.
*
* @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.
*
* @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.
*
* @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
* {@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.
*
* @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.
* @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.
*
* @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.
*
* @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.
*
* @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;
}
}
}