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