MtpDocumentsProvider.java revision 0abde68519f62d001aa075235c6da2e594ffc186
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            switch (mode) {
205                case "r":
206                    final long fileSize = getFileSize(documentId);
207                    // MTP getPartialObject operation does not support files that are larger than
208                    // 4GB. Fallback to non-seekable file descriptor.
209                    // TODO: Use getPartialObject64 for MTP devices that support Android vendor
210                    // extension.
211                    if (fileSize <= 0xffffffffl) {
212                        return mAppFuse.openFile(Integer.parseInt(documentId));
213                    } else {
214                        return getPipeManager(identifier).readDocument(mMtpManager, identifier);
215                    }
216                case "w":
217                    // TODO: Clear the parent document loader task (if exists) and call notify
218                    // when writing is completed.
219                    return getPipeManager(identifier).writeDocument(
220                            getContext(), mMtpManager, identifier);
221                case "rw":
222                    // TODO: Add support for "rw" mode.
223                    throw new UnsupportedOperationException(
224                            "The provider does not support 'rw' mode.");
225                default:
226                    throw new IllegalArgumentException("Unknown mode for openDocument: " + mode);
227            }
228        } catch (IOException error) {
229            Log.e(MtpDocumentsProvider.TAG, "openDocument", error);
230            throw new FileNotFoundException(error.getMessage());
231        }
232    }
233
234    @Override
235    public AssetFileDescriptor openDocumentThumbnail(
236            String documentId,
237            Point sizeHint,
238            CancellationSignal signal) throws FileNotFoundException {
239        final Identifier identifier = mDatabase.createIdentifier(documentId);
240        try {
241            openDevice(identifier.mDeviceId);
242            return new AssetFileDescriptor(
243                    getPipeManager(identifier).readThumbnail(mMtpManager, identifier),
244                    0,  // Start offset.
245                    AssetFileDescriptor.UNKNOWN_LENGTH);
246        } catch (IOException error) {
247            Log.e(MtpDocumentsProvider.TAG, "openDocumentThumbnail", error);
248            throw new FileNotFoundException(error.getMessage());
249        }
250    }
251
252    @Override
253    public void deleteDocument(String documentId) throws FileNotFoundException {
254        try {
255            final Identifier identifier = mDatabase.createIdentifier(documentId);
256            openDevice(identifier.mDeviceId);
257            final Identifier parentIdentifier = mDatabase.getParentIdentifier(documentId);
258            mMtpManager.deleteDocument(identifier.mDeviceId, identifier.mObjectHandle);
259            mDatabase.deleteDocument(documentId);
260            getDocumentLoader(parentIdentifier).clearTask(parentIdentifier);
261            notifyChildDocumentsChange(parentIdentifier.mDocumentId);
262            if (parentIdentifier.mDocumentType == MtpDatabaseConstants.DOCUMENT_TYPE_STORAGE) {
263                // If the parent is storage, the object might be appeared as child of device because
264                // we skip storage when the device has only one storage.
265                final Identifier deviceIdentifier = mDatabase.getParentIdentifier(
266                        parentIdentifier.mDocumentId);
267                notifyChildDocumentsChange(deviceIdentifier.mDocumentId);
268            }
269        } catch (IOException error) {
270            Log.e(MtpDocumentsProvider.TAG, "deleteDocument", error);
271            throw new FileNotFoundException(error.getMessage());
272        }
273    }
274
275    @Override
276    public void onTrimMemory(int level) {
277        synchronized (mDeviceListLock) {
278            for (final DeviceToolkit toolkit : mDeviceToolkits.values()) {
279                toolkit.mDocumentLoader.clearCompletedTasks();
280            }
281        }
282    }
283
284    @Override
285    public String createDocument(String parentDocumentId, String mimeType, String displayName)
286            throws FileNotFoundException {
287        if (DEBUG) {
288            Log.d(TAG, "createDocument: " + displayName);
289        }
290        try {
291            final Identifier parentId = mDatabase.createIdentifier(parentDocumentId);
292            openDevice(parentId.mDeviceId);
293            final ParcelFileDescriptor pipe[] = ParcelFileDescriptor.createReliablePipe();
294            pipe[0].close();  // 0 bytes for a new document.
295            final int formatCode = Document.MIME_TYPE_DIR.equals(mimeType) ?
296                    MtpConstants.FORMAT_ASSOCIATION :
297                    MediaFile.getFormatCode(displayName, mimeType);
298            final MtpObjectInfo info = new MtpObjectInfo.Builder()
299                    .setStorageId(parentId.mStorageId)
300                    .setParent(parentId.mObjectHandle)
301                    .setFormat(formatCode)
302                    .setName(displayName)
303                    .build();
304            final int objectHandle = mMtpManager.createDocument(parentId.mDeviceId, info, pipe[1]);
305            final MtpObjectInfo infoWithHandle =
306                    new MtpObjectInfo.Builder(info).setObjectHandle(objectHandle).build();
307            final String documentId = mDatabase.putNewDocument(
308                    parentId.mDeviceId, parentDocumentId, infoWithHandle);
309            getDocumentLoader(parentId).clearTask(parentId);
310            notifyChildDocumentsChange(parentDocumentId);
311            return documentId;
312        } catch (IOException error) {
313            Log.e(TAG, "createDocument", error);
314            throw new FileNotFoundException(error.getMessage());
315        }
316    }
317
318    void openDevice(int deviceId) throws IOException {
319        synchronized (mDeviceListLock) {
320            if (mDeviceToolkits.containsKey(deviceId)) {
321                return;
322            }
323            if (DEBUG) {
324                Log.d(TAG, "Open device " + deviceId);
325            }
326            mMtpManager.openDevice(deviceId);
327            mDeviceToolkits.put(
328                    deviceId, new DeviceToolkit(mMtpManager, mResolver, mDatabase));
329            mIntentSender.sendUpdateNotificationIntent();
330            try {
331                mRootScanner.resume().await();
332            } catch (InterruptedException error) {
333                Log.e(TAG, "openDevice", error);
334            }
335        }
336    }
337
338    void closeDevice(int deviceId) throws IOException, InterruptedException {
339        synchronized (mDeviceListLock) {
340            closeDeviceInternal(deviceId);
341        }
342        mRootScanner.resume();
343        mIntentSender.sendUpdateNotificationIntent();
344    }
345
346    int[] getOpenedDeviceIds() {
347        synchronized (mDeviceListLock) {
348            return mMtpManager.getOpenedDeviceIds();
349        }
350    }
351
352    String getDeviceName(int deviceId) throws IOException {
353        synchronized (mDeviceListLock) {
354            for (final MtpDeviceRecord device : mMtpManager.getDevices()) {
355                if (device.deviceId == deviceId) {
356                    return device.name;
357                }
358            }
359            throw new IOException("Not found the device: " + Integer.toString(deviceId));
360        }
361    }
362
363    /**
364     * Obtains document ID for the given device ID.
365     * @param deviceId
366     * @return document ID
367     * @throws FileNotFoundException device ID has not been build.
368     */
369    public String getDeviceDocumentId(int deviceId) throws FileNotFoundException {
370        return mDatabase.getDeviceDocumentId(deviceId);
371    }
372
373    /**
374     * Resumes root scanner to handle the update of device list.
375     */
376    void resumeRootScanner() {
377        mRootScanner.resume();
378    }
379
380    /**
381     * Finalize the content provider for unit tests.
382     */
383    @Override
384    public void shutdown() {
385        synchronized (mDeviceListLock) {
386            try {
387                for (final int id : mMtpManager.getOpenedDeviceIds()) {
388                    closeDeviceInternal(id);
389                }
390            } catch (InterruptedException|IOException e) {
391                // It should fail unit tests by throwing runtime exception.
392                throw new RuntimeException(e);
393            } finally {
394                mDatabase.close();
395                mAppFuse.close();
396                super.shutdown();
397            }
398        }
399    }
400
401    private void notifyChildDocumentsChange(String parentDocumentId) {
402        mResolver.notifyChange(
403                DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId),
404                null,
405                false);
406    }
407
408    /**
409     * Clears MTP identifier in the database.
410     */
411    private void resume() {
412        synchronized (mDeviceListLock) {
413            mDatabase.getMapper().clearMapping();
414        }
415    }
416
417    private void closeDeviceInternal(int deviceId) throws IOException, InterruptedException {
418        // TODO: Flush the device before closing (if not closed externally).
419        if (!mDeviceToolkits.containsKey(deviceId)) {
420            return;
421        }
422        if (DEBUG) {
423            Log.d(TAG, "Close device " + deviceId);
424        }
425        getDeviceToolkit(deviceId).mDocumentLoader.clearTasks();
426        mDeviceToolkits.remove(deviceId);
427        mMtpManager.closeDevice(deviceId);
428        if (getOpenedDeviceIds().length == 0) {
429            mRootScanner.pause();
430        }
431    }
432
433    private DeviceToolkit getDeviceToolkit(int deviceId) throws FileNotFoundException {
434        synchronized (mDeviceListLock) {
435            final DeviceToolkit toolkit = mDeviceToolkits.get(deviceId);
436            if (toolkit == null) {
437                throw new FileNotFoundException();
438            }
439            return toolkit;
440        }
441    }
442
443    private PipeManager getPipeManager(Identifier identifier) throws FileNotFoundException {
444        return getDeviceToolkit(identifier.mDeviceId).mPipeManager;
445    }
446
447    private DocumentLoader getDocumentLoader(Identifier identifier) throws FileNotFoundException {
448        return getDeviceToolkit(identifier.mDeviceId).mDocumentLoader;
449    }
450
451    private long getFileSize(String documentId) throws FileNotFoundException {
452        final Cursor cursor = mDatabase.queryDocument(
453                documentId,
454                MtpDatabase.strings(Document.COLUMN_SIZE, Document.COLUMN_DISPLAY_NAME));
455        try {
456            if (cursor.moveToNext()) {
457                return cursor.getLong(0);
458            } else {
459                throw new FileNotFoundException();
460            }
461        } finally {
462            cursor.close();
463        }
464    }
465
466    /**
467     * Creates empty cursor with specific error message.
468     *
469     * @param projection Column names.
470     * @param stringResId String resource ID of error message.
471     * @return Empty cursor with error message.
472     */
473    private Cursor createErrorCursor(String[] projection, int stringResId) {
474        final Bundle bundle = new Bundle();
475        bundle.putString(DocumentsContract.EXTRA_ERROR, mResources.getString(stringResId));
476        final Cursor cursor = new MatrixCursor(projection);
477        cursor.setExtras(bundle);
478        return cursor;
479    }
480
481    private static class DeviceToolkit {
482        public final PipeManager mPipeManager;
483        public final DocumentLoader mDocumentLoader;
484
485        public DeviceToolkit(MtpManager manager, ContentResolver resolver, MtpDatabase database) {
486            mPipeManager = new PipeManager();
487            mDocumentLoader = new DocumentLoader(manager, resolver, database);
488        }
489    }
490
491    private class AppFuseCallback implements AppFuse.Callback {
492        @Override
493        public long readObjectBytes(
494                int inode, long offset, long size, byte[] buffer) throws IOException {
495            final Identifier identifier = mDatabase.createIdentifier(Integer.toString(inode));
496            return mMtpManager.getPartialObject(
497                    identifier.mDeviceId, identifier.mObjectHandle, offset, size, buffer);
498        }
499
500        @Override
501        public long getFileSize(int inode) throws FileNotFoundException {
502            return MtpDocumentsProvider.this.getFileSize(String.valueOf(inode));
503        }
504    }
505}
506