blob: 1823711acd597f2cf9b8ab5cf0ba7893c93dc919 [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.content.ContentResolver;
import android.content.Context;
import android.content.UriPermission;
import android.content.res.AssetFileDescriptor;
import android.content.res.Resources;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.database.sqlite.SQLiteDiskIOException;
import android.graphics.Point;
import android.media.MediaFile;
import android.mtp.MtpConstants;
import android.mtp.MtpObjectInfo;
import android.net.Uri;
import android.os.Bundle;
import android.os.CancellationSignal;
import android.os.FileUtils;
import android.os.ParcelFileDescriptor;
import android.os.storage.StorageManager;
import android.provider.DocumentsContract.Document;
import android.provider.DocumentsContract.Root;
import android.provider.DocumentsContract;
import android.provider.DocumentsProvider;
import android.provider.Settings;
import android.system.ErrnoException;
import android.system.Os;
import android.system.OsConstants;
import android.util.Log;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeoutException;
/**
* DocumentsProvider for MTP devices.
*/
public class MtpDocumentsProvider extends DocumentsProvider {
static final String AUTHORITY = "com.android.mtp.documents";
static final String TAG = "MtpDocumentsProvider";
static final String[] DEFAULT_ROOT_PROJECTION = new String[] {
Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON,
Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID,
Root.COLUMN_AVAILABLE_BYTES,
};
static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] {
Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE,
Document.COLUMN_DISPLAY_NAME, Document.COLUMN_LAST_MODIFIED,
Document.COLUMN_FLAGS, Document.COLUMN_SIZE,
};
static final boolean DEBUG = false;
private final Object mDeviceListLock = new Object();
private static MtpDocumentsProvider sSingleton;
private MtpManager mMtpManager;
private ContentResolver mResolver;
@GuardedBy("mDeviceListLock")
private Map<Integer, DeviceToolkit> mDeviceToolkits;
private RootScanner mRootScanner;
private Resources mResources;
private MtpDatabase mDatabase;
private AppFuse mAppFuse;
private ServiceIntentSender mIntentSender;
private Context mContext;
/**
* Provides singleton instance to MtpDocumentsService.
*/
static MtpDocumentsProvider getInstance() {
return sSingleton;
}
@Override
public boolean onCreate() {
sSingleton = this;
mContext = getContext();
mResources = getContext().getResources();
mMtpManager = new MtpManager(getContext());
mResolver = getContext().getContentResolver();
mDeviceToolkits = new HashMap<Integer, DeviceToolkit>();
mDatabase = new MtpDatabase(getContext(), MtpDatabaseConstants.FLAG_DATABASE_IN_FILE);
mRootScanner = new RootScanner(mResolver, mMtpManager, mDatabase);
mAppFuse = new AppFuse(TAG, new AppFuseCallback());
mIntentSender = new ServiceIntentSender(getContext());
// Check boot count and cleans database if it's first time to launch MtpDocumentsProvider
// after booting.
try {
final int bootCount = Settings.Global.getInt(mResolver, Settings.Global.BOOT_COUNT, -1);
final int lastBootCount = mDatabase.getLastBootCount();
if (bootCount != -1 && bootCount != lastBootCount) {
mDatabase.setLastBootCount(bootCount);
final List<UriPermission> permissions =
mResolver.getOutgoingPersistedUriPermissions();
final Uri[] uris = new Uri[permissions.size()];
for (int i = 0; i < permissions.size(); i++) {
uris[i] = permissions.get(i).getUri();
}
mDatabase.cleanDatabase(uris);
}
} catch (SQLiteDiskIOException error) {
// It can happen due to disk shortage.
Log.e(TAG, "Failed to clean database.", error);
return false;
}
// TODO: Mount AppFuse on demands.
try {
mAppFuse.mount(getContext().getSystemService(StorageManager.class));
} catch (IOException error) {
Log.e(TAG, "Failed to start app fuse.", error);
return false;
}
resume();
return true;
}
@VisibleForTesting
boolean onCreateForTesting(
Context context,
Resources resources,
MtpManager mtpManager,
ContentResolver resolver,
MtpDatabase database,
StorageManager storageManager,
ServiceIntentSender intentSender) {
mContext = context;
mResources = resources;
mMtpManager = mtpManager;
mResolver = resolver;
mDeviceToolkits = new HashMap<Integer, DeviceToolkit>();
mDatabase = database;
mRootScanner = new RootScanner(mResolver, mMtpManager, mDatabase);
mAppFuse = new AppFuse(TAG, new AppFuseCallback());
mIntentSender = intentSender;
// TODO: Mount AppFuse on demands.
try {
mAppFuse.mount(storageManager);
} catch (IOException e) {
Log.e(TAG, "Failed to start app fuse.", e);
return false;
}
resume();
return true;
}
@Override
public Cursor queryRoots(String[] projection) throws FileNotFoundException {
if (projection == null) {
projection = MtpDocumentsProvider.DEFAULT_ROOT_PROJECTION;
}
final Cursor cursor = mDatabase.queryRoots(mResources, projection);
cursor.setNotificationUri(
mResolver, DocumentsContract.buildRootsUri(MtpDocumentsProvider.AUTHORITY));
return cursor;
}
@Override
public Cursor queryDocument(String documentId, String[] projection)
throws FileNotFoundException {
if (projection == null) {
projection = MtpDocumentsProvider.DEFAULT_DOCUMENT_PROJECTION;
}
return mDatabase.queryDocument(documentId, projection);
}
@Override
public Cursor queryChildDocuments(String parentDocumentId,
String[] projection, String sortOrder) throws FileNotFoundException {
if (DEBUG) {
Log.d(TAG, "queryChildDocuments: " + parentDocumentId);
}
if (projection == null) {
projection = MtpDocumentsProvider.DEFAULT_DOCUMENT_PROJECTION;
}
Identifier parentIdentifier = mDatabase.createIdentifier(parentDocumentId);
try {
openDevice(parentIdentifier.mDeviceId);
if (parentIdentifier.mDocumentType == MtpDatabaseConstants.DOCUMENT_TYPE_DEVICE) {
final String[] storageDocIds = mDatabase.getStorageDocumentIds(parentDocumentId);
if (storageDocIds.length == 0) {
// Remote device does not provide storages. Maybe it is locked.
return createErrorCursor(projection, R.string.error_locked_device);
} else if (storageDocIds.length > 1) {
// Returns storage list from database.
return mDatabase.queryChildDocuments(projection, parentDocumentId);
}
// Exact one storage is found. Skip storage and returns object in the single
// storage.
parentIdentifier = mDatabase.createIdentifier(storageDocIds[0]);
}
// Returns object list from document loader.
return getDocumentLoader(parentIdentifier).queryChildDocuments(
projection, parentIdentifier);
} catch (BusyDeviceException exception) {
return createErrorCursor(projection, R.string.error_busy_device);
} catch (IOException exception) {
Log.e(MtpDocumentsProvider.TAG, "queryChildDocuments", exception);
throw new FileNotFoundException(exception.getMessage());
}
}
@Override
public ParcelFileDescriptor openDocument(
String documentId, String mode, CancellationSignal signal)
throws FileNotFoundException {
if (DEBUG) {
Log.d(TAG, "openDocument: " + documentId);
}
final Identifier identifier = mDatabase.createIdentifier(documentId);
try {
openDevice(identifier.mDeviceId);
final MtpDeviceRecord device = getDeviceToolkit(identifier.mDeviceId).mDeviceRecord;
// Turn off MODE_CREATE because openDocument does not allow to create new files.
final int modeFlag =
ParcelFileDescriptor.parseMode(mode) & ~ParcelFileDescriptor.MODE_CREATE;
if ((modeFlag & ParcelFileDescriptor.MODE_READ_ONLY) != 0) {
long fileSize;
try {
fileSize = getFileSize(documentId);
} catch (UnsupportedOperationException exception) {
fileSize = -1;
}
if (MtpDeviceRecord.isPartialReadSupported(
device.operationsSupported, fileSize)) {
return mAppFuse.openFile(Integer.parseInt(documentId), modeFlag);
} else {
// If getPartialObject{|64} are not supported for the device, returns
// non-seekable pipe FD instead.
return getPipeManager(identifier).readDocument(mMtpManager, identifier);
}
} else if ((modeFlag & ParcelFileDescriptor.MODE_WRITE_ONLY) != 0) {
// TODO: Clear the parent document loader task (if exists) and call notify
// when writing is completed.
if (MtpDeviceRecord.isWritingSupported(device.operationsSupported)) {
return mAppFuse.openFile(Integer.parseInt(documentId), modeFlag);
} else {
throw new UnsupportedOperationException(
"The device does not support writing operation.");
}
} else {
// TODO: Add support for "rw" mode.
throw new UnsupportedOperationException("The provider does not support 'rw' mode.");
}
} catch (FileNotFoundException | RuntimeException error) {
Log.e(MtpDocumentsProvider.TAG, "openDocument", error);
throw error;
} catch (IOException error) {
Log.e(MtpDocumentsProvider.TAG, "openDocument", error);
throw new IllegalStateException(error);
}
}
@Override
public AssetFileDescriptor openDocumentThumbnail(
String documentId,
Point sizeHint,
CancellationSignal signal) throws FileNotFoundException {
final Identifier identifier = mDatabase.createIdentifier(documentId);
try {
openDevice(identifier.mDeviceId);
return new AssetFileDescriptor(
getPipeManager(identifier).readThumbnail(mMtpManager, identifier),
0, // Start offset.
AssetFileDescriptor.UNKNOWN_LENGTH);
} catch (IOException error) {
Log.e(MtpDocumentsProvider.TAG, "openDocumentThumbnail", error);
throw new FileNotFoundException(error.getMessage());
}
}
@Override
public void deleteDocument(String documentId) throws FileNotFoundException {
try {
final Identifier identifier = mDatabase.createIdentifier(documentId);
openDevice(identifier.mDeviceId);
final Identifier parentIdentifier = mDatabase.getParentIdentifier(documentId);
mMtpManager.deleteDocument(identifier.mDeviceId, identifier.mObjectHandle);
mDatabase.deleteDocument(documentId);
getDocumentLoader(parentIdentifier).cancelTask(parentIdentifier);
notifyChildDocumentsChange(parentIdentifier.mDocumentId);
if (parentIdentifier.mDocumentType == MtpDatabaseConstants.DOCUMENT_TYPE_STORAGE) {
// If the parent is storage, the object might be appeared as child of device because
// we skip storage when the device has only one storage.
final Identifier deviceIdentifier = mDatabase.getParentIdentifier(
parentIdentifier.mDocumentId);
notifyChildDocumentsChange(deviceIdentifier.mDocumentId);
}
} catch (IOException error) {
Log.e(MtpDocumentsProvider.TAG, "deleteDocument", error);
throw new FileNotFoundException(error.getMessage());
}
}
@Override
public void onTrimMemory(int level) {
synchronized (mDeviceListLock) {
for (final DeviceToolkit toolkit : mDeviceToolkits.values()) {
toolkit.mDocumentLoader.clearCompletedTasks();
}
}
}
@Override
public String createDocument(String parentDocumentId, String mimeType, String displayName)
throws FileNotFoundException {
if (DEBUG) {
Log.d(TAG, "createDocument: " + displayName);
}
final Identifier parentId;
final MtpDeviceRecord record;
final ParcelFileDescriptor[] pipe;
try {
parentId = mDatabase.createIdentifier(parentDocumentId);
openDevice(parentId.mDeviceId);
record = getDeviceToolkit(parentId.mDeviceId).mDeviceRecord;
if (!MtpDeviceRecord.isWritingSupported(record.operationsSupported)) {
throw new UnsupportedOperationException(
"Writing operation is not supported by the device.");
}
pipe = ParcelFileDescriptor.createReliablePipe();
int objectHandle = -1;
MtpObjectInfo info = null;
try {
pipe[0].close(); // 0 bytes for a new document.
final int formatCode = Document.MIME_TYPE_DIR.equals(mimeType) ?
MtpConstants.FORMAT_ASSOCIATION :
MediaFile.getFormatCode(displayName, mimeType);
info = new MtpObjectInfo.Builder()
.setStorageId(parentId.mStorageId)
.setParent(parentId.mObjectHandle)
.setFormat(formatCode)
.setName(displayName)
.build();
final String[] parts = FileUtils.splitFileName(mimeType, displayName);
final String baseName = parts[0];
final String extension = parts[1];
for (int i = 0; i <= 32; i++) {
final MtpObjectInfo infoUniqueName;
if (i == 0) {
infoUniqueName = info;
} else {
String suffixedName = baseName + " (" + i + " )";
if (!extension.isEmpty()) {
suffixedName += "." + extension;
}
infoUniqueName =
new MtpObjectInfo.Builder(info).setName(suffixedName).build();
}
try {
objectHandle = mMtpManager.createDocument(
parentId.mDeviceId, infoUniqueName, pipe[1]);
break;
} catch (SendObjectInfoFailure exp) {
// This can be caused when we have an existing file with the same name.
continue;
}
}
} finally {
pipe[1].close();
}
if (objectHandle == -1) {
throw new IllegalArgumentException(
"The file name \"" + displayName + "\" is conflicted with existing files " +
"and the provider failed to find unique name.");
}
final MtpObjectInfo infoWithHandle =
new MtpObjectInfo.Builder(info).setObjectHandle(objectHandle).build();
final String documentId = mDatabase.putNewDocument(
parentId.mDeviceId, parentDocumentId, record.operationsSupported,
infoWithHandle, 0l);
getDocumentLoader(parentId).cancelTask(parentId);
notifyChildDocumentsChange(parentDocumentId);
return documentId;
} catch (FileNotFoundException | RuntimeException error) {
Log.e(TAG, "createDocument", error);
throw error;
} catch (IOException error) {
Log.e(TAG, "createDocument", error);
throw new IllegalStateException(error);
}
}
void openDevice(int deviceId) throws IOException {
synchronized (mDeviceListLock) {
if (mDeviceToolkits.containsKey(deviceId)) {
return;
}
if (DEBUG) {
Log.d(TAG, "Open device " + deviceId);
}
final MtpDeviceRecord device = mMtpManager.openDevice(deviceId);
final DeviceToolkit toolkit =
new DeviceToolkit(mMtpManager, mResolver, mDatabase, device);
mDeviceToolkits.put(deviceId, toolkit);
mIntentSender.sendUpdateNotificationIntent();
try {
mRootScanner.resume().await();
} catch (InterruptedException error) {
Log.e(TAG, "openDevice", error);
}
// Resume document loader to remap disconnected document ID. Must be invoked after the
// root scanner resumes.
toolkit.mDocumentLoader.resume();
}
}
void closeDevice(int deviceId) throws IOException, InterruptedException {
synchronized (mDeviceListLock) {
closeDeviceInternal(deviceId);
}
mRootScanner.resume();
mIntentSender.sendUpdateNotificationIntent();
}
MtpDeviceRecord[] getOpenedDeviceRecordsCache() {
synchronized (mDeviceListLock) {
final MtpDeviceRecord[] records = new MtpDeviceRecord[mDeviceToolkits.size()];
int i = 0;
for (final DeviceToolkit toolkit : mDeviceToolkits.values()) {
records[i] = toolkit.mDeviceRecord;
i++;
}
return records;
}
}
/**
* Obtains document ID for the given device ID.
* @param deviceId
* @return document ID
* @throws FileNotFoundException device ID has not been build.
*/
public String getDeviceDocumentId(int deviceId) throws FileNotFoundException {
return mDatabase.getDeviceDocumentId(deviceId);
}
/**
* Resumes root scanner to handle the update of device list.
*/
void resumeRootScanner() {
if (DEBUG) {
Log.d(MtpDocumentsProvider.TAG, "resumeRootScanner");
}
mRootScanner.resume();
}
/**
* Finalize the content provider for unit tests.
*/
@Override
public void shutdown() {
synchronized (mDeviceListLock) {
try {
// Copy the opened key set because it will be modified when closing devices.
final Integer[] keySet =
mDeviceToolkits.keySet().toArray(new Integer[mDeviceToolkits.size()]);
for (final int id : keySet) {
closeDeviceInternal(id);
}
mRootScanner.pause();
} catch (InterruptedException | IOException | TimeoutException e) {
// It should fail unit tests by throwing runtime exception.
throw new RuntimeException(e);
} finally {
mDatabase.close();
mAppFuse.close();
super.shutdown();
}
}
}
private void notifyChildDocumentsChange(String parentDocumentId) {
mResolver.notifyChange(
DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId),
null,
false);
}
/**
* Clears MTP identifier in the database.
*/
private void resume() {
synchronized (mDeviceListLock) {
mDatabase.getMapper().clearMapping();
}
}
private void closeDeviceInternal(int deviceId) throws IOException, InterruptedException {
// TODO: Flush the device before closing (if not closed externally).
if (!mDeviceToolkits.containsKey(deviceId)) {
return;
}
if (DEBUG) {
Log.d(TAG, "Close device " + deviceId);
}
getDeviceToolkit(deviceId).close();
mDeviceToolkits.remove(deviceId);
mMtpManager.closeDevice(deviceId);
}
private DeviceToolkit getDeviceToolkit(int deviceId) throws FileNotFoundException {
synchronized (mDeviceListLock) {
final DeviceToolkit toolkit = mDeviceToolkits.get(deviceId);
if (toolkit == null) {
throw new FileNotFoundException();
}
return toolkit;
}
}
private PipeManager getPipeManager(Identifier identifier) throws FileNotFoundException {
return getDeviceToolkit(identifier.mDeviceId).mPipeManager;
}
private DocumentLoader getDocumentLoader(Identifier identifier) throws FileNotFoundException {
return getDeviceToolkit(identifier.mDeviceId).mDocumentLoader;
}
private long getFileSize(String documentId) throws FileNotFoundException {
final Cursor cursor = mDatabase.queryDocument(
documentId,
MtpDatabase.strings(Document.COLUMN_SIZE, Document.COLUMN_DISPLAY_NAME));
try {
if (cursor.moveToNext()) {
if (cursor.isNull(0)) {
throw new UnsupportedOperationException();
}
return cursor.getLong(0);
} else {
throw new FileNotFoundException();
}
} finally {
cursor.close();
}
}
/**
* Creates empty cursor with specific error message.
*
* @param projection Column names.
* @param stringResId String resource ID of error message.
* @return Empty cursor with error message.
*/
private Cursor createErrorCursor(String[] projection, int stringResId) {
final Bundle bundle = new Bundle();
bundle.putString(DocumentsContract.EXTRA_ERROR, mResources.getString(stringResId));
final Cursor cursor = new MatrixCursor(projection);
cursor.setExtras(bundle);
return cursor;
}
private static class DeviceToolkit implements AutoCloseable {
public final PipeManager mPipeManager;
public final DocumentLoader mDocumentLoader;
public final MtpDeviceRecord mDeviceRecord;
public DeviceToolkit(MtpManager manager,
ContentResolver resolver,
MtpDatabase database,
MtpDeviceRecord record) {
mPipeManager = new PipeManager(database);
mDocumentLoader = new DocumentLoader(record, manager, resolver, database);
mDeviceRecord = record;
}
@Override
public void close() throws InterruptedException {
mPipeManager.close();
mDocumentLoader.close();
}
}
private class AppFuseCallback implements AppFuse.Callback {
private final Map<Long, MtpFileWriter> mWriters = new HashMap<>();
@Override
public long getFileSize(int inode) throws FileNotFoundException {
return MtpDocumentsProvider.this.getFileSize(String.valueOf(inode));
}
@Override
public long readObjectBytes(
int inode, long offset, long size, byte[] buffer) throws IOException {
final Identifier identifier = mDatabase.createIdentifier(Integer.toString(inode));
final MtpDeviceRecord record = getDeviceToolkit(identifier.mDeviceId).mDeviceRecord;
if (MtpDeviceRecord.isSupported(
record.operationsSupported, MtpConstants.OPERATION_GET_PARTIAL_OBJECT_64)) {
return mMtpManager.getPartialObject64(
identifier.mDeviceId, identifier.mObjectHandle, offset, size, buffer);
}
if (0 <= offset && offset <= 0xffffffffL && MtpDeviceRecord.isSupported(
record.operationsSupported, MtpConstants.OPERATION_GET_PARTIAL_OBJECT)) {
return mMtpManager.getPartialObject(
identifier.mDeviceId, identifier.mObjectHandle, offset, size, buffer);
}
throw new UnsupportedOperationException();
}
@Override
public int writeObjectBytes(
long fileHandle, int inode, long offset, int size, byte[] bytes)
throws IOException, ErrnoException {
final MtpFileWriter writer;
if (mWriters.containsKey(fileHandle)) {
writer = mWriters.get(fileHandle);
} else {
writer = new MtpFileWriter(mContext, String.valueOf(inode));
mWriters.put(fileHandle, writer);
}
return writer.write(offset, size, bytes);
}
@Override
public void flushFileHandle(long fileHandle) throws IOException, ErrnoException {
final MtpFileWriter writer = mWriters.get(fileHandle);
if (writer == null) {
// File handle for reading.
return;
}
final MtpDeviceRecord device = getDeviceToolkit(
mDatabase.createIdentifier(writer.getDocumentId()).mDeviceId).mDeviceRecord;
writer.flush(mMtpManager, mDatabase, device.operationsSupported);
}
@Override
public void closeFileHandle(long fileHandle) throws IOException, ErrnoException {
final MtpFileWriter writer = mWriters.get(fileHandle);
if (writer == null) {
// File handle for reading.
return;
}
try {
writer.close();
} finally {
mWriters.remove(fileHandle);
}
}
}
}