• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
17 package com.android.internal.content;
18 
19 import android.annotation.CallSuper;
20 import android.annotation.NonNull;
21 import android.annotation.Nullable;
22 import android.content.ContentResolver;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.content.res.AssetFileDescriptor;
26 import android.database.Cursor;
27 import android.database.MatrixCursor;
28 import android.database.MatrixCursor.RowBuilder;
29 import android.graphics.Point;
30 import android.net.Uri;
31 import android.os.Binder;
32 import android.os.Bundle;
33 import android.os.CancellationSignal;
34 import android.os.FileObserver;
35 import android.os.FileUtils;
36 import android.os.Handler;
37 import android.os.ParcelFileDescriptor;
38 import android.provider.DocumentsContract;
39 import android.provider.DocumentsContract.Document;
40 import android.provider.DocumentsProvider;
41 import android.provider.MediaStore;
42 import android.provider.MetadataReader;
43 import android.system.Int64Ref;
44 import android.text.TextUtils;
45 import android.util.ArrayMap;
46 import android.util.Log;
47 import android.webkit.MimeTypeMap;
48 
49 import com.android.internal.annotations.GuardedBy;
50 import com.android.internal.util.ArrayUtils;
51 
52 import libcore.io.IoUtils;
53 
54 import java.io.File;
55 import java.io.FileInputStream;
56 import java.io.FileNotFoundException;
57 import java.io.IOException;
58 import java.io.InputStream;
59 import java.nio.file.FileSystems;
60 import java.nio.file.FileVisitResult;
61 import java.nio.file.FileVisitor;
62 import java.nio.file.Files;
63 import java.nio.file.Path;
64 import java.nio.file.attribute.BasicFileAttributes;
65 import java.util.Arrays;
66 import java.util.LinkedList;
67 import java.util.List;
68 import java.util.Locale;
69 import java.util.Set;
70 import java.util.concurrent.CopyOnWriteArrayList;
71 import java.util.function.Predicate;
72 import java.util.regex.Pattern;
73 
74 /**
75  * A helper class for {@link android.provider.DocumentsProvider} to perform file operations on local
76  * files.
77  */
78 public abstract class FileSystemProvider extends DocumentsProvider {
79 
80     private static final String TAG = "FileSystemProvider";
81 
82     private static final boolean LOG_INOTIFY = false;
83 
84     protected static final String SUPPORTED_QUERY_ARGS = joinNewline(
85             DocumentsContract.QUERY_ARG_DISPLAY_NAME,
86             DocumentsContract.QUERY_ARG_FILE_SIZE_OVER,
87             DocumentsContract.QUERY_ARG_LAST_MODIFIED_AFTER,
88             DocumentsContract.QUERY_ARG_MIME_TYPES);
89 
joinNewline(String... args)90     private static String joinNewline(String... args) {
91         return TextUtils.join("\n", args);
92     }
93 
94     private String[] mDefaultProjection;
95 
96     @GuardedBy("mObservers")
97     private final ArrayMap<File, DirectoryObserver> mObservers = new ArrayMap<>();
98 
99     private Handler mHandler;
100 
getFileForDocId(String docId, boolean visible)101     protected abstract File getFileForDocId(String docId, boolean visible)
102             throws FileNotFoundException;
103 
getDocIdForFile(File file)104     protected abstract String getDocIdForFile(File file) throws FileNotFoundException;
105 
buildNotificationUri(String docId)106     protected abstract Uri buildNotificationUri(String docId);
107 
108     /**
109      * Callback indicating that the given document has been modified. This gives
110      * the provider a hook to invalidate cached data, such as {@code sdcardfs}.
111      */
onDocIdChanged(String docId)112     protected void onDocIdChanged(String docId) {
113         // Default is no-op
114     }
115 
116     /**
117      * Callback indicating that the given document has been deleted or moved. This gives
118      * the provider a hook to revoke the uri permissions.
119      */
onDocIdDeleted(String docId)120     protected void onDocIdDeleted(String docId) {
121         // Default is no-op
122     }
123 
124     @Override
onCreate()125     public boolean onCreate() {
126         throw new UnsupportedOperationException(
127                 "Subclass should override this and call onCreate(defaultDocumentProjection)");
128     }
129 
130     @CallSuper
onCreate(String[] defaultProjection)131     protected void onCreate(String[] defaultProjection) {
132         mHandler = new Handler();
133         mDefaultProjection = defaultProjection;
134     }
135 
136     @Override
isChildDocument(String parentDocId, String docId)137     public boolean isChildDocument(String parentDocId, String docId) {
138         try {
139             final File parent = getFileForDocId(parentDocId).getCanonicalFile();
140             final File doc = getFileForDocId(docId).getCanonicalFile();
141             return FileUtils.contains(parent, doc);
142         } catch (IOException e) {
143             throw new IllegalArgumentException(
144                     "Failed to determine if " + docId + " is child of " + parentDocId + ": " + e);
145         }
146     }
147 
148     @Override
getDocumentMetadata(String documentId)149     public @Nullable Bundle getDocumentMetadata(String documentId)
150             throws FileNotFoundException {
151         File file = getFileForDocId(documentId);
152 
153         if (!file.exists()) {
154             throw new FileNotFoundException("Can't find the file for documentId: " + documentId);
155         }
156 
157         final String mimeType = getDocumentType(documentId);
158         if (Document.MIME_TYPE_DIR.equals(mimeType)) {
159             final Int64Ref treeCount = new Int64Ref(0);
160             final Int64Ref treeSize = new Int64Ref(0);
161             try {
162                 final Path path = FileSystems.getDefault().getPath(file.getAbsolutePath());
163                 Files.walkFileTree(path, new FileVisitor<Path>() {
164                     @Override
165                     public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
166                         return FileVisitResult.CONTINUE;
167                     }
168 
169                     @Override
170                     public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
171                         treeCount.value += 1;
172                         treeSize.value += attrs.size();
173                         return FileVisitResult.CONTINUE;
174                     }
175 
176                     @Override
177                     public FileVisitResult visitFileFailed(Path file, IOException exc) {
178                         return FileVisitResult.CONTINUE;
179                     }
180 
181                     @Override
182                     public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
183                         return FileVisitResult.CONTINUE;
184                     }
185                 });
186             } catch (IOException e) {
187                 Log.e(TAG, "An error occurred retrieving the metadata", e);
188                 return null;
189             }
190 
191             final Bundle res = new Bundle();
192             res.putLong(DocumentsContract.METADATA_TREE_COUNT, treeCount.value);
193             res.putLong(DocumentsContract.METADATA_TREE_SIZE, treeSize.value);
194             return res;
195         }
196 
197         if (!file.isFile()) {
198             Log.w(TAG, "Can't stream non-regular file. Returning empty metadata.");
199             return null;
200         }
201         if (!file.canRead()) {
202             Log.w(TAG, "Can't stream non-readable file. Returning empty metadata.");
203             return null;
204         }
205         if (!MetadataReader.isSupportedMimeType(mimeType)) {
206             Log.w(TAG, "Unsupported type " + mimeType + ". Returning empty metadata.");
207             return null;
208         }
209 
210         InputStream stream = null;
211         try {
212             Bundle metadata = new Bundle();
213             stream = new FileInputStream(file.getAbsolutePath());
214             MetadataReader.getMetadata(metadata, stream, mimeType, null);
215             return metadata;
216         } catch (IOException e) {
217             Log.e(TAG, "An error occurred retrieving the metadata", e);
218             return null;
219         } finally {
220             IoUtils.closeQuietly(stream);
221         }
222     }
223 
findDocumentPath(File parent, File doc)224     protected final List<String> findDocumentPath(File parent, File doc)
225             throws FileNotFoundException {
226 
227         if (!doc.exists()) {
228             throw new FileNotFoundException(doc + " is not found.");
229         }
230 
231         if (!FileUtils.contains(parent, doc)) {
232             throw new FileNotFoundException(doc + " is not found under " + parent);
233         }
234 
235         LinkedList<String> path = new LinkedList<>();
236         while (doc != null && FileUtils.contains(parent, doc)) {
237             path.addFirst(getDocIdForFile(doc));
238 
239             doc = doc.getParentFile();
240         }
241 
242         return path;
243     }
244 
245     @Override
createDocument(String docId, String mimeType, String displayName)246     public String createDocument(String docId, String mimeType, String displayName)
247             throws FileNotFoundException {
248         displayName = FileUtils.buildValidFatFilename(displayName);
249 
250         final File parent = getFileForDocId(docId);
251         if (!parent.isDirectory()) {
252             throw new IllegalArgumentException("Parent document isn't a directory");
253         }
254 
255         final File file = FileUtils.buildUniqueFile(parent, mimeType, displayName);
256         final String childId;
257         if (Document.MIME_TYPE_DIR.equals(mimeType)) {
258             if (!file.mkdir()) {
259                 throw new IllegalStateException("Failed to mkdir " + file);
260             }
261             childId = getDocIdForFile(file);
262             onDocIdChanged(childId);
263         } else {
264             try {
265                 if (!file.createNewFile()) {
266                     throw new IllegalStateException("Failed to touch " + file);
267                 }
268                 childId = getDocIdForFile(file);
269                 onDocIdChanged(childId);
270             } catch (IOException e) {
271                 throw new IllegalStateException("Failed to touch " + file + ": " + e);
272             }
273         }
274         updateMediaStore(getContext(), file);
275         return childId;
276     }
277 
278     @Override
renameDocument(String docId, String displayName)279     public String renameDocument(String docId, String displayName) throws FileNotFoundException {
280         // Since this provider treats renames as generating a completely new
281         // docId, we're okay with letting the MIME type change.
282         displayName = FileUtils.buildValidFatFilename(displayName);
283 
284         final File before = getFileForDocId(docId);
285         final File beforeVisibleFile = getFileForDocId(docId, true);
286         final File after = FileUtils.buildUniqueFile(before.getParentFile(), displayName);
287         if (!before.renameTo(after)) {
288             throw new IllegalStateException("Failed to rename to " + after);
289         }
290 
291         final String afterDocId = getDocIdForFile(after);
292         onDocIdChanged(docId);
293         onDocIdDeleted(docId);
294         onDocIdChanged(afterDocId);
295 
296         final File afterVisibleFile = getFileForDocId(afterDocId, true);
297 
298         updateMediaStore(getContext(), beforeVisibleFile);
299         updateMediaStore(getContext(), afterVisibleFile);
300 
301         if (!TextUtils.equals(docId, afterDocId)) {
302             return afterDocId;
303         } else {
304             return null;
305         }
306     }
307 
308     @Override
moveDocument(String sourceDocumentId, String sourceParentDocumentId, String targetParentDocumentId)309     public String moveDocument(String sourceDocumentId, String sourceParentDocumentId,
310             String targetParentDocumentId)
311             throws FileNotFoundException {
312         final File before = getFileForDocId(sourceDocumentId);
313         final File after = new File(getFileForDocId(targetParentDocumentId), before.getName());
314         final File visibleFileBefore = getFileForDocId(sourceDocumentId, true);
315 
316         if (after.exists()) {
317             throw new IllegalStateException("Already exists " + after);
318         }
319         if (!before.renameTo(after)) {
320             throw new IllegalStateException("Failed to move to " + after);
321         }
322 
323         final String docId = getDocIdForFile(after);
324         onDocIdChanged(sourceDocumentId);
325         onDocIdDeleted(sourceDocumentId);
326         onDocIdChanged(docId);
327         // update the database
328         updateMediaStore(getContext(), visibleFileBefore);
329         updateMediaStore(getContext(), getFileForDocId(docId, true));
330         return docId;
331     }
332 
updateMediaStore(@onNull Context context, File file)333     private static void updateMediaStore(@NonNull Context context, File file) {
334         if (file != null) {
335             final ContentResolver resolver = context.getContentResolver();
336             final String noMedia = ".nomedia";
337             // For file, check whether the file name is .nomedia or not.
338             // If yes, scan the parent directory to update all files in the directory.
339             if (!file.isDirectory() && file.getName().toLowerCase(Locale.ROOT).endsWith(noMedia)) {
340                 MediaStore.scanFile(resolver, file.getParentFile());
341             } else {
342                 MediaStore.scanFile(resolver, file);
343             }
344         }
345     }
346 
347     @Override
deleteDocument(String docId)348     public void deleteDocument(String docId) throws FileNotFoundException {
349         final File file = getFileForDocId(docId);
350         final File visibleFile = getFileForDocId(docId, true);
351 
352         final boolean isDirectory = file.isDirectory();
353         if (isDirectory) {
354             FileUtils.deleteContents(file);
355         }
356         // We could be deleting pending media which doesn't have any content yet, so only throw
357         // if the file exists and we fail to delete it.
358         if (file.exists() && !file.delete()) {
359             throw new IllegalStateException("Failed to delete " + file);
360         }
361 
362         onDocIdChanged(docId);
363         onDocIdDeleted(docId);
364         updateMediaStore(getContext(), visibleFile);
365     }
366 
367     @Override
queryDocument(String documentId, String[] projection)368     public Cursor queryDocument(String documentId, String[] projection)
369             throws FileNotFoundException {
370         final MatrixCursor result = new MatrixCursor(resolveProjection(projection));
371         includeFile(result, documentId, null);
372         return result;
373     }
374 
375     /**
376      * This method is similar to
377      * {@link DocumentsProvider#queryChildDocuments(String, String[], String)}. This method returns
378      * all children documents including hidden directories/files.
379      *
380      * <p>
381      * In a scoped storage world, access to "Android/data" style directories are hidden for privacy
382      * reasons. This method may show privacy sensitive data, so its usage should only be in
383      * restricted modes.
384      *
385      * @param parentDocumentId the directory to return children for.
386      * @param projection list of {@link Document} columns to put into the
387      *            cursor. If {@code null} all supported columns should be
388      *            included.
389      * @param sortOrder how to order the rows, formatted as an SQL
390      *            {@code ORDER BY} clause (excluding the ORDER BY itself).
391      *            Passing {@code null} will use the default sort order, which
392      *            may be unordered. This ordering is a hint that can be used to
393      *            prioritize how data is fetched from the network, but UI may
394      *            always enforce a specific ordering
395      * @throws FileNotFoundException when parent document doesn't exist or query fails
396      */
queryChildDocumentsShowAll( String parentDocumentId, String[] projection, String sortOrder)397     protected Cursor queryChildDocumentsShowAll(
398             String parentDocumentId, String[] projection, String sortOrder)
399             throws FileNotFoundException {
400         return queryChildDocuments(parentDocumentId, projection, sortOrder, File -> true);
401     }
402 
403     @Override
queryChildDocuments( String parentDocumentId, String[] projection, String sortOrder)404     public Cursor queryChildDocuments(
405             String parentDocumentId, String[] projection, String sortOrder)
406             throws FileNotFoundException {
407         // Access to some directories is hidden for privacy reasons.
408         return queryChildDocuments(parentDocumentId, projection, sortOrder, this::shouldShow);
409     }
410 
queryChildDocuments( String parentDocumentId, String[] projection, String sortOrder, @NonNull Predicate<File> filter)411     private Cursor queryChildDocuments(
412             String parentDocumentId, String[] projection, String sortOrder,
413             @NonNull Predicate<File> filter) throws FileNotFoundException {
414         final File parent = getFileForDocId(parentDocumentId);
415         final MatrixCursor result = new DirectoryCursor(
416                 resolveProjection(projection), parentDocumentId, parent);
417         if (parent.isDirectory()) {
418             for (File file : FileUtils.listFilesOrEmpty(parent)) {
419                 if (filter.test(file)) {
420                     includeFile(result, null, file);
421                 }
422             }
423         } else {
424             Log.w(TAG, "parentDocumentId '" + parentDocumentId + "' is not Directory");
425         }
426         return result;
427     }
428 
429     /**
430      * Searches documents under the given folder.
431      *
432      * To avoid runtime explosion only returns the at most 23 items.
433      *
434      * @param folder the root folder where recursive search begins
435      * @param query the search condition used to match file names
436      * @param projection projection of the returned cursor
437      * @param exclusion absolute file paths to exclude from result
438      * @param queryArgs the query arguments for search
439      * @return cursor containing search result. Include
440      *         {@link ContentResolver#EXTRA_HONORED_ARGS} in {@link Cursor}
441      *         extras {@link Bundle} when any QUERY_ARG_* value was honored
442      *         during the preparation of the results.
443      * @throws FileNotFoundException when root folder doesn't exist or search fails
444      *
445      * @see ContentResolver#EXTRA_HONORED_ARGS
446      */
querySearchDocuments( File folder, String[] projection, Set<String> exclusion, Bundle queryArgs)447     protected final Cursor querySearchDocuments(
448             File folder, String[] projection, Set<String> exclusion, Bundle queryArgs)
449             throws FileNotFoundException {
450         final MatrixCursor result = new MatrixCursor(resolveProjection(projection));
451         final LinkedList<File> pending = new LinkedList<>();
452         pending.add(folder);
453         while (!pending.isEmpty() && result.getCount() < 24) {
454             final File file = pending.removeFirst();
455             if (shouldHide(file)) continue;
456 
457             if (file.isDirectory()) {
458                 for (File child : FileUtils.listFilesOrEmpty(file)) {
459                     pending.add(child);
460                 }
461             }
462             if (!exclusion.contains(file.getAbsolutePath()) && matchSearchQueryArguments(file,
463                     queryArgs)) {
464                 includeFile(result, null, file);
465             }
466         }
467 
468         final String[] handledQueryArgs = DocumentsContract.getHandledQueryArguments(queryArgs);
469         if (handledQueryArgs.length > 0) {
470             final Bundle extras = new Bundle();
471             extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, handledQueryArgs);
472             result.setExtras(extras);
473         }
474         return result;
475     }
476 
477     @Override
getDocumentType(String documentId)478     public String getDocumentType(String documentId) throws FileNotFoundException {
479         return getDocumentType(documentId, getFileForDocId(documentId));
480     }
481 
getDocumentType(final String documentId, final File file)482     private String getDocumentType(final String documentId, final File file)
483             throws FileNotFoundException {
484         if (file.isDirectory()) {
485             return Document.MIME_TYPE_DIR;
486         } else {
487             final int lastDot = documentId.lastIndexOf('.');
488             if (lastDot >= 0) {
489                 final String extension = documentId.substring(lastDot + 1).toLowerCase();
490                 final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
491                 if (mime != null) {
492                     return mime;
493                 }
494             }
495             return ContentResolver.MIME_TYPE_DEFAULT;
496         }
497     }
498 
499     @Override
openDocument( String documentId, String mode, CancellationSignal signal)500     public ParcelFileDescriptor openDocument(
501             String documentId, String mode, CancellationSignal signal)
502             throws FileNotFoundException {
503         final File file = getFileForDocId(documentId);
504         final File visibleFile = getFileForDocId(documentId, true);
505 
506         final int pfdMode = ParcelFileDescriptor.parseMode(mode);
507         if (visibleFile == null) {
508             return ParcelFileDescriptor.open(file, pfdMode);
509         } else if (pfdMode == ParcelFileDescriptor.MODE_READ_ONLY) {
510             return openFileForRead(visibleFile);
511         } else {
512             try {
513                 // When finished writing, kick off media scanner
514                 return ParcelFileDescriptor.open(
515                         file, pfdMode, mHandler, (IOException e) -> {
516                             onDocIdChanged(documentId);
517                             scanFile(visibleFile);
518                         });
519             } catch (IOException e) {
520                 throw new FileNotFoundException("Failed to open for writing: " + e);
521             }
522         }
523     }
524 
openFileForRead(final File target)525     private ParcelFileDescriptor openFileForRead(final File target) throws FileNotFoundException {
526         final Uri uri = MediaStore.scanFile(getContext().getContentResolver(), target);
527         if (uri == null) {
528             Log.w(TAG, "Failed to retrieve media store URI for: " + target);
529             return ParcelFileDescriptor.open(target, ParcelFileDescriptor.MODE_READ_ONLY);
530         }
531 
532         // Passing the calling uid via EXTRA_MEDIA_CAPABILITIES_UID, so that the decision to
533         // transcode or not transcode can be made based upon the calling app's uid, and not based
534         // upon the Provider's uid.
535         final Bundle opts = new Bundle();
536         opts.putInt(MediaStore.EXTRA_MEDIA_CAPABILITIES_UID, Binder.getCallingUid());
537 
538         final AssetFileDescriptor afd =
539                 getContext().getContentResolver().openTypedAssetFileDescriptor(uri, "*/*", opts);
540         if (afd == null) {
541             Log.w(TAG, "Failed to open with media_capabilities uid for URI: " + uri);
542             return ParcelFileDescriptor.open(target, ParcelFileDescriptor.MODE_READ_ONLY);
543         }
544 
545         return afd.getParcelFileDescriptor();
546     }
547 
548     /**
549      * Test if the file matches the query arguments.
550      *
551      * @param file the file to test
552      * @param queryArgs the query arguments
553      */
matchSearchQueryArguments(File file, Bundle queryArgs)554     private boolean matchSearchQueryArguments(File file, Bundle queryArgs) {
555         if (file == null) {
556             return false;
557         }
558 
559         final String fileMimeType;
560         final String fileName = file.getName();
561 
562         if (file.isDirectory()) {
563             fileMimeType = DocumentsContract.Document.MIME_TYPE_DIR;
564         } else {
565             int dotPos = fileName.lastIndexOf('.');
566             if (dotPos < 0) {
567                 return false;
568             }
569             final String extension = fileName.substring(dotPos + 1);
570             fileMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
571         }
572         return DocumentsContract.matchSearchQueryArguments(queryArgs, fileName, fileMimeType,
573                 file.lastModified(), file.length());
574     }
575 
scanFile(File visibleFile)576     private void scanFile(File visibleFile) {
577         final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
578         intent.setData(Uri.fromFile(visibleFile));
579         getContext().sendBroadcast(intent);
580     }
581 
582     @Override
openDocumentThumbnail( String documentId, Point sizeHint, CancellationSignal signal)583     public AssetFileDescriptor openDocumentThumbnail(
584             String documentId, Point sizeHint, CancellationSignal signal)
585             throws FileNotFoundException {
586         final File file = getFileForDocId(documentId);
587         return DocumentsContract.openImageThumbnail(file);
588     }
589 
includeFile(final MatrixCursor result, String docId, File file)590     protected RowBuilder includeFile(final MatrixCursor result, String docId, File file)
591             throws FileNotFoundException {
592         final String[] columns = result.getColumnNames();
593         final RowBuilder row = result.newRow();
594 
595         if (docId == null) {
596             docId = getDocIdForFile(file);
597         } else {
598             file = getFileForDocId(docId);
599         }
600 
601         final String mimeType = getDocumentType(docId, file);
602         row.add(Document.COLUMN_DOCUMENT_ID, docId);
603         row.add(Document.COLUMN_MIME_TYPE, mimeType);
604 
605         final int flagIndex = ArrayUtils.indexOf(columns, Document.COLUMN_FLAGS);
606         if (flagIndex != -1) {
607             int flags = 0;
608             if (file.canWrite()) {
609                 if (mimeType.equals(Document.MIME_TYPE_DIR)) {
610                     flags |= Document.FLAG_DIR_SUPPORTS_CREATE;
611                     flags |= Document.FLAG_SUPPORTS_DELETE;
612                     flags |= Document.FLAG_SUPPORTS_RENAME;
613                     flags |= Document.FLAG_SUPPORTS_MOVE;
614 
615                     if (shouldBlockFromTree(docId)) {
616                         flags |= Document.FLAG_DIR_BLOCKS_OPEN_DOCUMENT_TREE;
617                     }
618 
619                 } else {
620                     flags |= Document.FLAG_SUPPORTS_WRITE;
621                     flags |= Document.FLAG_SUPPORTS_DELETE;
622                     flags |= Document.FLAG_SUPPORTS_RENAME;
623                     flags |= Document.FLAG_SUPPORTS_MOVE;
624                 }
625             }
626 
627             if (mimeType.startsWith("image/")) {
628                 flags |= Document.FLAG_SUPPORTS_THUMBNAIL;
629             }
630 
631             if (typeSupportsMetadata(mimeType)) {
632                 flags |= Document.FLAG_SUPPORTS_METADATA;
633             }
634             row.add(flagIndex, flags);
635         }
636 
637         final int displayNameIndex = ArrayUtils.indexOf(columns, Document.COLUMN_DISPLAY_NAME);
638         if (displayNameIndex != -1) {
639             row.add(displayNameIndex, file.getName());
640         }
641 
642         final int lastModifiedIndex = ArrayUtils.indexOf(columns, Document.COLUMN_LAST_MODIFIED);
643         if (lastModifiedIndex != -1) {
644             final long lastModified = file.lastModified();
645             // Only publish dates reasonably after epoch
646             if (lastModified > 31536000000L) {
647                 row.add(lastModifiedIndex, lastModified);
648             }
649         }
650         final int sizeIndex = ArrayUtils.indexOf(columns, Document.COLUMN_SIZE);
651         if (sizeIndex != -1) {
652             row.add(sizeIndex, file.length());
653         }
654 
655         // Return the row builder just in case any subclass want to add more stuff to it.
656         return row;
657     }
658 
659     private static final Pattern PATTERN_HIDDEN_PATH = Pattern.compile(
660             "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(?:data|obb|sandbox)$");
661 
662     /**
663      * In a scoped storage world, access to "Android/data" style directories are
664      * hidden for privacy reasons.
665      */
shouldHide(@onNull File file)666     protected boolean shouldHide(@NonNull File file) {
667         return (PATTERN_HIDDEN_PATH.matcher(file.getAbsolutePath()).matches());
668     }
669 
shouldShow(@onNull File file)670     private boolean shouldShow(@NonNull File file) {
671         return !shouldHide(file);
672     }
673 
shouldBlockFromTree(@onNull String docId)674     protected boolean shouldBlockFromTree(@NonNull String docId) {
675         return false;
676     }
677 
typeSupportsMetadata(String mimeType)678     protected boolean typeSupportsMetadata(String mimeType) {
679         return MetadataReader.isSupportedMimeType(mimeType)
680                 || Document.MIME_TYPE_DIR.equals(mimeType);
681     }
682 
getFileForDocId(String docId)683     protected final File getFileForDocId(String docId) throws FileNotFoundException {
684         return getFileForDocId(docId, false);
685     }
686 
resolveProjection(String[] projection)687     private String[] resolveProjection(String[] projection) {
688         return projection == null ? mDefaultProjection : projection;
689     }
690 
startObserving(File file, Uri notifyUri, DirectoryCursor cursor)691     private void startObserving(File file, Uri notifyUri, DirectoryCursor cursor) {
692         synchronized (mObservers) {
693             DirectoryObserver observer = mObservers.get(file);
694             if (observer == null) {
695                 observer =
696                         new DirectoryObserver(file, getContext().getContentResolver(), notifyUri);
697                 observer.startWatching();
698                 mObservers.put(file, observer);
699             }
700             observer.mCursors.add(cursor);
701 
702             if (LOG_INOTIFY) Log.d(TAG, "after start: " + observer);
703         }
704     }
705 
stopObserving(File file, DirectoryCursor cursor)706     private void stopObserving(File file, DirectoryCursor cursor) {
707         synchronized (mObservers) {
708             DirectoryObserver observer = mObservers.get(file);
709             if (observer == null) return;
710 
711             observer.mCursors.remove(cursor);
712             if (observer.mCursors.size() == 0) {
713                 mObservers.remove(file);
714                 observer.stopWatching();
715             }
716 
717             if (LOG_INOTIFY) Log.d(TAG, "after stop: " + observer);
718         }
719     }
720 
721     private static class DirectoryObserver extends FileObserver {
722         private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO
723                 | CREATE | DELETE | DELETE_SELF | MOVE_SELF;
724 
725         private final File mFile;
726         private final ContentResolver mResolver;
727         private final Uri mNotifyUri;
728         private final CopyOnWriteArrayList<DirectoryCursor> mCursors;
729 
DirectoryObserver(File file, ContentResolver resolver, Uri notifyUri)730         DirectoryObserver(File file, ContentResolver resolver, Uri notifyUri) {
731             super(file.getAbsolutePath(), NOTIFY_EVENTS);
732             mFile = file;
733             mResolver = resolver;
734             mNotifyUri = notifyUri;
735             mCursors = new CopyOnWriteArrayList<>();
736         }
737 
738         @Override
onEvent(int event, String path)739         public void onEvent(int event, String path) {
740             if ((event & NOTIFY_EVENTS) != 0) {
741                 if (LOG_INOTIFY) Log.d(TAG, "onEvent() " + event + " at " + path);
742                 for (DirectoryCursor cursor : mCursors) {
743                     cursor.notifyChanged();
744                 }
745                 mResolver.notifyChange(mNotifyUri, null, false);
746             }
747         }
748 
749         @Override
toString()750         public String toString() {
751             String filePath = mFile.getAbsolutePath();
752             return "DirectoryObserver{file=" + filePath + ", ref=" + mCursors.size() + "}";
753         }
754     }
755 
756     private class DirectoryCursor extends MatrixCursor {
757         private final File mFile;
758 
DirectoryCursor(String[] columnNames, String docId, File file)759         public DirectoryCursor(String[] columnNames, String docId, File file) {
760             super(columnNames);
761 
762             final Uri notifyUri = buildNotificationUri(docId);
763             boolean registerSelfObserver = false; // Our FileObserver sees all relevant changes.
764             setNotificationUris(getContext().getContentResolver(), Arrays.asList(notifyUri),
765                     getContext().getContentResolver().getUserId(), registerSelfObserver);
766 
767             mFile = file;
768             startObserving(mFile, notifyUri, this);
769         }
770 
notifyChanged()771         public void notifyChanged() {
772             onChange(false);
773         }
774 
775         @Override
close()776         public void close() {
777             super.close();
778             stopObserving(mFile, this);
779         }
780     }
781 }
782