blob: 421c2613b41ff71abf9bb83167196ec00857f2da [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 static com.android.providers.downloads.MediaStoreDownloadsHelper.getDocIdForMediaStoreDownload;
import static com.android.providers.downloads.MediaStoreDownloadsHelper.getMediaStoreIdString;
import static com.android.providers.downloads.MediaStoreDownloadsHelper.getMediaStoreUriForQuery;
import static com.android.providers.downloads.MediaStoreDownloadsHelper.isMediaStoreDownload;
import static com.android.providers.downloads.MediaStoreDownloadsHelper.isMediaStoreDownloadDir;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.DownloadManager;
import android.app.DownloadManager.Query;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.UriPermission;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.database.MatrixCursor.RowBuilder;
import android.media.MediaFile;
import android.net.Uri;
import android.os.Binder;
import android.os.Bundle;
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.provider.MediaStore;
import android.provider.MediaStore.DownloadColumns;
import android.text.TextUtils;
import android.util.Log;
import android.util.Pair;
import com.android.internal.annotations.GuardedBy;
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.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* 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, Root.COLUMN_QUERY_ARGS
};
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;
private static final int NO_LIMIT = -1;
@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(@NonNull MatrixCursor result, @NonNull Cursor cursor) {
final List<Uri> notifyUris = cursor.getNotificationUris();
if (notifyUris != null) {
result.setNotificationUris(getContext().getContentResolver(), notifyUris);
}
}
/**
* 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);
}
static void onMediaProviderDownloadsDelete(Context context, long[] ids, String[] mimeTypes) {
for (int i = 0; i < ids.length; ++i) {
final boolean isDir = mimeTypes[i] == null;
final Uri uri = DocumentsContract.buildDocumentUri(AUTHORITY,
MediaStoreDownloadsHelper.getDocIdForMediaStoreDownload(ids[i], isDir));
context.revokeUriPermission(uri, ~0);
}
}
static void revokeAllMediaStoreUriPermissions(Context context) {
final List<UriPermission> uriPermissions =
context.getContentResolver().getOutgoingUriPermissions();
final int size = uriPermissions.size();
final StringBuilder sb = new StringBuilder("Revoking permissions for uris: ");
for (int i = 0; i < size; ++i) {
final Uri uri = uriPermissions.get(i).getUri();
if (AUTHORITY.equals(uri.getAuthority())
&& isMediaStoreDownload(DocumentsContract.getDocumentId(uri))) {
context.revokeUriPermission(uri, ~0);
sb.append(uri + ",");
}
}
Log.d(TAG, sb.toString());
}
@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.
getPublicDownloadsDirectory().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);
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);
row.add(Root.COLUMN_QUERY_ARGS, SUPPORTED_QUERY_ARGS);
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)
&& !isMediaStoreDownload(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) || isMediaStoreDownload(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)
|| isMediaStoreDownloadDir(docId)) {
return super.renameDocument(docId, displayName);
}
displayName = FileUtils.buildValidFatFilename(displayName);
if (isMediaStoreDownload(docId)) {
return renameMediaStoreDownload(docId, displayName);
} else {
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 if (isMediaStoreDownload(docId)) {
cursor = getContext().getContentResolver().query(getMediaStoreUriForQuery(docId),
null, null, null);
copyNotificationUri(result, cursor);
if (cursor.moveToFirst()) {
includeDownloadFromMediaStore(result, cursor, null /* filePaths */,
false /* shouldExcludeMedia */);
}
} else {
cursor = mDm.query(new Query().setFilterById(Long.parseLong(docId)));
copyNotificationUri(result, cursor);
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, null /* filePaths */,
null /* queryArgs */);
}
}
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);
}
final DownloadsCursor result = new DownloadsCursor(projection,
getContext().getContentResolver());
final ArrayList<Uri> notificationUris = new ArrayList<>();
if (isMediaStoreDownloadDir(parentDocId)) {
includeDownloadsFromMediaStore(result, null /* queryArgs */,
null /* filePaths */, notificationUris,
getMediaStoreIdString(parentDocId), NO_LIMIT, manage);
} else {
assert (DOC_ID_ROOT.equals(parentDocId));
if (manage) {
cursor = mDm.query(
new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true));
} else {
cursor = mDm.query(
new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true)
.setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL));
}
final Set<String> filePaths = new HashSet<>();
while (cursor.moveToNext()) {
includeDownloadFromCursor(result, cursor, filePaths, null /* queryArgs */);
}
notificationUris.add(cursor.getNotificationUri());
includeDownloadsFromMediaStore(result, null /* queryArgs */,
filePaths, notificationUris,
null /* parentId */, NO_LIMIT, manage);
includeFilesFromSharedStorage(result, filePaths, null);
}
result.setNotificationUris(getContext().getContentResolver(), notificationUris);
result.start();
return result;
} finally {
IoUtils.closeQuietly(cursor);
Binder.restoreCallingIdentity(token);
}
}
@Override
public Cursor queryRecentDocuments(String rootId, String[] projection,
@Nullable Bundle queryArgs, @Nullable CancellationSignal signal)
throws FileNotFoundException {
final DownloadsCursor result =
new DownloadsCursor(projection, getContext().getContentResolver());
// Delegate to real provider
final long token = Binder.clearCallingIdentity();
int limit = 12;
if (queryArgs != null) {
limit = queryArgs.getInt(ContentResolver.QUERY_ARG_LIMIT, -1);
if (limit < 0) {
// Use default value, and no QUERY_ARG* is honored.
limit = 12;
} else {
// We are honoring the QUERY_ARG_LIMIT.
Bundle extras = new Bundle();
result.setExtras(extras);
extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, new String[]{
ContentResolver.QUERY_ARG_LIMIT
});
}
}
Cursor cursor = null;
final ArrayList<Uri> notificationUris = new ArrayList<>();
try {
cursor = mDm.query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true)
.setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL));
final Set<String> filePaths = new HashSet<>();
while (cursor.moveToNext() && result.getCount() < limit) {
final String mimeType = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIA_TYPE));
final String uri = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIAPROVIDER_URI));
// Skip images, videos and documents that have been inserted into the MediaStore so
// we don't duplicate them in the recent list. The audio root of
// MediaDocumentsProvider doesn't support recent, we add it into recent list.
if (mimeType == null || (MediaFile.isImageMimeType(mimeType)
|| MediaFile.isVideoMimeType(mimeType) || MediaFile.isDocumentMimeType(
mimeType)) && !TextUtils.isEmpty(uri)) {
continue;
}
includeDownloadFromCursor(result, cursor, filePaths,
null /* queryArgs */);
}
notificationUris.add(cursor.getNotificationUri());
// Skip media files that have been inserted into the MediaStore so we
// don't duplicate them in the recent list.
final Bundle args = new Bundle();
args.putBoolean(DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, true);
includeDownloadsFromMediaStore(result, args, filePaths,
notificationUris, null /* parentId */, (limit - result.getCount()),
false /* includePending */);
} finally {
IoUtils.closeQuietly(cursor);
Binder.restoreCallingIdentity(token);
}
result.setNotificationUris(getContext().getContentResolver(), notificationUris);
result.start();
return result;
}
@Override
public Cursor querySearchDocuments(String rootId, String[] projection, Bundle queryArgs)
throws FileNotFoundException {
final DownloadsCursor result =
new DownloadsCursor(projection, getContext().getContentResolver());
final ArrayList<Uri> notificationUris = new ArrayList<>();
// Delegate to real provider
final long token = Binder.clearCallingIdentity();
Cursor cursor = null;
try {
cursor = mDm.query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true)
.setFilterByString(DocumentsContract.getSearchDocumentsQuery(queryArgs)));
final Set<String> filePaths = new HashSet<>();
while (cursor.moveToNext()) {
includeDownloadFromCursor(result, cursor, filePaths, queryArgs);
}
notificationUris.add(cursor.getNotificationUri());
includeDownloadsFromMediaStore(result, queryArgs, filePaths,
notificationUris, null /* parentId */, NO_LIMIT, true /* includePending */);
includeSearchFilesFromSharedStorage(result, projection, filePaths, queryArgs);
} finally {
IoUtils.closeQuietly(cursor);
Binder.restoreCallingIdentity(token);
}
final String[] handledQueryArgs = DocumentsContract.getHandledQueryArguments(queryArgs);
if (handledQueryArgs.length > 0) {
final Bundle extras = new Bundle();
extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, handledQueryArgs);
result.setExtras(extras);
}
result.setNotificationUris(getContext().getContentResolver(), notificationUris);
result.start();
return result;
}
private void includeSearchFilesFromSharedStorage(DownloadsCursor result,
String[] projection, Set<String> filePaths,
Bundle queryArgs) throws FileNotFoundException {
final File downloadDir = getPublicDownloadsDirectory();
try (Cursor rawFilesCursor = super.querySearchDocuments(downloadDir,
projection, filePaths, queryArgs)) {
final boolean shouldExcludeMedia = queryArgs.getBoolean(
DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */);
while (rawFilesCursor.moveToNext()) {
final String mimeType = rawFilesCursor.getString(
rawFilesCursor.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE));
// When the value of shouldExcludeMedia is true, don't add media files into
// the result to avoid duplicated files. MediaScanner will scan the files
// into MediaStore. If the behavior is changed, we need to add the files back.
if (!shouldExcludeMedia || !isMediaMimeType(mimeType)) {
String docId = rawFilesCursor.getString(
rawFilesCursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID));
File rawFile = getFileForDocId(docId);
includeFileFromSharedStorage(result, rawFile);
}
}
}
}
@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 ContentResolver resolver = getContext().getContentResolver();
final Uri contentUri;
if (isMediaStoreDownload(docId)) {
contentUri = getMediaStoreUriForQuery(docId);
} else {
final long id = Long.parseLong(docId);
contentUri = mDm.getDownloadUri(id);
}
return resolver.getType(contentUri);
} 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 ContentResolver resolver = getContext().getContentResolver();
final Uri contentUri;
if (isMediaStoreDownload(docId)) {
contentUri = getMediaStoreUriForQuery(docId);
} else {
final long id = Long.parseLong(docId);
contentUri = mDm.getDownloadUri(id);
}
return resolver.openFileDescriptor(contentUri, 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 (isMediaStoreDownload(docId)) {
return getFileForMediaStoreDownload(docId);
}
if (DOC_ID_ROOT.equals(docId)) {
return getPublicDownloadsDirectory();
}
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 static boolean isMediaMimeType(String mimeType) {
return MediaFile.isImageMimeType(mimeType) || MediaFile.isVideoMimeType(mimeType)
|| MediaFile.isAudioMimeType(mimeType) || MediaFile.isDocumentMimeType(mimeType);
}
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, Bundle queryArgs) {
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";
}
if (queryArgs != null) {
final boolean shouldExcludeMedia = queryArgs.getBoolean(
DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */);
if (shouldExcludeMedia) {
final String uri = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIAPROVIDER_URI));
// Skip media files that have been inserted into the MediaStore so we
// don't duplicate them in the search list.
if (isMediaMimeType(mimeType) && !TextUtils.isEmpty(uri)) {
return;
}
}
}
// size could be -1 which indicates that download hasn't started.
final long size = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
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 > 0) {
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;
}
final long lastModified = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP));
if (!DocumentsContract.matchSearchQueryArguments(queryArgs, displayName, mimeType,
lastModified, size)) {
return;
}
includeDownload(result, docId, displayName, summary, size, mimeType,
lastModified, extraFlags, status == DownloadManager.STATUS_RUNNING);
if (filePaths != null && localFilePath != null) {
filePaths.add(localFilePath);
}
}
private void includeDownload(MatrixCursor result,
String docId, String displayName, String summary, long size,
String mimeType, long lastModifiedMs, int extraFlags, boolean isPending) {
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 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 == -1 ? null : 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 (!isPending) {
row.add(Document.COLUMN_LAST_MODIFIED, lastModifiedMs);
}
}
/**
* 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(DownloadsCursor result,
Set<String> downloadedFilePaths, @Nullable String searchString)
throws FileNotFoundException {
final File downloadsDir = getPublicDownloadsDirectory();
// Add every file from the Downloads directory to the result cursor. Ignore files that
// were in the supplied downloaded file paths.
for (File file : FileUtils.listFilesOrEmpty(downloadsDir)) {
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 getPublicDownloadsDirectory() {
return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
}
private String renameMediaStoreDownload(String docId, String displayName) {
final File before = getFileForMediaStoreDownload(docId);
final File after = new File(before.getParentFile(), displayName);
if (after.exists()) {
throw new IllegalStateException("Already exists " + after);
}
if (!before.renameTo(after)) {
throw new IllegalStateException("Failed to rename from " + before + " to " + after);
}
final String noMedia = ".nomedia";
// Scan the file to update the database
// For file, check whether the file is renamed to .nomedia. If yes, to scan the parent
// directory to update all files in the directory. We don't consider the case of renaming
// .nomedia file. We don't show .nomedia file.
if (!after.isDirectory() && displayName.toLowerCase(Locale.ROOT).endsWith(noMedia)) {
final Uri newUri = MediaStore.scanFile(getContext().getContentResolver(),
after.getParentFile());
// the file will not show in the list, return the parent docId to avoid not finding
// the detail for the file.
return getDocIdForMediaStoreDownloadUri(newUri, true /* isDir */);
}
// update the database for the old file
MediaStore.scanFile(getContext().getContentResolver(), before);
// Update tne database for the new file and get the new uri
final Uri newUri = MediaStore.scanFile(getContext().getContentResolver(), after);
return getDocIdForMediaStoreDownloadUri(newUri, after.isDirectory());
}
private static String getDocIdForMediaStoreDownloadUri(Uri uri, boolean isDir) {
if (uri != null) {
return getDocIdForMediaStoreDownload(Long.parseLong(uri.getLastPathSegment()), isDir);
}
return null;
}
private File getFileForMediaStoreDownload(String docId) {
final Uri mediaStoreUri = getMediaStoreUriForQuery(docId);
final long token = Binder.clearCallingIdentity();
try (Cursor cursor = queryForSingleItem(mediaStoreUri,
new String[] { DownloadColumns.DATA }, null, null, null)) {
final String filePath = cursor.getString(0);
if (filePath == null) {
throw new IllegalStateException("Missing _data for " + mediaStoreUri);
}
return new File(filePath);
} catch (FileNotFoundException e) {
throw new IllegalStateException(e);
} finally {
Binder.restoreCallingIdentity(token);
}
}
private Pair<String, String> getRelativePathAndDisplayNameForDownload(long id) {
final Uri mediaStoreUri = ContentUris.withAppendedId(
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL), id);
final long token = Binder.clearCallingIdentity();
try (Cursor cursor = queryForSingleItem(mediaStoreUri,
new String[] { DownloadColumns.RELATIVE_PATH, DownloadColumns.DISPLAY_NAME },
null, null, null)) {
final String relativePath = cursor.getString(0);
final String displayName = cursor.getString(1);
if (relativePath == null || displayName == null) {
throw new IllegalStateException(
"relative_path and _display_name should not be null for " + mediaStoreUri);
}
return Pair.create(relativePath, displayName);
} catch (FileNotFoundException e) {
throw new IllegalStateException(e);
} finally {
Binder.restoreCallingIdentity(token);
}
}
/**
* Copied from MediaProvider.java
*
* Query the given {@link Uri}, expecting only a single item to be found.
*
* @throws FileNotFoundException if no items were found, or multiple items
* were found, or there was trouble reading the data.
*/
private Cursor queryForSingleItem(Uri uri, String[] projection,
String selection, String[] selectionArgs, CancellationSignal signal)
throws FileNotFoundException {
final Cursor c = getContext().getContentResolver().query(uri, projection,
ContentResolver.createSqlQueryBundle(selection, selectionArgs, null), signal);
if (c == null) {
throw new FileNotFoundException("Missing cursor for " + uri);
} else if (c.getCount() < 1) {
IoUtils.closeQuietly(c);
throw new FileNotFoundException("No item at " + uri);
} else if (c.getCount() > 1) {
IoUtils.closeQuietly(c);
throw new FileNotFoundException("Multiple items at " + uri);
}
if (c.moveToFirst()) {
return c;
} else {
IoUtils.closeQuietly(c);
throw new FileNotFoundException("Failed to read row from " + uri);
}
}
private void includeDownloadsFromMediaStore(@NonNull MatrixCursor result,
@Nullable Bundle queryArgs,
@Nullable Set<String> filePaths, @NonNull ArrayList<Uri> notificationUris,
@Nullable String parentId, int limit, boolean includePending) {
if (limit == 0) {
return;
}
final long token = Binder.clearCallingIdentity();
final Uri uriInner = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL);
final Bundle queryArgsInner = new Bundle();
final Pair<String, String[]> selectionPair = buildSearchSelection(
queryArgs, filePaths, parentId);
queryArgsInner.putString(ContentResolver.QUERY_ARG_SQL_SELECTION,
selectionPair.first);
queryArgsInner.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS,
selectionPair.second);
if (limit != NO_LIMIT) {
queryArgsInner.putInt(ContentResolver.QUERY_ARG_LIMIT, limit);
}
if (includePending) {
queryArgsInner.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
}
try (Cursor cursor = getContext().getContentResolver().query(uriInner,
null, queryArgsInner, null)) {
final boolean shouldExcludeMedia = queryArgs != null && queryArgs.getBoolean(
DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */);
while (cursor.moveToNext()) {
includeDownloadFromMediaStore(result, cursor, filePaths, shouldExcludeMedia);
}
notificationUris.add(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL));
notificationUris.add(MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL));
} finally {
Binder.restoreCallingIdentity(token);
}
}
private void includeDownloadFromMediaStore(@NonNull MatrixCursor result,
@NonNull Cursor mediaCursor, @Nullable Set<String> filePaths,
boolean shouldExcludeMedia) {
final String mimeType = getMimeType(mediaCursor);
// Image, Audio and Video are excluded from buildSearchSelection in querySearchDocuments
// and queryRecentDocuments. Only exclude document type here for both cases.
if (shouldExcludeMedia && MediaFile.isDocumentMimeType(mimeType)) {
return;
}
final boolean isDir = Document.MIME_TYPE_DIR.equals(mimeType);
final String docId = getDocIdForMediaStoreDownload(
mediaCursor.getLong(mediaCursor.getColumnIndex(DownloadColumns._ID)), isDir);
final String displayName = mediaCursor.getString(
mediaCursor.getColumnIndex(DownloadColumns.DISPLAY_NAME));
final long size = mediaCursor.getLong(
mediaCursor.getColumnIndex(DownloadColumns.SIZE));
final long lastModifiedMs = mediaCursor.getLong(
mediaCursor.getColumnIndex(DownloadColumns.DATE_MODIFIED)) * 1000;
final boolean isPending = mediaCursor.getInt(
mediaCursor.getColumnIndex(DownloadColumns.IS_PENDING)) == 1;
int extraFlags = isPending ? Document.FLAG_PARTIAL : 0;
if (Document.MIME_TYPE_DIR.equals(mimeType)) {
extraFlags |= Document.FLAG_DIR_SUPPORTS_CREATE;
}
if (!isPending) {
extraFlags |= Document.FLAG_SUPPORTS_RENAME;
}
includeDownload(result, docId, displayName, null /* description */, size, mimeType,
lastModifiedMs, extraFlags, isPending);
if (filePaths != null) {
filePaths.add(mediaCursor.getString(
mediaCursor.getColumnIndex(DownloadColumns.DATA)));
}
}
private String getMimeType(@NonNull Cursor mediaCursor) {
final String mimeType = mediaCursor.getString(
mediaCursor.getColumnIndex(DownloadColumns.MIME_TYPE));
if (mimeType == null) {
return Document.MIME_TYPE_DIR;
}
return mimeType;
}
// Copied from MediaDocumentsProvider with some tweaks
private Pair<String, String[]> buildSearchSelection(@Nullable Bundle queryArgs,
@Nullable Set<String> filePaths, @Nullable String parentId) {
final StringBuilder selection = new StringBuilder();
final ArrayList<String> selectionArgs = new ArrayList<>();
if (parentId == null && filePaths != null && filePaths.size() > 0) {
if (selection.length() > 0) {
selection.append(" AND ");
}
selection.append(DownloadColumns.DATA + " NOT IN (");
selection.append(TextUtils.join(",", Collections.nCopies(filePaths.size(), "?")));
selection.append(")");
selectionArgs.addAll(filePaths);
}
if (parentId != null) {
if (selection.length() > 0) {
selection.append(" AND ");
}
selection.append(DownloadColumns.RELATIVE_PATH + "=?");
final Pair<String, String> data = getRelativePathAndDisplayNameForDownload(
Long.parseLong(parentId));
selectionArgs.add(data.first + data.second + "/");
} else {
if (selection.length() > 0) {
selection.append(" AND ");
}
selection.append(DownloadColumns.RELATIVE_PATH + "=?");
selectionArgs.add(Environment.DIRECTORY_DOWNLOADS + "/");
}
if (queryArgs != null) {
final boolean shouldExcludeMedia = queryArgs.getBoolean(
DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */);
if (shouldExcludeMedia) {
if (selection.length() > 0) {
selection.append(" AND ");
}
selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?");
selectionArgs.add("image/%");
selection.append(" AND ");
selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?");
selectionArgs.add("audio/%");
selection.append(" AND ");
selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?");
selectionArgs.add("video/%");
}
final String displayName = queryArgs.getString(
DocumentsContract.QUERY_ARG_DISPLAY_NAME);
if (!TextUtils.isEmpty(displayName)) {
if (selection.length() > 0) {
selection.append(" AND ");
}
selection.append(DownloadColumns.DISPLAY_NAME + " LIKE ?");
selectionArgs.add("%" + displayName + "%");
}
final long lastModifiedAfter = queryArgs.getLong(
DocumentsContract.QUERY_ARG_LAST_MODIFIED_AFTER, -1 /* defaultValue */);
if (lastModifiedAfter != -1) {
if (selection.length() > 0) {
selection.append(" AND ");
}
selection.append(DownloadColumns.DATE_MODIFIED
+ " > " + lastModifiedAfter / 1000);
}
final long fileSizeOver = queryArgs.getLong(
DocumentsContract.QUERY_ARG_FILE_SIZE_OVER, -1 /* defaultValue */);
if (fileSizeOver != -1) {
if (selection.length() > 0) {
selection.append(" AND ");
}
selection.append(DownloadColumns.SIZE + " > " + fileSizeOver);
}
final String[] mimeTypes = queryArgs.getStringArray(
DocumentsContract.QUERY_ARG_MIME_TYPES);
if (mimeTypes != null && mimeTypes.length > 0) {
if (selection.length() > 0) {
selection.append(" AND ");
}
selection.append("(");
final List<String> tempSelectionArgs = new ArrayList<>();
final StringBuilder tempSelection = new StringBuilder();
List<String> wildcardMimeTypeList = new ArrayList<>();
for (int i = 0; i < mimeTypes.length; ++i) {
final String mimeType = mimeTypes[i];
if (!TextUtils.isEmpty(mimeType) && mimeType.endsWith("/*")) {
wildcardMimeTypeList.add(mimeType);
continue;
}
if (tempSelectionArgs.size() > 0) {
tempSelection.append(",");
}
tempSelection.append("?");
tempSelectionArgs.add(mimeType);
}
for (int i = 0; i < wildcardMimeTypeList.size(); i++) {
selection.append(DownloadColumns.MIME_TYPE + " LIKE ?")
.append((i != wildcardMimeTypeList.size() - 1) ? " OR " : "");
final String mimeType = wildcardMimeTypeList.get(i);
selectionArgs.add(mimeType.substring(0, mimeType.length() - 1) + "%");
}
if (tempSelectionArgs.size() > 0) {
if (wildcardMimeTypeList.size() > 0) {
selection.append(" OR ");
}
selection.append(DownloadColumns.MIME_TYPE + " IN (")
.append(tempSelection.toString())
.append(")");
selectionArgs.addAll(tempSelectionArgs);
}
selection.append(")");
}
}
return new Pair<>(selection.toString(), selectionArgs.toArray(new String[0]));
}
/**
* 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,
Arrays.asList(getPublicDownloadsDirectory()));
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 File[] mDownloadDirs;
private final ContentResolver mResolver;
public ContentChangedRelay(ContentResolver resolver, List<File> downloadDirs) {
super(downloadDirs, NOTIFY_EVENTS);
mDownloadDirs = downloadDirs.toArray(new File[0]);
mResolver = resolver;
}
@Override
public void startWatching() {
super.startWatching();
if (DEBUG) Log.d(TAG, "Started watching for file changes in: "
+ Arrays.toString(mDownloadDirs));
}
@Override
public void stopWatching() {
super.stopWatching();
if (DEBUG) Log.d(TAG, "Stopped watching for file changes in: "
+ Arrays.toString(mDownloadDirs));
}
@Override
public void onEvent(int event, String path) {
if ((event & NOTIFY_EVENTS) != 0) {
if (DEBUG) Log.v(TAG, "Change detected at path: " + path);
mResolver.notifyChange(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, null, false);
mResolver.notifyChange(Downloads.Impl.CONTENT_URI, null, false);
}
}
}
}