diff options
5 files changed, 398 insertions, 43 deletions
diff --git a/core/java/android/content/MimeTypeFilter.java b/core/java/android/content/MimeTypeFilter.java new file mode 100644 index 000000000000..1c26fd917f76 --- /dev/null +++ b/core/java/android/content/MimeTypeFilter.java @@ -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()]); + } +} diff --git a/core/java/android/provider/DocumentsContract.java b/core/java/android/provider/DocumentsContract.java index 67e52aad9206..16d454dd0179 100644 --- a/core/java/android/provider/DocumentsContract.java +++ b/core/java/android/provider/DocumentsContract.java @@ -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; @@ -113,6 +104,54 @@ public final class DocumentsContract { 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. * * <p>Applicable to {@link Intent} with actions: @@ -929,6 +968,89 @@ public final class DocumentsContract { } /** + * 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) { diff --git a/core/java/android/provider/DocumentsProvider.java b/core/java/android/provider/DocumentsProvider.java index 68f8acd8a586..58f82134ec50 100644 --- a/core/java/android/provider/DocumentsProvider.java +++ b/core/java/android/provider/DocumentsProvider.java @@ -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; @@ -651,6 +651,55 @@ public abstract class DocumentsProvider extends ContentProvider { } /** + * 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. * * @param rootId the root to be ejected. @@ -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; - } - } } diff --git a/core/java/com/android/internal/content/FileSystemProvider.java b/core/java/com/android/internal/content/FileSystemProvider.java index 81dab2f6aeef..8bc90a891352 100644 --- a/core/java/com/android/internal/content/FileSystemProvider.java +++ b/core/java/com/android/internal/content/FileSystemProvider.java @@ -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)); diff --git a/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java b/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java index 4abcf73af109..c9ee5c87de0f 100644 --- a/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java +++ b/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java @@ -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 |