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