blob: a50a52219c742a8850411817b11431e94f05465f [file] [log] [blame]
Garfield Tan75379db2017-02-08 15:32:56 -08001/*
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;
Ivan Chiang730b3a32019-08-21 16:12:54 +080020import android.annotation.NonNull;
Garfield Tand21af532017-04-17 14:22:32 -070021import android.annotation.Nullable;
Garfield Tan75379db2017-02-08 15:32:56 -080022import android.content.ContentResolver;
23import android.content.Intent;
24import android.content.res.AssetFileDescriptor;
25import android.database.Cursor;
Sudheer Shanka7d28b5b2020-02-11 14:32:59 -080026import android.database.DatabaseUtils;
Garfield Tan75379db2017-02-08 15:32:56 -080027import android.database.MatrixCursor;
28import android.database.MatrixCursor.RowBuilder;
29import android.graphics.Point;
30import android.net.Uri;
Garfield Tanfac8aea2017-06-27 11:03:42 -070031import android.os.Binder;
Julian Mancinib6505152017-06-27 13:29:09 -070032import android.os.Bundle;
Garfield Tan75379db2017-02-08 15:32:56 -080033import android.os.CancellationSignal;
34import android.os.FileObserver;
35import android.os.FileUtils;
36import android.os.Handler;
37import android.os.ParcelFileDescriptor;
38import android.provider.DocumentsContract;
39import android.provider.DocumentsContract.Document;
40import android.provider.DocumentsProvider;
41import android.provider.MediaStore;
Sudheer Shanka7d28b5b2020-02-11 14:32:59 -080042import android.provider.MediaStore.Files.FileColumns;
Steve McKay5a10ff12017-08-01 15:02:50 -070043import android.provider.MetadataReader;
Jeff Sharkeyb95bd442018-12-11 10:35:02 -070044import android.system.Int64Ref;
Garfield Tan75379db2017-02-08 15:32:56 -080045import android.text.TextUtils;
46import android.util.ArrayMap;
47import android.util.Log;
48import android.webkit.MimeTypeMap;
49
50import com.android.internal.annotations.GuardedBy;
Anton Hansson788ec752019-04-30 15:21:24 +010051import com.android.internal.util.ArrayUtils;
Garfield Tan75379db2017-02-08 15:32:56 -080052
Julian Mancinib6505152017-06-27 13:29:09 -070053import libcore.io.IoUtils;
54
Garfield Tan75379db2017-02-08 15:32:56 -080055import java.io.File;
Julian Mancinib6505152017-06-27 13:29:09 -070056import java.io.FileInputStream;
Garfield Tan75379db2017-02-08 15:32:56 -080057import java.io.FileNotFoundException;
58import java.io.IOException;
Steve McKay5a10ff12017-08-01 15:02:50 -070059import java.io.InputStream;
Jeff Sharkeyb95bd442018-12-11 10:35:02 -070060import java.nio.file.FileSystems;
61import java.nio.file.FileVisitResult;
62import java.nio.file.FileVisitor;
63import java.nio.file.Files;
64import java.nio.file.Path;
65import java.nio.file.attribute.BasicFileAttributes;
Anton Hanssond79473f2019-04-30 16:57:10 +010066import java.util.Arrays;
Garfield Tan75379db2017-02-08 15:32:56 -080067import java.util.LinkedList;
68import java.util.List;
69import java.util.Set;
Anton Hanssond79473f2019-04-30 16:57:10 +010070import java.util.concurrent.CopyOnWriteArrayList;
Abhijeet Kaur553625d2020-05-07 13:11:55 +010071import java.util.function.Predicate;
Jeff Sharkeyc8879082019-12-21 17:44:11 -070072import java.util.regex.Pattern;
Garfield Tan75379db2017-02-08 15:32:56 -080073
74/**
75 * A helper class for {@link android.provider.DocumentsProvider} to perform file operations on local
76 * files.
77 */
78public abstract class FileSystemProvider extends DocumentsProvider {
79
80 private static final String TAG = "FileSystemProvider";
81
82 private static final boolean LOG_INOTIFY = false;
83
Ivan Chiangc26d3c22019-01-10 19:29:01 +080084 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
90 private static String joinNewline(String... args) {
91 return TextUtils.join("\n", args);
92 }
93
Garfield Tan75379db2017-02-08 15:32:56 -080094 private String[] mDefaultProjection;
95
96 @GuardedBy("mObservers")
97 private final ArrayMap<File, DirectoryObserver> mObservers = new ArrayMap<>();
98
99 private Handler mHandler;
100
101 protected abstract File getFileForDocId(String docId, boolean visible)
102 throws FileNotFoundException;
103
104 protected abstract String getDocIdForFile(File file) throws FileNotFoundException;
105
106 protected abstract Uri buildNotificationUri(String docId);
107
Jeff Sharkeyb00d5ea2018-05-01 10:01:52 -0600108 /**
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 */
112 protected void onDocIdChanged(String docId) {
113 // Default is no-op
114 }
115
Garfield Tan75379db2017-02-08 15:32:56 -0800116 @Override
117 public boolean onCreate() {
118 throw new UnsupportedOperationException(
119 "Subclass should override this and call onCreate(defaultDocumentProjection)");
120 }
121
122 @CallSuper
123 protected void onCreate(String[] defaultProjection) {
124 mHandler = new Handler();
125 mDefaultProjection = defaultProjection;
126 }
127
128 @Override
129 public boolean isChildDocument(String parentDocId, String docId) {
130 try {
131 final File parent = getFileForDocId(parentDocId).getCanonicalFile();
132 final File doc = getFileForDocId(docId).getCanonicalFile();
133 return FileUtils.contains(parent, doc);
134 } catch (IOException e) {
135 throw new IllegalArgumentException(
136 "Failed to determine if " + docId + " is child of " + parentDocId + ": " + e);
137 }
138 }
139
Julian Mancinib6505152017-06-27 13:29:09 -0700140 @Override
Steve McKay17a9ce32017-07-27 13:37:14 -0700141 public @Nullable Bundle getDocumentMetadata(String documentId)
Julian Mancinib6505152017-06-27 13:29:09 -0700142 throws FileNotFoundException {
143 File file = getFileForDocId(documentId);
Steve McKay36f1d7e2017-07-20 11:41:58 -0700144
145 if (!file.exists()) {
146 throw new FileNotFoundException("Can't find the file for documentId: " + documentId);
147 }
148
Jeff Sharkeyb95bd442018-12-11 10:35:02 -0700149 final String mimeType = getDocumentType(documentId);
150 if (Document.MIME_TYPE_DIR.equals(mimeType)) {
151 final Int64Ref treeCount = new Int64Ref(0);
152 final Int64Ref treeSize = new Int64Ref(0);
153 try {
154 final Path path = FileSystems.getDefault().getPath(file.getAbsolutePath());
155 Files.walkFileTree(path, new FileVisitor<Path>() {
156 @Override
157 public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
158 return FileVisitResult.CONTINUE;
159 }
160
161 @Override
162 public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
163 treeCount.value += 1;
164 treeSize.value += attrs.size();
165 return FileVisitResult.CONTINUE;
166 }
167
168 @Override
169 public FileVisitResult visitFileFailed(Path file, IOException exc) {
170 return FileVisitResult.CONTINUE;
171 }
172
173 @Override
174 public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
175 return FileVisitResult.CONTINUE;
176 }
177 });
178 } catch (IOException e) {
179 Log.e(TAG, "An error occurred retrieving the metadata", e);
180 return null;
181 }
182
183 final Bundle res = new Bundle();
184 res.putLong(DocumentsContract.METADATA_TREE_COUNT, treeCount.value);
185 res.putLong(DocumentsContract.METADATA_TREE_SIZE, treeSize.value);
186 return res;
187 }
188
Steve McKay36f1d7e2017-07-20 11:41:58 -0700189 if (!file.isFile()) {
190 Log.w(TAG, "Can't stream non-regular file. Returning empty metadata.");
Steve McKay5a10ff12017-08-01 15:02:50 -0700191 return null;
Julian Mancinib6505152017-06-27 13:29:09 -0700192 }
Steve McKay36f1d7e2017-07-20 11:41:58 -0700193 if (!file.canRead()) {
194 Log.w(TAG, "Can't stream non-readable file. Returning empty metadata.");
Steve McKay5a10ff12017-08-01 15:02:50 -0700195 return null;
Julian Mancinib6505152017-06-27 13:29:09 -0700196 }
Steve McKay5a10ff12017-08-01 15:02:50 -0700197 if (!MetadataReader.isSupportedMimeType(mimeType)) {
Jeff Sharkeyb95bd442018-12-11 10:35:02 -0700198 Log.w(TAG, "Unsupported type " + mimeType + ". Returning empty metadata.");
Steve McKay5a10ff12017-08-01 15:02:50 -0700199 return null;
200 }
Steve McKay36f1d7e2017-07-20 11:41:58 -0700201
Steve McKay5a10ff12017-08-01 15:02:50 -0700202 InputStream stream = null;
Steve McKay36f1d7e2017-07-20 11:41:58 -0700203 try {
Steve McKay5a10ff12017-08-01 15:02:50 -0700204 Bundle metadata = new Bundle();
205 stream = new FileInputStream(file.getAbsolutePath());
206 MetadataReader.getMetadata(metadata, stream, mimeType, null);
207 return metadata;
Steve McKay36f1d7e2017-07-20 11:41:58 -0700208 } catch (IOException e) {
209 Log.e(TAG, "An error occurred retrieving the metadata", e);
Steve McKay5a10ff12017-08-01 15:02:50 -0700210 return null;
Steve McKay36f1d7e2017-07-20 11:41:58 -0700211 } finally {
212 IoUtils.closeQuietly(stream);
213 }
Julian Mancinib6505152017-06-27 13:29:09 -0700214 }
215
Garfield Tan75379db2017-02-08 15:32:56 -0800216 protected final List<String> findDocumentPath(File parent, File doc)
217 throws FileNotFoundException {
218
219 if (!doc.exists()) {
220 throw new FileNotFoundException(doc + " is not found.");
221 }
222
223 if (!FileUtils.contains(parent, doc)) {
224 throw new FileNotFoundException(doc + " is not found under " + parent);
225 }
226
227 LinkedList<String> path = new LinkedList<>();
228 while (doc != null && FileUtils.contains(parent, doc)) {
229 path.addFirst(getDocIdForFile(doc));
230
231 doc = doc.getParentFile();
232 }
233
234 return path;
235 }
236
237 @Override
238 public String createDocument(String docId, String mimeType, String displayName)
239 throws FileNotFoundException {
240 displayName = FileUtils.buildValidFatFilename(displayName);
241
242 final File parent = getFileForDocId(docId);
243 if (!parent.isDirectory()) {
244 throw new IllegalArgumentException("Parent document isn't a directory");
245 }
246
247 final File file = FileUtils.buildUniqueFile(parent, mimeType, displayName);
Garfield Tan93615412017-03-20 17:21:55 -0700248 final String childId;
Garfield Tan75379db2017-02-08 15:32:56 -0800249 if (Document.MIME_TYPE_DIR.equals(mimeType)) {
250 if (!file.mkdir()) {
251 throw new IllegalStateException("Failed to mkdir " + file);
252 }
Garfield Tan93615412017-03-20 17:21:55 -0700253 childId = getDocIdForFile(file);
Jeff Sharkeyb00d5ea2018-05-01 10:01:52 -0600254 onDocIdChanged(childId);
Garfield Tan75379db2017-02-08 15:32:56 -0800255 } else {
256 try {
257 if (!file.createNewFile()) {
258 throw new IllegalStateException("Failed to touch " + file);
259 }
Garfield Tan93615412017-03-20 17:21:55 -0700260 childId = getDocIdForFile(file);
Jeff Sharkeyb00d5ea2018-05-01 10:01:52 -0600261 onDocIdChanged(childId);
Garfield Tan75379db2017-02-08 15:32:56 -0800262 } catch (IOException e) {
263 throw new IllegalStateException("Failed to touch " + file + ": " + e);
264 }
265 }
Jeff Sharkey04b4ba12019-12-15 22:42:42 -0700266 MediaStore.scanFile(getContext().getContentResolver(), file);
Garfield Tan75379db2017-02-08 15:32:56 -0800267
Garfield Tan93615412017-03-20 17:21:55 -0700268 return childId;
269 }
270
Garfield Tan75379db2017-02-08 15:32:56 -0800271 @Override
272 public String renameDocument(String docId, String displayName) throws FileNotFoundException {
273 // Since this provider treats renames as generating a completely new
274 // docId, we're okay with letting the MIME type change.
275 displayName = FileUtils.buildValidFatFilename(displayName);
276
277 final File before = getFileForDocId(docId);
Tony Huang7bf90402018-09-11 17:09:12 +0800278 final File beforeVisibleFile = getFileForDocId(docId, true);
Garfield Tan75379db2017-02-08 15:32:56 -0800279 final File after = FileUtils.buildUniqueFile(before.getParentFile(), displayName);
Garfield Tan75379db2017-02-08 15:32:56 -0800280 if (!before.renameTo(after)) {
281 throw new IllegalStateException("Failed to rename to " + after);
282 }
Garfield Tan75379db2017-02-08 15:32:56 -0800283
284 final String afterDocId = getDocIdForFile(after);
Jeff Sharkeyb00d5ea2018-05-01 10:01:52 -0600285 onDocIdChanged(docId);
286 onDocIdChanged(afterDocId);
287
Jeff Sharkeyb00d5ea2018-05-01 10:01:52 -0600288 final File afterVisibleFile = getFileForDocId(afterDocId, true);
289 moveInMediaStore(beforeVisibleFile, afterVisibleFile);
Garfield Tan75379db2017-02-08 15:32:56 -0800290
291 if (!TextUtils.equals(docId, afterDocId)) {
292 return afterDocId;
293 } else {
294 return null;
295 }
296 }
297
298 @Override
Garfield Tan75379db2017-02-08 15:32:56 -0800299 public String moveDocument(String sourceDocumentId, String sourceParentDocumentId,
300 String targetParentDocumentId)
301 throws FileNotFoundException {
302 final File before = getFileForDocId(sourceDocumentId);
303 final File after = new File(getFileForDocId(targetParentDocumentId), before.getName());
304 final File visibleFileBefore = getFileForDocId(sourceDocumentId, true);
305
306 if (after.exists()) {
307 throw new IllegalStateException("Already exists " + after);
308 }
309 if (!before.renameTo(after)) {
310 throw new IllegalStateException("Failed to move to " + after);
311 }
312
Garfield Tan75379db2017-02-08 15:32:56 -0800313 final String docId = getDocIdForFile(after);
Jeff Sharkeyb00d5ea2018-05-01 10:01:52 -0600314 onDocIdChanged(sourceDocumentId);
315 onDocIdChanged(docId);
Garfield Tan9bd2f6c2017-03-10 15:38:15 -0800316 moveInMediaStore(visibleFileBefore, getFileForDocId(docId, true));
Garfield Tan75379db2017-02-08 15:32:56 -0800317
318 return docId;
319 }
320
Garfield Tand21af532017-04-17 14:22:32 -0700321 private void moveInMediaStore(@Nullable File oldVisibleFile, @Nullable File newVisibleFile) {
Jeff Sharkeydb73a7d2019-04-14 12:23:35 -0600322 if (oldVisibleFile != null) {
Jeff Sharkey04b4ba12019-12-15 22:42:42 -0700323 MediaStore.scanFile(getContext().getContentResolver(), oldVisibleFile);
Jeff Sharkeydb73a7d2019-04-14 12:23:35 -0600324 }
325 if (newVisibleFile != null) {
Jeff Sharkey04b4ba12019-12-15 22:42:42 -0700326 MediaStore.scanFile(getContext().getContentResolver(), newVisibleFile);
Garfield Tan9bd2f6c2017-03-10 15:38:15 -0800327 }
328 }
329
330 @Override
331 public void deleteDocument(String docId) throws FileNotFoundException {
332 final File file = getFileForDocId(docId);
333 final File visibleFile = getFileForDocId(docId, true);
334
335 final boolean isDirectory = file.isDirectory();
336 if (isDirectory) {
337 FileUtils.deleteContents(file);
338 }
Sudheer Shanka7d28b5b2020-02-11 14:32:59 -0800339 // We could be deleting pending media which doesn't have any content yet, so only throw
340 // if the file exists and we fail to delete it.
341 if (file.exists() && !file.delete()) {
Garfield Tan9bd2f6c2017-03-10 15:38:15 -0800342 throw new IllegalStateException("Failed to delete " + file);
343 }
344
Jeff Sharkeyb00d5ea2018-05-01 10:01:52 -0600345 onDocIdChanged(docId);
Sudheer Shanka7d28b5b2020-02-11 14:32:59 -0800346 removeFromMediaStore(visibleFile);
Garfield Tan9bd2f6c2017-03-10 15:38:15 -0800347 }
348
Sudheer Shanka7d28b5b2020-02-11 14:32:59 -0800349 private void removeFromMediaStore(@Nullable File visibleFile)
Garfield Tan9bd2f6c2017-03-10 15:38:15 -0800350 throws FileNotFoundException {
Garfield Tand21af532017-04-17 14:22:32 -0700351 // visibleFolder is null if we're removing a document from external thumb drive or SD card.
Garfield Tan75379db2017-02-08 15:32:56 -0800352 if (visibleFile != null) {
Garfield Tanfac8aea2017-06-27 11:03:42 -0700353 final long token = Binder.clearCallingIdentity();
Garfield Tan75379db2017-02-08 15:32:56 -0800354
Garfield Tanfac8aea2017-06-27 11:03:42 -0700355 try {
356 final ContentResolver resolver = getContext().getContentResolver();
357 final Uri externalUri = MediaStore.Files.getContentUri("external");
Sudheer Shanka7d28b5b2020-02-11 14:32:59 -0800358 final Bundle queryArgs = new Bundle();
359 queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
Garfield Tanfac8aea2017-06-27 11:03:42 -0700360
Sudheer Shanka7d28b5b2020-02-11 14:32:59 -0800361 // Remove the media store entry corresponding to visibleFile and if it is a
362 // directory, also remove media store entries for any files inside this directory.
363 // Logic borrowed from com.android.providers.media.scan.ModernMediaScanner.
364 final String pathEscapedForLike = DatabaseUtils.escapeForLike(
365 visibleFile.getAbsolutePath());
366 ContentResolver.includeSqlSelectionArgs(queryArgs,
367 FileColumns.DATA + " LIKE ? ESCAPE '\\' OR "
368 + FileColumns.DATA + " LIKE ? ESCAPE '\\'",
369 new String[] {pathEscapedForLike + "/%", pathEscapedForLike});
370 resolver.delete(externalUri, queryArgs);
Garfield Tanfac8aea2017-06-27 11:03:42 -0700371 } finally {
372 Binder.restoreCallingIdentity(token);
Garfield Tan75379db2017-02-08 15:32:56 -0800373 }
Garfield Tan75379db2017-02-08 15:32:56 -0800374 }
375 }
376
377 @Override
378 public Cursor queryDocument(String documentId, String[] projection)
379 throws FileNotFoundException {
380 final MatrixCursor result = new MatrixCursor(resolveProjection(projection));
381 includeFile(result, documentId, null);
382 return result;
383 }
384
Abhijeet Kaur553625d2020-05-07 13:11:55 +0100385 /**
386 * This method is similar to
387 * {@link DocumentsProvider#queryChildDocuments(String, String[], String)}. This method returns
388 * all children documents including hidden directories/files.
389 *
390 * <p>
391 * In a scoped storage world, access to "Android/data" style directories are hidden for privacy
392 * reasons. This method may show privacy sensitive data, so its usage should only be in
393 * restricted modes.
394 *
395 * @param parentDocumentId the directory to return children for.
396 * @param projection list of {@link Document} columns to put into the
397 * cursor. If {@code null} all supported columns should be
398 * included.
399 * @param sortOrder how to order the rows, formatted as an SQL
400 * {@code ORDER BY} clause (excluding the ORDER BY itself).
401 * Passing {@code null} will use the default sort order, which
402 * may be unordered. This ordering is a hint that can be used to
403 * prioritize how data is fetched from the network, but UI may
404 * always enforce a specific ordering
405 * @throws FileNotFoundException when parent document doesn't exist or query fails
406 */
407 protected Cursor queryChildDocumentsShowAll(
408 String parentDocumentId, String[] projection, String sortOrder)
409 throws FileNotFoundException {
410 return queryChildDocuments(parentDocumentId, projection, sortOrder, File -> true);
411 }
412
Garfield Tan75379db2017-02-08 15:32:56 -0800413 @Override
414 public Cursor queryChildDocuments(
415 String parentDocumentId, String[] projection, String sortOrder)
416 throws FileNotFoundException {
Abhijeet Kaur553625d2020-05-07 13:11:55 +0100417 // Access to some directories is hidden for privacy reasons.
418 return queryChildDocuments(parentDocumentId, projection, sortOrder, this::shouldShow);
419 }
Garfield Tan75379db2017-02-08 15:32:56 -0800420
Abhijeet Kaur553625d2020-05-07 13:11:55 +0100421 private Cursor queryChildDocuments(
422 String parentDocumentId, String[] projection, String sortOrder,
423 @NonNull Predicate<File> filter) throws FileNotFoundException {
Garfield Tan75379db2017-02-08 15:32:56 -0800424 final File parent = getFileForDocId(parentDocumentId);
425 final MatrixCursor result = new DirectoryCursor(
426 resolveProjection(projection), parentDocumentId, parent);
Bill Linfe5a9ed2018-08-23 19:24:07 +0800427 if (parent.isDirectory()) {
428 for (File file : FileUtils.listFilesOrEmpty(parent)) {
Abhijeet Kaur553625d2020-05-07 13:11:55 +0100429 if (filter.test(file)) {
Jeff Sharkeyc8879082019-12-21 17:44:11 -0700430 includeFile(result, null, file);
431 }
Bill Linfe5a9ed2018-08-23 19:24:07 +0800432 }
433 } else {
434 Log.w(TAG, "parentDocumentId '" + parentDocumentId + "' is not Directory");
Garfield Tan75379db2017-02-08 15:32:56 -0800435 }
436 return result;
437 }
438
439 /**
440 * Searches documents under the given folder.
441 *
442 * To avoid runtime explosion only returns the at most 23 items.
443 *
444 * @param folder the root folder where recursive search begins
445 * @param query the search condition used to match file names
446 * @param projection projection of the returned cursor
447 * @param exclusion absolute file paths to exclude from result
Ivan Chianga972d042018-10-15 15:23:02 +0800448 * @param queryArgs the query arguments for search
449 * @return cursor containing search result. Include
450 * {@link ContentResolver#EXTRA_HONORED_ARGS} in {@link Cursor}
451 * extras {@link Bundle} when any QUERY_ARG_* value was honored
452 * during the preparation of the results.
Garfield Tan75379db2017-02-08 15:32:56 -0800453 * @throws FileNotFoundException when root folder doesn't exist or search fails
Ivan Chianga972d042018-10-15 15:23:02 +0800454 *
455 * @see ContentResolver#EXTRA_HONORED_ARGS
Garfield Tan75379db2017-02-08 15:32:56 -0800456 */
457 protected final Cursor querySearchDocuments(
Ivan Chianga972d042018-10-15 15:23:02 +0800458 File folder, String[] projection, Set<String> exclusion, Bundle queryArgs)
Garfield Tan75379db2017-02-08 15:32:56 -0800459 throws FileNotFoundException {
Garfield Tan75379db2017-02-08 15:32:56 -0800460 final MatrixCursor result = new MatrixCursor(resolveProjection(projection));
461 final LinkedList<File> pending = new LinkedList<>();
462 pending.add(folder);
463 while (!pending.isEmpty() && result.getCount() < 24) {
464 final File file = pending.removeFirst();
Jeff Sharkeyc8879082019-12-21 17:44:11 -0700465 if (shouldHide(file)) continue;
466
Garfield Tan75379db2017-02-08 15:32:56 -0800467 if (file.isDirectory()) {
Martijn Coenen8b68b8c2020-03-11 14:10:26 +0100468 for (File child : FileUtils.listFilesOrEmpty(file)) {
Garfield Tan75379db2017-02-08 15:32:56 -0800469 pending.add(child);
470 }
471 }
Ivan Chianga972d042018-10-15 15:23:02 +0800472 if (!exclusion.contains(file.getAbsolutePath()) && matchSearchQueryArguments(file,
473 queryArgs)) {
Garfield Tan75379db2017-02-08 15:32:56 -0800474 includeFile(result, null, file);
475 }
476 }
Ivan Chianga972d042018-10-15 15:23:02 +0800477
478 final String[] handledQueryArgs = DocumentsContract.getHandledQueryArguments(queryArgs);
479 if (handledQueryArgs.length > 0) {
480 final Bundle extras = new Bundle();
481 extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, handledQueryArgs);
482 result.setExtras(extras);
483 }
Garfield Tan75379db2017-02-08 15:32:56 -0800484 return result;
485 }
486
487 @Override
488 public String getDocumentType(String documentId) throws FileNotFoundException {
Anton Hansson788ec752019-04-30 15:21:24 +0100489 return getDocumentType(documentId, getFileForDocId(documentId));
490 }
491
492 private String getDocumentType(final String documentId, final File file)
493 throws FileNotFoundException {
Arthur Hung430c6e62018-06-01 13:42:48 +0800494 if (file.isDirectory()) {
495 return Document.MIME_TYPE_DIR;
496 } else {
497 final int lastDot = documentId.lastIndexOf('.');
498 if (lastDot >= 0) {
499 final String extension = documentId.substring(lastDot + 1).toLowerCase();
500 final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
501 if (mime != null) {
502 return mime;
503 }
504 }
Jeff Sharkey91e3cd42018-08-27 18:03:33 -0600505 return ContentResolver.MIME_TYPE_DEFAULT;
Arthur Hung430c6e62018-06-01 13:42:48 +0800506 }
Garfield Tan75379db2017-02-08 15:32:56 -0800507 }
508
509 @Override
510 public ParcelFileDescriptor openDocument(
511 String documentId, String mode, CancellationSignal signal)
512 throws FileNotFoundException {
513 final File file = getFileForDocId(documentId);
514 final File visibleFile = getFileForDocId(documentId, true);
515
516 final int pfdMode = ParcelFileDescriptor.parseMode(mode);
517 if (pfdMode == ParcelFileDescriptor.MODE_READ_ONLY || visibleFile == null) {
518 return ParcelFileDescriptor.open(file, pfdMode);
519 } else {
520 try {
521 // When finished writing, kick off media scanner
522 return ParcelFileDescriptor.open(
Jeff Sharkeyb00d5ea2018-05-01 10:01:52 -0600523 file, pfdMode, mHandler, (IOException e) -> {
524 onDocIdChanged(documentId);
525 scanFile(visibleFile);
526 });
Garfield Tan75379db2017-02-08 15:32:56 -0800527 } catch (IOException e) {
528 throw new FileNotFoundException("Failed to open for writing: " + e);
529 }
530 }
531 }
532
Ivan Chianga972d042018-10-15 15:23:02 +0800533 /**
534 * Test if the file matches the query arguments.
535 *
536 * @param file the file to test
537 * @param queryArgs the query arguments
538 */
539 private boolean matchSearchQueryArguments(File file, Bundle queryArgs) {
540 if (file == null) {
541 return false;
542 }
543
544 final String fileMimeType;
545 final String fileName = file.getName();
546
547 if (file.isDirectory()) {
548 fileMimeType = DocumentsContract.Document.MIME_TYPE_DIR;
549 } else {
550 int dotPos = fileName.lastIndexOf('.');
551 if (dotPos < 0) {
552 return false;
553 }
554 final String extension = fileName.substring(dotPos + 1);
555 fileMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
556 }
557 return DocumentsContract.matchSearchQueryArguments(queryArgs, fileName, fileMimeType,
558 file.lastModified(), file.length());
559 }
560
Garfield Tan75379db2017-02-08 15:32:56 -0800561 private void scanFile(File visibleFile) {
562 final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
563 intent.setData(Uri.fromFile(visibleFile));
564 getContext().sendBroadcast(intent);
565 }
566
567 @Override
568 public AssetFileDescriptor openDocumentThumbnail(
569 String documentId, Point sizeHint, CancellationSignal signal)
570 throws FileNotFoundException {
571 final File file = getFileForDocId(documentId);
572 return DocumentsContract.openImageThumbnail(file);
573 }
574
Anton Hansson788ec752019-04-30 15:21:24 +0100575 protected RowBuilder includeFile(final MatrixCursor result, String docId, File file)
Garfield Tan75379db2017-02-08 15:32:56 -0800576 throws FileNotFoundException {
Anton Hansson788ec752019-04-30 15:21:24 +0100577 final String[] columns = result.getColumnNames();
578 final RowBuilder row = result.newRow();
579
Garfield Tan75379db2017-02-08 15:32:56 -0800580 if (docId == null) {
581 docId = getDocIdForFile(file);
582 } else {
583 file = getFileForDocId(docId);
584 }
Jeff Sharkeyc8879082019-12-21 17:44:11 -0700585
Anton Hansson788ec752019-04-30 15:21:24 +0100586 final String mimeType = getDocumentType(docId, file);
587 row.add(Document.COLUMN_DOCUMENT_ID, docId);
588 row.add(Document.COLUMN_MIME_TYPE, mimeType);
Garfield Tan75379db2017-02-08 15:32:56 -0800589
Anton Hansson788ec752019-04-30 15:21:24 +0100590 final int flagIndex = ArrayUtils.indexOf(columns, Document.COLUMN_FLAGS);
591 if (flagIndex != -1) {
592 int flags = 0;
593 if (file.canWrite()) {
594 if (mimeType.equals(Document.MIME_TYPE_DIR)) {
595 flags |= Document.FLAG_DIR_SUPPORTS_CREATE;
596 flags |= Document.FLAG_SUPPORTS_DELETE;
597 flags |= Document.FLAG_SUPPORTS_RENAME;
598 flags |= Document.FLAG_SUPPORTS_MOVE;
Ivan Chiang730b3a32019-08-21 16:12:54 +0800599
600 if (shouldBlockFromTree(docId)) {
Ivan Chiang84b55922020-01-15 11:00:31 +0800601 flags |= Document.FLAG_DIR_BLOCKS_OPEN_DOCUMENT_TREE;
Ivan Chiang730b3a32019-08-21 16:12:54 +0800602 }
603
Anton Hansson788ec752019-04-30 15:21:24 +0100604 } else {
605 flags |= Document.FLAG_SUPPORTS_WRITE;
606 flags |= Document.FLAG_SUPPORTS_DELETE;
607 flags |= Document.FLAG_SUPPORTS_RENAME;
608 flags |= Document.FLAG_SUPPORTS_MOVE;
609 }
610 }
Garfield Tan75379db2017-02-08 15:32:56 -0800611
Anton Hansson788ec752019-04-30 15:21:24 +0100612 if (mimeType.startsWith("image/")) {
613 flags |= Document.FLAG_SUPPORTS_THUMBNAIL;
614 }
615
616 if (typeSupportsMetadata(mimeType)) {
617 flags |= Document.FLAG_SUPPORTS_METADATA;
618 }
619 row.add(flagIndex, flags);
620 }
621
622 final int displayNameIndex = ArrayUtils.indexOf(columns, Document.COLUMN_DISPLAY_NAME);
623 if (displayNameIndex != -1) {
624 row.add(displayNameIndex, file.getName());
625 }
626
627 final int lastModifiedIndex = ArrayUtils.indexOf(columns, Document.COLUMN_LAST_MODIFIED);
628 if (lastModifiedIndex != -1) {
629 final long lastModified = file.lastModified();
630 // Only publish dates reasonably after epoch
631 if (lastModified > 31536000000L) {
632 row.add(lastModifiedIndex, lastModified);
Garfield Tan75379db2017-02-08 15:32:56 -0800633 }
634 }
Anton Hansson788ec752019-04-30 15:21:24 +0100635 final int sizeIndex = ArrayUtils.indexOf(columns, Document.COLUMN_SIZE);
636 if (sizeIndex != -1) {
637 row.add(sizeIndex, file.length());
Garfield Tan75379db2017-02-08 15:32:56 -0800638 }
639
640 // Return the row builder just in case any subclass want to add more stuff to it.
641 return row;
642 }
643
Jeff Sharkeyc8879082019-12-21 17:44:11 -0700644 private static final Pattern PATTERN_HIDDEN_PATH = Pattern.compile(
645 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(?:data|obb|sandbox)$");
646
647 /**
648 * In a scoped storage world, access to "Android/data" style directories are
649 * hidden for privacy reasons.
650 */
651 protected boolean shouldHide(@NonNull File file) {
652 return (PATTERN_HIDDEN_PATH.matcher(file.getAbsolutePath()).matches());
653 }
654
Abhijeet Kaur553625d2020-05-07 13:11:55 +0100655 private boolean shouldShow(@NonNull File file) {
656 return !shouldHide(file);
657 }
658
Ivan Chiang730b3a32019-08-21 16:12:54 +0800659 protected boolean shouldBlockFromTree(@NonNull String docId) {
660 return false;
661 }
662
Steve McKay36f1d7e2017-07-20 11:41:58 -0700663 protected boolean typeSupportsMetadata(String mimeType) {
Jeff Sharkeyb95bd442018-12-11 10:35:02 -0700664 return MetadataReader.isSupportedMimeType(mimeType)
665 || Document.MIME_TYPE_DIR.equals(mimeType);
Steve McKay36f1d7e2017-07-20 11:41:58 -0700666 }
667
Garfield Tan75379db2017-02-08 15:32:56 -0800668 protected final File getFileForDocId(String docId) throws FileNotFoundException {
669 return getFileForDocId(docId, false);
670 }
671
672 private String[] resolveProjection(String[] projection) {
673 return projection == null ? mDefaultProjection : projection;
674 }
675
Anton Hanssond79473f2019-04-30 16:57:10 +0100676 private void startObserving(File file, Uri notifyUri, DirectoryCursor cursor) {
Garfield Tan75379db2017-02-08 15:32:56 -0800677 synchronized (mObservers) {
678 DirectoryObserver observer = mObservers.get(file);
679 if (observer == null) {
Anton Hanssond79473f2019-04-30 16:57:10 +0100680 observer =
681 new DirectoryObserver(file, getContext().getContentResolver(), notifyUri);
Garfield Tan75379db2017-02-08 15:32:56 -0800682 observer.startWatching();
683 mObservers.put(file, observer);
684 }
Anton Hanssond79473f2019-04-30 16:57:10 +0100685 observer.mCursors.add(cursor);
Garfield Tan75379db2017-02-08 15:32:56 -0800686
687 if (LOG_INOTIFY) Log.d(TAG, "after start: " + observer);
688 }
689 }
690
Anton Hanssond79473f2019-04-30 16:57:10 +0100691 private void stopObserving(File file, DirectoryCursor cursor) {
Garfield Tan75379db2017-02-08 15:32:56 -0800692 synchronized (mObservers) {
693 DirectoryObserver observer = mObservers.get(file);
694 if (observer == null) return;
695
Anton Hanssond79473f2019-04-30 16:57:10 +0100696 observer.mCursors.remove(cursor);
697 if (observer.mCursors.size() == 0) {
Garfield Tan75379db2017-02-08 15:32:56 -0800698 mObservers.remove(file);
699 observer.stopWatching();
700 }
701
702 if (LOG_INOTIFY) Log.d(TAG, "after stop: " + observer);
703 }
704 }
705
706 private static class DirectoryObserver extends FileObserver {
707 private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO
708 | CREATE | DELETE | DELETE_SELF | MOVE_SELF;
709
710 private final File mFile;
711 private final ContentResolver mResolver;
712 private final Uri mNotifyUri;
Anton Hanssond79473f2019-04-30 16:57:10 +0100713 private final CopyOnWriteArrayList<DirectoryCursor> mCursors;
Garfield Tan75379db2017-02-08 15:32:56 -0800714
Anton Hanssond79473f2019-04-30 16:57:10 +0100715 DirectoryObserver(File file, ContentResolver resolver, Uri notifyUri) {
Garfield Tan75379db2017-02-08 15:32:56 -0800716 super(file.getAbsolutePath(), NOTIFY_EVENTS);
717 mFile = file;
718 mResolver = resolver;
719 mNotifyUri = notifyUri;
Anton Hanssond79473f2019-04-30 16:57:10 +0100720 mCursors = new CopyOnWriteArrayList<>();
Garfield Tan75379db2017-02-08 15:32:56 -0800721 }
722
723 @Override
724 public void onEvent(int event, String path) {
725 if ((event & NOTIFY_EVENTS) != 0) {
726 if (LOG_INOTIFY) Log.d(TAG, "onEvent() " + event + " at " + path);
Anton Hanssond79473f2019-04-30 16:57:10 +0100727 for (DirectoryCursor cursor : mCursors) {
728 cursor.notifyChanged();
729 }
Garfield Tan75379db2017-02-08 15:32:56 -0800730 mResolver.notifyChange(mNotifyUri, null, false);
731 }
732 }
733
734 @Override
735 public String toString() {
Anton Hanssond79473f2019-04-30 16:57:10 +0100736 String filePath = mFile.getAbsolutePath();
737 return "DirectoryObserver{file=" + filePath + ", ref=" + mCursors.size() + "}";
Garfield Tan75379db2017-02-08 15:32:56 -0800738 }
739 }
740
741 private class DirectoryCursor extends MatrixCursor {
742 private final File mFile;
743
744 public DirectoryCursor(String[] columnNames, String docId, File file) {
745 super(columnNames);
746
747 final Uri notifyUri = buildNotificationUri(docId);
Anton Hanssond79473f2019-04-30 16:57:10 +0100748 boolean registerSelfObserver = false; // Our FileObserver sees all relevant changes.
749 setNotificationUris(getContext().getContentResolver(), Arrays.asList(notifyUri),
750 getContext().getContentResolver().getUserId(), registerSelfObserver);
Garfield Tan75379db2017-02-08 15:32:56 -0800751
752 mFile = file;
Anton Hanssond79473f2019-04-30 16:57:10 +0100753 startObserving(mFile, notifyUri, this);
754 }
755
756 public void notifyChanged() {
757 onChange(false);
Garfield Tan75379db2017-02-08 15:32:56 -0800758 }
759
760 @Override
761 public void close() {
762 super.close();
Anton Hanssond79473f2019-04-30 16:57:10 +0100763 stopObserving(mFile, this);
Garfield Tan75379db2017-02-08 15:32:56 -0800764 }
765 }
766}