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.Binder;
     31 import android.os.Bundle;
     32 import android.os.CancellationSignal;
     33 import android.os.FileObserver;
     34 import android.os.FileUtils;
     35 import android.os.Handler;
     36 import android.os.ParcelFileDescriptor;
     37 import android.provider.DocumentsContract;
     38 import android.provider.DocumentsContract.Document;
     39 import android.provider.DocumentsProvider;
     40 import android.provider.MediaStore;
     41 import android.provider.MetadataReader;
     42 import android.text.TextUtils;
     43 import android.util.ArrayMap;
     44 import android.util.Log;
     45 import android.webkit.MimeTypeMap;
     46 
     47 import com.android.internal.annotations.GuardedBy;
     48 
     49 import libcore.io.IoUtils;
     50 
     51 import java.io.File;
     52 import java.io.FileInputStream;
     53 import java.io.FileNotFoundException;
     54 import java.io.IOException;
     55 import java.io.InputStream;
     56 import java.util.LinkedList;
     57 import java.util.List;
     58 import java.util.Set;
     59 
     60 /**
     61  * A helper class for {@link android.provider.DocumentsProvider} to perform file operations on local
     62  * files.
     63  */
     64 public abstract class FileSystemProvider extends DocumentsProvider {
     65 
     66     private static final String TAG = "FileSystemProvider";
     67 
     68     private static final boolean LOG_INOTIFY = false;
     69 
     70     private String[] mDefaultProjection;
     71 
     72     @GuardedBy("mObservers")
     73     private final ArrayMap<File, DirectoryObserver> mObservers = new ArrayMap<>();
     74 
     75     private Handler mHandler;
     76 
     77     private static final String MIMETYPE_JPEG = "image/jpeg";
     78     private static final String MIMETYPE_JPG = "image/jpg";
     79     private static final String MIMETYPE_OCTET_STREAM = "application/octet-stream";
     80 
     81     protected abstract File getFileForDocId(String docId, boolean visible)
     82             throws FileNotFoundException;
     83 
     84     protected abstract String getDocIdForFile(File file) throws FileNotFoundException;
     85 
     86     protected abstract Uri buildNotificationUri(String docId);
     87 
     88     /**
     89      * Callback indicating that the given document has been modified. This gives
     90      * the provider a hook to invalidate cached data, such as {@code sdcardfs}.
     91      */
     92     protected void onDocIdChanged(String docId) {
     93         // Default is no-op
     94     }
     95 
     96     @Override
     97     public boolean onCreate() {
     98         throw new UnsupportedOperationException(
     99                 "Subclass should override this and call onCreate(defaultDocumentProjection)");
    100     }
    101 
    102     @CallSuper
    103     protected void onCreate(String[] defaultProjection) {
    104         mHandler = new Handler();
    105         mDefaultProjection = defaultProjection;
    106     }
    107 
    108     @Override
    109     public boolean isChildDocument(String parentDocId, String docId) {
    110         try {
    111             final File parent = getFileForDocId(parentDocId).getCanonicalFile();
    112             final File doc = getFileForDocId(docId).getCanonicalFile();
    113             return FileUtils.contains(parent, doc);
    114         } catch (IOException e) {
    115             throw new IllegalArgumentException(
    116                     "Failed to determine if " + docId + " is child of " + parentDocId + ": " + e);
    117         }
    118     }
    119 
    120     @Override
    121     public @Nullable Bundle getDocumentMetadata(String documentId)
    122             throws FileNotFoundException {
    123         File file = getFileForDocId(documentId);
    124 
    125         if (!file.exists()) {
    126             throw new FileNotFoundException("Can't find the file for documentId: " + documentId);
    127         }
    128 
    129         if (!file.isFile()) {
    130             Log.w(TAG, "Can't stream non-regular file. Returning empty metadata.");
    131             return null;
    132         }
    133 
    134         if (!file.canRead()) {
    135             Log.w(TAG, "Can't stream non-readable file. Returning empty metadata.");
    136             return null;
    137         }
    138 
    139         String mimeType = getTypeForFile(file);
    140         if (!MetadataReader.isSupportedMimeType(mimeType)) {
    141             return null;
    142         }
    143 
    144         InputStream stream = null;
    145         try {
    146             Bundle metadata = new Bundle();
    147             stream = new FileInputStream(file.getAbsolutePath());
    148             MetadataReader.getMetadata(metadata, stream, mimeType, null);
    149             return metadata;
    150         } catch (IOException e) {
    151             Log.e(TAG, "An error occurred retrieving the metadata", e);
    152             return null;
    153         } finally {
    154             IoUtils.closeQuietly(stream);
    155         }
    156     }
    157 
    158     protected final List<String> findDocumentPath(File parent, File doc)
    159             throws FileNotFoundException {
    160 
    161         if (!doc.exists()) {
    162             throw new FileNotFoundException(doc + " is not found.");
    163         }
    164 
    165         if (!FileUtils.contains(parent, doc)) {
    166             throw new FileNotFoundException(doc + " is not found under " + parent);
    167         }
    168 
    169         LinkedList<String> path = new LinkedList<>();
    170         while (doc != null && FileUtils.contains(parent, doc)) {
    171             path.addFirst(getDocIdForFile(doc));
    172 
    173             doc = doc.getParentFile();
    174         }
    175 
    176         return path;
    177     }
    178 
    179     @Override
    180     public String createDocument(String docId, String mimeType, String displayName)
    181             throws FileNotFoundException {
    182         displayName = FileUtils.buildValidFatFilename(displayName);
    183 
    184         final File parent = getFileForDocId(docId);
    185         if (!parent.isDirectory()) {
    186             throw new IllegalArgumentException("Parent document isn't a directory");
    187         }
    188 
    189         final File file = FileUtils.buildUniqueFile(parent, mimeType, displayName);
    190         final String childId;
    191         if (Document.MIME_TYPE_DIR.equals(mimeType)) {
    192             if (!file.mkdir()) {
    193                 throw new IllegalStateException("Failed to mkdir " + file);
    194             }
    195             childId = getDocIdForFile(file);
    196             onDocIdChanged(childId);
    197             addFolderToMediaStore(getFileForDocId(childId, true));
    198         } else {
    199             try {
    200                 if (!file.createNewFile()) {
    201                     throw new IllegalStateException("Failed to touch " + file);
    202                 }
    203                 childId = getDocIdForFile(file);
    204                 onDocIdChanged(childId);
    205             } catch (IOException e) {
    206                 throw new IllegalStateException("Failed to touch " + file + ": " + e);
    207             }
    208         }
    209 
    210         return childId;
    211     }
    212 
    213     private void addFolderToMediaStore(@Nullable File visibleFolder) {
    214         // visibleFolder is null if we're adding a folder to external thumb drive or SD card.
    215         if (visibleFolder != null) {
    216             assert (visibleFolder.isDirectory());
    217 
    218             final long token = Binder.clearCallingIdentity();
    219 
    220             try {
    221                 final ContentResolver resolver = getContext().getContentResolver();
    222                 final Uri uri = MediaStore.Files.getDirectoryUri("external");
    223                 ContentValues values = new ContentValues();
    224                 values.put(MediaStore.Files.FileColumns.DATA, visibleFolder.getAbsolutePath());
    225                 resolver.insert(uri, values);
    226             } finally {
    227                 Binder.restoreCallingIdentity(token);
    228             }
    229         }
    230     }
    231 
    232     @Override
    233     public String renameDocument(String docId, String displayName) throws FileNotFoundException {
    234         // Since this provider treats renames as generating a completely new
    235         // docId, we're okay with letting the MIME type change.
    236         displayName = FileUtils.buildValidFatFilename(displayName);
    237 
    238         final File before = getFileForDocId(docId);
    239         final File after = FileUtils.buildUniqueFile(before.getParentFile(), displayName);
    240         if (!before.renameTo(after)) {
    241             throw new IllegalStateException("Failed to rename to " + after);
    242         }
    243 
    244         final String afterDocId = getDocIdForFile(after);
    245         onDocIdChanged(docId);
    246         onDocIdChanged(afterDocId);
    247 
    248         final File beforeVisibleFile = getFileForDocId(docId, true);
    249         final File afterVisibleFile = getFileForDocId(afterDocId, true);
    250         moveInMediaStore(beforeVisibleFile, afterVisibleFile);
    251 
    252         if (!TextUtils.equals(docId, afterDocId)) {
    253             scanFile(afterVisibleFile);
    254             return afterDocId;
    255         } else {
    256             return null;
    257         }
    258     }
    259 
    260     @Override
    261     public String moveDocument(String sourceDocumentId, String sourceParentDocumentId,
    262             String targetParentDocumentId)
    263             throws FileNotFoundException {
    264         final File before = getFileForDocId(sourceDocumentId);
    265         final File after = new File(getFileForDocId(targetParentDocumentId), before.getName());
    266         final File visibleFileBefore = getFileForDocId(sourceDocumentId, true);
    267 
    268         if (after.exists()) {
    269             throw new IllegalStateException("Already exists " + after);
    270         }
    271         if (!before.renameTo(after)) {
    272             throw new IllegalStateException("Failed to move to " + after);
    273         }
    274 
    275         final String docId = getDocIdForFile(after);
    276         onDocIdChanged(sourceDocumentId);
    277         onDocIdChanged(docId);
    278         moveInMediaStore(visibleFileBefore, getFileForDocId(docId, true));
    279 
    280         return docId;
    281     }
    282 
    283     private void moveInMediaStore(@Nullable File oldVisibleFile, @Nullable File newVisibleFile) {
    284         // visibleFolders are null if we're moving a document in external thumb drive or SD card.
    285         //
    286         // They should be all null or not null at the same time. File#renameTo() doesn't work across
    287         // volumes so an exception will be thrown before calling this method.
    288         if (oldVisibleFile != null && newVisibleFile != null) {
    289             final long token = Binder.clearCallingIdentity();
    290 
    291             try {
    292                 final ContentResolver resolver = getContext().getContentResolver();
    293                 final Uri externalUri = newVisibleFile.isDirectory()
    294                         ? MediaStore.Files.getDirectoryUri("external")
    295                         : MediaStore.Files.getContentUri("external");
    296 
    297                 ContentValues values = new ContentValues();
    298                 values.put(MediaStore.Files.FileColumns.DATA, newVisibleFile.getAbsolutePath());
    299 
    300                 // Logic borrowed from MtpDatabase.
    301                 // note - we are relying on a special case in MediaProvider.update() to update
    302                 // the paths for all children in the case where this is a directory.
    303                 final String path = oldVisibleFile.getAbsolutePath();
    304                 resolver.update(externalUri,
    305                         values,
    306                         "_data LIKE ? AND lower(_data)=lower(?)",
    307                         new String[]{path, path});
    308             } finally {
    309                 Binder.restoreCallingIdentity(token);
    310             }
    311         }
    312     }
    313 
    314     @Override
    315     public void deleteDocument(String docId) throws FileNotFoundException {
    316         final File file = getFileForDocId(docId);
    317         final File visibleFile = getFileForDocId(docId, true);
    318 
    319         final boolean isDirectory = file.isDirectory();
    320         if (isDirectory) {
    321             FileUtils.deleteContents(file);
    322         }
    323         if (!file.delete()) {
    324             throw new IllegalStateException("Failed to delete " + file);
    325         }
    326 
    327         onDocIdChanged(docId);
    328         removeFromMediaStore(visibleFile, isDirectory);
    329     }
    330 
    331     private void removeFromMediaStore(@Nullable File visibleFile, boolean isFolder)
    332             throws FileNotFoundException {
    333         // visibleFolder is null if we're removing a document from external thumb drive or SD card.
    334         if (visibleFile != null) {
    335             final long token = Binder.clearCallingIdentity();
    336 
    337             try {
    338                 final ContentResolver resolver = getContext().getContentResolver();
    339                 final Uri externalUri = MediaStore.Files.getContentUri("external");
    340 
    341                 // Remove media store entries for any files inside this directory, using
    342                 // path prefix match. Logic borrowed from MtpDatabase.
    343                 if (isFolder) {
    344                     final String path = visibleFile.getAbsolutePath() + "/";
    345                     resolver.delete(externalUri,
    346                             "_data LIKE ?1 AND lower(substr(_data,1,?2))=lower(?3)",
    347                             new String[]{path + "%", Integer.toString(path.length()), path});
    348                 }
    349 
    350                 // Remove media store entry for this exact file.
    351                 final String path = visibleFile.getAbsolutePath();
    352                 resolver.delete(externalUri,
    353                         "_data LIKE ?1 AND lower(_data)=lower(?2)",
    354                         new String[]{path, path});
    355             } finally {
    356                 Binder.restoreCallingIdentity(token);
    357             }
    358         }
    359     }
    360 
    361     @Override
    362     public Cursor queryDocument(String documentId, String[] projection)
    363             throws FileNotFoundException {
    364         final MatrixCursor result = new MatrixCursor(resolveProjection(projection));
    365         includeFile(result, documentId, null);
    366         return result;
    367     }
    368 
    369     @Override
    370     public Cursor queryChildDocuments(
    371             String parentDocumentId, String[] projection, String sortOrder)
    372             throws FileNotFoundException {
    373 
    374         final File parent = getFileForDocId(parentDocumentId);
    375         final MatrixCursor result = new DirectoryCursor(
    376                 resolveProjection(projection), parentDocumentId, parent);
    377         for (File file : parent.listFiles()) {
    378             includeFile(result, null, file);
    379         }
    380         return result;
    381     }
    382 
    383     /**
    384      * Searches documents under the given folder.
    385      *
    386      * To avoid runtime explosion only returns the at most 23 items.
    387      *
    388      * @param folder the root folder where recursive search begins
    389      * @param query the search condition used to match file names
    390      * @param projection projection of the returned cursor
    391      * @param exclusion absolute file paths to exclude from result
    392      * @return cursor containing search result
    393      * @throws FileNotFoundException when root folder doesn't exist or search fails
    394      */
    395     protected final Cursor querySearchDocuments(
    396             File folder, String query, String[] projection, Set<String> exclusion)
    397             throws FileNotFoundException {
    398 
    399         query = query.toLowerCase();
    400         final MatrixCursor result = new MatrixCursor(resolveProjection(projection));
    401         final LinkedList<File> pending = new LinkedList<>();
    402         pending.add(folder);
    403         while (!pending.isEmpty() && result.getCount() < 24) {
    404             final File file = pending.removeFirst();
    405             if (file.isDirectory()) {
    406                 for (File child : file.listFiles()) {
    407                     pending.add(child);
    408                 }
    409             }
    410             if (file.getName().toLowerCase().contains(query)
    411                     && !exclusion.contains(file.getAbsolutePath())) {
    412                 includeFile(result, null, file);
    413             }
    414         }
    415         return result;
    416     }
    417 
    418     @Override
    419     public String getDocumentType(String documentId) throws FileNotFoundException {
    420         final File file = getFileForDocId(documentId);
    421         return getTypeForFile(file);
    422     }
    423 
    424     @Override
    425     public ParcelFileDescriptor openDocument(
    426             String documentId, String mode, CancellationSignal signal)
    427             throws FileNotFoundException {
    428         final File file = getFileForDocId(documentId);
    429         final File visibleFile = getFileForDocId(documentId, true);
    430 
    431         final int pfdMode = ParcelFileDescriptor.parseMode(mode);
    432         if (pfdMode == ParcelFileDescriptor.MODE_READ_ONLY || visibleFile == null) {
    433             return ParcelFileDescriptor.open(file, pfdMode);
    434         } else {
    435             try {
    436                 // When finished writing, kick off media scanner
    437                 return ParcelFileDescriptor.open(
    438                         file, pfdMode, mHandler, (IOException e) -> {
    439                             onDocIdChanged(documentId);
    440                             scanFile(visibleFile);
    441                         });
    442             } catch (IOException e) {
    443                 throw new FileNotFoundException("Failed to open for writing: " + e);
    444             }
    445         }
    446     }
    447 
    448     private void scanFile(File visibleFile) {
    449         final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
    450         intent.setData(Uri.fromFile(visibleFile));
    451         getContext().sendBroadcast(intent);
    452     }
    453 
    454     @Override
    455     public AssetFileDescriptor openDocumentThumbnail(
    456             String documentId, Point sizeHint, CancellationSignal signal)
    457             throws FileNotFoundException {
    458         final File file = getFileForDocId(documentId);
    459         return DocumentsContract.openImageThumbnail(file);
    460     }
    461 
    462     protected RowBuilder includeFile(MatrixCursor result, String docId, File file)
    463             throws FileNotFoundException {
    464         if (docId == null) {
    465             docId = getDocIdForFile(file);
    466         } else {
    467             file = getFileForDocId(docId);
    468         }
    469 
    470         int flags = 0;
    471 
    472         if (file.canWrite()) {
    473             if (file.isDirectory()) {
    474                 flags |= Document.FLAG_DIR_SUPPORTS_CREATE;
    475                 flags |= Document.FLAG_SUPPORTS_DELETE;
    476                 flags |= Document.FLAG_SUPPORTS_RENAME;
    477                 flags |= Document.FLAG_SUPPORTS_MOVE;
    478             } else {
    479                 flags |= Document.FLAG_SUPPORTS_WRITE;
    480                 flags |= Document.FLAG_SUPPORTS_DELETE;
    481                 flags |= Document.FLAG_SUPPORTS_RENAME;
    482                 flags |= Document.FLAG_SUPPORTS_MOVE;
    483             }
    484         }
    485 
    486         final String mimeType = getTypeForFile(file);
    487         final String displayName = file.getName();
    488         if (mimeType.startsWith("image/")) {
    489             flags |= Document.FLAG_SUPPORTS_THUMBNAIL;
    490         }
    491 
    492         if (typeSupportsMetadata(mimeType)) {
    493             flags |= Document.FLAG_SUPPORTS_METADATA;
    494         }
    495 
    496         final RowBuilder row = result.newRow();
    497         row.add(Document.COLUMN_DOCUMENT_ID, docId);
    498         row.add(Document.COLUMN_DISPLAY_NAME, displayName);
    499         row.add(Document.COLUMN_SIZE, file.length());
    500         row.add(Document.COLUMN_MIME_TYPE, mimeType);
    501         row.add(Document.COLUMN_FLAGS, flags);
    502 
    503         // Only publish dates reasonably after epoch
    504         long lastModified = file.lastModified();
    505         if (lastModified > 31536000000L) {
    506             row.add(Document.COLUMN_LAST_MODIFIED, lastModified);
    507         }
    508 
    509         // Return the row builder just in case any subclass want to add more stuff to it.
    510         return row;
    511     }
    512 
    513     private static String getTypeForFile(File file) {
    514         if (file.isDirectory()) {
    515             return Document.MIME_TYPE_DIR;
    516         } else {
    517             return getTypeForName(file.getName());
    518         }
    519     }
    520 
    521     protected boolean typeSupportsMetadata(String mimeType) {
    522         return MetadataReader.isSupportedMimeType(mimeType);
    523     }
    524 
    525     private static String getTypeForName(String name) {
    526         final int lastDot = name.lastIndexOf('.');
    527         if (lastDot >= 0) {
    528             final String extension = name.substring(lastDot + 1).toLowerCase();
    529             final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
    530             if (mime != null) {
    531                 return mime;
    532             }
    533         }
    534 
    535         return MIMETYPE_OCTET_STREAM;
    536     }
    537 
    538     protected final File getFileForDocId(String docId) throws FileNotFoundException {
    539         return getFileForDocId(docId, false);
    540     }
    541 
    542     private String[] resolveProjection(String[] projection) {
    543         return projection == null ? mDefaultProjection : projection;
    544     }
    545 
    546     private void startObserving(File file, Uri notifyUri) {
    547         synchronized (mObservers) {
    548             DirectoryObserver observer = mObservers.get(file);
    549             if (observer == null) {
    550                 observer = new DirectoryObserver(
    551                         file, getContext().getContentResolver(), notifyUri);
    552                 observer.startWatching();
    553                 mObservers.put(file, observer);
    554             }
    555             observer.mRefCount++;
    556 
    557             if (LOG_INOTIFY) Log.d(TAG, "after start: " + observer);
    558         }
    559     }
    560 
    561     private void stopObserving(File file) {
    562         synchronized (mObservers) {
    563             DirectoryObserver observer = mObservers.get(file);
    564             if (observer == null) return;
    565 
    566             observer.mRefCount--;
    567             if (observer.mRefCount == 0) {
    568                 mObservers.remove(file);
    569                 observer.stopWatching();
    570             }
    571 
    572             if (LOG_INOTIFY) Log.d(TAG, "after stop: " + observer);
    573         }
    574     }
    575 
    576     private static class DirectoryObserver extends FileObserver {
    577         private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO
    578                 | CREATE | DELETE | DELETE_SELF | MOVE_SELF;
    579 
    580         private final File mFile;
    581         private final ContentResolver mResolver;
    582         private final Uri mNotifyUri;
    583 
    584         private int mRefCount = 0;
    585 
    586         public DirectoryObserver(File file, ContentResolver resolver, Uri notifyUri) {
    587             super(file.getAbsolutePath(), NOTIFY_EVENTS);
    588             mFile = file;
    589             mResolver = resolver;
    590             mNotifyUri = notifyUri;
    591         }
    592 
    593         @Override
    594         public void onEvent(int event, String path) {
    595             if ((event & NOTIFY_EVENTS) != 0) {
    596                 if (LOG_INOTIFY) Log.d(TAG, "onEvent() " + event + " at " + path);
    597                 mResolver.notifyChange(mNotifyUri, null, false);
    598             }
    599         }
    600 
    601         @Override
    602         public String toString() {
    603             return "DirectoryObserver{file=" + mFile.getAbsolutePath() + ", ref=" + mRefCount + "}";
    604         }
    605     }
    606 
    607     private class DirectoryCursor extends MatrixCursor {
    608         private final File mFile;
    609 
    610         public DirectoryCursor(String[] columnNames, String docId, File file) {
    611             super(columnNames);
    612 
    613             final Uri notifyUri = buildNotificationUri(docId);
    614             setNotificationUri(getContext().getContentResolver(), notifyUri);
    615 
    616             mFile = file;
    617             startObserving(mFile, notifyUri);
    618         }
    619 
    620         @Override
    621         public void close() {
    622             super.close();
    623             stopObserving(mFile);
    624         }
    625     }
    626 }
    627