diff --git a/core/java/android/provider/DocumentsContract.java b/core/java/android/provider/DocumentsContract.java index 98371f444c78b..a8583249bcdcd 100644 --- a/core/java/android/provider/DocumentsContract.java +++ b/core/java/android/provider/DocumentsContract.java @@ -19,6 +19,10 @@ package android.provider; import static android.net.TrafficStats.KB_IN_BYTES; 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.Nullable; import android.content.ContentProviderClient; import android.content.ContentResolver; @@ -55,6 +59,7 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.util.List; +import java.util.Objects; /** * Defines the contract between a documents provider and the platform. @@ -1311,21 +1316,26 @@ public final class DocumentsContract { } /** - * Finds the canonical path to the root. Document id should be unique across - * roots. + * Finds the canonical path to the top of the tree. The return value starts + * from the top of the tree or the root document to the requested document, + * both inclusive. * - * @param documentUri uri of the document which path is requested. - * @return the path to the root of the document, or {@code null} if failed. - * @see DocumentsProvider#findPath(String) + * Document id should be unique across roots. + * + * @param treeUri treeUri of the document which path is requested. + * @return a list of documents ID starting from the top of the tree to the + * requested document, or {@code null} if failed. + * @see DocumentsProvider#findPath(String, String) * * {@hide} */ - public static Path findPath(ContentResolver resolver, Uri documentUri) - throws RemoteException { + public static List findPath(ContentResolver resolver, Uri treeUri) { + checkArgument(isTreeUri(treeUri), treeUri + " is not a tree uri."); + final ContentProviderClient client = resolver.acquireUnstableContentProviderClient( - documentUri.getAuthority()); + treeUri.getAuthority()); try { - return findPath(client, documentUri); + return findPath(client, treeUri).getPath(); } catch (Exception e) { Log.w(TAG, "Failed to find path", e); return null; @@ -1334,11 +1344,24 @@ public final class DocumentsContract { } } - /** {@hide} */ - public static Path findPath(ContentProviderClient client, Uri documentUri) - throws RemoteException { + /** + * Finds the canonical path. If uri is a document uri returns path to a root and + * its associated root id. If uri is a tree uri returns the path to the top of + * the tree. The {@link Path#getPath()} in the return value starts from the top of + * the tree or the root document to the requested document, both inclusive. + * + * Document id should be unique across roots. + * + * @param uri uri of the document which path is requested. It can be either a + * plain document uri or a tree uri. + * @return the path of the document. + * @see DocumentsProvider#findPath(String, String) + * + * {@hide} + */ + public static Path findPath(ContentProviderClient client, Uri uri) throws RemoteException { final Bundle in = new Bundle(); - in.putParcelable(DocumentsContract.EXTRA_URI, documentUri); + in.putParcelable(DocumentsContract.EXTRA_URI, uri); final Bundle out = client.call(METHOD_FIND_PATH, null, in); @@ -1392,20 +1415,71 @@ public final class DocumentsContract { */ public static final class Path implements Parcelable { - public final String mRootId; - public final List mPath; + private final @Nullable String mRootId; + private final List mPath; /** * Creates a Path. - * @param rootId the id of the root - * @param path the list of document ids from the root document - * at position 0 to the target document + * + * @param rootId the ID of the root. May be null. + * @param path the list of document ids from the parent document at + * position 0 to the child document. */ public Path(String rootId, List path) { + checkCollectionNotEmpty(path, "path"); + checkCollectionElementsNotNull(path, "path"); + mRootId = rootId; mPath = path; } + /** + * Returns the root id or null if the calling package doesn't have + * permission to access root information. + */ + public @Nullable String getRootId() { + return mRootId; + } + + /** + * Returns the path. The path is trimmed to the top of tree if + * calling package doesn't have permission to access those + * documents. + */ + public List getPath() { + return mPath; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || !(o instanceof Path)) { + return false; + } + Path path = (Path) o; + return Objects.equals(mRootId, path.mRootId) && + Objects.equals(mPath, path.mPath); + } + + @Override + public int hashCode() { + return Objects.hash(mRootId, mPath); + } + + @Override + public String toString() { + return new StringBuilder() + .append("DocumentsContract.Path{") + .append("rootId=") + .append(mRootId) + .append(", path=") + .append(mPath) + .append("}") + .toString(); + } + @Override public void writeToParcel(Parcel dest, int flags) { dest.writeString(mRootId); diff --git a/core/java/android/provider/DocumentsProvider.java b/core/java/android/provider/DocumentsProvider.java index 6234f6ae71312..d75781b792165 100644 --- a/core/java/android/provider/DocumentsProvider.java +++ b/core/java/android/provider/DocumentsProvider.java @@ -36,6 +36,7 @@ import static android.provider.DocumentsContract.isTreeUri; import android.Manifest; import android.annotation.CallSuper; +import android.annotation.Nullable; import android.content.ClipDescription; import android.content.ContentProvider; import android.content.ContentResolver; @@ -54,8 +55,8 @@ import android.os.CancellationSignal; import android.os.ParcelFileDescriptor; import android.os.ParcelFileDescriptor.OnCloseListener; import android.provider.DocumentsContract.Document; -import android.provider.DocumentsContract.Root; import android.provider.DocumentsContract.Path; +import android.provider.DocumentsContract.Root; import android.util.Log; import libcore.io.IoUtils; @@ -154,17 +155,7 @@ public abstract class DocumentsProvider extends ContentProvider { */ @Override public void attachInfo(Context context, ProviderInfo info) { - mAuthority = info.authority; - - mMatcher = new UriMatcher(UriMatcher.NO_MATCH); - mMatcher.addURI(mAuthority, "root", MATCH_ROOTS); - mMatcher.addURI(mAuthority, "root/*", MATCH_ROOT); - mMatcher.addURI(mAuthority, "root/*/recent", MATCH_RECENT); - mMatcher.addURI(mAuthority, "root/*/search", MATCH_SEARCH); - mMatcher.addURI(mAuthority, "document/*", MATCH_DOCUMENT); - mMatcher.addURI(mAuthority, "document/*/children", MATCH_CHILDREN); - mMatcher.addURI(mAuthority, "tree/*/document/*", MATCH_DOCUMENT_TREE); - mMatcher.addURI(mAuthority, "tree/*/document/*/children", MATCH_CHILDREN_TREE); + registerAuthority(info.authority); // Sanity check our setup if (!info.exported) { @@ -181,6 +172,28 @@ public abstract class DocumentsProvider extends ContentProvider { super.attachInfo(context, info); } + /** {@hide} */ + @Override + public void attachInfoForTesting(Context context, ProviderInfo info) { + registerAuthority(info.authority); + + super.attachInfoForTesting(context, info); + } + + private void registerAuthority(String authority) { + mAuthority = authority; + + mMatcher = new UriMatcher(UriMatcher.NO_MATCH); + mMatcher.addURI(mAuthority, "root", MATCH_ROOTS); + mMatcher.addURI(mAuthority, "root/*", MATCH_ROOT); + mMatcher.addURI(mAuthority, "root/*/recent", MATCH_RECENT); + mMatcher.addURI(mAuthority, "root/*/search", MATCH_SEARCH); + mMatcher.addURI(mAuthority, "document/*", MATCH_DOCUMENT); + mMatcher.addURI(mAuthority, "document/*/children", MATCH_CHILDREN); + mMatcher.addURI(mAuthority, "tree/*/document/*", MATCH_DOCUMENT_TREE); + mMatcher.addURI(mAuthority, "tree/*/document/*/children", MATCH_CHILDREN_TREE); + } + /** * Test if a document is descendant (child, grandchild, etc) from the given * parent. For example, providers must implement this to support @@ -326,23 +339,28 @@ public abstract class DocumentsProvider extends ContentProvider { } /** - * Finds the canonical path to the root for the requested document. If there are - * more than one path to this document, return the most typical one. + * Finds the canonical path for the requested document. The path must start + * from the parent document if parentDocumentId is not null or the root document + * if parentDocumentId is null. If there are more than one path to this document, + * return the most typical one. Include both the parent document or root document + * and the requested document in the returned path. * - *

This API assumes that document id has enough info to infer the root. - * Different roots should use different document id to refer to the same + *

This API assumes that document ID has enough info to infer the root. + * Different roots should use different document ID to refer to the same * document. * - * @param documentId the document which path is requested. - * @return the path of the requested document to the root, or null if - * such operation is not supported. + * @param childDocumentId the document which path is requested. + * @param parentDocumentId the document with which path starts if not null, or + * null to indicate path to root is requested. + * @return the path of the requested document. If parentDocumentId is null + * returned root ID must not be null. If parentDocumentId is not null + * returned root ID must be null. * * @hide */ - public Path findPath(String documentId) + public Path findPath(String childDocumentId, @Nullable String parentDocumentId) throws FileNotFoundException { - Log.w(TAG, "findPath is called on an unsupported provider."); - return null; + throw new UnsupportedOperationException("findPath not supported."); } /** @@ -897,9 +915,27 @@ public abstract class DocumentsProvider extends ContentProvider { // It's responsibility of the provider to revoke any grants, as the document may be // still attached to another parents. } else if (METHOD_FIND_PATH.equals(method)) { - getContext().enforceCallingPermission(Manifest.permission.MANAGE_DOCUMENTS, null); + final boolean isTreeUri = isTreeUri(documentUri); - final Path path = findPath(documentId); + if (isTreeUri) { + enforceReadPermissionInner(documentUri, getCallingPackage(), null); + } else { + getContext().enforceCallingPermission(Manifest.permission.MANAGE_DOCUMENTS, null); + } + + final String parentDocumentId = isTreeUri + ? DocumentsContract.getTreeDocumentId(documentUri) + : null; + + final Path path = findPath(documentId, parentDocumentId); + + // Ensure provider doesn't leak information to unprivileged callers. + if (isTreeUri + && (path.getRootId() != null + || !Objects.equals(path.getPath().get(0), parentDocumentId))) { + throw new IllegalStateException( + "Provider returns an invalid result for findPath."); + } out.putParcelable(DocumentsContract.EXTRA_RESULT, path); } else { diff --git a/core/tests/coretests/src/android/provider/DocumentsProviderTest.java b/core/tests/coretests/src/android/provider/DocumentsProviderTest.java new file mode 100644 index 0000000000000..0b4675c756e1c --- /dev/null +++ b/core/tests/coretests/src/android/provider/DocumentsProviderTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2016 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.provider; + +import android.content.ContentProviderClient; +import android.content.ContentResolver; +import android.net.Uri; +import android.provider.DocumentsContract.Path; +import android.support.test.filters.SmallTest; +import android.test.ProviderTestCase2; + +import java.util.Arrays; +import java.util.List; + +/** + * Unit tests for {@link DocumentsProvider}. + */ +@SmallTest +public class DocumentsProviderTest extends ProviderTestCase2 { + + private static final String ROOT_ID = "rootId"; + private static final String DOCUMENT_ID = "docId"; + private static final String PARENT_DOCUMENT_ID = "parentDocId"; + private static final String ANCESTOR_DOCUMENT_ID = "ancestorDocId"; + + private TestDocumentsProvider mProvider; + + private ContentResolver mResolver; + + public DocumentsProviderTest() { + super(TestDocumentsProvider.class, TestDocumentsProvider.AUTHORITY); + } + + public void setUp() throws Exception { + super.setUp(); + + mProvider = getProvider(); + mResolver = getMockContentResolver(); + } + + public void testFindPath_docUri() throws Exception { + final Path expected = new Path(ROOT_ID, Arrays.asList(PARENT_DOCUMENT_ID, DOCUMENT_ID)); + mProvider.nextPath = expected; + + final Uri docUri = + DocumentsContract.buildDocumentUri(TestDocumentsProvider.AUTHORITY, DOCUMENT_ID); + try (ContentProviderClient client = + mResolver.acquireUnstableContentProviderClient(docUri)) { + final Path actual = DocumentsContract.findPath(client, docUri); + assertEquals(expected, actual); + } + } + + public void testFindPath_treeUri() throws Exception { + mProvider.nextIsChildDocument = true; + + final Path expected = new Path(null, Arrays.asList(PARENT_DOCUMENT_ID, DOCUMENT_ID)); + mProvider.nextPath = expected; + + final Uri docUri = buildTreeDocumentUri( + TestDocumentsProvider.AUTHORITY, PARENT_DOCUMENT_ID, DOCUMENT_ID); + final List actual = DocumentsContract.findPath(mResolver, docUri); + + assertEquals(expected.getPath(), actual); + } + + public void testFindPath_treeUri_throwsOnNonChildDocument() throws Exception { + mProvider.nextPath = new Path(null, Arrays.asList(PARENT_DOCUMENT_ID, DOCUMENT_ID)); + + final Uri docUri = buildTreeDocumentUri( + TestDocumentsProvider.AUTHORITY, PARENT_DOCUMENT_ID, DOCUMENT_ID); + assertNull(DocumentsContract.findPath(mResolver, docUri)); + } + + public void testFindPath_treeUri_throwsOnNonNullRootId() throws Exception { + mProvider.nextIsChildDocument = true; + + mProvider.nextPath = new Path(ROOT_ID, Arrays.asList(PARENT_DOCUMENT_ID, DOCUMENT_ID)); + + final Uri docUri = buildTreeDocumentUri( + TestDocumentsProvider.AUTHORITY, PARENT_DOCUMENT_ID, DOCUMENT_ID); + assertNull(DocumentsContract.findPath(mResolver, docUri)); + } + + public void testFindPath_treeUri_throwsOnDifferentParentDocId() throws Exception { + mProvider.nextIsChildDocument = true; + + mProvider.nextPath = new Path( + null, Arrays.asList(ANCESTOR_DOCUMENT_ID, PARENT_DOCUMENT_ID, DOCUMENT_ID)); + + final Uri docUri = buildTreeDocumentUri( + TestDocumentsProvider.AUTHORITY, PARENT_DOCUMENT_ID, DOCUMENT_ID); + assertNull(DocumentsContract.findPath(mResolver, docUri)); + } + + private static Uri buildTreeDocumentUri(String authority, String parentDocId, String docId) { + final Uri treeUri = DocumentsContract.buildTreeDocumentUri(authority, parentDocId); + return DocumentsContract.buildDocumentUriUsingTree(treeUri, docId); + } +} diff --git a/core/tests/coretests/src/android/provider/TestDocumentsProvider.java b/core/tests/coretests/src/android/provider/TestDocumentsProvider.java new file mode 100644 index 0000000000000..8dcf56601d8e7 --- /dev/null +++ b/core/tests/coretests/src/android/provider/TestDocumentsProvider.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2016 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.provider; + +import android.annotation.Nullable; +import android.app.AppOpsManager; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.pm.ProviderInfo; +import android.database.Cursor; +import android.net.Uri; +import android.os.CancellationSignal; +import android.os.IBinder; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract.Path; + +import org.mockito.Mockito; + +import java.io.FileNotFoundException; + +/** + * Provides a test double of {@link DocumentsProvider}. + */ +public class TestDocumentsProvider extends DocumentsProvider { + public static final String AUTHORITY = "android.provider.TestDocumentsProvider"; + + public Path nextPath; + + public boolean nextIsChildDocument; + + public String lastDocumentId; + public String lastParentDocumentId; + + @Override + public void attachInfoForTesting(Context context, ProviderInfo info) { + context = new TestContext(context); + super.attachInfoForTesting(context, info); + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor queryRoots(String[] projection) throws FileNotFoundException { + return null; + } + + @Override + public Cursor queryDocument(String documentId, String[] projection) + throws FileNotFoundException { + return null; + } + + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, + String sortOrder) throws FileNotFoundException { + return null; + } + + @Override + public ParcelFileDescriptor openDocument(String documentId, String mode, + CancellationSignal signal) throws FileNotFoundException { + return null; + } + + @Override + public boolean isChildDocument(String parentDocumentId, String documentId) { + return nextIsChildDocument; + } + + @Override + public Path findPath(String documentId, @Nullable String parentDocumentId) { + lastDocumentId = documentId; + lastParentDocumentId = parentDocumentId; + + return nextPath; + } + + @Override + protected int enforceReadPermissionInner(Uri uri, String callingPkg, IBinder callerToken) { + return AppOpsManager.MODE_ALLOWED; + } + + @Override + protected int enforceWritePermissionInner(Uri uri, String callingPkg, IBinder callerToken) { + return AppOpsManager.MODE_ALLOWED; + } + + private static class TestContext extends ContextWrapper { + + private TestContext(Context context) { + super(context); + } + + @Override + public void enforceCallingPermission(String permission, String message) { + // Always granted + } + + @Override + public Object getSystemService(String name) { + if (Context.APP_OPS_SERVICE.equals(name)) { + return Mockito.mock(AppOpsManager.class); + } + + return super.getSystemService(name); + } + } +} diff --git a/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java b/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java index 3b575a8a03a64..662a1cde53cdf 100644 --- a/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java +++ b/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java @@ -16,6 +16,7 @@ package com.android.externalstorage; +import android.annotation.Nullable; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; @@ -40,8 +41,8 @@ import android.os.storage.StorageManager; import android.os.storage.VolumeInfo; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; -import android.provider.DocumentsContract.Root; import android.provider.DocumentsContract.Path; +import android.provider.DocumentsContract.Root; import android.provider.DocumentsProvider; import android.provider.MediaStore; import android.provider.Settings; @@ -325,14 +326,19 @@ public class ExternalStorageProvider extends DocumentsProvider { } private File getFileForDocId(String docId, boolean visible) throws FileNotFoundException { - return resolveDocId(docId, visible).second; + RootInfo root = getRootFromDocId(docId); + return buildFile(root, docId, visible); } private Pair resolveDocId(String docId, boolean visible) throws FileNotFoundException { + RootInfo root = getRootFromDocId(docId); + return Pair.create(root, buildFile(root, docId, visible)); + } + + private RootInfo getRootFromDocId(String docId) throws FileNotFoundException { final int splitIndex = docId.indexOf(':', 1); final String tag = docId.substring(0, splitIndex); - final String path = docId.substring(splitIndex + 1); RootInfo root; synchronized (mRootsLock) { @@ -342,6 +348,14 @@ public class ExternalStorageProvider extends DocumentsProvider { throw new FileNotFoundException("No root for " + tag); } + return root; + } + + private File buildFile(RootInfo root, String docId, boolean visible) + throws FileNotFoundException { + final int splitIndex = docId.indexOf(':', 1); + final String path = docId.substring(splitIndex + 1); + File target = visible ? root.visiblePath : root.path; if (target == null) { return null; @@ -353,7 +367,7 @@ public class ExternalStorageProvider extends DocumentsProvider { if (!target.exists()) { throw new FileNotFoundException("Missing file for " + docId + " at " + target); } - return Pair.create(root, target); + return target; } private void includeFile(MatrixCursor result, String docId, File file) @@ -430,25 +444,33 @@ public class ExternalStorageProvider extends DocumentsProvider { } @Override - public Path findPath(String documentId) + public Path findPath(String childDocId, @Nullable String parentDocId) throws FileNotFoundException { LinkedList path = new LinkedList<>(); - final Pair resolvedDocId = resolveDocId(documentId, false); - RootInfo root = resolvedDocId.first; - File file = resolvedDocId.second; + final Pair resolvedDocId = resolveDocId(childDocId, false); + final RootInfo root = resolvedDocId.first; + File child = resolvedDocId.second; - if (!file.exists()) { - throw new FileNotFoundException(); + final File parent = TextUtils.isEmpty(parentDocId) + ? root.path + : getFileForDocId(parentDocId); + + if (!child.exists()) { + throw new FileNotFoundException(childDocId + " is not found."); } - while (file != null && file.getAbsolutePath().startsWith(root.path.getAbsolutePath())) { - path.addFirst(getDocIdForFile(file)); - - file = file.getParentFile(); + if (!child.getAbsolutePath().startsWith(parent.getAbsolutePath())) { + throw new FileNotFoundException(childDocId + " is not found under " + parentDocId); } - return new Path(root.rootId, path); + while (child != null && child.getAbsolutePath().startsWith(parent.getAbsolutePath())) { + path.addFirst(getDocIdForFile(child)); + + child = child.getParentFile(); + } + + return new Path(parentDocId == null ? root.rootId : null, path); } @Override