MtpDocumentsProvider.java revision 61ba923ca0cb5c928a16729d0aa67b6bf4b2f027
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.content.ContentResolver; 20import android.content.res.AssetFileDescriptor; 21import android.content.res.Resources; 22import android.database.Cursor; 23import android.database.MatrixCursor; 24import android.graphics.Point; 25import android.media.MediaFile; 26import android.mtp.MtpConstants; 27import android.mtp.MtpObjectInfo; 28import android.os.Bundle; 29import android.os.CancellationSignal; 30import android.os.ParcelFileDescriptor; 31import android.os.storage.StorageManager; 32import android.provider.DocumentsContract.Document; 33import android.provider.DocumentsContract.Root; 34import android.provider.DocumentsContract; 35import android.provider.DocumentsProvider; 36import android.util.Log; 37 38import com.android.internal.annotations.GuardedBy; 39import com.android.internal.annotations.VisibleForTesting; 40import com.android.mtp.exceptions.BusyDeviceException; 41 42import java.io.FileNotFoundException; 43import java.io.IOException; 44import java.util.HashMap; 45import java.util.Map; 46 47/** 48 * DocumentsProvider for MTP devices. 49 */ 50public class MtpDocumentsProvider extends DocumentsProvider { 51 static final String AUTHORITY = "com.android.mtp.documents"; 52 static final String TAG = "MtpDocumentsProvider"; 53 static final String[] DEFAULT_ROOT_PROJECTION = new String[] { 54 Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON, 55 Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID, 56 Root.COLUMN_AVAILABLE_BYTES, 57 }; 58 static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { 59 Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, 60 Document.COLUMN_DISPLAY_NAME, Document.COLUMN_LAST_MODIFIED, 61 Document.COLUMN_FLAGS, Document.COLUMN_SIZE, 62 }; 63 64 static final boolean DEBUG = false; 65 66 private final Object mDeviceListLock = new Object(); 67 68 private static MtpDocumentsProvider sSingleton; 69 70 private MtpManager mMtpManager; 71 private ContentResolver mResolver; 72 @GuardedBy("mDeviceListLock") 73 private Map<Integer, DeviceToolkit> mDeviceToolkits; 74 private RootScanner mRootScanner; 75 private Resources mResources; 76 private MtpDatabase mDatabase; 77 private AppFuse mAppFuse; 78 private ServiceIntentSender mIntentSender; 79 80 /** 81 * Provides singleton instance to MtpDocumentsService. 82 */ 83 static MtpDocumentsProvider getInstance() { 84 return sSingleton; 85 } 86 87 @Override 88 public boolean onCreate() { 89 sSingleton = this; 90 mResources = getContext().getResources(); 91 mMtpManager = new MtpManager(getContext()); 92 mResolver = getContext().getContentResolver(); 93 mDeviceToolkits = new HashMap<Integer, DeviceToolkit>(); 94 mDatabase = new MtpDatabase(getContext(), MtpDatabaseConstants.FLAG_DATABASE_IN_FILE); 95 mRootScanner = new RootScanner(mResolver, mMtpManager, mDatabase); 96 mAppFuse = new AppFuse(TAG, new AppFuseCallback()); 97 mIntentSender = new ServiceIntentSender(getContext()); 98 // TODO: Mount AppFuse on demands. 99 try { 100 mAppFuse.mount(getContext().getSystemService(StorageManager.class)); 101 } catch (IOException e) { 102 Log.e(TAG, "Failed to start app fuse.", e); 103 return false; 104 } 105 resume(); 106 return true; 107 } 108 109 @VisibleForTesting 110 boolean onCreateForTesting( 111 Resources resources, 112 MtpManager mtpManager, 113 ContentResolver resolver, 114 MtpDatabase database, 115 StorageManager storageManager, 116 ServiceIntentSender intentSender) { 117 mResources = resources; 118 mMtpManager = mtpManager; 119 mResolver = resolver; 120 mDeviceToolkits = new HashMap<Integer, DeviceToolkit>(); 121 mDatabase = database; 122 mRootScanner = new RootScanner(mResolver, mMtpManager, mDatabase); 123 mAppFuse = new AppFuse(TAG, new AppFuseCallback()); 124 mIntentSender = intentSender; 125 // TODO: Mount AppFuse on demands. 126 try { 127 mAppFuse.mount(storageManager); 128 } catch (IOException e) { 129 Log.e(TAG, "Failed to start app fuse.", e); 130 return false; 131 } 132 resume(); 133 return true; 134 } 135 136 @Override 137 public Cursor queryRoots(String[] projection) throws FileNotFoundException { 138 if (projection == null) { 139 projection = MtpDocumentsProvider.DEFAULT_ROOT_PROJECTION; 140 } 141 final Cursor cursor = mDatabase.queryRoots(mResources, projection); 142 cursor.setNotificationUri( 143 mResolver, DocumentsContract.buildRootsUri(MtpDocumentsProvider.AUTHORITY)); 144 return cursor; 145 } 146 147 @Override 148 public Cursor queryDocument(String documentId, String[] projection) 149 throws FileNotFoundException { 150 if (projection == null) { 151 projection = MtpDocumentsProvider.DEFAULT_DOCUMENT_PROJECTION; 152 } 153 return mDatabase.queryDocument(documentId, projection); 154 } 155 156 @Override 157 public Cursor queryChildDocuments(String parentDocumentId, 158 String[] projection, String sortOrder) throws FileNotFoundException { 159 if (DEBUG) { 160 Log.d(TAG, "queryChildDocuments: " + parentDocumentId); 161 } 162 if (projection == null) { 163 projection = MtpDocumentsProvider.DEFAULT_DOCUMENT_PROJECTION; 164 } 165 Identifier parentIdentifier = mDatabase.createIdentifier(parentDocumentId); 166 try { 167 openDevice(parentIdentifier.mDeviceId); 168 if (parentIdentifier.mDocumentType == MtpDatabaseConstants.DOCUMENT_TYPE_DEVICE) { 169 final String[] storageDocIds = mDatabase.getStorageDocumentIds(parentDocumentId); 170 if (storageDocIds.length == 0) { 171 // Remote device does not provide storages. Maybe it is locked. 172 return createErrorCursor(projection, R.string.error_locked_device); 173 } else if (storageDocIds.length > 1) { 174 // Returns storage list from database. 175 return mDatabase.queryChildDocuments(projection, parentDocumentId); 176 } 177 178 // Exact one storage is found. Skip storage and returns object in the single 179 // storage. 180 parentIdentifier = mDatabase.createIdentifier(storageDocIds[0]); 181 } 182 183 // Returns object list from document loader. 184 return getDocumentLoader(parentIdentifier).queryChildDocuments( 185 projection, parentIdentifier); 186 } catch (BusyDeviceException exception) { 187 return createErrorCursor(projection, R.string.error_busy_device); 188 } catch (IOException exception) { 189 Log.e(MtpDocumentsProvider.TAG, "queryChildDocuments", exception); 190 throw new FileNotFoundException(exception.getMessage()); 191 } 192 } 193 194 @Override 195 public ParcelFileDescriptor openDocument( 196 String documentId, String mode, CancellationSignal signal) 197 throws FileNotFoundException { 198 if (DEBUG) { 199 Log.d(TAG, "openDocument: " + documentId); 200 } 201 final Identifier identifier = mDatabase.createIdentifier(documentId); 202 try { 203 openDevice(identifier.mDeviceId); 204 final MtpDeviceRecord device = getDeviceToolkit(identifier.mDeviceId).mDeviceRecord; 205 switch (mode) { 206 case "r": 207 final long fileSize = getFileSize(documentId); 208 // MTP getPartialObject operation does not support files that are larger than 209 // 4GB. Fallback to non-seekable file descriptor. 210 // TODO: Use getPartialObject64 for MTP devices that support Android vendor 211 // extension. 212 if (MtpDeviceRecord.isPartialReadSupported( 213 device.operationsSupported, fileSize)) { 214 return mAppFuse.openFile(Integer.parseInt(documentId)); 215 } else { 216 return getPipeManager(identifier).readDocument(mMtpManager, identifier); 217 } 218 case "w": 219 // TODO: Clear the parent document loader task (if exists) and call notify 220 // when writing is completed. 221 if (MtpDeviceRecord.isWritingSupported(device.operationsSupported)) { 222 return getPipeManager(identifier).writeDocument( 223 getContext(), mMtpManager, identifier, device.operationsSupported); 224 } else { 225 throw new UnsupportedOperationException( 226 "The device does not support writing operation."); 227 } 228 case "rw": 229 // TODO: Add support for "rw" mode. 230 throw new UnsupportedOperationException( 231 "The provider does not support 'rw' mode."); 232 default: 233 throw new IllegalArgumentException("Unknown mode for openDocument: " + mode); 234 } 235 } catch (IOException error) { 236 Log.e(MtpDocumentsProvider.TAG, "openDocument", error); 237 throw new FileNotFoundException(error.getMessage()); 238 } 239 } 240 241 @Override 242 public AssetFileDescriptor openDocumentThumbnail( 243 String documentId, 244 Point sizeHint, 245 CancellationSignal signal) throws FileNotFoundException { 246 final Identifier identifier = mDatabase.createIdentifier(documentId); 247 try { 248 openDevice(identifier.mDeviceId); 249 return new AssetFileDescriptor( 250 getPipeManager(identifier).readThumbnail(mMtpManager, identifier), 251 0, // Start offset. 252 AssetFileDescriptor.UNKNOWN_LENGTH); 253 } catch (IOException error) { 254 Log.e(MtpDocumentsProvider.TAG, "openDocumentThumbnail", error); 255 throw new FileNotFoundException(error.getMessage()); 256 } 257 } 258 259 @Override 260 public void deleteDocument(String documentId) throws FileNotFoundException { 261 try { 262 final Identifier identifier = mDatabase.createIdentifier(documentId); 263 openDevice(identifier.mDeviceId); 264 final Identifier parentIdentifier = mDatabase.getParentIdentifier(documentId); 265 mMtpManager.deleteDocument(identifier.mDeviceId, identifier.mObjectHandle); 266 mDatabase.deleteDocument(documentId); 267 getDocumentLoader(parentIdentifier).clearTask(parentIdentifier); 268 notifyChildDocumentsChange(parentIdentifier.mDocumentId); 269 if (parentIdentifier.mDocumentType == MtpDatabaseConstants.DOCUMENT_TYPE_STORAGE) { 270 // If the parent is storage, the object might be appeared as child of device because 271 // we skip storage when the device has only one storage. 272 final Identifier deviceIdentifier = mDatabase.getParentIdentifier( 273 parentIdentifier.mDocumentId); 274 notifyChildDocumentsChange(deviceIdentifier.mDocumentId); 275 } 276 } catch (IOException error) { 277 Log.e(MtpDocumentsProvider.TAG, "deleteDocument", error); 278 throw new FileNotFoundException(error.getMessage()); 279 } 280 } 281 282 @Override 283 public void onTrimMemory(int level) { 284 synchronized (mDeviceListLock) { 285 for (final DeviceToolkit toolkit : mDeviceToolkits.values()) { 286 toolkit.mDocumentLoader.clearCompletedTasks(); 287 } 288 } 289 } 290 291 @Override 292 public String createDocument(String parentDocumentId, String mimeType, String displayName) 293 throws FileNotFoundException { 294 if (DEBUG) { 295 Log.d(TAG, "createDocument: " + displayName); 296 } 297 try { 298 final Identifier parentId = mDatabase.createIdentifier(parentDocumentId); 299 openDevice(parentId.mDeviceId); 300 final MtpDeviceRecord record = getDeviceToolkit(parentId.mDeviceId).mDeviceRecord; 301 if (!MtpDeviceRecord.isWritingSupported(record.operationsSupported)) { 302 throw new UnsupportedOperationException(); 303 } 304 final ParcelFileDescriptor pipe[] = ParcelFileDescriptor.createReliablePipe(); 305 pipe[0].close(); // 0 bytes for a new document. 306 final int formatCode = Document.MIME_TYPE_DIR.equals(mimeType) ? 307 MtpConstants.FORMAT_ASSOCIATION : 308 MediaFile.getFormatCode(displayName, mimeType); 309 final MtpObjectInfo info = new MtpObjectInfo.Builder() 310 .setStorageId(parentId.mStorageId) 311 .setParent(parentId.mObjectHandle) 312 .setFormat(formatCode) 313 .setName(displayName) 314 .build(); 315 final int objectHandle = mMtpManager.createDocument(parentId.mDeviceId, info, pipe[1]); 316 final MtpObjectInfo infoWithHandle = 317 new MtpObjectInfo.Builder(info).setObjectHandle(objectHandle).build(); 318 final String documentId = mDatabase.putNewDocument( 319 parentId.mDeviceId, parentDocumentId, record.operationsSupported, 320 infoWithHandle); 321 getDocumentLoader(parentId).clearTask(parentId); 322 notifyChildDocumentsChange(parentDocumentId); 323 return documentId; 324 } catch (IOException error) { 325 Log.e(TAG, "createDocument", error); 326 throw new FileNotFoundException(error.getMessage()); 327 } 328 } 329 330 void openDevice(int deviceId) throws IOException { 331 synchronized (mDeviceListLock) { 332 if (mDeviceToolkits.containsKey(deviceId)) { 333 return; 334 } 335 if (DEBUG) { 336 Log.d(TAG, "Open device " + deviceId); 337 } 338 final MtpDeviceRecord device = mMtpManager.openDevice(deviceId); 339 final DeviceToolkit toolkit = 340 new DeviceToolkit(mMtpManager, mResolver, mDatabase, device); 341 mDeviceToolkits.put(deviceId, toolkit); 342 mIntentSender.sendUpdateNotificationIntent(); 343 try { 344 mRootScanner.resume().await(); 345 } catch (InterruptedException error) { 346 Log.e(TAG, "openDevice", error); 347 } 348 // Resume document loader to remap disconnected document ID. Must be invoked after the 349 // root scanner resumes. 350 toolkit.mDocumentLoader.resume(); 351 } 352 } 353 354 void closeDevice(int deviceId) throws IOException, InterruptedException { 355 synchronized (mDeviceListLock) { 356 closeDeviceInternal(deviceId); 357 } 358 mRootScanner.resume(); 359 mIntentSender.sendUpdateNotificationIntent(); 360 } 361 362 MtpDeviceRecord[] getOpenedDeviceRecordsCache() { 363 synchronized (mDeviceListLock) { 364 final MtpDeviceRecord[] records = new MtpDeviceRecord[mDeviceToolkits.size()]; 365 int i = 0; 366 for (final DeviceToolkit toolkit : mDeviceToolkits.values()) { 367 records[i] = toolkit.mDeviceRecord; 368 i++; 369 } 370 return records; 371 } 372 } 373 374 /** 375 * Obtains document ID for the given device ID. 376 * @param deviceId 377 * @return document ID 378 * @throws FileNotFoundException device ID has not been build. 379 */ 380 public String getDeviceDocumentId(int deviceId) throws FileNotFoundException { 381 return mDatabase.getDeviceDocumentId(deviceId); 382 } 383 384 /** 385 * Resumes root scanner to handle the update of device list. 386 */ 387 void resumeRootScanner() { 388 if (DEBUG) { 389 Log.d(MtpDocumentsProvider.TAG, "resumeRootScanner"); 390 } 391 mRootScanner.resume(); 392 } 393 394 /** 395 * Finalize the content provider for unit tests. 396 */ 397 @Override 398 public void shutdown() { 399 synchronized (mDeviceListLock) { 400 try { 401 // Copy the opened key set because it will be modified when closing devices. 402 final Integer[] keySet = 403 mDeviceToolkits.keySet().toArray(new Integer[mDeviceToolkits.size()]); 404 for (final int id : keySet) { 405 closeDeviceInternal(id); 406 } 407 } catch (InterruptedException|IOException e) { 408 // It should fail unit tests by throwing runtime exception. 409 throw new RuntimeException(e); 410 } finally { 411 mDatabase.close(); 412 mAppFuse.close(); 413 super.shutdown(); 414 } 415 } 416 } 417 418 private void notifyChildDocumentsChange(String parentDocumentId) { 419 mResolver.notifyChange( 420 DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId), 421 null, 422 false); 423 } 424 425 /** 426 * Clears MTP identifier in the database. 427 */ 428 private void resume() { 429 synchronized (mDeviceListLock) { 430 mDatabase.getMapper().clearMapping(); 431 } 432 } 433 434 private void closeDeviceInternal(int deviceId) throws IOException, InterruptedException { 435 // TODO: Flush the device before closing (if not closed externally). 436 if (!mDeviceToolkits.containsKey(deviceId)) { 437 return; 438 } 439 if (DEBUG) { 440 Log.d(TAG, "Close device " + deviceId); 441 } 442 getDeviceToolkit(deviceId).mDocumentLoader.close(); 443 mDeviceToolkits.remove(deviceId); 444 mMtpManager.closeDevice(deviceId); 445 if (mDeviceToolkits.size() == 0) { 446 mRootScanner.pause(); 447 } 448 } 449 450 private DeviceToolkit getDeviceToolkit(int deviceId) throws FileNotFoundException { 451 synchronized (mDeviceListLock) { 452 final DeviceToolkit toolkit = mDeviceToolkits.get(deviceId); 453 if (toolkit == null) { 454 throw new FileNotFoundException(); 455 } 456 return toolkit; 457 } 458 } 459 460 private PipeManager getPipeManager(Identifier identifier) throws FileNotFoundException { 461 return getDeviceToolkit(identifier.mDeviceId).mPipeManager; 462 } 463 464 private DocumentLoader getDocumentLoader(Identifier identifier) throws FileNotFoundException { 465 return getDeviceToolkit(identifier.mDeviceId).mDocumentLoader; 466 } 467 468 private long getFileSize(String documentId) throws FileNotFoundException { 469 final Cursor cursor = mDatabase.queryDocument( 470 documentId, 471 MtpDatabase.strings(Document.COLUMN_SIZE, Document.COLUMN_DISPLAY_NAME)); 472 try { 473 if (cursor.moveToNext()) { 474 return cursor.getLong(0); 475 } else { 476 throw new FileNotFoundException(); 477 } 478 } finally { 479 cursor.close(); 480 } 481 } 482 483 /** 484 * Creates empty cursor with specific error message. 485 * 486 * @param projection Column names. 487 * @param stringResId String resource ID of error message. 488 * @return Empty cursor with error message. 489 */ 490 private Cursor createErrorCursor(String[] projection, int stringResId) { 491 final Bundle bundle = new Bundle(); 492 bundle.putString(DocumentsContract.EXTRA_ERROR, mResources.getString(stringResId)); 493 final Cursor cursor = new MatrixCursor(projection); 494 cursor.setExtras(bundle); 495 return cursor; 496 } 497 498 private static class DeviceToolkit { 499 public final PipeManager mPipeManager; 500 public final DocumentLoader mDocumentLoader; 501 public final MtpDeviceRecord mDeviceRecord; 502 503 public DeviceToolkit(MtpManager manager, 504 ContentResolver resolver, 505 MtpDatabase database, 506 MtpDeviceRecord record) { 507 mPipeManager = new PipeManager(database); 508 mDocumentLoader = new DocumentLoader(record, manager, resolver, database); 509 mDeviceRecord = record; 510 } 511 } 512 513 private class AppFuseCallback implements AppFuse.Callback { 514 @Override 515 public long readObjectBytes( 516 int inode, long offset, long size, byte[] buffer) throws IOException { 517 final Identifier identifier = mDatabase.createIdentifier(Integer.toString(inode)); 518 final MtpDeviceRecord record = getDeviceToolkit(identifier.mDeviceId).mDeviceRecord; 519 if (MtpDeviceRecord.isPartialReadSupported(record.operationsSupported, offset)) { 520 return mMtpManager.getPartialObject( 521 identifier.mDeviceId, identifier.mObjectHandle, offset, size, buffer); 522 } else { 523 throw new UnsupportedOperationException(); 524 } 525 } 526 527 @Override 528 public long getFileSize(int inode) throws FileNotFoundException { 529 return MtpDocumentsProvider.this.getFileSize(String.valueOf(inode)); 530 } 531 } 532} 533