Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 9d2ae77c authored by Ivan Chiang's avatar Ivan Chiang Committed by Android (Google) Code Review
Browse files

Merge "Extend DocumentsContract search to accept mime types"

parents b1c6ba02 a972d044
Loading
Loading
Loading
Loading
+154 −0
Original line number Diff line number Diff line
/*
 * 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()]);
    }
}
+143 −12
Original line number Diff line number Diff line
@@ -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) {
+54 −23
Original line number Diff line number Diff line
@@ -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;
        }
    }
}
+45 −6
Original line number Diff line number Diff line
@@ -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));
+2 −2
Original line number Diff line number Diff line
@@ -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