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