blob: afcba96141429b6a95ed480597e1fc77a15507d2 [file] [log] [blame]
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.providers.downloads;
import android.app.DownloadManager;
import android.app.DownloadManager.Query;
import android.content.ContentResolver;
import android.content.Context;
import android.content.res.AssetFileDescriptor;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.database.MatrixCursor.RowBuilder;
import android.graphics.Point;
import android.net.Uri;
import android.os.Binder;
import android.os.CancellationSignal;
import android.os.Environment;
import android.os.FileObserver;
import android.os.FileUtils;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Document;
import android.provider.DocumentsContract.Path;
import android.provider.DocumentsContract.Root;
import android.provider.Downloads;
import android.text.TextUtils;
import android.util.Log;
import com.android.internal.content.FileSystemProvider;
import libcore.io.IoUtils;
import java.io.File;
import java.io.FileNotFoundException;
import java.text.NumberFormat;
import java.util.HashSet;
import java.util.Set;
import javax.annotation.Nullable;
import javax.annotation.concurrent.GuardedBy;
/**
* Presents files located in {@link Environment#DIRECTORY_DOWNLOADS} and contents from
* {@link DownloadManager}. {@link DownloadManager} contents include active downloads and completed
* downloads added by other applications using
* {@link DownloadManager#addCompletedDownload(String, String, boolean, String, String, long, boolean, boolean, Uri, Uri)}
* .
*/
public class DownloadStorageProvider extends FileSystemProvider {
private static final String TAG = "DownloadStorageProvider";
private static final boolean DEBUG = false;
private static final String AUTHORITY = Constants.STORAGE_AUTHORITY;
private static final String DOC_ID_ROOT = Constants.STORAGE_ROOT_ID;
private static final String[] DEFAULT_ROOT_PROJECTION = new String[] {
Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON,
Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID,
};
private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] {
Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_SUMMARY, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
};
private DownloadManager mDm;
@Override
public boolean onCreate() {
super.onCreate(DEFAULT_DOCUMENT_PROJECTION);
mDm = (DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE);
mDm.setAccessAllDownloads(true);
mDm.setAccessFilename(true);
return true;
}
private static String[] resolveRootProjection(String[] projection) {
return projection != null ? projection : DEFAULT_ROOT_PROJECTION;
}
private static String[] resolveDocumentProjection(String[] projection) {
return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION;
}
private void copyNotificationUri(MatrixCursor result, Cursor cursor) {
result.setNotificationUri(getContext().getContentResolver(), cursor.getNotificationUri());
}
/**
* Called by {@link DownloadProvider} when deleting a row in the {@link DownloadManager}
* database.
*/
static void onDownloadProviderDelete(Context context, long id) {
final Uri uri = DocumentsContract.buildDocumentUri(AUTHORITY, Long.toString(id));
context.revokeUriPermission(uri, ~0);
}
@Override
public Cursor queryRoots(String[] projection) throws FileNotFoundException {
// It's possible that the folder does not exist on disk, so we will create the folder if
// that is the case. If user decides to delete the folder later, then it's OK to fail on
// subsequent queries.
getDownloadsDirectory().mkdirs();
final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection));
final RowBuilder row = result.newRow();
row.add(Root.COLUMN_ROOT_ID, DOC_ID_ROOT);
row.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_RECENTS
| Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH
| Root.FLAG_SUPPORTS_IS_CHILD);
row.add(Root.COLUMN_ICON, R.mipmap.ic_launcher_download);
row.add(Root.COLUMN_TITLE, getContext().getString(R.string.root_downloads));
row.add(Root.COLUMN_DOCUMENT_ID, DOC_ID_ROOT);
return result;
}
@Override
public Path findDocumentPath(@Nullable String parentDocId, String docId) throws FileNotFoundException {
// parentDocId is null if the client is asking for the path to the root of a doc tree.
// Don't share root information with those who shouldn't know it.
final String rootId = (parentDocId == null) ? DOC_ID_ROOT : null;
if (parentDocId == null) {
parentDocId = DOC_ID_ROOT;
}
final File parent = getFileForDocId(parentDocId);
final File doc = getFileForDocId(docId);
return new Path(rootId, findDocumentPath(parent, doc));
}
/**
* Calls on {@link FileSystemProvider#createDocument(String, String, String)}, and then creates
* a new database entry in {@link DownloadManager} if it is not a raw file and not a folder.
*/
@Override
public String createDocument(String parentDocId, String mimeType, String displayName)
throws FileNotFoundException {
// Delegate to real provider
final long token = Binder.clearCallingIdentity();
try {
String newDocumentId = super.createDocument(parentDocId, mimeType, displayName);
if (!Document.MIME_TYPE_DIR.equals(mimeType)
&& !RawDocumentsHelper.isRawDocId(parentDocId)) {
File newFile = getFileForDocId(newDocumentId);
newDocumentId = Long.toString(mDm.addCompletedDownload(
newFile.getName(), newFile.getName(), true, mimeType,
newFile.getAbsolutePath(), 0L,
false, true));
}
return newDocumentId;
} finally {
Binder.restoreCallingIdentity(token);
}
}
@Override
public void deleteDocument(String docId) throws FileNotFoundException {
// Delegate to real provider
final long token = Binder.clearCallingIdentity();
try {
if (RawDocumentsHelper.isRawDocId(docId)) {
super.deleteDocument(docId);
return;
}
if (mDm.remove(Long.parseLong(docId)) != 1) {
throw new IllegalStateException("Failed to delete " + docId);
}
} finally {
Binder.restoreCallingIdentity(token);
}
}
@Override
public String renameDocument(String docId, String displayName)
throws FileNotFoundException {
final long token = Binder.clearCallingIdentity();
try {
if (RawDocumentsHelper.isRawDocId(docId)) {
return super.renameDocument(docId, displayName);
}
displayName = FileUtils.buildValidFatFilename(displayName);
final long id = Long.parseLong(docId);
if (!mDm.rename(getContext(), id, displayName)) {
throw new IllegalStateException(
"Failed to rename to " + displayName + " in downloadsManager");
}
return null;
} finally {
Binder.restoreCallingIdentity(token);
}
}
@Override
public Cursor queryDocument(String docId, String[] projection) throws FileNotFoundException {
// Delegate to real provider
final long token = Binder.clearCallingIdentity();
Cursor cursor = null;
try {
if (RawDocumentsHelper.isRawDocId(docId)) {
return super.queryDocument(docId, projection);
}
final DownloadsCursor result = new DownloadsCursor(projection,
getContext().getContentResolver());
if (DOC_ID_ROOT.equals(docId)) {
includeDefaultDocument(result);
} else {
cursor = mDm.query(new Query().setFilterById(Long.parseLong(docId)));
copyNotificationUri(result, cursor);
Set<String> filePaths = new HashSet<>();
if (cursor.moveToFirst()) {
// We don't know if this queryDocument() call is from Downloads (manage)
// or Files. Safely assume it's Files.
includeDownloadFromCursor(result, cursor, filePaths);
}
}
result.start();
return result;
} finally {
IoUtils.closeQuietly(cursor);
Binder.restoreCallingIdentity(token);
}
}
@Override
public Cursor queryChildDocuments(String parentDocId, String[] projection, String sortOrder)
throws FileNotFoundException {
return queryChildDocuments(parentDocId, projection, sortOrder, false);
}
@Override
public Cursor queryChildDocumentsForManage(
String parentDocId, String[] projection, String sortOrder)
throws FileNotFoundException {
return queryChildDocuments(parentDocId, projection, sortOrder, true);
}
private Cursor queryChildDocuments(String parentDocId, String[] projection,
String sortOrder, boolean manage) throws FileNotFoundException {
// Delegate to real provider
final long token = Binder.clearCallingIdentity();
Cursor cursor = null;
try {
if (RawDocumentsHelper.isRawDocId(parentDocId)) {
return super.queryChildDocuments(parentDocId, projection, sortOrder);
}
assert (DOC_ID_ROOT.equals(parentDocId));
final DownloadsCursor result = new DownloadsCursor(projection,
getContext().getContentResolver());
if (manage) {
cursor = mDm.query(
new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true));
} else {
cursor = mDm
.query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true)
.setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL));
}
copyNotificationUri(result, cursor);
Set<String> filePaths = new HashSet<>();
while (cursor.moveToNext()) {
includeDownloadFromCursor(result, cursor, filePaths);
}
includeFilesFromSharedStorage(result, filePaths, null);
result.start();
return result;
} finally {
IoUtils.closeQuietly(cursor);
Binder.restoreCallingIdentity(token);
}
}
@Override
public Cursor queryRecentDocuments(String rootId, String[] projection)
throws FileNotFoundException {
final DownloadsCursor result =
new DownloadsCursor(projection, getContext().getContentResolver());
// Delegate to real provider
final long token = Binder.clearCallingIdentity();
Cursor cursor = null;
try {
cursor = mDm.query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true)
.setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL));
copyNotificationUri(result, cursor);
while (cursor.moveToNext() && result.getCount() < 12) {
final String mimeType = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIA_TYPE));
final String uri = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIAPROVIDER_URI));
// Skip images that have been inserted into the MediaStore so we
// don't duplicate them in the recents list.
if (mimeType == null
|| (mimeType.startsWith("image/") && !TextUtils.isEmpty(uri))) {
continue;
}
}
} finally {
IoUtils.closeQuietly(cursor);
Binder.restoreCallingIdentity(token);
}
result.start();
return result;
}
@Override
public Cursor querySearchDocuments(String rootId, String query, String[] projection)
throws FileNotFoundException {
final DownloadsCursor result =
new DownloadsCursor(projection, getContext().getContentResolver());
// Delegate to real provider
final long token = Binder.clearCallingIdentity();
Cursor cursor = null;
try {
cursor = mDm.query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true)
.setFilterByString(query));
copyNotificationUri(result, cursor);
Set<String> filePaths = new HashSet<>();
while (cursor.moveToNext()) {
includeDownloadFromCursor(result, cursor, filePaths);
}
Cursor rawFilesCursor = super.querySearchDocuments(getDownloadsDirectory(), query,
projection, filePaths);
while (rawFilesCursor.moveToNext()) {
String docId = rawFilesCursor.getString(
rawFilesCursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID));
File rawFile = getFileForDocId(docId);
includeFileFromSharedStorage(result, rawFile);
}
} finally {
IoUtils.closeQuietly(cursor);
Binder.restoreCallingIdentity(token);
}
result.start();
return result;
}
@Override
public String getDocumentType(String docId) throws FileNotFoundException {
// Delegate to real provider
final long token = Binder.clearCallingIdentity();
try {
if (RawDocumentsHelper.isRawDocId(docId)) {
return super.getDocumentType(docId);
}
final long id = Long.parseLong(docId);
final ContentResolver resolver = getContext().getContentResolver();
return resolver.getType(mDm.getDownloadUri(id));
} finally {
Binder.restoreCallingIdentity(token);
}
}
@Override
public ParcelFileDescriptor openDocument(String docId, String mode, CancellationSignal signal)
throws FileNotFoundException {
// Delegate to real provider
final long token = Binder.clearCallingIdentity();
try {
if (RawDocumentsHelper.isRawDocId(docId)) {
return super.openDocument(docId, mode, signal);
}
final long id = Long.parseLong(docId);
final ContentResolver resolver = getContext().getContentResolver();
return resolver.openFileDescriptor(mDm.getDownloadUri(id), mode, signal);
} finally {
Binder.restoreCallingIdentity(token);
}
}
@Override
protected File getFileForDocId(String docId, boolean visible) throws FileNotFoundException {
if (RawDocumentsHelper.isRawDocId(docId)) {
return new File(RawDocumentsHelper.getAbsoluteFilePath(docId));
}
if (DOC_ID_ROOT.equals(docId)) {
return getDownloadsDirectory();
}
final long token = Binder.clearCallingIdentity();
Cursor cursor = null;
String localFilePath = null;
try {
cursor = mDm.query(new Query().setFilterById(Long.parseLong(docId)));
if (cursor.moveToFirst()) {
localFilePath = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_FILENAME));
}
} finally {
IoUtils.closeQuietly(cursor);
Binder.restoreCallingIdentity(token);
}
if (localFilePath == null) {
throw new IllegalStateException("File has no filepath. Could not be found.");
}
return new File(localFilePath);
}
@Override
protected String getDocIdForFile(File file) throws FileNotFoundException {
return RawDocumentsHelper.getDocIdForFile(file);
}
@Override
protected Uri buildNotificationUri(String docId) {
return DocumentsContract.buildChildDocumentsUri(AUTHORITY, docId);
}
private void includeDefaultDocument(MatrixCursor result) {
final RowBuilder row = result.newRow();
row.add(Document.COLUMN_DOCUMENT_ID, DOC_ID_ROOT);
// We have the same display name as our root :)
row.add(Document.COLUMN_DISPLAY_NAME,
getContext().getString(R.string.root_downloads));
row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
row.add(Document.COLUMN_FLAGS,
Document.FLAG_DIR_PREFERS_LAST_MODIFIED | Document.FLAG_DIR_SUPPORTS_CREATE);
}
/**
* Adds the entry from the cursor to the result only if the entry is valid. That is,
* if the file exists in the file system.
*/
private void includeDownloadFromCursor(MatrixCursor result, Cursor cursor,
Set<String> filePaths) {
final long id = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID));
final String docId = String.valueOf(id);
final String displayName = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE));
String summary = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_DESCRIPTION));
String mimeType = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIA_TYPE));
if (mimeType == null) {
// Provide fake MIME type so it's openable
mimeType = "vnd.android.document/file";
}
Long size = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
if (size == -1) {
size = null;
}
String localFilePath = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_FILENAME));
int extraFlags = Document.FLAG_PARTIAL;
final int status = cursor.getInt(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
switch (status) {
case DownloadManager.STATUS_SUCCESSFUL:
// Verify that the document still exists in external storage. This is necessary
// because files can be deleted from the file system without their entry being
// removed from DownloadsManager.
if (localFilePath == null || !new File(localFilePath).exists()) {
return;
}
extraFlags = Document.FLAG_SUPPORTS_RENAME; // only successful is non-partial
break;
case DownloadManager.STATUS_PAUSED:
summary = getContext().getString(R.string.download_queued);
break;
case DownloadManager.STATUS_PENDING:
summary = getContext().getString(R.string.download_queued);
break;
case DownloadManager.STATUS_RUNNING:
final long progress = cursor.getLong(cursor.getColumnIndexOrThrow(
DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
if (size != null) {
String percent =
NumberFormat.getPercentInstance().format((double) progress / size);
summary = getContext().getString(R.string.download_running_percent, percent);
} else {
summary = getContext().getString(R.string.download_running);
}
break;
case DownloadManager.STATUS_FAILED:
default:
summary = getContext().getString(R.string.download_error);
break;
}
int flags = Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_WRITE | extraFlags;
if (mimeType.startsWith("image/")) {
flags |= Document.FLAG_SUPPORTS_THUMBNAIL;
}
if (typeSupportsMetadata(mimeType)) {
flags |= Document.FLAG_SUPPORTS_METADATA;
}
final long lastModified = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP));
final RowBuilder row = result.newRow();
row.add(Document.COLUMN_DOCUMENT_ID, docId);
row.add(Document.COLUMN_DISPLAY_NAME, displayName);
row.add(Document.COLUMN_SUMMARY, summary);
row.add(Document.COLUMN_SIZE, size);
row.add(Document.COLUMN_MIME_TYPE, mimeType);
row.add(Document.COLUMN_FLAGS, flags);
// Incomplete downloads get a null timestamp. This prevents thrashy UI when a bunch of
// active downloads get sorted by mod time.
if (status != DownloadManager.STATUS_RUNNING) {
row.add(Document.COLUMN_LAST_MODIFIED, lastModified);
}
filePaths.add(localFilePath);
}
/**
* Takes all the top-level files from the Downloads directory and adds them to the result.
*
* @param result cursor containing all documents to be returned by queryChildDocuments or
* queryChildDocumentsForManage.
* @param downloadedFilePaths The absolute file paths of all the files in the result Cursor.
* @param searchString query used to filter out unwanted results.
*/
private void includeFilesFromSharedStorage(MatrixCursor result,
Set<String> downloadedFilePaths, @Nullable String searchString)
throws FileNotFoundException {
File downloadsDir = getDownloadsDirectory();
// Add every file from the Downloads directory to the result cursor. Ignore files that
// were in the supplied downloaded file paths.
for (File file : downloadsDir.listFiles()) {
boolean inResultsAlready = downloadedFilePaths.contains(file.getAbsolutePath());
boolean containsQuery = searchString == null || file.getName().contains(searchString);
if (!inResultsAlready && containsQuery) {
includeFileFromSharedStorage(result, file);
}
}
}
/**
* Adds a file to the result cursor. It uses a combination of {@code #RAW_PREFIX} and its
* absolute file path for its id. Directories are not to be included.
*
* @param result cursor containing all documents to be returned by queryChildDocuments or
* queryChildDocumentsForManage.
* @param file file to be included in the result cursor.
*/
private void includeFileFromSharedStorage(MatrixCursor result, File file)
throws FileNotFoundException {
includeFile(result, null, file);
}
private static File getDownloadsDirectory() {
return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
}
/**
* A MatrixCursor that spins up a file observer when the first instance is
* started ({@link #start()}, and stops the file observer when the last instance
* closed ({@link #close()}. When file changes are observed, a content change
* notification is sent on the Downloads content URI.
*
* <p>This is necessary as other processes, like ExternalStorageProvider,
* can access and modify files directly (without sending operations
* through DownloadStorageProvider).
*
* <p>Without this, contents accessible by one a Downloads cursor instance
* (like the Downloads root in Files app) can become state.
*/
private static final class DownloadsCursor extends MatrixCursor {
private static final Object mLock = new Object();
@GuardedBy("mLock")
private static int mOpenCursorCount = 0;
@GuardedBy("mLock")
private static @Nullable ContentChangedRelay mFileWatcher;
private final ContentResolver mResolver;
DownloadsCursor(String[] projection, ContentResolver resolver) {
super(resolveDocumentProjection(projection));
mResolver = resolver;
}
void start() {
synchronized (mLock) {
if (mOpenCursorCount++ == 0) {
mFileWatcher = new ContentChangedRelay(mResolver);
mFileWatcher.startWatching();
}
}
}
@Override
public void close() {
super.close();
synchronized (mLock) {
if (--mOpenCursorCount == 0) {
mFileWatcher.stopWatching();
mFileWatcher = null;
}
}
}
}
/**
* A file observer that notifies on the Downloads content URI(s) when
* files change on disk.
*/
private static class ContentChangedRelay extends FileObserver {
private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO
| CREATE | DELETE | DELETE_SELF | MOVE_SELF;
private static final String DOWNLOADS_PATH = getDownloadsDirectory().getAbsolutePath();
private final ContentResolver mResolver;
public ContentChangedRelay(ContentResolver resolver) {
super(DOWNLOADS_PATH, NOTIFY_EVENTS);
mResolver = resolver;
}
@Override
public void startWatching() {
super.startWatching();
if (DEBUG) Log.d(TAG, "Started watching for file changes in: " + DOWNLOADS_PATH);
}
@Override
public void stopWatching() {
super.stopWatching();
if (DEBUG) Log.d(TAG, "Stopped watching for file changes in: " + DOWNLOADS_PATH);
}
@Override
public void onEvent(int event, String path) {
if ((event & NOTIFY_EVENTS) != 0) {
if (DEBUG) Log.v(TAG, "Change detected at path: " + DOWNLOADS_PATH);
mResolver.notifyChange(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, null, false);
mResolver.notifyChange(Downloads.Impl.CONTENT_URI, null, false);
}
}
}
}