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