1 /*
2  * Copyright (C) 2013 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.externalstorage;
18 
19 import android.content.ContentResolver;
20 import android.content.Context;
21 import android.content.Intent;
22 import android.content.res.AssetFileDescriptor;
23 import android.database.Cursor;
24 import android.database.MatrixCursor;
25 import android.database.MatrixCursor.RowBuilder;
26 import android.graphics.Point;
27 import android.net.Uri;
28 import android.os.CancellationSignal;
29 import android.os.FileObserver;
30 import android.os.FileUtils;
31 import android.os.Handler;
32 import android.os.ParcelFileDescriptor;
33 import android.os.ParcelFileDescriptor.OnCloseListener;
34 import android.os.UserHandle;
35 import android.os.storage.StorageManager;
36 import android.os.storage.VolumeInfo;
37 import android.provider.DocumentsContract;
38 import android.provider.DocumentsContract.Document;
39 import android.provider.DocumentsContract.Root;
40 import android.provider.DocumentsProvider;
41 import android.text.TextUtils;
42 import android.util.ArrayMap;
43 import android.util.DebugUtils;
44 import android.util.Log;
45 import android.webkit.MimeTypeMap;
46 
47 import com.android.internal.annotations.GuardedBy;
48 import com.android.internal.util.IndentingPrintWriter;
49 
50 import java.io.File;
51 import java.io.FileDescriptor;
52 import java.io.FileNotFoundException;
53 import java.io.IOException;
54 import java.io.PrintWriter;
55 import java.util.LinkedList;
56 import java.util.List;
57 
58 public class ExternalStorageProvider extends DocumentsProvider {
59     private static final String TAG = "ExternalStorage";
60 
61     private static final boolean LOG_INOTIFY = false;
62 
63     public static final String AUTHORITY = "com.android.externalstorage.documents";
64 
65     private static final Uri BASE_URI =
66             new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT).authority(AUTHORITY).build();
67 
68     // docId format: root:path/to/file
69 
70     private static final String[] DEFAULT_ROOT_PROJECTION = new String[] {
71             Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON, Root.COLUMN_TITLE,
72             Root.COLUMN_DOCUMENT_ID, Root.COLUMN_AVAILABLE_BYTES,
73     };
74 
75     private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] {
76             Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME,
77             Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE,
78     };
79 
80     private static class RootInfo {
81         public String rootId;
82         public int flags;
83         public String title;
84         public String docId;
85         public File visiblePath;
86         public File path;
87     }
88 
89     private static final String ROOT_ID_PRIMARY_EMULATED = "primary";
90 
91     private StorageManager mStorageManager;
92     private Handler mHandler;
93 
94     private final Object mRootsLock = new Object();
95 
96     @GuardedBy("mRootsLock")
97     private ArrayMap<String, RootInfo> mRoots = new ArrayMap<>();
98 
99     @GuardedBy("mObservers")
100     private ArrayMap<File, DirectoryObserver> mObservers = new ArrayMap<>();
101 
102     @Override
onCreate()103     public boolean onCreate() {
104         mStorageManager = (StorageManager) getContext().getSystemService(Context.STORAGE_SERVICE);
105         mHandler = new Handler();
106 
107         updateVolumes();
108         return true;
109     }
110 
updateVolumes()111     public void updateVolumes() {
112         synchronized (mRootsLock) {
113             updateVolumesLocked();
114         }
115     }
116 
updateVolumesLocked()117     private void updateVolumesLocked() {
118         mRoots.clear();
119 
120         final int userId = UserHandle.myUserId();
121         final List<VolumeInfo> volumes = mStorageManager.getVolumes();
122         for (VolumeInfo volume : volumes) {
123             if (!volume.isMountedReadable()) continue;
124 
125             final String rootId;
126             final String title;
127             if (volume.getType() == VolumeInfo.TYPE_EMULATED) {
128                 // We currently only support a single emulated volume mounted at
129                 // a time, and it's always considered the primary
130                 rootId = ROOT_ID_PRIMARY_EMULATED;
131                 if (VolumeInfo.ID_EMULATED_INTERNAL.equals(volume.getId())) {
132                     title = getContext().getString(R.string.root_internal_storage);
133                 } else {
134                     final VolumeInfo privateVol = mStorageManager.findPrivateForEmulated(volume);
135                     title = mStorageManager.getBestVolumeDescription(privateVol);
136                 }
137             } else if (volume.getType() == VolumeInfo.TYPE_PUBLIC) {
138                 rootId = volume.getFsUuid();
139                 title = mStorageManager.getBestVolumeDescription(volume);
140             } else {
141                 // Unsupported volume; ignore
142                 continue;
143             }
144 
145             if (TextUtils.isEmpty(rootId)) {
146                 Log.d(TAG, "Missing UUID for " + volume.getId() + "; skipping");
147                 continue;
148             }
149             if (mRoots.containsKey(rootId)) {
150                 Log.w(TAG, "Duplicate UUID " + rootId + " for " + volume.getId() + "; skipping");
151                 continue;
152             }
153 
154             try {
155                 final RootInfo root = new RootInfo();
156                 mRoots.put(rootId, root);
157 
158                 root.rootId = rootId;
159                 root.flags = Root.FLAG_SUPPORTS_CREATE | Root.FLAG_LOCAL_ONLY | Root.FLAG_ADVANCED
160                         | Root.FLAG_SUPPORTS_SEARCH | Root.FLAG_SUPPORTS_IS_CHILD;
161                 root.title = title;
162                 if (volume.getType() == VolumeInfo.TYPE_PUBLIC) {
163                     root.flags |= Root.FLAG_HAS_SETTINGS;
164                 }
165                 if (volume.isVisibleForRead(userId)) {
166                     root.visiblePath = volume.getPathForUser(userId);
167                 } else {
168                     root.visiblePath = null;
169                 }
170                 root.path = volume.getInternalPathForUser(userId);
171                 root.docId = getDocIdForFile(root.path);
172 
173             } catch (FileNotFoundException e) {
174                 throw new IllegalStateException(e);
175             }
176         }
177 
178         Log.d(TAG, "After updating volumes, found " + mRoots.size() + " active roots");
179 
180         // Note this affects content://com.android.externalstorage.documents/root/39BD-07C5
181         // as well as content://com.android.externalstorage.documents/document/*/children,
182         // so just notify on content://com.android.externalstorage.documents/.
183         getContext().getContentResolver().notifyChange(BASE_URI, null, false);
184     }
185 
resolveRootProjection(String[] projection)186     private static String[] resolveRootProjection(String[] projection) {
187         return projection != null ? projection : DEFAULT_ROOT_PROJECTION;
188     }
189 
resolveDocumentProjection(String[] projection)190     private static String[] resolveDocumentProjection(String[] projection) {
191         return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION;
192     }
193 
getDocIdForFile(File file)194     private String getDocIdForFile(File file) throws FileNotFoundException {
195         String path = file.getAbsolutePath();
196 
197         // Find the most-specific root path
198         String mostSpecificId = null;
199         String mostSpecificPath = null;
200         synchronized (mRootsLock) {
201             for (int i = 0; i < mRoots.size(); i++) {
202                 final String rootId = mRoots.keyAt(i);
203                 final String rootPath = mRoots.valueAt(i).path.getAbsolutePath();
204                 if (path.startsWith(rootPath) && (mostSpecificPath == null
205                         || rootPath.length() > mostSpecificPath.length())) {
206                     mostSpecificId = rootId;
207                     mostSpecificPath = rootPath;
208                 }
209             }
210         }
211 
212         if (mostSpecificPath == null) {
213             throw new FileNotFoundException("Failed to find root that contains " + path);
214         }
215 
216         // Start at first char of path under root
217         final String rootPath = mostSpecificPath;
218         if (rootPath.equals(path)) {
219             path = "";
220         } else if (rootPath.endsWith("/")) {
221             path = path.substring(rootPath.length());
222         } else {
223             path = path.substring(rootPath.length() + 1);
224         }
225 
226         return mostSpecificId + ':' + path;
227     }
228 
getFileForDocId(String docId)229     private File getFileForDocId(String docId) throws FileNotFoundException {
230         return getFileForDocId(docId, false);
231     }
232 
getFileForDocId(String docId, boolean visible)233     private File getFileForDocId(String docId, boolean visible) throws FileNotFoundException {
234         final int splitIndex = docId.indexOf(':', 1);
235         final String tag = docId.substring(0, splitIndex);
236         final String path = docId.substring(splitIndex + 1);
237 
238         RootInfo root;
239         synchronized (mRootsLock) {
240             root = mRoots.get(tag);
241         }
242         if (root == null) {
243             throw new FileNotFoundException("No root for " + tag);
244         }
245 
246         File target = visible ? root.visiblePath : root.path;
247         if (target == null) {
248             return null;
249         }
250         if (!target.exists()) {
251             target.mkdirs();
252         }
253         target = new File(target, path);
254         if (!target.exists()) {
255             throw new FileNotFoundException("Missing file for " + docId + " at " + target);
256         }
257         return target;
258     }
259 
includeFile(MatrixCursor result, String docId, File file)260     private void includeFile(MatrixCursor result, String docId, File file)
261             throws FileNotFoundException {
262         if (docId == null) {
263             docId = getDocIdForFile(file);
264         } else {
265             file = getFileForDocId(docId);
266         }
267 
268         int flags = 0;
269 
270         if (file.canWrite()) {
271             if (file.isDirectory()) {
272                 flags |= Document.FLAG_DIR_SUPPORTS_CREATE;
273                 flags |= Document.FLAG_SUPPORTS_DELETE;
274                 flags |= Document.FLAG_SUPPORTS_RENAME;
275             } else {
276                 flags |= Document.FLAG_SUPPORTS_WRITE;
277                 flags |= Document.FLAG_SUPPORTS_DELETE;
278                 flags |= Document.FLAG_SUPPORTS_RENAME;
279             }
280         }
281 
282         final String displayName = file.getName();
283         final String mimeType = getTypeForFile(file);
284         if (mimeType.startsWith("image/")) {
285             flags |= Document.FLAG_SUPPORTS_THUMBNAIL;
286         }
287 
288         final RowBuilder row = result.newRow();
289         row.add(Document.COLUMN_DOCUMENT_ID, docId);
290         row.add(Document.COLUMN_DISPLAY_NAME, displayName);
291         row.add(Document.COLUMN_SIZE, file.length());
292         row.add(Document.COLUMN_MIME_TYPE, mimeType);
293         row.add(Document.COLUMN_FLAGS, flags);
294 
295         // Only publish dates reasonably after epoch
296         long lastModified = file.lastModified();
297         if (lastModified > 31536000000L) {
298             row.add(Document.COLUMN_LAST_MODIFIED, lastModified);
299         }
300     }
301 
302     @Override
queryRoots(String[] projection)303     public Cursor queryRoots(String[] projection) throws FileNotFoundException {
304         final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection));
305         synchronized (mRootsLock) {
306             for (RootInfo root : mRoots.values()) {
307                 final RowBuilder row = result.newRow();
308                 row.add(Root.COLUMN_ROOT_ID, root.rootId);
309                 row.add(Root.COLUMN_FLAGS, root.flags);
310                 row.add(Root.COLUMN_TITLE, root.title);
311                 row.add(Root.COLUMN_DOCUMENT_ID, root.docId);
312                 row.add(Root.COLUMN_AVAILABLE_BYTES, root.path.getFreeSpace());
313             }
314         }
315         return result;
316     }
317 
318     @Override
isChildDocument(String parentDocId, String docId)319     public boolean isChildDocument(String parentDocId, String docId) {
320         try {
321             final File parent = getFileForDocId(parentDocId).getCanonicalFile();
322             final File doc = getFileForDocId(docId).getCanonicalFile();
323             return FileUtils.contains(parent, doc);
324         } catch (IOException e) {
325             throw new IllegalArgumentException(
326                     "Failed to determine if " + docId + " is child of " + parentDocId + ": " + e);
327         }
328     }
329 
330     @Override
createDocument(String docId, String mimeType, String displayName)331     public String createDocument(String docId, String mimeType, String displayName)
332             throws FileNotFoundException {
333         displayName = FileUtils.buildValidFatFilename(displayName);
334 
335         final File parent = getFileForDocId(docId);
336         if (!parent.isDirectory()) {
337             throw new IllegalArgumentException("Parent document isn't a directory");
338         }
339 
340         final File file = FileUtils.buildUniqueFile(parent, mimeType, displayName);
341         if (Document.MIME_TYPE_DIR.equals(mimeType)) {
342             if (!file.mkdir()) {
343                 throw new IllegalStateException("Failed to mkdir " + file);
344             }
345         } else {
346             try {
347                 if (!file.createNewFile()) {
348                     throw new IllegalStateException("Failed to touch " + file);
349                 }
350             } catch (IOException e) {
351                 throw new IllegalStateException("Failed to touch " + file + ": " + e);
352             }
353         }
354 
355         return getDocIdForFile(file);
356     }
357 
358     @Override
renameDocument(String docId, String displayName)359     public String renameDocument(String docId, String displayName) throws FileNotFoundException {
360         // Since this provider treats renames as generating a completely new
361         // docId, we're okay with letting the MIME type change.
362         displayName = FileUtils.buildValidFatFilename(displayName);
363 
364         final File before = getFileForDocId(docId);
365         final File after = new File(before.getParentFile(), displayName);
366         if (after.exists()) {
367             throw new IllegalStateException("Already exists " + after);
368         }
369         if (!before.renameTo(after)) {
370             throw new IllegalStateException("Failed to rename to " + after);
371         }
372         final String afterDocId = getDocIdForFile(after);
373         if (!TextUtils.equals(docId, afterDocId)) {
374             return afterDocId;
375         } else {
376             return null;
377         }
378     }
379 
380     @Override
deleteDocument(String docId)381     public void deleteDocument(String docId) throws FileNotFoundException {
382         final File file = getFileForDocId(docId);
383         if (file.isDirectory()) {
384             FileUtils.deleteContents(file);
385         }
386         if (!file.delete()) {
387             throw new IllegalStateException("Failed to delete " + file);
388         }
389     }
390 
391     @Override
queryDocument(String documentId, String[] projection)392     public Cursor queryDocument(String documentId, String[] projection)
393             throws FileNotFoundException {
394         final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection));
395         includeFile(result, documentId, null);
396         return result;
397     }
398 
399     @Override
queryChildDocuments( String parentDocumentId, String[] projection, String sortOrder)400     public Cursor queryChildDocuments(
401             String parentDocumentId, String[] projection, String sortOrder)
402             throws FileNotFoundException {
403         final File parent = getFileForDocId(parentDocumentId);
404         final MatrixCursor result = new DirectoryCursor(
405                 resolveDocumentProjection(projection), parentDocumentId, parent);
406         for (File file : parent.listFiles()) {
407             includeFile(result, null, file);
408         }
409         return result;
410     }
411 
412     @Override
querySearchDocuments(String rootId, String query, String[] projection)413     public Cursor querySearchDocuments(String rootId, String query, String[] projection)
414             throws FileNotFoundException {
415         final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection));
416 
417         final File parent;
418         synchronized (mRootsLock) {
419             parent = mRoots.get(rootId).path;
420         }
421 
422         final LinkedList<File> pending = new LinkedList<File>();
423         pending.add(parent);
424         while (!pending.isEmpty() && result.getCount() < 24) {
425             final File file = pending.removeFirst();
426             if (file.isDirectory()) {
427                 for (File child : file.listFiles()) {
428                     pending.add(child);
429                 }
430             }
431             if (file.getName().toLowerCase().contains(query)) {
432                 includeFile(result, null, file);
433             }
434         }
435         return result;
436     }
437 
438     @Override
getDocumentType(String documentId)439     public String getDocumentType(String documentId) throws FileNotFoundException {
440         final File file = getFileForDocId(documentId);
441         return getTypeForFile(file);
442     }
443 
444     @Override
openDocument( String documentId, String mode, CancellationSignal signal)445     public ParcelFileDescriptor openDocument(
446             String documentId, String mode, CancellationSignal signal)
447             throws FileNotFoundException {
448         final File file = getFileForDocId(documentId);
449         final File visibleFile = getFileForDocId(documentId, true);
450 
451         final int pfdMode = ParcelFileDescriptor.parseMode(mode);
452         if (pfdMode == ParcelFileDescriptor.MODE_READ_ONLY || visibleFile == null) {
453             return ParcelFileDescriptor.open(file, pfdMode);
454         } else {
455             try {
456                 // When finished writing, kick off media scanner
457                 return ParcelFileDescriptor.open(file, pfdMode, mHandler, new OnCloseListener() {
458                     @Override
459                     public void onClose(IOException e) {
460                         final Intent intent = new Intent(
461                                 Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
462                         intent.setData(Uri.fromFile(visibleFile));
463                         getContext().sendBroadcast(intent);
464                     }
465                 });
466             } catch (IOException e) {
467                 throw new FileNotFoundException("Failed to open for writing: " + e);
468             }
469         }
470     }
471 
472     @Override
473     public AssetFileDescriptor openDocumentThumbnail(
474             String documentId, Point sizeHint, CancellationSignal signal)
475             throws FileNotFoundException {
476         final File file = getFileForDocId(documentId);
477         return DocumentsContract.openImageThumbnail(file);
478     }
479 
480     @Override
481     public void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
482         final IndentingPrintWriter pw = new IndentingPrintWriter(writer, "  ", 160);
483         synchronized (mRootsLock) {
484             for (int i = 0; i < mRoots.size(); i++) {
485                 final RootInfo root = mRoots.valueAt(i);
486                 pw.println("Root{" + root.rootId + "}:");
487                 pw.increaseIndent();
488                 pw.printPair("flags", DebugUtils.flagsToString(Root.class, "FLAG_", root.flags));
489                 pw.println();
490                 pw.printPair("title", root.title);
491                 pw.printPair("docId", root.docId);
492                 pw.println();
493                 pw.printPair("path", root.path);
494                 pw.printPair("visiblePath", root.visiblePath);
495                 pw.decreaseIndent();
496                 pw.println();
497             }
498         }
499     }
500 
501     private static String getTypeForFile(File file) {
502         if (file.isDirectory()) {
503             return Document.MIME_TYPE_DIR;
504         } else {
505             return getTypeForName(file.getName());
506         }
507     }
508 
509     private static String getTypeForName(String name) {
510         final int lastDot = name.lastIndexOf('.');
511         if (lastDot >= 0) {
512             final String extension = name.substring(lastDot + 1).toLowerCase();
513             final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
514             if (mime != null) {
515                 return mime;
516             }
517         }
518 
519         return "application/octet-stream";
520     }
521 
522     private void startObserving(File file, Uri notifyUri) {
523         synchronized (mObservers) {
524             DirectoryObserver observer = mObservers.get(file);
525             if (observer == null) {
526                 observer = new DirectoryObserver(
527                         file, getContext().getContentResolver(), notifyUri);
528                 observer.startWatching();
529                 mObservers.put(file, observer);
530             }
531             observer.mRefCount++;
532 
533             if (LOG_INOTIFY) Log.d(TAG, "after start: " + observer);
534         }
535     }
536 
537     private void stopObserving(File file) {
538         synchronized (mObservers) {
539             DirectoryObserver observer = mObservers.get(file);
540             if (observer == null) return;
541 
542             observer.mRefCount--;
543             if (observer.mRefCount == 0) {
544                 mObservers.remove(file);
545                 observer.stopWatching();
546             }
547 
548             if (LOG_INOTIFY) Log.d(TAG, "after stop: " + observer);
549         }
550     }
551 
552     private static class DirectoryObserver extends FileObserver {
553         private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO
554                 | CREATE | DELETE | DELETE_SELF | MOVE_SELF;
555 
556         private final File mFile;
557         private final ContentResolver mResolver;
558         private final Uri mNotifyUri;
559 
560         private int mRefCount = 0;
561 
562         public DirectoryObserver(File file, ContentResolver resolver, Uri notifyUri) {
563             super(file.getAbsolutePath(), NOTIFY_EVENTS);
564             mFile = file;
565             mResolver = resolver;
566             mNotifyUri = notifyUri;
567         }
568 
569         @Override
570         public void onEvent(int event, String path) {
571             if ((event & NOTIFY_EVENTS) != 0) {
572                 if (LOG_INOTIFY) Log.d(TAG, "onEvent() " + event + " at " + path);
573                 mResolver.notifyChange(mNotifyUri, null, false);
574             }
575         }
576 
577         @Override
578         public String toString() {
579             return "DirectoryObserver{file=" + mFile.getAbsolutePath() + ", ref=" + mRefCount + "}";
580         }
581     }
582 
583     private class DirectoryCursor extends MatrixCursor {
584         private final File mFile;
585 
586         public DirectoryCursor(String[] columnNames, String docId, File file) {
587             super(columnNames);
588 
589             final Uri notifyUri = DocumentsContract.buildChildDocumentsUri(
590                     AUTHORITY, docId);
591             setNotificationUri(getContext().getContentResolver(), notifyUri);
592 
593             mFile = file;
594             startObserving(mFile, notifyUri);
595         }
596 
597         @Override
598         public void close() {
599             super.close();
600             stopObserving(mFile);
601         }
602     }
603 }
604