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