Extend DocumentsContract search to accept mime types

1. Add the key of query arguments and match method
   in DocumentsContract.
2. Implement new querySearchDocuments method in
   DocumentsProvider, ExternalStoragProvider and
   FileSystemProvider.

Bug: 111786939
Test: Manual Test
Change-Id: I04e9f2be971f10ac1e9584a3486c948aaddea0a4
This commit is contained in:
Ivan Chiang
2018-10-15 15:23:02 +08:00
parent 2823d3abdd
commit a972d0449a
5 changed files with 398 additions and 43 deletions

View File

@@ -0,0 +1,154 @@
/*
* Copyright (C) 2018 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 android.content;
import android.annotation.NonNull;
import android.annotation.Nullable;
import java.util.ArrayList;
/**
* Provides utility methods for matching MIME type filters used in ContentProvider.
*
* <p>Wildcards are allowed only instead of the entire type or subtype with a tree prefix.
* Eg. image\/*, *\/* is a valid filter and will match image/jpeg, but image/j* is invalid and
* it will not match image/jpeg. Suffixes and parameters are not supported, and they are treated
* as part of the subtype during matching. Neither type nor subtype can be empty.
*
* <p><em>Note: MIME type matching in the Android framework is case-sensitive, unlike the formal
* RFC definitions. As a result, you should always write these elements with lower case letters,
* or use {@link android.content.Intent#normalizeMimeType} to ensure that they are converted to
* lower case.</em>
*
* <p>MIME types can be null or ill-formatted. In such case they won't match anything.
*
* <p>MIME type filters must be correctly formatted, or an exception will be thrown.
* Copied from support library.
* {@hide}
*/
public final class MimeTypeFilter {
private MimeTypeFilter() {
}
private static boolean mimeTypeAgainstFilter(
@NonNull String[] mimeTypeParts, @NonNull String[] filterParts) {
if (filterParts.length != 2) {
throw new IllegalArgumentException(
"Ill-formatted MIME type filter. Must be type/subtype.");
}
if (filterParts[0].isEmpty() || filterParts[1].isEmpty()) {
throw new IllegalArgumentException(
"Ill-formatted MIME type filter. Type or subtype empty.");
}
if (mimeTypeParts.length != 2) {
return false;
}
if (!"*".equals(filterParts[0])
&& !filterParts[0].equals(mimeTypeParts[0])) {
return false;
}
if (!"*".equals(filterParts[1])
&& !filterParts[1].equals(mimeTypeParts[1])) {
return false;
}
return true;
}
/**
* Matches one nullable MIME type against one MIME type filter.
* @return True if the {@code mimeType} matches the {@code filter}.
*/
public static boolean matches(@Nullable String mimeType, @NonNull String filter) {
if (mimeType == null) {
return false;
}
final String[] mimeTypeParts = mimeType.split("/");
final String[] filterParts = filter.split("/");
return mimeTypeAgainstFilter(mimeTypeParts, filterParts);
}
/**
* Matches one nullable MIME type against an array of MIME type filters.
* @return The first matching filter, or null if nothing matches.
*/
@Nullable
public static String matches(
@Nullable String mimeType, @NonNull String[] filters) {
if (mimeType == null) {
return null;
}
final String[] mimeTypeParts = mimeType.split("/");
for (String filter : filters) {
final String[] filterParts = filter.split("/");
if (mimeTypeAgainstFilter(mimeTypeParts, filterParts)) {
return filter;
}
}
return null;
}
/**
* Matches multiple MIME types against an array of MIME type filters.
* @return The first matching MIME type, or null if nothing matches.
*/
@Nullable
public static String matches(
@Nullable String[] mimeTypes, @NonNull String filter) {
if (mimeTypes == null) {
return null;
}
final String[] filterParts = filter.split("/");
for (String mimeType : mimeTypes) {
final String[] mimeTypeParts = mimeType.split("/");
if (mimeTypeAgainstFilter(mimeTypeParts, filterParts)) {
return mimeType;
}
}
return null;
}
/**
* Matches multiple MIME types against an array of MIME type filters.
* @return The list of matching MIME types, or empty array if nothing matches.
*/
@NonNull
public static String[] matchesMany(
@Nullable String[] mimeTypes, @NonNull String filter) {
if (mimeTypes == null) {
return new String[] {};
}
final ArrayList<String> list = new ArrayList<>();
final String[] filterParts = filter.split("/");
for (String mimeType : mimeTypes) {
final String[] mimeTypeParts = mimeType.split("/");
if (mimeTypeAgainstFilter(mimeTypeParts, filterParts)) {
list.add(mimeType);
}
}
return list.toArray(new String[list.size()]);
}
}

View File

@@ -16,12 +16,11 @@
package android.provider;
import static android.system.OsConstants.SEEK_SET;
import static com.android.internal.util.Preconditions.checkArgument;
import static com.android.internal.util.Preconditions.checkCollectionElementsNotNull;
import static com.android.internal.util.Preconditions.checkCollectionNotEmpty;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.UnsupportedAppUsage;
import android.content.ContentProviderClient;
@@ -29,13 +28,12 @@ import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentSender;
import android.content.MimeTypeFilter;
import android.content.pm.ResolveInfo;
import android.content.res.AssetFileDescriptor;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.ImageDecoder;
import android.graphics.Matrix;
import android.graphics.Point;
import android.media.ExifInterface;
import android.net.Uri;
@@ -50,20 +48,13 @@ import android.os.Parcelable;
import android.os.ParcelableException;
import android.os.RemoteException;
import android.os.storage.StorageVolume;
import android.system.ErrnoException;
import android.system.Os;
import android.util.DataUnit;
import android.util.Log;
import android.util.Size;
import libcore.io.IoUtils;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -112,6 +103,54 @@ public final class DocumentsContract {
/** {@hide} */
public static final String EXTRA_TARGET_URI = "android.content.extra.TARGET_URI";
/**
* Key for {@link DocumentsProvider} to query display name is matched.
* The match of display name is partial matching and case-insensitive.
* Ex: The value is "o", the display name of the results will contain
* both "foo" and "Open".
*
* @see DocumentsProvider#querySearchDocuments(String, String[],
* Bundle)
* {@hide}
*/
public static final String QUERY_ARG_DISPLAY_NAME = "android:query-arg-display-name";
/**
* Key for {@link DocumentsProvider} to query mime types is matched.
* The value is a string array, it can support different mime types.
* Each items will be treated as "OR" condition. Ex: {"image/*" ,
* "video/*"}. The mime types of the results will contain both image
* type and video type.
*
* @see DocumentsProvider#querySearchDocuments(String, String[],
* Bundle)
* {@hide}
*/
public static final String QUERY_ARG_MIME_TYPES = "android:query-arg-mime-types";
/**
* Key for {@link DocumentsProvider} to query the file size in bytes is
* larger than the value.
*
* @see DocumentsProvider#querySearchDocuments(String, String[],
* Bundle)
* {@hide}
*/
public static final String QUERY_ARG_FILE_SIZE_OVER = "android:query-arg-file-size-over";
/**
* Key for {@link DocumentsProvider} to query the last modified time
* is newer than the value. The unit is in milliseconds since
* January 1, 1970 00:00:00.0 UTC.
*
* @see DocumentsProvider#querySearchDocuments(String, String[],
* Bundle)
* @see Document#COLUMN_LAST_MODIFIED
* {@hide}
*/
public static final String QUERY_ARG_LAST_MODIFIED_AFTER =
"android:query-arg-last-modified-after";
/**
* Sets the desired initial location visible to user when file chooser is shown.
*
@@ -928,6 +967,89 @@ public final class DocumentsContract {
.appendQueryParameter(PARAM_QUERY, query).build();
}
/**
* Check if the values match the query arguments.
*
* @param queryArgs the query arguments
* @param displayName the display time to check against
* @param mimeType the mime type to check against
* @param lastModified the last modified time to check against
* @param size the size to check against
* @hide
*/
public static boolean matchSearchQueryArguments(Bundle queryArgs, String displayName,
String mimeType, long lastModified, long size) {
if (queryArgs == null) {
return true;
}
final String argDisplayName = queryArgs.getString(QUERY_ARG_DISPLAY_NAME, "");
if (!argDisplayName.isEmpty()) {
// TODO (118795812) : Enhance the search string handled in DocumentsProvider
if (!displayName.toLowerCase().contains(argDisplayName.toLowerCase())) {
return false;
}
}
final long argFileSize = queryArgs.getLong(QUERY_ARG_FILE_SIZE_OVER, -1 /* defaultValue */);
if (argFileSize != -1 && size < argFileSize) {
return false;
}
final long argLastModified = queryArgs.getLong(QUERY_ARG_LAST_MODIFIED_AFTER,
-1 /* defaultValue */);
if (argLastModified != -1 && lastModified < argLastModified) {
return false;
}
final String[] argMimeTypes = queryArgs.getStringArray(QUERY_ARG_MIME_TYPES);
if (argMimeTypes != null && argMimeTypes.length > 0) {
mimeType = Intent.normalizeMimeType(mimeType);
for (String type : argMimeTypes) {
if (MimeTypeFilter.matches(mimeType, Intent.normalizeMimeType(type))) {
return true;
}
}
return false;
}
return true;
}
/**
* Get the handled query arguments from the query bundle. The handled arguments are
* {@link DocumentsContract#QUERY_ARG_DISPLAY_NAME},
* {@link DocumentsContract#QUERY_ARG_MIME_TYPES},
* {@link DocumentsContract#QUERY_ARG_FILE_SIZE_OVER} and
* {@link DocumentsContract#QUERY_ARG_LAST_MODIFIED_AFTER}.
*
* @param queryArgs the query arguments to be parsed.
* @return the handled query arguments
* @hide
*/
public static String[] getHandledQueryArguments(Bundle queryArgs) {
if (queryArgs == null) {
return new String[0];
}
final ArrayList<String> args = new ArrayList<>();
if (queryArgs.keySet().contains(QUERY_ARG_DISPLAY_NAME)) {
args.add(QUERY_ARG_DISPLAY_NAME);
}
if (queryArgs.keySet().contains(QUERY_ARG_FILE_SIZE_OVER)) {
args.add(QUERY_ARG_FILE_SIZE_OVER);
}
if (queryArgs.keySet().contains(QUERY_ARG_LAST_MODIFIED_AFTER)) {
args.add(QUERY_ARG_LAST_MODIFIED_AFTER);
}
if (queryArgs.keySet().contains(QUERY_ARG_MIME_TYPES)) {
args.add(QUERY_ARG_MIME_TYPES);
}
return args.toArray(new String[0]);
}
/**
* Test if the given URI represents a {@link Document} backed by a
* {@link DocumentsProvider}.
@@ -1052,6 +1174,15 @@ public final class DocumentsContract {
return searchDocumentsUri.getQueryParameter(PARAM_QUERY);
}
/**
* Extract the search query from a Bundle
* {@link #QUERY_ARG_DISPLAY_NAME}.
* {@hide}
*/
public static String getSearchDocumentsQuery(@NonNull Bundle bundle) {
return bundle.getString(QUERY_ARG_DISPLAY_NAME, "" /* defaultValue */);
}
/** {@hide} */
@UnsupportedAppUsage
public static Uri setManageMode(Uri uri) {

View File

@@ -32,7 +32,6 @@ import static android.provider.DocumentsContract.buildDocumentUriMaybeUsingTree;
import static android.provider.DocumentsContract.buildTreeDocumentUri;
import static android.provider.DocumentsContract.getDocumentId;
import static android.provider.DocumentsContract.getRootId;
import static android.provider.DocumentsContract.getSearchDocumentsQuery;
import static android.provider.DocumentsContract.getTreeDocumentId;
import static android.provider.DocumentsContract.isTreeUri;
@@ -47,6 +46,7 @@ import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.IntentSender;
import android.content.MimeTypeFilter;
import android.content.UriMatcher;
import android.content.pm.PackageManager;
import android.content.pm.ProviderInfo;
@@ -650,6 +650,55 @@ public abstract class DocumentsProvider extends ContentProvider {
throw new UnsupportedOperationException("Search not supported");
}
/**
* Return documents that match the given query under the requested
* root. The returned documents should be sorted by relevance in descending
* order. How documents are matched against the query string is an
* implementation detail left to each provider, but it's suggested that at
* least {@link Document#COLUMN_DISPLAY_NAME} be matched in a
* case-insensitive fashion.
* <p>
* If your provider is cloud-based, and you have some data cached or pinned
* locally, you may return the local data immediately, setting
* {@link DocumentsContract#EXTRA_LOADING} on the Cursor to indicate that
* you are still fetching additional data. Then, when the network data is
* available, you can send a change notification to trigger a requery and
* return the complete contents.
* <p>
* To support change notifications, you must
* {@link Cursor#setNotificationUri(ContentResolver, Uri)} with a relevant
* Uri, such as {@link DocumentsContract#buildSearchDocumentsUri(String,
* String, String)}. Then you can call {@link ContentResolver#notifyChange(Uri,
* android.database.ContentObserver, boolean)} with that Uri to send change
* notifications.
*
* @param rootId the root to search under.
* @param projection list of {@link Document} columns to put into the
* cursor. If {@code null} all supported columns should be
* included.
* @param queryArgs the query arguments.
* {@link DocumentsContract#QUERY_ARG_DISPLAY_NAME},
* {@link DocumentsContract#QUERY_ARG_MIME_TYPES},
* {@link DocumentsContract#QUERY_ARG_FILE_SIZE_OVER},
* {@link DocumentsContract#QUERY_ARG_LAST_MODIFIED_AFTER}.
* @return cursor containing search result. Include
* {@link ContentResolver#EXTRA_HONORED_ARGS} in {@link Cursor}
* extras {@link Bundle} when any QUERY_ARG_* value was honored
* during the preparation of the results.
*
* @see ContentResolver#EXTRA_HONORED_ARGS
* @see DocumentsContract#EXTRA_LOADING
* @see DocumentsContract#EXTRA_INFO
* @see DocumentsContract#EXTRA_ERROR
* {@hide}
*/
@SuppressWarnings("unused")
public Cursor querySearchDocuments(String rootId, String[] projection, Bundle queryArgs)
throws FileNotFoundException {
return querySearchDocuments(rootId, DocumentsContract.getSearchDocumentsQuery(queryArgs),
projection);
}
/**
* Ejects the root. Throws {@link IllegalStateException} if ejection failed.
*
@@ -795,7 +844,7 @@ public abstract class DocumentsProvider extends ContentProvider {
* {@link #queryDocument(String, String[])},
* {@link #queryRecentDocuments(String, String[])},
* {@link #queryRoots(String[])}, and
* {@link #querySearchDocuments(String, String, String[])}.
* {@link #querySearchDocuments(String, String[], Bundle)}.
*/
@Override
public Cursor query(Uri uri, String[] projection, String selection,
@@ -812,7 +861,7 @@ public abstract class DocumentsProvider extends ContentProvider {
* @see #queryRecentDocuments(String, String[], Bundle, CancellationSignal)
* @see #queryDocument(String, String[])
* @see #queryChildDocuments(String, String[], String)
* @see #querySearchDocuments(String, String, String[])
* @see #querySearchDocuments(String, String[], Bundle)
*/
@Override
public final Cursor query(
@@ -825,8 +874,7 @@ public abstract class DocumentsProvider extends ContentProvider {
return queryRecentDocuments(
getRootId(uri), projection, queryArgs, cancellationSignal);
case MATCH_SEARCH:
return querySearchDocuments(
getRootId(uri), getSearchDocumentsQuery(uri), projection);
return querySearchDocuments(getRootId(uri), projection, queryArgs);
case MATCH_DOCUMENT:
case MATCH_DOCUMENT_TREE:
enforceTree(uri);
@@ -1301,7 +1349,7 @@ public abstract class DocumentsProvider extends ContentProvider {
final long flags =
cursor.getLong(cursor.getColumnIndexOrThrow(Document.COLUMN_FLAGS));
if ((flags & Document.FLAG_VIRTUAL_DOCUMENT) == 0 && mimeType != null &&
mimeTypeMatches(mimeTypeFilter, mimeType)) {
MimeTypeFilter.matches(mimeType, mimeTypeFilter)) {
return new String[] { mimeType };
}
}
@@ -1354,21 +1402,4 @@ public abstract class DocumentsProvider extends ContentProvider {
// For any other yet unhandled case, let the provider subclass handle it.
return openTypedDocument(documentId, mimeTypeFilter, opts, signal);
}
/**
* @hide
*/
public static boolean mimeTypeMatches(String filter, String test) {
if (test == null) {
return false;
} else if (filter == null || "*/*".equals(filter)) {
return true;
} else if (filter.equals(test)) {
return true;
} else if (filter.endsWith("/*")) {
return filter.regionMatches(0, test, 0, filter.indexOf('/'));
} else {
return false;
}
}
}

View File

@@ -389,14 +389,18 @@ public abstract class FileSystemProvider extends DocumentsProvider {
* @param query the search condition used to match file names
* @param projection projection of the returned cursor
* @param exclusion absolute file paths to exclude from result
* @return cursor containing search result
* @param queryArgs the query arguments for search
* @return cursor containing search result. Include
* {@link ContentResolver#EXTRA_HONORED_ARGS} in {@link Cursor}
* extras {@link Bundle} when any QUERY_ARG_* value was honored
* during the preparation of the results.
* @throws FileNotFoundException when root folder doesn't exist or search fails
*
* @see ContentResolver#EXTRA_HONORED_ARGS
*/
protected final Cursor querySearchDocuments(
File folder, String query, String[] projection, Set<String> exclusion)
File folder, String[] projection, Set<String> exclusion, Bundle queryArgs)
throws FileNotFoundException {
query = query.toLowerCase();
final MatrixCursor result = new MatrixCursor(resolveProjection(projection));
final LinkedList<File> pending = new LinkedList<>();
pending.add(folder);
@@ -407,11 +411,18 @@ public abstract class FileSystemProvider extends DocumentsProvider {
pending.add(child);
}
}
if (file.getName().toLowerCase().contains(query)
&& !exclusion.contains(file.getAbsolutePath())) {
if (!exclusion.contains(file.getAbsolutePath()) && matchSearchQueryArguments(file,
queryArgs)) {
includeFile(result, null, file);
}
}
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);
}
return result;
}
@@ -457,6 +468,34 @@ public abstract class FileSystemProvider extends DocumentsProvider {
}
}
/**
* Test if the file matches the query arguments.
*
* @param file the file to test
* @param queryArgs the query arguments
*/
private boolean matchSearchQueryArguments(File file, Bundle queryArgs) {
if (file == null) {
return false;
}
final String fileMimeType;
final String fileName = file.getName();
if (file.isDirectory()) {
fileMimeType = DocumentsContract.Document.MIME_TYPE_DIR;
} else {
int dotPos = fileName.lastIndexOf('.');
if (dotPos < 0) {
return false;
}
final String extension = fileName.substring(dotPos + 1);
fileMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
}
return DocumentsContract.matchSearchQueryArguments(queryArgs, fileName, fileMimeType,
file.lastModified(), file.length());
}
private void scanFile(File visibleFile) {
final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
intent.setData(Uri.fromFile(visibleFile));

View File

@@ -541,14 +541,14 @@ public class ExternalStorageProvider extends FileSystemProvider {
}
@Override
public Cursor querySearchDocuments(String rootId, String query, String[] projection)
public Cursor querySearchDocuments(String rootId, String[] projection, Bundle queryArgs)
throws FileNotFoundException {
final File parent;
synchronized (mRootsLock) {
parent = mRoots.get(rootId).path;
}
return querySearchDocuments(parent, query, projection, Collections.emptySet());
return querySearchDocuments(parent, projection, Collections.emptySet(), queryArgs);
}
@Override