FileSystemProvider.java revision 49ccf1316189f33b2a38f2637243da0ea398aadb
1/*
2 * Copyright (C) 2017 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.internal.content;
18
19import android.annotation.CallSuper;
20import android.annotation.Nullable;
21import android.content.ContentResolver;
22import android.content.ContentValues;
23import android.content.Intent;
24import android.content.res.AssetFileDescriptor;
25import android.database.Cursor;
26import android.database.MatrixCursor;
27import android.database.MatrixCursor.RowBuilder;
28import android.graphics.Point;
29import android.net.Uri;
30import android.os.Binder;
31import android.os.Bundle;
32import android.os.CancellationSignal;
33import android.os.FileObserver;
34import android.os.FileUtils;
35import android.os.Handler;
36import android.os.ParcelFileDescriptor;
37import android.provider.DocumentsContract;
38import android.provider.DocumentsContract.Document;
39import android.provider.DocumentsProvider;
40import android.provider.MediaStore;
41import android.provider.MetadataReader;
42import android.text.TextUtils;
43import android.util.ArrayMap;
44import android.util.Log;
45import android.webkit.MimeTypeMap;
46
47import com.android.internal.annotations.GuardedBy;
48
49import libcore.io.IoUtils;
50
51import java.io.File;
52import java.io.FileInputStream;
53import java.io.FileNotFoundException;
54import java.io.IOException;
55import java.util.LinkedList;
56import java.util.List;
57import java.util.Set;
58
59/**
60 * A helper class for {@link android.provider.DocumentsProvider} to perform file operations on local
61 * files.
62 */
63public abstract class FileSystemProvider extends DocumentsProvider {
64
65    private static final String TAG = "FileSystemProvider";
66
67    private static final boolean LOG_INOTIFY = false;
68
69    private String[] mDefaultProjection;
70
71    @GuardedBy("mObservers")
72    private final ArrayMap<File, DirectoryObserver> mObservers = new ArrayMap<>();
73
74    private Handler mHandler;
75
76
77    private static final String MIMETYPE_JPEG = "image/jpeg";
78
79    private static final String MIMETYPE_JPG = "image/jpg";
80
81
82
83    protected abstract File getFileForDocId(String docId, boolean visible)
84            throws FileNotFoundException;
85
86    protected abstract String getDocIdForFile(File file) throws FileNotFoundException;
87
88    protected abstract Uri buildNotificationUri(String docId);
89
90    @Override
91    public boolean onCreate() {
92        throw new UnsupportedOperationException(
93                "Subclass should override this and call onCreate(defaultDocumentProjection)");
94    }
95
96    @CallSuper
97    protected void onCreate(String[] defaultProjection) {
98        mHandler = new Handler();
99        mDefaultProjection = defaultProjection;
100    }
101
102    @Override
103    public boolean isChildDocument(String parentDocId, String docId) {
104        try {
105            final File parent = getFileForDocId(parentDocId).getCanonicalFile();
106            final File doc = getFileForDocId(docId).getCanonicalFile();
107            return FileUtils.contains(parent, doc);
108        } catch (IOException e) {
109            throw new IllegalArgumentException(
110                    "Failed to determine if " + docId + " is child of " + parentDocId + ": " + e);
111        }
112    }
113
114    @Override
115    public @Nullable Bundle getDocumentMetadata(String documentId, @Nullable String[] tags)
116            throws FileNotFoundException {
117        File file = getFileForDocId(documentId);
118        if (!(file.exists() && file.isFile() && file.canRead())) {
119            return Bundle.EMPTY;
120        }
121        String filePath = file.getAbsolutePath();
122        Bundle metadata = new Bundle();
123        if (getTypeForFile(file).equals(MIMETYPE_JPEG)
124                || getTypeForFile(file).equals(MIMETYPE_JPG)) {
125            FileInputStream stream = new FileInputStream(filePath);
126            try {
127                MetadataReader.getMetadata(metadata, stream, getTypeForFile(file), tags);
128                return metadata;
129            } catch (IOException e) {
130                Log.e(TAG, "An error occurred retrieving the metadata", e);
131            } finally {
132                IoUtils.closeQuietly(stream);
133            }
134        }
135        return null;
136    }
137
138    protected final List<String> findDocumentPath(File parent, File doc)
139            throws FileNotFoundException {
140
141        if (!doc.exists()) {
142            throw new FileNotFoundException(doc + " is not found.");
143        }
144
145        if (!FileUtils.contains(parent, doc)) {
146            throw new FileNotFoundException(doc + " is not found under " + parent);
147        }
148
149        LinkedList<String> path = new LinkedList<>();
150        while (doc != null && FileUtils.contains(parent, doc)) {
151            path.addFirst(getDocIdForFile(doc));
152
153            doc = doc.getParentFile();
154        }
155
156        return path;
157    }
158
159    @Override
160    public String createDocument(String docId, String mimeType, String displayName)
161            throws FileNotFoundException {
162        displayName = FileUtils.buildValidFatFilename(displayName);
163
164        final File parent = getFileForDocId(docId);
165        if (!parent.isDirectory()) {
166            throw new IllegalArgumentException("Parent document isn't a directory");
167        }
168
169        final File file = FileUtils.buildUniqueFile(parent, mimeType, displayName);
170        final String childId;
171        if (Document.MIME_TYPE_DIR.equals(mimeType)) {
172            if (!file.mkdir()) {
173                throw new IllegalStateException("Failed to mkdir " + file);
174            }
175            childId = getDocIdForFile(file);
176            addFolderToMediaStore(getFileForDocId(childId, true));
177        } else {
178            try {
179                if (!file.createNewFile()) {
180                    throw new IllegalStateException("Failed to touch " + file);
181                }
182                childId = getDocIdForFile(file);
183            } catch (IOException e) {
184                throw new IllegalStateException("Failed to touch " + file + ": " + e);
185            }
186        }
187
188        return childId;
189    }
190
191    private void addFolderToMediaStore(@Nullable File visibleFolder) {
192        // visibleFolder is null if we're adding a folder to external thumb drive or SD card.
193        if (visibleFolder != null) {
194            assert (visibleFolder.isDirectory());
195
196            final long token = Binder.clearCallingIdentity();
197
198            try {
199                final ContentResolver resolver = getContext().getContentResolver();
200                final Uri uri = MediaStore.Files.getDirectoryUri("external");
201                ContentValues values = new ContentValues();
202                values.put(MediaStore.Files.FileColumns.DATA, visibleFolder.getAbsolutePath());
203                resolver.insert(uri, values);
204            } finally {
205                Binder.restoreCallingIdentity(token);
206            }
207        }
208    }
209
210    @Override
211    public String renameDocument(String docId, String displayName) throws FileNotFoundException {
212        // Since this provider treats renames as generating a completely new
213        // docId, we're okay with letting the MIME type change.
214        displayName = FileUtils.buildValidFatFilename(displayName);
215
216        final File before = getFileForDocId(docId);
217        final File after = FileUtils.buildUniqueFile(before.getParentFile(), displayName);
218        final File visibleFileBefore = getFileForDocId(docId, true);
219        if (!before.renameTo(after)) {
220            throw new IllegalStateException("Failed to rename to " + after);
221        }
222
223        final String afterDocId = getDocIdForFile(after);
224        moveInMediaStore(visibleFileBefore, getFileForDocId(afterDocId, true));
225
226        if (!TextUtils.equals(docId, afterDocId)) {
227            return afterDocId;
228        } else {
229            return null;
230        }
231    }
232
233    @Override
234    public String moveDocument(String sourceDocumentId, String sourceParentDocumentId,
235            String targetParentDocumentId)
236            throws FileNotFoundException {
237        final File before = getFileForDocId(sourceDocumentId);
238        final File after = new File(getFileForDocId(targetParentDocumentId), before.getName());
239        final File visibleFileBefore = getFileForDocId(sourceDocumentId, true);
240
241        if (after.exists()) {
242            throw new IllegalStateException("Already exists " + after);
243        }
244        if (!before.renameTo(after)) {
245            throw new IllegalStateException("Failed to move to " + after);
246        }
247
248        final String docId = getDocIdForFile(after);
249        moveInMediaStore(visibleFileBefore, getFileForDocId(docId, true));
250
251        return docId;
252    }
253
254    private void moveInMediaStore(@Nullable File oldVisibleFile, @Nullable File newVisibleFile) {
255        // visibleFolders are null if we're moving a document in external thumb drive or SD card.
256        //
257        // They should be all null or not null at the same time. File#renameTo() doesn't work across
258        // volumes so an exception will be thrown before calling this method.
259        if (oldVisibleFile != null && newVisibleFile != null) {
260            final long token = Binder.clearCallingIdentity();
261
262            try {
263                final ContentResolver resolver = getContext().getContentResolver();
264                final Uri externalUri = newVisibleFile.isDirectory()
265                        ? MediaStore.Files.getDirectoryUri("external")
266                        : MediaStore.Files.getContentUri("external");
267
268                ContentValues values = new ContentValues();
269                values.put(MediaStore.Files.FileColumns.DATA, newVisibleFile.getAbsolutePath());
270
271                // Logic borrowed from MtpDatabase.
272                // note - we are relying on a special case in MediaProvider.update() to update
273                // the paths for all children in the case where this is a directory.
274                final String path = oldVisibleFile.getAbsolutePath();
275                resolver.update(externalUri,
276                        values,
277                        "_data LIKE ? AND lower(_data)=lower(?)",
278                        new String[]{path, path});
279            } finally {
280                Binder.restoreCallingIdentity(token);
281            }
282        }
283    }
284
285    @Override
286    public void deleteDocument(String docId) throws FileNotFoundException {
287        final File file = getFileForDocId(docId);
288        final File visibleFile = getFileForDocId(docId, true);
289
290        final boolean isDirectory = file.isDirectory();
291        if (isDirectory) {
292            FileUtils.deleteContents(file);
293        }
294        if (!file.delete()) {
295            throw new IllegalStateException("Failed to delete " + file);
296        }
297
298        removeFromMediaStore(visibleFile, isDirectory);
299    }
300
301    private void removeFromMediaStore(@Nullable File visibleFile, boolean isFolder)
302            throws FileNotFoundException {
303        // visibleFolder is null if we're removing a document from external thumb drive or SD card.
304        if (visibleFile != null) {
305            final long token = Binder.clearCallingIdentity();
306
307            try {
308                final ContentResolver resolver = getContext().getContentResolver();
309                final Uri externalUri = MediaStore.Files.getContentUri("external");
310
311                // Remove media store entries for any files inside this directory, using
312                // path prefix match. Logic borrowed from MtpDatabase.
313                if (isFolder) {
314                    final String path = visibleFile.getAbsolutePath() + "/";
315                    resolver.delete(externalUri,
316                            "_data LIKE ?1 AND lower(substr(_data,1,?2))=lower(?3)",
317                            new String[]{path + "%", Integer.toString(path.length()), path});
318                }
319
320                // Remove media store entry for this exact file.
321                final String path = visibleFile.getAbsolutePath();
322                resolver.delete(externalUri,
323                        "_data LIKE ?1 AND lower(_data)=lower(?2)",
324                        new String[]{path, path});
325            } finally {
326                Binder.restoreCallingIdentity(token);
327            }
328        }
329    }
330
331    @Override
332    public Cursor queryDocument(String documentId, String[] projection)
333            throws FileNotFoundException {
334        final MatrixCursor result = new MatrixCursor(resolveProjection(projection));
335        includeFile(result, documentId, null);
336        return result;
337    }
338
339    @Override
340    public Cursor queryChildDocuments(
341            String parentDocumentId, String[] projection, String sortOrder)
342            throws FileNotFoundException {
343
344        final File parent = getFileForDocId(parentDocumentId);
345        final MatrixCursor result = new DirectoryCursor(
346                resolveProjection(projection), parentDocumentId, parent);
347        for (File file : parent.listFiles()) {
348            includeFile(result, null, file);
349        }
350        return result;
351    }
352
353    /**
354     * Searches documents under the given folder.
355     *
356     * To avoid runtime explosion only returns the at most 23 items.
357     *
358     * @param folder the root folder where recursive search begins
359     * @param query the search condition used to match file names
360     * @param projection projection of the returned cursor
361     * @param exclusion absolute file paths to exclude from result
362     * @return cursor containing search result
363     * @throws FileNotFoundException when root folder doesn't exist or search fails
364     */
365    protected final Cursor querySearchDocuments(
366            File folder, String query, String[] projection, Set<String> exclusion)
367            throws FileNotFoundException {
368
369        query = query.toLowerCase();
370        final MatrixCursor result = new MatrixCursor(resolveProjection(projection));
371        final LinkedList<File> pending = new LinkedList<>();
372        pending.add(folder);
373        while (!pending.isEmpty() && result.getCount() < 24) {
374            final File file = pending.removeFirst();
375            if (file.isDirectory()) {
376                for (File child : file.listFiles()) {
377                    pending.add(child);
378                }
379            }
380            if (file.getName().toLowerCase().contains(query)
381                    && !exclusion.contains(file.getAbsolutePath())) {
382                includeFile(result, null, file);
383            }
384        }
385        return result;
386    }
387
388    @Override
389    public String getDocumentType(String documentId) throws FileNotFoundException {
390        final File file = getFileForDocId(documentId);
391        return getTypeForFile(file);
392    }
393
394    @Override
395    public ParcelFileDescriptor openDocument(
396            String documentId, String mode, CancellationSignal signal)
397            throws FileNotFoundException {
398        final File file = getFileForDocId(documentId);
399        final File visibleFile = getFileForDocId(documentId, true);
400
401        final int pfdMode = ParcelFileDescriptor.parseMode(mode);
402        if (pfdMode == ParcelFileDescriptor.MODE_READ_ONLY || visibleFile == null) {
403            return ParcelFileDescriptor.open(file, pfdMode);
404        } else {
405            try {
406                // When finished writing, kick off media scanner
407                return ParcelFileDescriptor.open(
408                        file, pfdMode, mHandler, (IOException e) -> scanFile(visibleFile));
409            } catch (IOException e) {
410                throw new FileNotFoundException("Failed to open for writing: " + e);
411            }
412        }
413    }
414
415    private void scanFile(File visibleFile) {
416        final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
417        intent.setData(Uri.fromFile(visibleFile));
418        getContext().sendBroadcast(intent);
419    }
420
421    @Override
422    public AssetFileDescriptor openDocumentThumbnail(
423            String documentId, Point sizeHint, CancellationSignal signal)
424            throws FileNotFoundException {
425        final File file = getFileForDocId(documentId);
426        return DocumentsContract.openImageThumbnail(file);
427    }
428
429    protected RowBuilder includeFile(MatrixCursor result, String docId, File file)
430            throws FileNotFoundException {
431        if (docId == null) {
432            docId = getDocIdForFile(file);
433        } else {
434            file = getFileForDocId(docId);
435        }
436
437        int flags = 0;
438
439        if (file.canWrite()) {
440            if (file.isDirectory()) {
441                flags |= Document.FLAG_DIR_SUPPORTS_CREATE;
442                flags |= Document.FLAG_SUPPORTS_DELETE;
443                flags |= Document.FLAG_SUPPORTS_RENAME;
444                flags |= Document.FLAG_SUPPORTS_MOVE;
445            } else {
446                flags |= Document.FLAG_SUPPORTS_WRITE;
447                flags |= Document.FLAG_SUPPORTS_DELETE;
448                flags |= Document.FLAG_SUPPORTS_RENAME;
449                flags |= Document.FLAG_SUPPORTS_MOVE;
450            }
451        }
452
453        final String mimeType = getTypeForFile(file);
454        final String displayName = file.getName();
455        if (mimeType.startsWith("image/")) {
456            flags |= Document.FLAG_SUPPORTS_THUMBNAIL;
457        }
458
459        final RowBuilder row = result.newRow();
460        row.add(Document.COLUMN_DOCUMENT_ID, docId);
461        row.add(Document.COLUMN_DISPLAY_NAME, displayName);
462        row.add(Document.COLUMN_SIZE, file.length());
463        row.add(Document.COLUMN_MIME_TYPE, mimeType);
464        row.add(Document.COLUMN_FLAGS, flags);
465
466        // Only publish dates reasonably after epoch
467        long lastModified = file.lastModified();
468        if (lastModified > 31536000000L) {
469            row.add(Document.COLUMN_LAST_MODIFIED, lastModified);
470        }
471
472        // Return the row builder just in case any subclass want to add more stuff to it.
473        return row;
474    }
475
476    private static String getTypeForFile(File file) {
477        if (file.isDirectory()) {
478            return Document.MIME_TYPE_DIR;
479        } else {
480            return getTypeForName(file.getName());
481        }
482    }
483
484    private static String getTypeForName(String name) {
485        final int lastDot = name.lastIndexOf('.');
486        if (lastDot >= 0) {
487            final String extension = name.substring(lastDot + 1).toLowerCase();
488            final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
489            if (mime != null) {
490                return mime;
491            }
492        }
493
494        return "application/octet-stream";
495    }
496
497    protected final File getFileForDocId(String docId) throws FileNotFoundException {
498        return getFileForDocId(docId, false);
499    }
500
501    private String[] resolveProjection(String[] projection) {
502        return projection == null ? mDefaultProjection : projection;
503    }
504
505    private void startObserving(File file, Uri notifyUri) {
506        synchronized (mObservers) {
507            DirectoryObserver observer = mObservers.get(file);
508            if (observer == null) {
509                observer = new DirectoryObserver(
510                        file, getContext().getContentResolver(), notifyUri);
511                observer.startWatching();
512                mObservers.put(file, observer);
513            }
514            observer.mRefCount++;
515
516            if (LOG_INOTIFY) Log.d(TAG, "after start: " + observer);
517        }
518    }
519
520    private void stopObserving(File file) {
521        synchronized (mObservers) {
522            DirectoryObserver observer = mObservers.get(file);
523            if (observer == null) return;
524
525            observer.mRefCount--;
526            if (observer.mRefCount == 0) {
527                mObservers.remove(file);
528                observer.stopWatching();
529            }
530
531            if (LOG_INOTIFY) Log.d(TAG, "after stop: " + observer);
532        }
533    }
534
535    private static class DirectoryObserver extends FileObserver {
536        private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO
537                | CREATE | DELETE | DELETE_SELF | MOVE_SELF;
538
539        private final File mFile;
540        private final ContentResolver mResolver;
541        private final Uri mNotifyUri;
542
543        private int mRefCount = 0;
544
545        public DirectoryObserver(File file, ContentResolver resolver, Uri notifyUri) {
546            super(file.getAbsolutePath(), NOTIFY_EVENTS);
547            mFile = file;
548            mResolver = resolver;
549            mNotifyUri = notifyUri;
550        }
551
552        @Override
553        public void onEvent(int event, String path) {
554            if ((event & NOTIFY_EVENTS) != 0) {
555                if (LOG_INOTIFY) Log.d(TAG, "onEvent() " + event + " at " + path);
556                mResolver.notifyChange(mNotifyUri, null, false);
557            }
558        }
559
560        @Override
561        public String toString() {
562            return "DirectoryObserver{file=" + mFile.getAbsolutePath() + ", ref=" + mRefCount + "}";
563        }
564    }
565
566    private class DirectoryCursor extends MatrixCursor {
567        private final File mFile;
568
569        public DirectoryCursor(String[] columnNames, String docId, File file) {
570            super(columnNames);
571
572            final Uri notifyUri = buildNotificationUri(docId);
573            setNotificationUri(getContext().getContentResolver(), notifyUri);
574
575            mFile = file;
576            startObserving(mFile, notifyUri);
577        }
578
579        @Override
580        public void close() {
581            super.close();
582            stopObserving(mFile);
583        }
584    }
585}
586