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

Commit 21de56a9 authored by Jeff Sharkey's avatar Jeff Sharkey
Browse files

Add directory selection to DocumentsProvider.

Introduce new ACTION_PICK_DIRECTORY that allows users to grant access
to an entire document subtree.  Instead of requiring grants for each
individual document, this leverages new prefix URI permission grants
by defining new "via"-style URIs:

content://com.example/via/12/document/24/

This references document 24 by using a prefix grant given for
document 12.  Internally, we use isChildDocument() to enforce that
24 is actually a descendant (child, grandchild, etc) of 12.  Since
this is an optional API, providers indicate support with
Root.FLAG_SUPPORTS_DIR_SELECTION.

Extend DocumentsUI to support picking directories.  Expose
createDocument() API to work with returned directories.

Offer to canonicalize via-style URIs into direct URIs, generating
exact permission grants along the way.  Override openAssetFile()
to pass through CancellationSignal.  Move testing code into ApiDemos.

Bug: 10607375
Change-Id: Ifffc1cff878870f8152eb6ca0199c5d014b9cb07
parent 846318a3
Loading
Loading
Loading
Loading
+11 −0
Original line number Diff line number Diff line
@@ -6927,6 +6927,7 @@ package android.content {
    field public static final java.lang.String ACTION_PASTE = "android.intent.action.PASTE";
    field public static final java.lang.String ACTION_PICK = "android.intent.action.PICK";
    field public static final java.lang.String ACTION_PICK_ACTIVITY = "android.intent.action.PICK_ACTIVITY";
    field public static final java.lang.String ACTION_PICK_DIRECTORY = "android.intent.action.PICK_DIRECTORY";
    field public static final java.lang.String ACTION_POWER_CONNECTED = "android.intent.action.ACTION_POWER_CONNECTED";
    field public static final java.lang.String ACTION_POWER_DISCONNECTED = "android.intent.action.ACTION_POWER_DISCONNECTED";
    field public static final java.lang.String ACTION_POWER_USAGE_SUMMARY = "android.intent.action.POWER_USAGE_SUMMARY";
@@ -22480,16 +22481,21 @@ package android.provider {
  public final class DocumentsContract {
    method public static android.net.Uri buildChildDocumentsUri(java.lang.String, java.lang.String);
    method public static android.net.Uri buildChildDocumentsViaUri(android.net.Uri, java.lang.String);
    method public static android.net.Uri buildDocumentUri(java.lang.String, java.lang.String);
    method public static android.net.Uri buildDocumentViaUri(android.net.Uri, java.lang.String);
    method public static android.net.Uri buildRecentDocumentsUri(java.lang.String, java.lang.String);
    method public static android.net.Uri buildRootUri(java.lang.String, java.lang.String);
    method public static android.net.Uri buildRootsUri(java.lang.String);
    method public static android.net.Uri buildSearchDocumentsUri(java.lang.String, java.lang.String, java.lang.String);
    method public static android.net.Uri buildViaUri(java.lang.String, java.lang.String);
    method public static android.net.Uri createDocument(android.content.ContentResolver, android.net.Uri, java.lang.String, java.lang.String);
    method public static boolean deleteDocument(android.content.ContentResolver, android.net.Uri);
    method public static java.lang.String getDocumentId(android.net.Uri);
    method public static android.graphics.Bitmap getDocumentThumbnail(android.content.ContentResolver, android.net.Uri, android.graphics.Point, android.os.CancellationSignal);
    method public static java.lang.String getRootId(android.net.Uri);
    method public static java.lang.String getSearchDocumentsQuery(android.net.Uri);
    method public static java.lang.String getViaDocumentId(android.net.Uri);
    method public static boolean isDocumentUri(android.content.Context, android.net.Uri);
    field public static final java.lang.String EXTRA_ERROR = "error";
    field public static final java.lang.String EXTRA_INFO = "info";
@@ -22526,6 +22532,7 @@ package android.provider {
    field public static final java.lang.String COLUMN_TITLE = "title";
    field public static final int FLAG_LOCAL_ONLY = 2; // 0x2
    field public static final int FLAG_SUPPORTS_CREATE = 1; // 0x1
    field public static final int FLAG_SUPPORTS_DIR_SELECTION = 16; // 0x10
    field public static final int FLAG_SUPPORTS_RECENTS = 4; // 0x4
    field public static final int FLAG_SUPPORTS_SEARCH = 8; // 0x8
  }
@@ -22538,6 +22545,9 @@ package android.provider {
    method public java.lang.String getDocumentType(java.lang.String) throws java.io.FileNotFoundException;
    method public final java.lang.String getType(android.net.Uri);
    method public final android.net.Uri insert(android.net.Uri, android.content.ContentValues);
    method public boolean isChildDocument(java.lang.String, java.lang.String);
    method public final android.content.res.AssetFileDescriptor openAssetFile(android.net.Uri, java.lang.String) throws java.io.FileNotFoundException;
    method public final android.content.res.AssetFileDescriptor openAssetFile(android.net.Uri, java.lang.String, android.os.CancellationSignal) throws java.io.FileNotFoundException;
    method public abstract android.os.ParcelFileDescriptor openDocument(java.lang.String, java.lang.String, android.os.CancellationSignal) throws java.io.FileNotFoundException;
    method public android.content.res.AssetFileDescriptor openDocumentThumbnail(java.lang.String, android.graphics.Point, android.os.CancellationSignal) throws java.io.FileNotFoundException;
    method public final android.os.ParcelFileDescriptor openFile(android.net.Uri, java.lang.String) throws java.io.FileNotFoundException;
@@ -22550,6 +22560,7 @@ package android.provider {
    method public android.database.Cursor queryRecentDocuments(java.lang.String, java.lang.String[]) throws java.io.FileNotFoundException;
    method public abstract android.database.Cursor queryRoots(java.lang.String[]) throws java.io.FileNotFoundException;
    method public android.database.Cursor querySearchDocuments(java.lang.String, java.lang.String, java.lang.String[]) throws java.io.FileNotFoundException;
    method public final void revokeDocumentPermission(java.lang.String);
    method public final int update(android.net.Uri, android.content.ContentValues, java.lang.String, java.lang.String[]);
  }
+28 −6
Original line number Diff line number Diff line
@@ -2700,9 +2700,11 @@ public class Intent implements Parcelable, Cloneable {
     * take the persistable permissions using
     * {@link ContentResolver#takePersistableUriPermission(Uri, int)}.
     * <p>
     * Callers can restrict document selection to a specific kind of data, such
     * as photos, by setting one or more MIME types in
     * {@link #EXTRA_MIME_TYPES}.
     * Callers must indicate the acceptable document MIME types through
     * {@link #setType(String)}. For example, to select photos, use
     * {@code image/*}. If multiple disjoint MIME types are acceptable, define
     * them in {@link #EXTRA_MIME_TYPES} and {@link #setType(String)} to
     * {@literal *}/*.
     * <p>
     * If the caller can handle multiple returned items (the user performing
     * multiple selection), then you can specify {@link #EXTRA_ALLOW_MULTIPLE}
@@ -2712,9 +2714,10 @@ public class Intent implements Parcelable, Cloneable {
     * returned URIs can be opened with
     * {@link ContentResolver#openFileDescriptor(Uri, String)}.
     * <p>
     * Output: The URI of the item that was picked. This must be a
     * {@code content://} URI so that any receiver can access it. If multiple
     * documents were selected, they are returned in {@link #getClipData()}.
     * Output: The URI of the item that was picked, returned in
     * {@link #getData()}. This must be a {@code content://} URI so that any
     * receiver can access it. If multiple documents were selected, they are
     * returned in {@link #getClipData()}.
     *
     * @see DocumentsContract
     * @see #ACTION_CREATE_DOCUMENT
@@ -2756,6 +2759,24 @@ public class Intent implements Parcelable, Cloneable {
    @SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION)
    public static final String ACTION_CREATE_DOCUMENT = "android.intent.action.CREATE_DOCUMENT";

    /**
     * Activity Action: Allow the user to pick a directory. When invoked, the
     * system will display the various {@link DocumentsProvider} instances
     * installed on the device, letting the user navigate through them. Apps can
     * fully manage documents within the returned directory.
     * <p>
     * To gain access to descendant (child, grandchild, etc) documents, use
     * {@link DocumentsContract#buildDocumentViaUri(Uri, String)} and
     * {@link DocumentsContract#buildChildDocumentsViaUri(Uri, String)} using
     * the returned directory URI.
     * <p>
     * Output: The URI representing the selected directory.
     *
     * @see DocumentsContract
     */
    @SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION)
    public static final String ACTION_PICK_DIRECTORY = "android.intent.action.PICK_DIRECTORY";

    // ---------------------------------------------------------------------
    // ---------------------------------------------------------------------
    // Standard intent categories (see addCategory()).
@@ -3334,6 +3355,7 @@ public class Intent implements Parcelable, Cloneable {
     * @see #ACTION_GET_CONTENT
     * @see #ACTION_OPEN_DOCUMENT
     * @see #ACTION_CREATE_DOCUMENT
     * @see #ACTION_PICK_DIRECTORY
     */
    public static final String EXTRA_LOCAL_ONLY =
            "android.intent.extra.LOCAL_ONLY";
+2 −2
Original line number Diff line number Diff line
@@ -370,8 +370,8 @@ public class FileUtils {
     * attacks.
     */
    public static boolean contains(File dir, File file) {
        String dirPath = dir.getPath();
        String filePath = file.getPath();
        String dirPath = dir.getAbsolutePath();
        String filePath = file.getAbsolutePath();

        if (dirPath.equals(filePath)) {
            return true;
+132 −22
Original line number Diff line number Diff line
@@ -57,6 +57,10 @@ import java.util.List;
 * <p>
 * To create a document provider, extend {@link DocumentsProvider}, which
 * provides a foundational implementation of this contract.
 * <p>
 * All client apps must hold a valid URI permission grant to access documents,
 * typically issued when a user makes a selection through
 * {@link Intent#ACTION_OPEN_DOCUMENT} or {@link Intent#ACTION_CREATE_DOCUMENT}.
 *
 * @see DocumentsProvider
 */
@@ -69,6 +73,8 @@ public final class DocumentsContract {
    // content://com.example/root/sdcard/search/?query=pony
    // content://com.example/document/12/
    // content://com.example/document/12/children/
    // content://com.example/via/12/document/24/
    // content://com.example/via/12/document/24/children/

    private DocumentsContract() {
    }
@@ -424,6 +430,14 @@ public final class DocumentsContract {
         */
        public static final int FLAG_SUPPORTS_SEARCH = 1 << 3;

        /**
         * Flag indicating that this root supports directory selection.
         *
         * @see #COLUMN_FLAGS
         * @see DocumentsProvider#isChildDocument(String, String)
         */
        public static final int FLAG_SUPPORTS_DIR_SELECTION = 1 << 4;

        /**
         * Flag indicating that this root is currently empty. This may be used
         * to hide the root when opening documents, but the root will still be
@@ -484,12 +498,15 @@ public final class DocumentsContract {

    /** {@hide} */
    public static final String EXTRA_THUMBNAIL_SIZE = "thumbnail_size";
    /** {@hide} */
    public static final String EXTRA_URI = "uri";

    private static final String PATH_ROOT = "root";
    private static final String PATH_RECENT = "recent";
    private static final String PATH_DOCUMENT = "document";
    private static final String PATH_CHILDREN = "children";
    private static final String PATH_SEARCH = "search";
    private static final String PATH_VIA = "via";

    private static final String PARAM_QUERY = "query";
    private static final String PARAM_MANAGE = "manage";
@@ -531,6 +548,17 @@ public final class DocumentsContract {
                .appendPath(PATH_RECENT).build();
    }

    /**
     * Build URI representing access to descendant documents of the given
     * {@link Document#COLUMN_DOCUMENT_ID}.
     *
     * @see #getViaDocumentId(Uri)
     */
    public static Uri buildViaUri(String authority, String documentId) {
        return new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT).authority(authority)
                .appendPath(PATH_VIA).appendPath(documentId).build();
    }

    /**
     * Build URI representing the given {@link Document#COLUMN_DOCUMENT_ID} in a
     * document provider. When queried, a provider will return a single row with
@@ -544,6 +572,41 @@ public final class DocumentsContract {
                .authority(authority).appendPath(PATH_DOCUMENT).appendPath(documentId).build();
    }

    /**
     * Build URI representing the given {@link Document#COLUMN_DOCUMENT_ID} in a
     * document provider. Instead of directly accessing the target document,
     * gain access via another document. The target document must be a
     * descendant (child, grandchild, etc) of the via document.
     * <p>
     * This is typically used to access documents under a user-selected
     * directory, since it doesn't require the user to separately confirm each
     * new document access.
     *
     * @param viaUri a related document (directory) that the caller is
     *            leveraging to gain access to the target document. The target
     *            document must be a descendant of this directory.
     * @param documentId the target document, which the caller may not have
     *            direct access to.
     * @see Intent#ACTION_PICK_DIRECTORY
     * @see DocumentsProvider#isChildDocument(String, String)
     * @see #buildDocumentUri(String, String)
     */
    public static Uri buildDocumentViaUri(Uri viaUri, String documentId) {
        return new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT)
                .authority(viaUri.getAuthority()).appendPath(PATH_VIA)
                .appendPath(getViaDocumentId(viaUri)).appendPath(PATH_DOCUMENT)
                .appendPath(documentId).build();
    }

    /** {@hide} */
    public static Uri buildDocumentMaybeViaUri(Uri baseUri, String documentId) {
        if (isViaUri(baseUri)) {
            return buildDocumentViaUri(baseUri, documentId);
        } else {
            return buildDocumentUri(baseUri.getAuthority(), documentId);
        }
    }

    /**
     * Build URI representing the children of the given directory in a document
     * provider. When queried, a provider will return zero or more rows with
@@ -561,6 +624,32 @@ public final class DocumentsContract {
                .build();
    }

    /**
     * Build URI representing the children of the given directory in a document
     * provider. Instead of directly accessing the target document, gain access
     * via another document. The target document must be a descendant (child,
     * grandchild, etc) of the via document.
     * <p>
     * This is typically used to access documents under a user-selected
     * directory, since it doesn't require the user to separately confirm each
     * new document access.
     *
     * @param viaUri a related document (directory) that the caller is
     *            leveraging to gain access to the target document. The target
     *            document must be a descendant of this directory.
     * @param parentDocumentId the target document, which the caller may not
     *            have direct access to.
     * @see Intent#ACTION_PICK_DIRECTORY
     * @see DocumentsProvider#isChildDocument(String, String)
     * @see #buildChildDocumentsUri(String, String)
     */
    public static Uri buildChildDocumentsViaUri(Uri viaUri, String parentDocumentId) {
        return new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT)
                .authority(viaUri.getAuthority()).appendPath(PATH_VIA)
                .appendPath(getViaDocumentId(viaUri)).appendPath(PATH_DOCUMENT)
                .appendPath(parentDocumentId).appendPath(PATH_CHILDREN).build();
    }

    /**
     * Build URI representing a search for matching documents under a specific
     * root in a document provider. When queried, a provider will return zero or
@@ -580,21 +669,31 @@ public final class DocumentsContract {
    /**
     * Test if the given URI represents a {@link Document} backed by a
     * {@link DocumentsProvider}.
     *
     * @see #buildDocumentUri(String, String)
     * @see #buildDocumentViaUri(Uri, String)
     */
    public static boolean isDocumentUri(Context context, Uri uri) {
        final List<String> paths = uri.getPathSegments();
        if (paths.size() < 2) {
            return false;
        if (paths.size() >= 2
                && (PATH_DOCUMENT.equals(paths.get(0)) || PATH_VIA.equals(paths.get(0)))) {
            return isDocumentsProvider(context, uri.getAuthority());
        }
        if (!PATH_DOCUMENT.equals(paths.get(0))) {
        return false;
    }

    /** {@hide} */
    public static boolean isViaUri(Uri uri) {
        final List<String> paths = uri.getPathSegments();
        return (paths.size() >= 2 && PATH_VIA.equals(paths.get(0)));
    }

    private static boolean isDocumentsProvider(Context context, String authority) {
        final Intent intent = new Intent(PROVIDER_INTERFACE);
        final List<ResolveInfo> infos = context.getPackageManager()
                .queryIntentContentProviders(intent, 0);
        for (ResolveInfo info : infos) {
            if (uri.getAuthority().equals(info.providerInfo.authority)) {
            if (authority.equals(info.providerInfo.authority)) {
                return true;
            }
        }
@@ -606,28 +705,41 @@ public final class DocumentsContract {
     */
    public static String getRootId(Uri rootUri) {
        final List<String> paths = rootUri.getPathSegments();
        if (paths.size() < 2) {
            throw new IllegalArgumentException("Not a root: " + rootUri);
        }
        if (!PATH_ROOT.equals(paths.get(0))) {
            throw new IllegalArgumentException("Not a root: " + rootUri);
        }
        if (paths.size() >= 2 && PATH_ROOT.equals(paths.get(0))) {
            return paths.get(1);
        }
        throw new IllegalArgumentException("Invalid URI: " + rootUri);
    }

    /**
     * Extract the {@link Document#COLUMN_DOCUMENT_ID} from the given URI.
     *
     * @see #isDocumentUri(Context, Uri)
     */
    public static String getDocumentId(Uri documentUri) {
        final List<String> paths = documentUri.getPathSegments();
        if (paths.size() < 2) {
            throw new IllegalArgumentException("Not a document: " + documentUri);
        if (paths.size() >= 2 && PATH_DOCUMENT.equals(paths.get(0))) {
            return paths.get(1);
        }
        if (paths.size() >= 4 && PATH_VIA.equals(paths.get(0))
                && PATH_DOCUMENT.equals(paths.get(2))) {
            return paths.get(3);
        }
        if (!PATH_DOCUMENT.equals(paths.get(0))) {
            throw new IllegalArgumentException("Not a document: " + documentUri);
        throw new IllegalArgumentException("Invalid URI: " + documentUri);
    }

    /**
     * Extract the via {@link Document#COLUMN_DOCUMENT_ID} from the given URI.
     *
     * @see #isViaUri(Uri)
     */
    public static String getViaDocumentId(Uri documentUri) {
        final List<String> paths = documentUri.getPathSegments();
        if (paths.size() >= 2 && PATH_VIA.equals(paths.get(0))) {
            return paths.get(1);
        }
        throw new IllegalArgumentException("Invalid URI: " + documentUri);
    }

    /**
     * Extract the search query from a URI built by
@@ -758,7 +870,6 @@ public final class DocumentsContract {
     * @param mimeType MIME type of new document
     * @param displayName name of new document
     * @return newly created document, or {@code null} if failed
     * @hide
     */
    public static Uri createDocument(ContentResolver resolver, Uri parentDocumentUri,
            String mimeType, String displayName) {
@@ -778,13 +889,12 @@ public final class DocumentsContract {
    public static Uri createDocument(ContentProviderClient client, Uri parentDocumentUri,
            String mimeType, String displayName) throws RemoteException {
        final Bundle in = new Bundle();
        in.putString(Document.COLUMN_DOCUMENT_ID, getDocumentId(parentDocumentUri));
        in.putParcelable(DocumentsContract.EXTRA_URI, parentDocumentUri);
        in.putString(Document.COLUMN_MIME_TYPE, mimeType);
        in.putString(Document.COLUMN_DISPLAY_NAME, displayName);

        final Bundle out = client.call(METHOD_CREATE_DOCUMENT, null, in);
        return buildDocumentUri(
                parentDocumentUri.getAuthority(), out.getString(Document.COLUMN_DOCUMENT_ID));
        return out.getParcelable(DocumentsContract.EXTRA_URI);
    }

    /**
@@ -811,7 +921,7 @@ public final class DocumentsContract {
    public static void deleteDocument(ContentProviderClient client, Uri documentUri)
            throws RemoteException {
        final Bundle in = new Bundle();
        in.putString(Document.COLUMN_DOCUMENT_ID, getDocumentId(documentUri));
        in.putParcelable(DocumentsContract.EXTRA_URI, documentUri);

        client.call(METHOD_DELETE_DOCUMENT, null, in);
    }
+151 −30

File changed.

Preview size limit exceeded, changes collapsed.

Loading